@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.
- package/CHANGELOG.md +81 -0
- package/docs/work-orchestrator.md +184 -0
- package/package.json +4 -1
- package/server/index.ts +12 -0
- package/server/lib/claude-bridge.ts +4 -0
- package/server/lib/query-job-coordinator.ts +5 -1
- package/server/lib/work-chat.ts +76 -9
- package/server/lib/work-lens-rounds.ts +445 -0
- package/server/lib/work-orchestrator/discovery.ts +99 -0
- package/server/lib/work-orchestrator/input.ts +109 -0
- package/server/lib/work-orchestrator/pickup.ts +85 -0
- package/server/lib/work-orchestrator/plan-log.ts +38 -0
- package/server/lib/work-orchestrator/redact.ts +41 -0
- package/server/lib/work-orchestrator/runtime.ts +74 -0
- package/server/lib/work-orchestrator/session-log.ts +114 -0
- package/server/lib/work-orchestrator/snapshot.ts +87 -0
- package/server/lib/work-orchestrator/tag-index.ts +272 -0
- package/server/lib/work-orchestrator/tags.ts +52 -0
- package/server/lib/work-orchestrator/usage-events.ts +30 -0
- package/server/lib/work-orchestrator/usage.ts +341 -0
- package/server/routes/work-chat.ts +54 -11
- package/server/routes/work-lens-rounds.ts +179 -0
- package/server/routes/work-orchestrator.ts +71 -0
- package/server/scripts/work-lens-rounds-mutation-gate.mjs +151 -0
|
@@ -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
|
+
}
|