@gotcos/glasses-server 6.78.2 → 6.79.1

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,413 @@
1
+ // Live cue verdicts (6.79.0): a durable log of every cue the v2 engine emitted, and Miles's rating of each one, so the bar,
2
+ // spacing and repeat rules can be tuned on rated cues rather than on the trace alone.
3
+ //
4
+ // live-cues-log.jsonl: one row per emitted cue, appended before the cue is held or emitted (so a fast rating never 404s),
5
+ // mode 0600. Unlike the trace it carries transcript text: `before`, the last 400 characters of what was said before the cue,
6
+ // so a cue can be judged against it. `before` is blanked after 7 days and the row dropped after 30. The reader validates
7
+ // every line and skips (and counts) anything else, so a torn line or a hand-seeded row with a typo never breaks history.
8
+ // live-cues-verdicts.json: { v: 1, verdicts: { [cueId]: { verdict?, note?, vote?, at } } }, atomic write, last write wins.
9
+ // An unreadable or corrupt verdicts file fails closed: it is left byte for byte and every write is refused, because a
10
+ // read-modify-write from an empty map would erase the week's ratings.
11
+ //
12
+ // Concurrency: every read-modify-write here is synchronous fs from read to rename, so two requests on the event loop can
13
+ // never interleave inside one. No maintenance lease: a history poll must never hold up an update drain.
14
+ //
15
+ // Standalone on purpose: verdicts and history work with v2 off, and a v2-off server must never build the engine (it
16
+ // constructs the Jev and SubQ clients). The route passes in which meeting the running engine, if any, holds the cue for.
17
+
18
+ import { appendFileSync, closeSync, constants, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, statSync } from 'node:fs'
19
+ import { dirname } from 'node:path'
20
+ import { dataPath } from './data-dir.js'
21
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
22
+ import { errMsg } from './utils.js'
23
+ import { getMeetingStore } from './meeting-store.js'
24
+ import { findCosOperationsMeetingBySessionId, findDirectLibraryMeetingBySessionId } from './cos-operations-meetings.js'
25
+ import { CUE_FEEDBACK_REASON_MAX, type CueKind, type CueSourceRef, type LiveCuesV2MemorySource } from '../../shared/live-cues-v2.js'
26
+ import type { WouldWithhold } from './live-cues-filter.js'
27
+
28
+ // ── Wire types (server-local: shared/live-cues-v2.ts is hash-pinned with the companion copy) ──
29
+ export const LIVE_CUE_VERDICTS_PROTOCOL = 1 as const
30
+ /** `before`: the last characters (code points) said before the cue, whitespace-collapsed. */
31
+ export const CUE_BEFORE_MAX = 400
32
+ export const CUE_VERDICTS = ['useful', 'late', 'repeat', 'wrong', 'obvious'] as const
33
+ export type CueVerdict = typeof CUE_VERDICTS[number]
34
+ export type CueVote = 'up' | 'down'
35
+ export type CueTitleSource = 'saved' | 'library' | 'capture'
36
+
37
+ /** One line of live-cues-log.jsonl. `armedAt`, `prompt`, `memorySource` and `kept` are absent on rows seeded by hand. */
38
+ export interface LiveCueLogRow {
39
+ v: 1
40
+ cueId: string
41
+ sessionId: string
42
+ /** The arm-time title as-is ("G2 Recording 2026-10-19 0741"); history resolves the saved title. */
43
+ meetingTitle: string
44
+ createdAt: number
45
+ armedAt?: number
46
+ kind: CueKind
47
+ headline: string
48
+ detail: string
49
+ gate: number
50
+ sources: CueSourceRef[]
51
+ model: string
52
+ /** Writer rules version (v2, v3; v4 and later read the same way, so rows from a newer writer are never skipped). */
53
+ prompt?: string
54
+ memorySource?: LiveCuesV2MemorySource
55
+ /** Notes the judge kept for the writer. */
56
+ kept?: number
57
+ /** Shadow owner/date template rules (never enforced): would this cue be withheld per meeting, or per topic. */
58
+ wouldWithhold?: WouldWithhold
59
+ before: string
60
+ }
61
+ /** A history row's rating. `verdict` null with a `vote` is a lens vote alone: unrated. */
62
+ export interface CueRating { verdict: CueVerdict | null; note?: string; vote?: CueVote; at: number }
63
+ export interface LiveCueHistoryRow extends LiveCueLogRow { title: string; titleSource: CueTitleSource; rating: CueRating | null }
64
+ export interface LiveCuesHistoryResponse {
65
+ v: 1
66
+ cues: LiveCueHistoryRow[]
67
+ /** After the sessionId filter, before `unrated` and `limit`. */
68
+ counts: { total: number; rated: number; unrated: number; byVerdict: Record<CueVerdict, number> }
69
+ /** The newest `limit` rows were kept and older ones cut. */
70
+ truncated: boolean
71
+ /** Log lines that were not a valid cue row (torn, foreign, bad seed). */
72
+ skipped: number
73
+ }
74
+ export interface LiveCuesVerdictResponse { ok: true; cueId: string; verdict: CueVerdict | null; vote: CueVote | null }
75
+
76
+ const DAY_MS = 24 * 60 * 60_000
77
+ export const LIVE_CUE_VERDICT_LIMITS = {
78
+ retentionMs: 30 * DAY_MS,
79
+ /** `before` (transcript text) is blanked on rows older than this; the rest of the row stays for the retention window. */
80
+ beforeRetentionMs: 7 * DAY_MS,
81
+ pruneEveryMs: DAY_MS,
82
+ titleCacheMs: 60_000,
83
+ maxDays: 30,
84
+ defaultDays: 7,
85
+ defaultLimit: 200,
86
+ maxLimit: 1000,
87
+ maxSessionIds: 5,
88
+ } as const
89
+
90
+ /** The `before` excerpt: whitespace-collapsed, the last CUE_BEFORE_MAX code points (never half a surrogate pair). */
91
+ export function cueBefore(said: string): string {
92
+ return Array.from(said.replace(/\s+/g, ' ').trim()).slice(-CUE_BEFORE_MAX).join('')
93
+ }
94
+ /** A note as stored: whitespace-collapsed, truncated at CUE_FEEDBACK_REASON_MAX like the lens reason. */
95
+ export function cleanNote(note: string | undefined): string {
96
+ return (note ?? '').replace(/\s+/g, ' ').trim().slice(0, CUE_FEEDBACK_REASON_MAX)
97
+ }
98
+ export function isCueVerdict(value: unknown): value is CueVerdict {
99
+ return typeof value === 'string' && (CUE_VERDICTS as readonly string[]).includes(value)
100
+ }
101
+
102
+ const str = (v: unknown): v is string => typeof v === 'string'
103
+ const num = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v)
104
+ const optional = (v: unknown, ok: (x: unknown) => boolean) => v === undefined || ok(v)
105
+ function isWouldWithhold(v: unknown): boolean {
106
+ const w = v as Partial<WouldWithhold> | null
107
+ return !!w && typeof w === 'object' && typeof w.perMeeting === 'boolean' && typeof w.perTopic === 'boolean' && optional(w.blockerCueId, str)
108
+ }
109
+ /** The full row schema. Hand-seeded rows must pass it too (`before: ""` and no optional fields are fine). */
110
+ export function isLogRow(value: unknown): value is LiveCueLogRow {
111
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false
112
+ const r = value as Record<string, unknown>
113
+ return r.v === 1 && str(r.cueId) && r.cueId.length > 0 && str(r.sessionId) && r.sessionId.length > 0 && str(r.meetingTitle)
114
+ && num(r.createdAt) && (r.kind === 'reinforce' || r.kind === 'challenge')
115
+ && str(r.headline) && str(r.detail) && num(r.gate) && str(r.model) && str(r.before)
116
+ && Array.isArray(r.sources) && r.sources.every(s => !!s && typeof s === 'object' && str((s as CueSourceRef).id) && str((s as CueSourceRef).date) && str((s as CueSourceRef).title))
117
+ && optional(r.armedAt, num) && optional(r.prompt, p => typeof p === 'string' && /^v\d{1,2}$/.test(p))
118
+ && optional(r.memorySource, m => m === 'cue_memory' || m === 'notes' || m === 'none') && optional(r.kept, num)
119
+ && optional(r.wouldWithhold, isWouldWithhold)
120
+ }
121
+
122
+ export class LiveCueLog {
123
+ /** Parsed rows keyed by the file's identity, so a 15 s poll does not reparse megabytes on the event loop. */
124
+ private cache: { key: string; rows: LiveCueLogRow[]; skipped: number } | null = null
125
+ constructor(private readonly file: string) {}
126
+
127
+ /** Throws on failure: the engine records it on the evaluation's trace row. */
128
+ append(row: LiveCueLogRow): void {
129
+ mkdirSync(dirname(this.file), { recursive: true, mode: 0o700 })
130
+ // A torn last line (a crash or failure mid-append) must not glue onto this row: start on a fresh line.
131
+ const lead = this.endsMidLine() ? '\n' : ''
132
+ appendFileSync(this.file, lead + JSON.stringify(row) + '\n', { mode: 0o600 })
133
+ }
134
+
135
+ /** Also the regular-file gate for append: a FIFO planted at this path would block the event loop forever on a
136
+ * blocking open, so it is opened non-blocking and refused before appendFileSync ever opens it. */
137
+ private endsMidLine(): boolean {
138
+ const nonBlock = constants.O_NONBLOCK ?? 0, noFollow = constants.O_NOFOLLOW ?? 0
139
+ let fd: number
140
+ try { fd = openSync(this.file, constants.O_RDONLY | nonBlock | noFollow) } catch (error) {
141
+ if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') return false
142
+ throw error
143
+ }
144
+ try {
145
+ const st = fstatSync(fd)
146
+ if (!st.isFile()) throw new Error('the cue log is not a regular file')
147
+ const size = st.size
148
+ if (size === 0) return false
149
+ const last = Buffer.alloc(1)
150
+ readSync(fd, last, 0, 1, size - 1)
151
+ return last[0] !== 0x0a
152
+ } finally { closeSync(fd) }
153
+ }
154
+
155
+ /** Every valid row in file order, and how many non-blank lines were skipped. Never throws. */
156
+ read(): { rows: LiveCueLogRow[]; skipped: number } {
157
+ let key = ''
158
+ try {
159
+ const st = statSync(this.file)
160
+ if (!st.isFile()) return { rows: [], skipped: 0 } // never readFileSync a FIFO: it blocks the event loop
161
+ key = `${st.ino}:${st.size}:${st.mtimeMs}`
162
+ } catch { return { rows: [], skipped: 0 } }
163
+ if (this.cache?.key === key) return { rows: this.cache.rows, skipped: this.cache.skipped }
164
+ let text = ''
165
+ try { text = readFileSync(this.file, 'utf8') } catch { return { rows: [], skipped: 0 } }
166
+ const rows: LiveCueLogRow[] = []
167
+ let skipped = 0
168
+ for (const line of text.split('\n')) {
169
+ if (!line.trim()) continue
170
+ let value: unknown
171
+ try { value = JSON.parse(line) } catch { skipped += 1; continue }
172
+ if (isLogRow(value)) rows.push(value); else skipped += 1
173
+ }
174
+ this.cache = { key, rows, skipped }
175
+ return { rows, skipped }
176
+ }
177
+
178
+ /** The first logged row for this cue, under any meeting. */
179
+ find(cueId: string): LiveCueLogRow | null {
180
+ return this.read().rows.find(r => r.cueId === cueId) ?? null
181
+ }
182
+
183
+ /** Drops rows past retention and blanks `before` on rows past 7 days. Rewrites only when something changed; synchronous
184
+ * read, filter and rename, so an append (also synchronous, same process) cannot land mid-rewrite. Null when there is
185
+ * no log, otherwise the cueIds kept. */
186
+ prune(nowMs: number): { dropped: number; blanked: number; kept: Set<string> } | null {
187
+ if (!existsSync(this.file)) return null
188
+ const { rows } = this.read()
189
+ const cutoff = nowMs - LIVE_CUE_VERDICT_LIMITS.retentionMs
190
+ const textCutoff = nowMs - LIVE_CUE_VERDICT_LIMITS.beforeRetentionMs
191
+ let blanked = 0
192
+ const keep = rows.filter(r => r.createdAt >= cutoff).map(r => {
193
+ if (r.createdAt >= textCutoff || r.before === '') return r
194
+ blanked += 1
195
+ return { ...r, before: '' }
196
+ })
197
+ const dropped = rows.length - keep.length
198
+ if (dropped || blanked) durableAtomicWriteFileSync(this.file, keep.map(r => JSON.stringify(r) + '\n').join(''), { mode: 0o600 })
199
+ return { dropped, blanked, kept: new Set(keep.map(r => r.cueId)) }
200
+ }
201
+ }
202
+
203
+ export class VerdictsUnreadableError extends Error {
204
+ readonly code = 'verdicts_unreadable'
205
+ constructor(message: string) { super(message); this.name = 'VerdictsUnreadableError' }
206
+ }
207
+
208
+ interface StoredVerdict { verdict?: CueVerdict; note?: string; vote?: CueVote; at: number }
209
+ interface VerdictFile { v: 1; verdicts: Record<string, StoredVerdict> }
210
+
211
+ export class LiveCueVerdictStore {
212
+ constructor(private readonly file: string) {}
213
+
214
+ /** Missing is empty. Unreadable or not the expected shape throws, and the file is left exactly as it is. */
215
+ read(): Record<string, StoredVerdict> {
216
+ if (!existsSync(this.file)) return {}
217
+ let parsed: unknown
218
+ try {
219
+ // Never readFileSync a FIFO or a device: it blocks the event loop.
220
+ if (!statSync(this.file).isFile()) throw new Error('not a regular file')
221
+ parsed = JSON.parse(readFileSync(this.file, 'utf8'))
222
+ } catch (error) {
223
+ console.error(`[live-cue-verdicts] ${this.file} is unreadable; ratings are refused until it is fixed or moved: ${errMsg(error)}`)
224
+ throw new VerdictsUnreadableError(errMsg(error))
225
+ }
226
+ const verdicts = (parsed as Partial<VerdictFile> | null)?.verdicts
227
+ if (!verdicts || typeof verdicts !== 'object' || Array.isArray(verdicts)) {
228
+ console.error(`[live-cue-verdicts] ${this.file} is not a verdicts file; ratings are refused until it is fixed or moved`)
229
+ throw new VerdictsUnreadableError('not a verdicts file')
230
+ }
231
+ const out: Record<string, StoredVerdict> = {}
232
+ for (const [cueId, r] of Object.entries(verdicts)) {
233
+ if (!r || typeof r !== 'object' || typeof r.at !== 'number') continue
234
+ const rec: StoredVerdict = { at: r.at }
235
+ if (isCueVerdict(r.verdict)) rec.verdict = r.verdict
236
+ if (rec.verdict && typeof r.note === 'string' && r.note) rec.note = r.note
237
+ if (r.vote === 'up' || r.vote === 'down') rec.vote = r.vote
238
+ if (rec.verdict || rec.vote) out[cueId] = rec
239
+ }
240
+ return out
241
+ }
242
+
243
+ write(verdicts: Record<string, StoredVerdict>): void {
244
+ mkdirSync(dirname(this.file), { recursive: true, mode: 0o700 })
245
+ durableAtomicWriteFileSync(this.file, JSON.stringify({ v: 1, verdicts } satisfies VerdictFile) + '\n', { mode: 0o600 })
246
+ }
247
+
248
+ /** Drops records whose cue left the log. */
249
+ prune(kept: Set<string>): number {
250
+ const all = this.read()
251
+ const gone = Object.keys(all).filter(id => !kept.has(id))
252
+ if (!gone.length) return 0
253
+ for (const id of gone) delete all[id]
254
+ this.write(all)
255
+ return gone.length
256
+ }
257
+ }
258
+
259
+ function ratingOf(rec: StoredVerdict | undefined): CueRating | null {
260
+ if (!rec) return null
261
+ return { verdict: rec.verdict ?? null, ...(rec.note ? { note: rec.note } : {}), ...(rec.vote ? { vote: rec.vote } : {}), at: rec.at }
262
+ }
263
+
264
+ /** The saved meeting's title for a capture: this server's own save first, then the COS meeting library (including a
265
+ * merged scribe that declares the capture). Best effort; null when neither knows it. */
266
+ export function resolveSavedTitle(sessionId: string): { title: string; source: 'saved' | 'library' } | null {
267
+ try { const m = getMeetingStore().findBySessionId(sessionId); if (m?.title) return { title: m.title, source: 'saved' } } catch { /* next */ }
268
+ try { const m = findCosOperationsMeetingBySessionId(sessionId); if (m?.title) return { title: m.title, source: 'library' } } catch { /* next */ }
269
+ try { const m = findDirectLibraryMeetingBySessionId(sessionId); if (m?.title) return { title: m.title, source: 'library' } } catch { /* none */ }
270
+ return null
271
+ }
272
+
273
+ export interface LiveCueVerdictsDeps {
274
+ log: LiveCueLog
275
+ store: LiveCueVerdictStore
276
+ now(): number
277
+ trace(row: Record<string, unknown>): void
278
+ resolveTitle(sessionId: string): { title: string; source: 'saved' | 'library' } | null
279
+ }
280
+
281
+ export type VerdictResult = { ok: true; response: LiveCuesVerdictResponse } | { ok: false; error: 'unknown_cue' | 'cue_session_mismatch' }
282
+
283
+ export class LiveCueVerdicts {
284
+ private lastPruneAt = 0
285
+ private readonly titles = new Map<string, { at: number; value: { title: string; source: 'saved' | 'library' } | null }>()
286
+ constructor(readonly deps: LiveCueVerdictsDeps) {}
287
+
288
+ /** From the engine, before the cue is held or emitted. Throws on failure (the engine traces it and carries on). */
289
+ logCue(row: LiveCueLogRow): void {
290
+ this.deps.log.append(row)
291
+ this.maybePrune()
292
+ }
293
+
294
+ /** The meeting this cue belongs to: the running engine's (`liveOwner`), else the log's. Null when unknown. */
295
+ private owner(cueId: string, liveOwner: string | null): string | null {
296
+ return liveOwner ?? this.deps.log.find(cueId)?.sessionId ?? null
297
+ }
298
+
299
+ /** Throws VerdictsUnreadableError when the verdicts file cannot be read; nothing is written then. */
300
+ rate(input: { sessionId: string; cueId: string; verdict: CueVerdict | 'clear'; note?: string }, liveOwner: string | null): VerdictResult {
301
+ this.maybePrune()
302
+ const owner = this.owner(input.cueId, liveOwner)
303
+ if (!owner) return { ok: false, error: 'unknown_cue' }
304
+ if (owner !== input.sessionId) return { ok: false, error: 'cue_session_mismatch' }
305
+ const all = this.deps.store.read()
306
+ const prev = all[input.cueId]
307
+ const at = this.deps.now()
308
+ const note = cleanNote(input.note)
309
+ // clear drops the verdict and its note and keeps the lens vote; the record goes only when nothing is left.
310
+ const next: StoredVerdict = { at, ...(prev?.vote ? { vote: prev.vote } : {}) }
311
+ if (input.verdict !== 'clear') { next.verdict = input.verdict; if (note) next.note = note }
312
+ if (next.verdict || next.vote) all[input.cueId] = next; else delete all[input.cueId]
313
+ this.deps.store.write(all)
314
+ // The trace never carries the note or any transcript text.
315
+ this.trace({ kind: 'feedback', at: new Date(at).toISOString(), sessionId: input.sessionId, cueId: input.cueId, verdict: input.verdict, knownCue: true, ...(note && input.verdict !== 'clear' ? { noted: true } : {}) })
316
+ return { ok: true, response: { ok: true, cueId: input.cueId, verdict: next.verdict ?? null, vote: next.vote ?? null } }
317
+ }
318
+
319
+ /** The lens vote, beside any verdict and never over it, for a cue known under this meeting. Throws
320
+ * VerdictsUnreadableError like rate(); the vote route's own answer is unchanged either way. */
321
+ vote(sessionId: string, cueId: string, vote: CueVote, liveOwner: string | null): void {
322
+ if (this.owner(cueId, liveOwner) !== sessionId) return
323
+ const all = this.deps.store.read()
324
+ all[cueId] = { ...all[cueId], vote, at: this.deps.now() }
325
+ this.deps.store.write(all)
326
+ }
327
+
328
+ /** Oldest first. With more rows than `limit`, the NEWEST `limit` are kept (truncated: true). Throws
329
+ * VerdictsUnreadableError rather than report every cue as unrated. */
330
+ history(q: { days: number; sessionIds?: string[]; unrated: boolean; limit: number }): LiveCuesHistoryResponse {
331
+ this.maybePrune()
332
+ const cutoff = this.deps.now() - q.days * DAY_MS
333
+ const verdicts = this.deps.store.read()
334
+ const { rows: logged, skipped } = this.deps.log.read()
335
+ const sessions = q.sessionIds?.length ? new Set(q.sessionIds) : null
336
+ const seen = new Set<string>()
337
+ const rows = logged
338
+ .filter(r => r.createdAt >= cutoff && (!sessions || sessions.has(r.sessionId)))
339
+ .filter(r => { if (seen.has(r.cueId)) return false; seen.add(r.cueId); return true })
340
+ .sort((a, b) => a.createdAt - b.createdAt)
341
+ const byVerdict = Object.fromEntries(CUE_VERDICTS.map(v => [v, 0])) as Record<CueVerdict, number>
342
+ let rated = 0
343
+ for (const r of rows) { const v = verdicts[r.cueId]?.verdict; if (v) { byVerdict[v] += 1; rated += 1 } }
344
+ const wanted = rows.filter(r => !q.unrated || !verdicts[r.cueId]?.verdict)
345
+ const page = wanted.slice(-q.limit)
346
+ return {
347
+ v: 1,
348
+ cues: page.map((r): LiveCueHistoryRow => {
349
+ const saved = this.title(r.sessionId)
350
+ return { ...r, title: saved?.title ?? r.meetingTitle, titleSource: saved?.source ?? 'capture', rating: ratingOf(verdicts[r.cueId]) }
351
+ }),
352
+ counts: { total: rows.length, rated, unrated: rows.length - rated, byVerdict },
353
+ truncated: wanted.length > q.limit,
354
+ skipped,
355
+ }
356
+ }
357
+
358
+ /** Every logged cue for one meeting, whatever its age (for the per-meeting count once the engine's counter expired). */
359
+ loggedCues(sessionId: string): number {
360
+ return new Set(this.deps.log.read().rows.filter(r => r.sessionId === sessionId).map(r => r.cueId)).size
361
+ }
362
+
363
+ private title(sessionId: string): { title: string; source: 'saved' | 'library' } | null {
364
+ const nowMs = this.deps.now()
365
+ const hit = this.titles.get(sessionId)
366
+ if (hit && nowMs - hit.at < LIVE_CUE_VERDICT_LIMITS.titleCacheMs) return hit.value
367
+ let value: { title: string; source: 'saved' | 'library' } | null = null
368
+ try { value = this.deps.resolveTitle(sessionId) } catch { value = null }
369
+ this.titles.set(sessionId, { at: nowMs, value })
370
+ return value
371
+ }
372
+
373
+ /** Rows past retention, `before` past 7 days, and the verdicts of cues that left the log. Never throws. */
374
+ prune(): void {
375
+ this.lastPruneAt = this.deps.now()
376
+ try {
377
+ const log = this.deps.log.prune(this.deps.now())
378
+ // No log at all is not "every cue left": leave the verdicts alone.
379
+ const gone = log ? this.deps.store.prune(log.kept) : 0
380
+ if (log?.dropped || log?.blanked || gone) {
381
+ console.log(`[live-cue-verdicts] pruned ${log?.dropped ?? 0} cue(s) past 30 days, blanked ${log?.blanked ?? 0} excerpt(s) past 7 days, dropped ${gone} verdict(s)`)
382
+ }
383
+ } catch (error) {
384
+ console.warn(`[live-cue-verdicts] prune failed: ${errMsg(error)}`)
385
+ }
386
+ }
387
+
388
+ /** First use in this process (there is no boot hook and nothing runs at import), then at most once a day. */
389
+ maybePrune(): void {
390
+ if (this.deps.now() - this.lastPruneAt < LIVE_CUE_VERDICT_LIMITS.pruneEveryMs) return
391
+ this.prune()
392
+ }
393
+
394
+ private trace(row: Record<string, unknown>): void {
395
+ try { this.deps.trace(row) } catch { /* trace is best effort */ }
396
+ }
397
+ }
398
+
399
+ let singleton: LiveCueVerdicts | null = null
400
+ export function liveCueVerdicts(): LiveCueVerdicts {
401
+ if (!singleton) {
402
+ const traceFile = dataPath('live-cues-trace.jsonl')
403
+ singleton = new LiveCueVerdicts({
404
+ log: new LiveCueLog(dataPath('live-cues-log.jsonl')),
405
+ store: new LiveCueVerdictStore(dataPath('live-cues-verdicts.json')),
406
+ now: () => Date.now(),
407
+ // The same trace file and feedback row shape as the engine's lens votes (live-cues-v2.ts realDeps).
408
+ trace(row) { mkdirSync(dirname(traceFile), { recursive: true, mode: 0o700 }); appendFileSync(traceFile, JSON.stringify(row) + '\n', { mode: 0o600 }) },
409
+ resolveTitle: resolveSavedTitle,
410
+ })
411
+ }
412
+ return singleton
413
+ }
@@ -0,0 +1,72 @@
1
+ // Live cues v2 post-writer filter (6.79.0): zero model calls. A TypeScript port of the offline reference
2
+ // (cue_verdicts_mvp/v4_replay/filter_cues.py, RESULTS_filter.md); it must agree with it on every vector.
3
+ //
4
+ // tokens(cue): lowercase, scan [a-z0-9]+(?:[.,][0-9]+)*, drop commas inside a match ("3,000" -> "3000"), keep a token
5
+ // with a digit and length >= 2, or an all-letter token of length >= 3 that is not a stopword. The SET of those.
6
+ // overlap(A, B) = |A ∩ B| / min(|A|, |B|), 0 when either is empty.
7
+ // isTemplate(cue): TEMPLATE_RE anywhere in the raw text (an owner or date ask).
8
+ //
9
+ // The repeat rule (enforced only when COS_LIVE_CUES_V2_REPEAT_OVERLAP is set) withholds a cue whose overlap with an
10
+ // earlier SHOWN cue is >= the threshold. The template rules are shadow only: logged as `wouldWithhold`, never enforced.
11
+
12
+ /** The last two lines are cue scaffolding, so two different owner/date asks do not look like repeats. */
13
+ export const CUE_FILTER_STOPWORDS: ReadonlySet<string> = new Set(`
14
+ all and any are ask but can did don for get got had has her him his how its let may new not now off one our out own per put say see set she the too two use was way who why yet you
15
+ about above after again against also already another anyone around back because been before being below between both cannot
16
+ could didn does doesn doing done down during each else even ever every everyone from further going gonna have haven having
17
+ here into itself just know like made make many more most much must need needs next only onto other ours over really said says
18
+ same should since some someone something still such than that their theirs them then there these they thing things think this
19
+ those through under until upon very wasn well were what when where whether which while will with within without would wouldn
20
+ yeah your yours okay
21
+ settle decide decides decided name named names room nobody none owner owners owns ownership date dates deadline stated
22
+ moving moves ends call meeting asks check
23
+ `.split(/\s+/).filter(Boolean))
24
+
25
+ const TOKEN_RE = /[a-z0-9]+(?:[.,][0-9]+)*/g
26
+ export const CUE_TEMPLATE_RE = /\b(owner|owners|owns|ownership|deadline|due date|by when|by what date|a date|no date|set a date|settle a date|who (owns|will|should|builds|takes|drives|runs|tracks|chases|approves|signs off|finalizes|tests|gathers|asks|computes|is computing|does))\b/i
27
+ /** The shadow per-topic rule's threshold (RESULTS_filter.md: 0.25 against earlier shown template cues). */
28
+ export const CUE_TOPIC_OVERLAP = 0.25
29
+
30
+ export function cueTokens(cue: string): Set<string> {
31
+ const out = new Set<string>()
32
+ for (const m of (cue || '').toLowerCase().match(TOKEN_RE) ?? []) {
33
+ const t = m.replace(/,/g, '')
34
+ if (/[0-9]/.test(t)) { if (t.length >= 2) out.add(t) }
35
+ else if (t.length >= 3 && !CUE_FILTER_STOPWORDS.has(t)) out.add(t)
36
+ }
37
+ return out
38
+ }
39
+
40
+ export function cueOverlap(a: Set<string>, b: Set<string>): number {
41
+ if (!a.size || !b.size) return 0
42
+ let shared = 0
43
+ for (const t of a) if (b.has(t)) shared += 1
44
+ return shared / Math.min(a.size, b.size)
45
+ }
46
+
47
+ export function isTemplateCue(cue: string): boolean { return CUE_TEMPLATE_RE.test(cue || '') }
48
+
49
+ export interface ShownCue { cueId: string; detail: string }
50
+
51
+ /** Rule R: the first earlier shown cue this one repeats at >= threshold, or null. */
52
+ export function repeatOf(detail: string, shown: readonly ShownCue[], threshold: number): { blocker: string; overlap: number } | null {
53
+ const mine = cueTokens(detail)
54
+ for (const s of shown) {
55
+ const o = cueOverlap(mine, cueTokens(s.detail))
56
+ if (o >= threshold) return { blocker: s.cueId, overlap: Math.round(o * 1000) / 1000 }
57
+ }
58
+ return null
59
+ }
60
+
61
+ export interface WouldWithhold { perMeeting: boolean; perTopic: boolean; blockerCueId?: string }
62
+
63
+ /** Rule T in shadow, both forms: per meeting (any earlier shown template cue) and per topic (one with overlap >= 0.25).
64
+ * The blocker is the per-topic one when that form fires, else the per-meeting one. */
65
+ export function wouldWithhold(detail: string, shown: readonly ShownCue[]): WouldWithhold {
66
+ if (!isTemplateCue(detail)) return { perMeeting: false, perTopic: false }
67
+ const mine = cueTokens(detail)
68
+ const templates = shown.filter(s => isTemplateCue(s.detail))
69
+ const topic = templates.find(s => cueOverlap(mine, cueTokens(s.detail)) >= CUE_TOPIC_OVERLAP)
70
+ const blocker = topic ?? templates[0]
71
+ return { perMeeting: templates.length > 0, perTopic: !!topic, ...(blocker ? { blockerCueId: blocker.cueId } : {}) }
72
+ }
@@ -36,6 +36,9 @@ import { COS_SCRIPTS_DIR, PYTHON_BIN, contextSourceAvailable } from './python-br
36
36
  import { dataPath } from './data-dir.js'
