@gotcos/glasses-server 6.82.0 → 6.83.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,272 @@
1
+ /**
2
+ * 6.83.0 (canary C3): an incremental index of COS-WORK tags in the session logs on this Mac.
3
+ *
4
+ * Logs are append-only, so each file is read from the offset the last pass stopped at; after the first pass only new
5
+ * bytes are scanned. A line is parsed only when it contains `COS-WORK`. The index stores hits, never text:
6
+ * `{tag, kind, sid, at}`. It lives in `data/work-orchestrator/tag-index.json` (0600, atomic), and a missing or corrupt
7
+ * index is rebuilt from zero.
8
+ *
9
+ * Which hits count (canary C3 precision fixes):
10
+ * - A Claude row is credited to its OWN `sessionId` (a resumed file replays older sessions' rows).
11
+ * - Claude subagent transcripts are not scanned (a subagent's mention is never membership).
12
+ * - Codex guardian threads and other non-spawn child threads are skipped; `thread_spawn` children are folded into
13
+ * their parent, which for membership means they add nothing (a subagent mention), so they are recorded as skipped.
14
+ * - Kinds: `prompt` (a user turn with this tag's handoff template), `status` (a valid status line in the session's own
15
+ * reply), `quoted` (a user turn that pastes some other handoff), `tool` (tool input or output), `mention` (anything
16
+ * else). Only `prompt` and `status` can ever be evidence of membership; discovery decides how much.
17
+ */
18
+ import { open, readdir, stat } from 'node:fs/promises'
19
+ import { dirname, join } from 'node:path'
20
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs'
21
+ import { atomicWriteFileSync } from '../atomic-fs.js'
22
+ import { codexSessionRootsOf, type AgentSessionRoots } from '../agent-session-store.js'
23
+ import { codexThreadKind, type LogProvider } from './session-log.js'
24
+ import { HANDOFF_TEMPLATE, isHandoffPrompt, statusLines, tagsIn } from './tags.js'
25
+
26
+ export type HitKind = 'prompt' | 'status' | 'quoted' | 'tool' | 'mention'
27
+ export interface TagHit { tag: string; kind: HitKind; sid: string; at: number | null }
28
+ interface FileEntry {
29
+ size: number; mtimeMs: number; offset: number; provider: LogProvider; sid: string | null
30
+ skip?: 'guardian' | 'spawn' | 'other_child'; hits: TagHit[]
31
+ }
32
+ interface IndexDoc { version: 1; files: Record<string, FileEntry> }
33
+
34
+ const NEEDLE = Buffer.from('COS-WORK')
35
+ const CHUNK = 8 * 1024 * 1024
36
+ /** A single row longer than this is not scanned (no handoff is that long; it keeps memory bounded). */
37
+ const MAX_LINE = 32 * 1024 * 1024
38
+ const MAX_HITS_PER_FILE = 400
39
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
40
+ const ROLLOUT_ID = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i
41
+
42
+ async function entries(dir: string): Promise<string[]> { try { return await readdir(dir) } catch { return [] } }
43
+
44
+ /** Every log file this index covers, with the provider and (when the name gives it) the session id. */
45
+ export async function logFiles(roots: AgentSessionRoots): Promise<Array<{ path: string; provider: LogProvider; sid: string | null }>> {
46
+ const out: Array<{ path: string; provider: LogProvider; sid: string | null }> = []
47
+ for (const project of await entries(roots.claudeProjects)) {
48
+ const dir = join(roots.claudeProjects, project)
49
+ for (const name of await entries(dir)) {
50
+ if (name.endsWith('.jsonl') && UUID.test(name.slice(0, -6))) out.push({ path: join(dir, name), provider: 'claude', sid: 'claude:' + name.slice(0, -6).toLowerCase() })
51
+ }
52
+ }
53
+ const codexRoots = codexSessionRootsOf(roots)
54
+ const walk = async (dir: string, depth: number): Promise<void> => {
55
+ for (const name of await entries(dir)) {
56
+ const path = join(dir, name)
57
+ if (name.endsWith('.jsonl')) { const m = ROLLOUT_ID.exec(name); if (m) out.push({ path, provider: 'codex', sid: 'codex:' + m[1]!.toLowerCase() }); continue }
58
+ if (depth < 3 && /^\d+$/.test(name)) await walk(path, depth + 1)
59
+ }
60
+ }
61
+ for (const root of codexRoots) await walk(root, 0)
62
+ await walk(join(dirname(roots.codexSessions), 'archived_sessions'), 3)
63
+ for (const project of await entries(roots.cursorProjects)) {
64
+ const dir = join(roots.cursorProjects, project, 'agent-transcripts')
65
+ for (const name of await entries(dir)) {
66
+ if (name.endsWith('.jsonl') && UUID.test(name.slice(0, -6))) { out.push({ path: join(dir, name), provider: 'cursor', sid: 'cursor:' + name.slice(0, -6).toLowerCase() }); continue }
67
+ if (UUID.test(name)) {
68
+ const inner = join(dir, name, name + '.jsonl')
69
+ if (existsSync(inner)) out.push({ path: inner, provider: 'cursor', sid: 'cursor:' + name.toLowerCase() })
70
+ }
71
+ }
72
+ }
73
+ return out
74
+ }
75
+
76
+ /** (role, kind, text) blocks of one row, across the three providers (canary discover.py `blocks`). */
77
+ function blocks(o: Record<string, any>): Array<{ role: string; kind: 'text' | 'tool'; text: string }> {
78
+ const out: Array<{ role: string; kind: 'text' | 'tool'; text: string }> = []
79
+ if (o.message && typeof o.message === 'object') {
80
+ const role = String(o.message.role || o.type || o.role || '')
81
+ const c = o.message.content
82
+ if (typeof c === 'string') { out.push({ role, kind: 'text', text: c }); return out }
83
+ for (const b of Array.isArray(c) ? c : []) {
84
+ if (!b || typeof b !== 'object') continue
85
+ if (b.type === 'text' && typeof b.text === 'string') out.push({ role, kind: 'text', text: b.text })
86
+ else if (b.type === 'tool_result') out.push({ role, kind: 'tool', text: typeof b.content === 'string' ? b.content : JSON.stringify(b.content ?? '') })
87
+ else if (b.type === 'tool_use') out.push({ role, kind: 'tool', text: JSON.stringify(b.input ?? '') })
88
+ }
89
+ return out
90
+ }
91
+ const p = o.payload && typeof o.payload === 'object' ? o.payload : {}
92
+ if (o.type === 'response_item' && p.type === 'message') {
93
+ for (const b of Array.isArray(p.content) ? p.content : []) if (b && typeof b.text === 'string') out.push({ role: String(p.role || ''), kind: 'text', text: b.text })
94
+ } else if (o.type === 'response_item') out.push({ role: 'tool', kind: 'tool', text: JSON.stringify(p) })
95
+ // event_msg rows duplicate response items (agent_message / user_message): skipped.
96
+ return out
97
+ }
98
+
99
+ export function classifyRow(o: Record<string, any>, provider: LogProvider, fileSid: string | null, fallbackAt: number | null): TagHit[] {
100
+ let sid = fileSid
101
+ if (provider === 'claude' && typeof o.sessionId === 'string' && UUID.test(o.sessionId)) sid = 'claude:' + o.sessionId.toLowerCase()
102
+ if (!sid) return []
103
+ if (o.isSidechain === true) return []
104
+ const ts = typeof o.timestamp === 'string' ? Date.parse(o.timestamp) : NaN
105
+ const at = Number.isFinite(ts) ? ts : fallbackAt
106
+ const out: TagHit[] = []
107
+ for (const b of blocks(o)) {
108
+ if (!b.text.includes('COS-WORK')) continue
109
+ const status = b.role === 'assistant' && b.kind === 'text' ? new Set(statusLines(b.text).map(s => s.tag)) : new Set<string>()
110
+ for (const tag of tagsIn(b.text)) {
111
+ let kind: HitKind
112
+ if (status.has(tag)) kind = 'status'
113
+ else if (b.role === 'user' && b.kind === 'text' && isHandoffPrompt(b.text, tag)) kind = 'prompt'
114
+ else if (b.role === 'user' && b.kind === 'text' && b.text.includes(HANDOFF_TEMPLATE)) kind = 'quoted'
115
+ else if (b.kind === 'tool') kind = 'tool'
116
+ else kind = 'mention'
117
+ out.push({ tag, kind, sid, at })
118
+ }
119
+ }
120
+ return out
121
+ }
122
+
123
+ function mergeHits(into: TagHit[], add: TagHit[], cap = MAX_HITS_PER_FILE): TagHit[] {
124
+ const key = (h: TagHit) => `${h.tag}\u0000${h.kind}\u0000${h.sid}`
125
+ const map = new Map(into.map(h => [key(h), h]))
126
+ for (const h of add) {
127
+ const k = key(h), prev = map.get(k)
128
+ if (!prev || (h.at ?? 0) > (prev.at ?? 0)) map.set(k, h)
129
+ }
130
+ return [...map.values()].sort((a, b) => (b.at ?? 0) - (a.at ?? 0)).slice(0, cap)
131
+ }
132
+
133
+ async function firstBytes(path: string, n: number): Promise<string> {
134
+ const handle = await open(path, 'r')
135
+ try { const buf = Buffer.alloc(n); const { bytesRead } = await handle.read(buf, 0, n, 0); return buf.subarray(0, bytesRead).toString('utf8') } finally { await handle.close() }
136
+ }
137
+
138
+ /** A Codex child thread's kind from the start of its first line (the line can be hundreds of KB of instructions). */
139
+ export async function codexKindOf(path: string): Promise<ReturnType<typeof codexThreadKind>> {
140
+ const head = await firstBytes(path, 512 * 1024)
141
+ const nl = head.indexOf('\n')
142
+ if (nl >= 0) return codexThreadKind(head.slice(0, nl))
143
+ if (/"source"\s*:\s*\{\s*"subagent"\s*:\s*\{\s*"other"\s*:\s*"guardian"/.test(head)) return { kind: 'guardian' }
144
+ const spawn = /"thread_spawn"\s*:\s*\{\s*"parent_thread_id"\s*:\s*"([0-9a-f-]{36})"/i.exec(head)
145
+ if (spawn) return { kind: 'spawn', parent: spawn[1]!.toLowerCase() }
146
+ return /"source"\s*:\s*\{\s*"subagent"/.test(head) ? { kind: 'other_child' } : { kind: 'thread' }
147
+ }
148
+
149
+ /** Scan [offset, size) of one file. Returns the new offset (just past the last whole line) and the hits found. */
150
+ export async function scanRange(path: string, provider: LogProvider, sid: string | null, offset: number, size: number, mtimeMs: number): Promise<{ offset: number; hits: TagHit[] }> {
151
+ const handle = await open(path, 'r')
152
+ const hits: TagHit[] = []
153
+ let pos = offset
154
+ let carry: Buffer = Buffer.alloc(0)
155
+ let carryStart = offset
156
+ try {
157
+ while (pos < size) {
158
+ const length = Math.min(CHUNK, size - pos)
159
+ const buf = Buffer.alloc(length)
160
+ const { bytesRead } = await handle.read(buf, 0, length, pos)
161
+ if (bytesRead === 0) break
162
+ pos += bytesRead
163
+ const data = carry.length ? Buffer.concat([carry, buf.subarray(0, bytesRead)]) : buf.subarray(0, bytesRead)
164
+ const lastNl = data.lastIndexOf(10)
165
+ if (lastNl < 0) {
166
+ carry = data.length > MAX_LINE ? Buffer.alloc(0) : Buffer.from(data)
167
+ if (data.length > MAX_LINE) carryStart = pos
168
+ continue
169
+ }
170
+ const whole = data.subarray(0, lastNl + 1)
171
+ if (whole.indexOf(NEEDLE) >= 0) {
172
+ let start = 0
173
+ while (start < whole.length) {
174
+ const end = whole.indexOf(10, start)
175
+ const line = whole.subarray(start, end < 0 ? whole.length : end)
176
+ start = end < 0 ? whole.length : end + 1
177
+ if (line.indexOf(NEEDLE) < 0) continue
178
+ let o: unknown
179
+ try { o = JSON.parse(line.toString('utf8')) } catch { continue }
180
+ if (o && typeof o === 'object' && !Array.isArray(o)) hits.push(...classifyRow(o as Record<string, any>, provider, sid, provider === 'cursor' ? mtimeMs : null))
181
+ }
182
+ }
183
+ carryStart += whole.length
184
+ carry = Buffer.from(data.subarray(lastNl + 1))
185
+ }
186
+ } finally { await handle.close() }
187
+ return { offset: carryStart, hits }
188
+ }
189
+
190
+ export interface TagIndexOptions { roots: AgentSessionRoots; path: string; budgetMs?: number; now?: () => number }
191
+ export interface TagIndexStats { files: number; scannedFiles: number; scannedBytes: number; complete: boolean; ms: number; at: number }
192
+
193
+ export class TagIndex {
194
+ private doc: IndexDoc
195
+ private running: Promise<TagIndexStats> | null = null
196
+ lastStats: TagIndexStats | null = null
197
+ constructor(private readonly options: TagIndexOptions) { this.doc = this.load() }
198
+ private load(): IndexDoc {
199
+ try {
200
+ const v = JSON.parse(readFileSync(this.options.path, 'utf8'))
201
+ if (v && v.version === 1 && v.files && typeof v.files === 'object') return v
202
+ } catch { /* missing or corrupt: rebuilt from zero */ }
203
+ return { version: 1, files: {} }
204
+ }
205
+ private save(): void {
206
+ try { mkdirSync(dirname(this.options.path), { recursive: true, mode: 0o700 }); atomicWriteFileSync(this.options.path, JSON.stringify(this.doc)) } catch (e) {
207
+ console.warn(`[work-orchestrator] tag index not saved: ${(e as Error).message}`)
208
+ }
209
+ }
210
+ /** One pass at a time; a second caller shares the pass already running. */
211
+ refresh(): Promise<TagIndexStats> {
212
+ if (!this.running) this.running = this.pass().finally(() => { this.running = null })
213
+ return this.running
214
+ }
215
+ private async pass(): Promise<TagIndexStats> {
216
+ const now = this.options.now ?? Date.now
217
+ const t0 = now(), budget = this.options.budgetMs ?? 60_000
218
+ const files = await logFiles(this.options.roots)
219
+ const seen = new Set<string>()
220
+ let scannedFiles = 0, scannedBytes = 0, complete = true
221
+ for (const f of files) {
222
+ seen.add(f.path)
223
+ let info
224
+ try { info = await stat(f.path) } catch { continue }
225
+ let entry = this.doc.files[f.path]
226
+ // A file that shrank or was replaced is read again from the start.
227
+ if (!entry || info.size < entry.offset || entry.provider !== f.provider) {
228
+ entry = { size: 0, mtimeMs: 0, offset: 0, provider: f.provider, sid: f.sid, hits: [] }
229
+ if (f.provider === 'codex') {
230
+ try {
231
+ const k = await codexKindOf(f.path)
232
+ if (k.kind === 'guardian' || k.kind === 'other_child') entry.skip = k.kind
233
+ else if (k.kind === 'spawn') entry.skip = 'spawn'
234
+ } catch { /* unreadable: scanned as a thread */ }
235
+ }
236
+ this.doc.files[f.path] = entry
237
+ }
238
+ if (entry.skip) { entry.size = info.size; entry.mtimeMs = info.mtimeMs; entry.offset = info.size; continue }
239
+ if (info.size === entry.size && info.mtimeMs === entry.mtimeMs && entry.offset >= info.size) continue
240
+ if (now() - t0 > budget) { complete = false; continue }
241
+ try {
242
+ const r = await scanRange(f.path, f.provider, f.sid, entry.offset, info.size, info.mtimeMs)
243
+ scannedBytes += info.size - entry.offset
244
+ entry.offset = r.offset; entry.size = info.size; entry.mtimeMs = info.mtimeMs
245
+ entry.hits = mergeHits(entry.hits, r.hits)
246
+ scannedFiles++
247
+ } catch { /* unreadable this pass: tried again next pass */ }
248
+ }
249
+ for (const path of Object.keys(this.doc.files)) if (!seen.has(path)) delete this.doc.files[path]
250
+ if (scannedFiles) this.save()
251
+ const stats = { files: files.length, scannedFiles, scannedBytes, complete, ms: now() - t0, at: now() }
252
+ this.lastStats = stats
253
+ return stats
254
+ }
255
+ /** Every hit, one per (tag, kind, session) with its newest time: a resumed file and its source can carry the same row. */
256
+ hits(): TagHit[] { return mergeHits([], Object.values(this.doc.files).flatMap(e => e.skip ? [] : e.hits), Infinity) }
257
+ /** The newest log file for a session id (`provider:uuid`), when the index knows one. */
258
+ pathOf(sid: string): string | null {
259
+ let best: { path: string; mtimeMs: number } | null = null
260
+ for (const [path, e] of Object.entries(this.doc.files)) {
261
+ if (e.skip || e.sid !== sid) continue
262
+ if (!best || e.mtimeMs > best.mtimeMs) best = { path, mtimeMs: e.mtimeMs }
263
+ }
264
+ return best?.path ?? null
265
+ }
266
+ /** Codex child threads the index skipped (guardian and others), so the session list can leave them out too. */
267
+ skippedSessions(): Set<string> {
268
+ const out = new Set<string>()
269
+ for (const e of Object.values(this.doc.files)) if (e.skip && e.sid) out.add(e.sid)
270
+ return out
271
+ }
272
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * 6.83.0 Work orchestrator: the COS-WORK tag a card's handoffs carry, and the two shapes the tag takes in a log.
3
+ * Leaf module. The tag rule is COS Control's `WorkProgress.tag(forWorkID:)` (0.5.247), so both sides agree:
4
+ * a task card's tag is its 12-hex identity; any other card's is the first 12 hex of SHA-256 of its work ID.
5
+ */
6
+ import { createHash } from 'node:crypto'
7
+
8
+ export const TAG_HEX = /^[a-f0-9]{12}$/
9
+ export const TAG_IN_TEXT = /COS-WORK\s+([a-fA-F0-9]{12})/g
10
+ /** The template COS Control appends to every handoff (WorkProgress.instruction). Only a real handoff carries it. */
11
+ export const HANDOFF_TEMPLATE = '<done, needs input or blocked>'
12
+ const STATUS_LINE = /^COS-WORK\s+([a-fA-F0-9]{12})\s*:\s*(done|needs[ _-]input|blocked)\s*[:\-–—]\s*(.+)$/i
13
+
14
+ export function tagForWorkID(workID: string): string {
15
+ const parts = workID.split(':')
16
+ if (parts.length === 3 && parts[0] === 'task' && TAG_HEX.test(parts[2]!)) return parts[2]!
17
+ return createHash('sha256').update(workID, 'utf8').digest('hex').slice(0, 12)
18
+ }
19
+
20
+ export function tagsIn(text: string): string[] {
21
+ const out = new Set<string>()
22
+ for (const m of text.matchAll(TAG_IN_TEXT)) out.add(m[1]!.toLowerCase())
23
+ return [...out]
24
+ }
25
+
26
+ export interface StatusLine { tag: string; status: 'done' | 'needs input' | 'blocked'; evidence: string }
27
+
28
+ /**
29
+ * Valid status lines in one reply: the line stands alone (Markdown emphasis, bullets and quote marks stripped), names
30
+ * one of the three states, and carries at least three characters of evidence that are not the template itself.
31
+ */
32
+ export function statusLines(text: string): StatusLine[] {
33
+ const out: StatusLine[] = []
34
+ for (const raw of text.split('\n')) {
35
+ let line = raw.replace(/\*\*/g, '').replace(/__/g, '').replace(/`/g, '').trim()
36
+ while (line && '*_>#-•'.includes(line[0]!)) line = line.slice(1).trim()
37
+ while (line && '*_'.includes(line[line.length - 1]!)) line = line.slice(0, -1)
38
+ const m = STATUS_LINE.exec(line)
39
+ if (!m) continue
40
+ const evidence = m[3]!.trim()
41
+ if (evidence.length < 3 || evidence.startsWith('<')) continue
42
+ const state = m[2]!.toLowerCase().replace(/[ _-]/, ' ')
43
+ out.push({ tag: m[1]!.toLowerCase(), status: state as StatusLine['status'], evidence: evidence.slice(0, 200) })
44
+ }
45
+ return out
46
+ }
47
+
48
+ /** A user turn that received this tag's handoff: the template follows the tag, exactly as Control sends it. */
49
+ export function isHandoffPrompt(text: string, tag: string): boolean {
50
+ if (!TAG_HEX.test(tag)) return false
51
+ return new RegExp(`COS-WORK\\s+${tag}\\s*:\\s*${HANDOFF_TEMPLATE.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'i').test(text)
52
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * 6.83.0: the two places that learn about plan usage while running jobs (the Claude bridge and the job coordinator)
3
+ * report here. Leaf module, no I/O: bridges stay as light to import (and to mock in tests) as before. The orchestrator
4
+ * runtime registers the sink at boot; until then, and in every test that does not register one, a report is dropped.
5
+ */
6
+ import { isProfilePreference } from '../../../shared/key-preference.js'
7
+
8
+ export interface UsageEventSink {
9
+ limit(provider: 'claude' | 'codex' | 'cursor', message: string, source: string): void
10
+ claudeFrame(frame: unknown): void
11
+ }
12
+ let sink: UsageEventSink | null = null
13
+ export function setUsageEventSink(next: UsageEventSink | null): void { sink = next }
14
+
15
+ /**
16
+ * A plan-lane job failed with the provider's limit text (code `provider_limit`). A key-lane job (a `lane` on the job,
17
+ * or a key profile as its model) is never counted: its limit is the key's, not the plan's (Q2).
18
+ */
19
+ export function noteJobProviderLimit(provider: string | undefined, code: string, message: string, model: unknown, lane: unknown): void {
20
+ try {
21
+ if (code !== 'provider_limit' || lane || isProfilePreference(model)) return
22
+ if (provider !== 'claude' && provider !== 'codex' && provider !== 'cursor') return
23
+ sink?.limit(provider, message, 'provider_limit')
24
+ } catch { /* observation only */ }
25
+ }
26
+
27
+ /** A `rate_limit_event` frame from a plan-lane Claude job (the bridge never calls this for a keyed turn). */
28
+ export function noteClaudeRateLimitFrame(frame: unknown): void {
29
+ try { sink?.claudeFrame(frame) } catch { /* observation only */ }
30
+ }