▍ humdrum codex / ticktock v0.0.2
license AGPL-3.0
4.1 KB raw
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
// Package autotrack ports the Python passive activity daemon: poll the
// frontmost app / window title / browser tab / idle state, coalesce
// consecutive same-context samples into segments, and append them to a daily
// staging log (~/.local/share/ticktock/activity-YYYY-MM-DD.jsonl). It writes
// NOTHING to tock — suggest-then-confirm.
package autotrack

import (
	"net/url"
	"strings"
	"time"
)

// Ctx is one classified foreground context. JSON tags match the Python
// record keys (used by --once output and the winctx helper).
type Ctx struct {
	App    string `json:"app"`
	Title  string `json:"title"`
	URL    string `json:"url"`
	Domain string `json:"domain"`
	Idle   bool   `json:"idle"`
}

// Segment is an open (End zero) or closed span of one activity.
type Segment struct {
	Ctx
	Start, End time.Time
}

// awayApps: frontmost one of these ⇒ locked/screensaver ⇒ away.
var awayApps = map[string]bool{"loginwindow": true, "ScreenSaverEngine": true}

// Away reports whether the raw context is the lock/screensaver window.
func Away(raw *Ctx) bool { return raw != nil && awayApps[raw.App] }

// Domain extracts the URL's hostname, lowercased, with "www." stripped —
// parity with Python's urlparse(url).hostname.replace("www.", "").
func Domain(rawURL string) string {
	if rawURL == "" {
		return ""
	}
	u, err := url.Parse(rawURL)
	if err != nil {
		return ""
	}
	return strings.ReplaceAll(strings.ToLower(u.Hostname()), "www.", "")
}

// Classify maps (idle seconds, foreground context) to the segment context to
// record. Locked or an away-app ⇒ idle immediately; idle >= idleGrace ⇒ idle;
// otherwise the active context passes through (present-but-passive, e.g.
// watching an agent run or being on a call, counts as active). nil when there
// is nothing to record.
func Classify(idle int, raw *Ctx, idleGrace int, locked bool) *Ctx {
	if locked || Away(raw) {
		return &Ctx{Idle: true}
	}
	if idle >= idleGrace {
		return &Ctx{Idle: true}
	}
	return raw
}

// Key identifies "the same activity". Idle is its own key; active is
// (app, domain-or-title). The zero Key means no context. Comparable, so the
// segmentation loop can use != directly.
type Key struct {
	Valid bool
	Idle  bool
	App   string
	Rest  string // domain, or title for non-browsers
}

// CtxKey computes the identity key for a classified context.
func CtxKey(c *Ctx) Key {
	if c == nil {
		return Key{}
	}
	if c.Idle {
		return Key{Valid: true, Idle: true}
	}
	rest := c.Domain
	if rest == "" {
		rest = c.Title
	}
	return Key{Valid: true, App: c.App, Rest: rest}
}

// Step is the pure segmentation state machine. Given the open segment
// (cur/curKey) and a fresh sample (now, idle seconds, classified ctx), it
// returns (cur, curKey, emitted) where emitted is a finished segment to
// persist, or nil.
//
// A context change closes the open segment and opens a new one; the same
// context just extends the open segment's end.
//
// Crossing into idle anchors the transition boundary:
//   - hard idle (screen locked / away app): now - idle — the whole gap is
//     away, so the active tail is trimmed to the last real input.
//   - soft idle (HID gap alone): now - (idle - grace) — the grace window is
//     credited to the active app, so present-but-passive work keeps its time.
//
// The boundary is clamped to [cur.Start, now] so a segment never ends before
// it began nor in the future.
func Step(cur *Segment, curKey Key, now time.Time, idle int, ctx *Ctx, grace int, hard bool) (*Segment, Key, *Segment) {
	key := CtxKey(ctx)
	boundary := now
	if ctx != nil && ctx.Idle {
		offset := idle
		if !hard {
			offset = idle - grace
			if offset < 0 {
				offset = 0
			}
		}
		boundary = now.Add(-time.Duration(offset) * time.Second)
	}
	var emitted *Segment
	if key != curKey {
		if cur != nil {
			if boundary.After(now) {
				boundary = now
			}
			if boundary.Before(cur.Start) {
				boundary = cur.Start
			}
			closed := *cur
			closed.End = boundary
			emitted = &closed
		}
		if ctx != nil {
			cur = &Segment{Ctx: *ctx, Start: boundary}
		} else {
			cur = nil
		}
		curKey = key
	} else if cur != nil {
		next := *cur
		next.End = now
		cur = &next
	}
	return cur, curKey, emitted
}