37
37
  import { durableAtomicWriteFileSync } from './atomic-fs.js'
38
38
  import { terminateProviderProcess } from './provider-process-lifecycle.js'
39
+ import { cueBefore, liveCueVerdicts, type LiveCueLogRow } from './live-cue-verdicts.js'
40
+ import { repeatOf, wouldWithhold } from './live-cues-filter.js'
41
+ import { errMsg } from './utils.js'
39
42
  import { CUE_GATE_QUESTIONS, CUE_JUDGE_INSTRUCTIONS, CUE_JUDGE_NONE, CUE_WRITER_SYSTEM_V2, CUE_WRITER_SYSTEM_V3 } from './live-cues-v2-prompts.js'
40
43
  import {
41
44
  CUE_DETAIL_MAX, CUE_FEEDBACK_REASON_MAX, CUE_HEADLINE_MAX, LIVE_CUES_V2_PROTOCOL, isLiveCueV2,
@@ -88,6 +91,39 @@ export function liveCuesV2Gate(env: NodeJS.ProcessEnv = process.env): number {
88
91
  const raw = Number(env.COS_LIVE_CUES_V2_GATE)
89
92
  return Number.isFinite(raw) && raw >= 0.3 && raw <= 0.99 ? raw : LIVE_CUES_V2_LIMITS.defaultGate
90
93
  }
94
+ /** 6.79.0 tuning knobs for Friday's rated-cue review. Unset (or empty, which a managed env may pass for an unset optional
95
+ * variable) is the calibrated default with no log. Anything else outside the accepted form falls back to the default with
96
+ * one log line per distinct value per process: the knobs are read on every transcript feed. */
97
+ const knobWarned = new Set<string>()
98
+ function knobFallback<T>(name: string, raw: string, fallback: T, accepted: string): T {
99
+ if (!knobWarned.has(`${name}=${raw}`)) {
100
+ knobWarned.add(`${name}=${raw}`)
101
+ console.warn(`[live-cues-v2] ${name}=${JSON.stringify(raw)} is not ${accepted}; using the default`)
102
+ }
103
+ return fallback
104
+ }
105
+ export function liveCuesV2CooldownMs(env: NodeJS.ProcessEnv = process.env): number {
106
+ const raw = env.COS_LIVE_CUES_V2_COOLDOWN_S
107
+ if (raw === undefined || raw.trim() === '') return LIVE_CUES_V2_LIMITS.cooldownAfterCueMs
108
+ const seconds = /^\d{1,3}$/.test(raw.trim()) ? Number(raw.trim()) : Number.NaN // whole seconds only
109
+ return seconds >= 20 && seconds <= 900 ? seconds * 1000
110
+ : knobFallback('COS_LIVE_CUES_V2_COOLDOWN_S', raw, LIVE_CUES_V2_LIMITS.cooldownAfterCueMs, 'whole seconds from 20 to 900')
111
+ }
112
+ export function liveCuesV2JudgeKeep(env: NodeJS.ProcessEnv = process.env): number {
113
+ const raw = env.COS_LIVE_CUES_V2_JUDGE_KEEP
114
+ if (raw === undefined || raw.trim() === '') return LIVE_CUES_V2_LIMITS.judgeKeep
115
+ const keep = Number(raw)
116
+ return keep >= 0.5 && keep <= 0.95 ? keep : knobFallback('COS_LIVE_CUES_V2_JUDGE_KEEP', raw, LIVE_CUES_V2_LIMITS.judgeKeep, 'a number from 0.5 to 0.95')
117
+ }
118
+ /** COS_LIVE_CUES_V2_REPEAT_OVERLAP: the repeat rule's threshold, 0.3 to 0.9, or null (off, today's behaviour) when unset
119
+ * or empty. Any other value leaves the rule off, with one log line per value. RESULTS_filter.md: 0.5 withheld no useful
120
+ * cue on any receipt; 0.4 withheld three. */
121
+ export function liveCuesV2RepeatOverlap(env: NodeJS.ProcessEnv = process.env): number | null {
122
+ const raw = env.COS_LIVE_CUES_V2_REPEAT_OVERLAP
123
+ if (raw === undefined || raw.trim() === '') return null
124
+ const t = Number(raw)
125
+ return t >= 0.3 && t <= 0.9 ? t : knobFallback<number | null>('COS_LIVE_CUES_V2_REPEAT_OVERLAP', raw, null, 'a number from 0.3 to 0.9 (the repeat rule stays off)')
126
+ }
91
127
  export function liveCuesV2Model(env: NodeJS.ProcessEnv = process.env): string {
92
128
  const raw = (env.COS_LIVE_CUES_V2_MODEL ?? '').trim().toLowerCase()
93
129
  if (raw === 'haiku') return 'claude-haiku-5-5'
@@ -273,6 +309,11 @@ export class LiveCuesV2Store {
273
309
  const m = this.read().meetings[sessionId]
274
310
  return { cues: m?.cues ?? 0, writerCalls: m?.writerCalls ?? 0 }
275
311
  }
312
+ /** The durable per-meeting counters, or null once they expired (24 h) or never existed. */
313
+ meetingCounters(sessionId: string): { cues: number; writerCalls: number } | null {
314
+ const m = this.read().meetings[sessionId]
315
+ return m ? { cues: m.cues, writerCalls: m.writerCalls } : null
316
+ }
276
317
  weeklyUsed(): number { return this.read().writerCalls }
277
318
  end(sessionId: string): void { const s = this.read(); s.ended[sessionId] = this.now().getTime(); delete s.paused[sessionId]; this.write(s) }
278
319
  isEnded(sessionId: string): boolean { return sessionId in this.read().ended }
@@ -322,6 +363,9 @@ export interface LiveCuesV2Deps {
322
363
  now(): Date
323
364
  store: LiveCuesV2Store
324
365
  trace(row: Record<string, unknown>): void
366
+ /** 6.79.0: the durable cue log (live-cues-log.jsonl) that verdicts are recorded against. May throw; never the trace,
367
+ * which must stay free of transcript text. */
368
+ cueLog?(row: LiveCueLogRow): void
325
369
  env: NodeJS.ProcessEnv
326
370
  }
327
371
 
@@ -408,6 +452,7 @@ function realDeps(): LiveCuesV2Deps {
408
452
  trace(row) {
409
453
  try { mkdirSync(dirname(traceFile), { recursive: true, mode: 0o700 }); appendFileSync(traceFile, JSON.stringify(row) + '\n', { mode: 0o600 }) } catch { /* trace is best effort */ }
410
454
  },
455
+ cueLog: row => liveCueVerdicts().logCue(row),
411
456
  env: process.env,
412
457
  }
413
458
  }
@@ -582,6 +627,12 @@ export class LiveCuesV2Engine {
582
627
  return s.stopReason
583
628
  }
584
629
 
630
+ /** The cue, if this process emitted it and still holds it, under whichever meeting. */
631
+ findCue(cueId: string): LiveCueV2 | null {
632
+ for (const s of this.sessions.values()) { const cue = s.cues.find(c => c.cueId === cueId); if (cue) return cue }
633
+ return null
634
+ }
635
+
585
636
  recordFeedback(input: { sessionId: string; cueId: string; vote: 'up' | 'down'; reason?: string }): { ok: true; knownCue: boolean } {
586
637
  const knownCue = !!this.sessions.get(input.sessionId)?.cues.some(c => c.cueId === input.cueId)
587
638
  const reason = (input.reason ?? '').replace(/\s+/g, ' ').trim().slice(0, CUE_FEEDBACK_REASON_MAX)
@@ -614,7 +665,7 @@ export class LiveCuesV2Engine {
614
665
  if (s.breaker) return this.traceSkip(s, 'breaker_open', nowMs)
615
666
  if (this.deps.store.isPaused(sessionId)) return this.traceSkip(s, 'paused', nowMs)
616
667
  if (s.wordsSinceEval < L.minNewWords) return
617
- if (nowMs - s.lastEvalAt < L.minIntervalMs || nowMs - s.lastCueAt < L.cooldownAfterCueMs) return
668
+ if (nowMs - s.lastEvalAt < L.minIntervalMs || nowMs - s.lastCueAt < liveCuesV2CooldownMs(this.deps.env)) return
618
669
  const counters = this.deps.store.counters(sessionId)
619
670
  if (counters.cues >= L.maxCuesPerMeeting || counters.writerCalls >= L.maxWriterCallsPerMeeting) return this.traceSkip(s, 'meeting_cap', nowMs)
620
671
  if (this.deps.store.weeklyUsed() >= liveCuesV2WeeklyCap(this.deps.env)) return this.traceSkip(s, 'budget_exhausted', nowMs) // before the gate
@@ -637,6 +688,8 @@ export class LiveCuesV2Engine {
637
688
  s.lastEvalAt = started
638
689
  s.wordsSinceEval = 0
639
690
  const window = cleanWindow(s.chunks.join('\n')).slice(-L.windowChars)
691
+ // 6.79.0: what was said up to this evaluation, speaker labels kept, for the cue log's `before` (never a model input).
692
+ const said = s.chunks.join('\n').replace(/^\[\d+\]\s*/gm, '')
640
693
  const trace: Record<string, unknown> = { kind: 'evaluation', at: new Date(started).toISOString(), sessionId: s.sessionId, n: s.evaluations }
641
694
  let lease: MaintenanceWorkLease | null = null
642
695
  let wallHit = false
@@ -673,7 +726,7 @@ export class LiveCuesV2Engine {
673
726
  const t2 = Date.now()
674
727
  const judged = await this.deps.judge(s.title, window, mem.notes, abort.signal, provider)
675
728
  Object.assign(trace, { judgeMs: Date.now() - t2, judgeTokens: judged.tokens })
676
- kept = mem.notes.filter(m => (judged.probs[m.id] ?? 0) >= L.judgeKeep).slice(0, L.judgeMaxNotes).map((m, i) => ({ ...m, id: `M${i + 1}` }))
729
+ kept = mem.notes.filter(m => (judged.probs[m.id] ?? 0) >= liveCuesV2JudgeKeep(this.deps.env)).slice(0, L.judgeMaxNotes).map((m, i) => ({ ...m, id: `M${i + 1}` }))
677
730
  }
678
731
  trace.kept = kept.length
679
732
  if (abort.signal.aborted) { trace.outcome = 'cancelled'; return }
@@ -702,6 +755,21 @@ export class LiveCuesV2Engine {
702
755
  const cue: LiveCueV2 = { protocol: 2, cueId: randomUUID(), sessionId: s.sessionId, headline: cueHeadline(parsed.detail), detail: parsed.detail,
703
756
  kind: parsed.kind, sources, gate: round(pCue), createdAt: this.deps.now().getTime(), model: out.model ?? model }
704
757
  if (!isLiveCueV2(cue)) { trace.outcome = 'withheld_invalid'; return } // never emit what the companion would drop
758
+ // 6.79.0 repeat rule, off unless COS_LIVE_CUES_V2_REPEAT_OVERLAP is set: a cue that repeats an earlier SHOWN cue of
759
+ // this meeting is not logged, emitted or counted (the writer call already counted). Ids and the overlap only.
760
+ const repeatT = liveCuesV2RepeatOverlap(this.deps.env)
761
+ const repeat = repeatT === null ? null : repeatOf(cue.detail, s.cues, repeatT)
762
+ if (repeat) { Object.assign(trace, { outcome: 'withheld_repeat', repeatOf: repeat.blocker, repeatOverlap: repeat.overlap }); return }
763
+ // 6.79.0: logged before it is held or emitted, so a rating that arrives at once finds it. Its own try/catch: a log
764
+ // failure is traced on this evaluation and is never a writer failure or a step toward the breaker.
765
+ try {
766
+ this.deps.cueLog?.({ v: 1, cueId: cue.cueId, sessionId: s.sessionId, meetingTitle: s.title, createdAt: cue.createdAt, armedAt: s.armedAt, kind: cue.kind,
767
+ headline: cue.headline, detail: cue.detail, gate: cue.gate, sources: cue.sources, model: cue.model, prompt: version,
768
+ memorySource: mem.notes.length ? source : 'none', kept: kept.length, wouldWithhold: wouldWithhold(cue.detail, s.cues), before: cueBefore(said) })
769
+ } catch (error) {
770
+ trace.cueLogError = (error as NodeJS.ErrnoException)?.code ?? 'append_failed'
771
+ console.warn(`[live-cues-v2] cue log append failed: ${errMsg(error)}`)
772
+ }
705
773
  s.cues.push(cue)
706
774
  s.lastCueAt = cue.createdAt
707
775
  this.deps.store.recordCue(s.sessionId)
@@ -798,4 +866,8 @@ export function setLiveCuesV2Enabled(enabled: boolean, env: NodeJS.ProcessEnv =
798
866
  export function liveCuesV2Advertised(env: NodeJS.ProcessEnv = process.env): LiveCuesV2Capability | undefined {
799
867
  return liveCuesV2Enabled(env) ? liveCuesV2Capability() : undefined
800
868
  }
869
+ /** 6.79.0: a meeting's durable cue and writer-call counters for the history route, read without building the engine. */
870
+ export function liveCuesV2MeetingCounters(sessionId: string): { cues: number; writerCalls: number } | null {
871
+ return new LiveCuesV2Store(dataPath('live-cues-v2-state.json'), () => new Date()).meetingCounters(sessionId)
872
+ }
801
873
  export function shutdownLiveCuesV2(): void { engine?.shutdown(); unsubscribe?.(); unsubscribe = null; engine = null }