@gotcos/glasses-server 6.78.2 → 6.79.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 CHANGED
@@ -1,3 +1,24 @@
1
+ ## 6.79.0 - Live cue verdicts: every cue is logged, rated from COS Control, and listed for Friday's tuning
2
+
3
+ Miles, 2026-10-19: rate every live cue this week, then tune the bar, spacing and repeat rules on Friday Oct 23 from those ratings. Until now a cue's text lived only in engine memory, so a restart erased it (6.78.2's install erased that morning's 10; the trace keeps ids and scores, never text).
4
+
5
+ - **Client matrix.** Pairs with COS Control 0.5.327. No companion change: companion 6.10.646 keeps voting up/down from the lens exactly as before. Control 0.5.326 and older never call the new routes. Control 0.5.327 on a 6.78.x server finds no `verdicts` capability and hides both rating surfaces.
6
+ - **Transcript-excerpt policy change.** 6.75.0 promised live-cue logs "without transcript text". The new cue log holds the cue itself and `before`, the last 400 characters (code points) of what was said before it, with speaker labels. That excerpt is kept 7 days and then blanked in place (the rest of the row stays 30 days). The file is mode 0600, and neither `before` nor a rating note is ever written to the trace or to any diagnostics export (`/api/diag` exports only client diagnostics). The trace stays text-free: a rating adds a `feedback` row with the verdict and `noted: true` when a note was given, never the note.
7
+ - **Cue log.** `live-cues-log.jsonl` in the data dir, one row per cue: `{v:1, cueId, sessionId, meetingTitle, createdAt, armedAt, kind, headline, detail, gate, sources, model, prompt, memorySource, kept, before}`. `prompt` is the writer rules version (v2 or v3), `memorySource` where the notes came from (cue_memory, notes, none), `kept` how many notes the judge kept, and `wouldWithhold` the shadow template rules below, for Friday's segmentation. `meetingTitle` is the arm-time title as-is ("G2 Recording 2026-10-19 0741"); the engine's title is a gate, judge and writer input and is not changed. The row is appended synchronously after the cue passes validation and BEFORE it is held or emitted, so a rating that arrives at once always finds it. A failed append is one warning and a `cueLogError` code on that evaluation's trace row; it never withholds the cue, is never a writer failure and never moves the breaker.
8
+ - **Reader.** Every line is validated against the row schema; torn, foreign or malformed lines anywhere are skipped and counted (`skipped` in history), never thrown. A fragment left without a newline makes the next append start a fresh line. Rows seeded by hand pass when they match the schema (`before: ""` and none of the optional fields is fine). Parsed rows are cached until the file's inode, size or mtime changes, so a 15 s poll does not reparse the file. Neither file is ever opened blocking unless it is a regular file: the log is probed with a non-blocking, no-follow open and the verdicts file is stat'ed first, so a FIFO planted at either path is refused at once instead of freezing the event loop (the `hazard-invariants` ratchet of unguarded opens stays at 10).
9
+ - **Retention.** Pruned on first use in the process (a cue, a rating or a history read; nothing runs at import or boot) and then at most once a day: rows past 30 days dropped, `before` past 7 days blanked, verdicts of cues that left the log dropped (with no log file at all, verdicts are left alone). Synchronous read, filter and atomic rename, rewritten only when something changed.
10
+ - **Verdicts.** `POST /api/live-cues/feedback {sessionId, cueId, verdict: useful|late|repeat|wrong|obvious|clear, note?}` answers `{ok, cueId, verdict, vote}`. Exactly one of `vote` and `verdict` (both is 400; so is a bad verdict, a non-string note, or a bad sessionId or cueId). The verdict branch runs before the v2 switch check and never builds the v2 engine, so cues are rated with v2 off on a server that constructs no Jev or SubQ client to answer. The cue's meeting is the running engine's when it holds the cue, else the log's: unknown is 404 `unknown_cue`, another meeting is 409 `cue_session_mismatch`. `clear` drops the verdict and note and keeps the lens vote; the record goes when nothing is left. The note is whitespace-collapsed and truncated at 200 characters like the lens reason. Stored in `live-cues-verdicts.json` (`{v:1, verdicts: {[cueId]: {verdict?, note?, vote?, at}}}`, mode 0600, fsync'd atomic write, last write wins). An unreadable or corrupt verdicts file fails closed: ratings and history answer 503 `verdicts_unreadable`, the file is left byte for byte and the error is logged, because a write from an empty map would erase the week's ratings.
11
+ - **Lens vote, unchanged.** `{sessionId, cueId, vote, reason?}` still answers exactly `{ok, knownCue}` and is still 404 `v2_disabled` with v2 off. Its vote is now also kept in the cue's record, beside any verdict and never over it, when the cue is known under that meeting. The existing pins (`live-cues-v2-route.test.ts`, the prompt and contract hashes in `live-cues-v2.test.ts`) are byte-identical.
12
+ - **History.** `GET /api/live-cues/history?days=7&sessionId=a,b&unrated=1&limit=200` (`Cache-Control: private, no-store`) answers `{v:1, cues, counts, truncated, skipped, maxCuesPerMeeting, meetings?}`. Rows are oldest first, each a log row plus `title` and `titleSource` (`saved`: this server's own save; `library`: the COS meeting library, a merged scribe included; `capture`: the logged title) and `rating` (null with no record; `{verdict, note?, vote?, at}` otherwise, `verdict: null` for a lens vote alone, which is unrated). `counts` (`total, rated, unrated, byVerdict`) apply after the sessionId filter and before `unrated` and `limit`. With more rows than `limit`, the newest `limit` are returned and `truncated` is true. `sessionId` is one id or a comma list of up to 5 (a saved meeting can hold several captures); with it, `meetings` gives each `{cues, cap, writerCalls, writerCap}` from the engine's durable counters, or the log's cue count and `writerCalls: null` once those expired (24 h). `days` 1 to 30 (default 7), `limit` 1 to 1000 (default 200), `unrated` 0 or 1; anything else is 400. Not gated on the v2 switch, and no maintenance lease, so a poll never holds up an update drain. Titles are resolved per meeting and cached for a minute.
13
+ - **Feature detection.** `GET /api/live-cues/status` without sessionId gains an unconditional top-level `verdicts: {protocol: 1}`. `shared/live-cues-v2.ts` and its companion hash pin are untouched (the new types live in `server/lib/live-cue-verdicts.ts`), and `/api/health` is unchanged.
14
+ - **Rollback.** A 6.78.x server never reads, writes or prunes `live-cues-log.jsonl` or `live-cues-verdicts.json`; both stay as they are until a 6.79+ server runs again (the excerpts are not blanked meanwhile).
15
+ - **Two tuning knobs, defaults unchanged.** `COS_LIVE_CUES_V2_COOLDOWN_S`: the wait after a cue before the next evaluation, whole seconds from 20 to 900 (default 45, today's cooldown). `COS_LIVE_CUES_V2_JUDGE_KEEP`: the judge's keep bar, 0.5 to 0.95 (default 0.8). Unset or empty is the default with no log; any other value outside those forms (a fraction of a second, `1e2`, out of range, not a number) falls back to the default with one `[live-cues-v2]` log line per distinct value per process. With neither set, behaviour is byte-identical to 6.78.2. Both are in `managed-runtime-contract.json` `optionalEnvironment` and the forwarding test, so a managed install can set them for Friday's tuning without a release. No v4 writer prompt and no owner role: the replay rejected both.
16
+ - **Repeat filter, off unless set (zero model calls).** `COS_LIVE_CUES_V2_REPEAT_OVERLAP` (0.3 to 0.9; unset or empty is off, today's behaviour; any other value leaves it off with one log line per value). When set, a cue whose content-token overlap with an earlier cue SHOWN in the same meeting is at or above the value is withheld after the writer and parse, before the log and the emit: not logged, not emitted, not counted toward the meeting's 25 cues (its writer call still counts toward the 50), and it never blocks a later cue. Its evaluation traces `withheld_repeat` with `repeatOf` (the earlier cueId) and `repeatOverlap`, never text. Tokens, stopwords, the template pattern and overlap = |A ∩ B| / min(|A|, |B|) are a port of the offline reference (`filter_cues.py`; RESULTS_filter.md recommends 0.5, which withheld no useful cue on any receipt). Earlier shown cues are this process's for the meeting, so a restart mid-meeting starts the comparison afresh.
17
+ - **Shadow template rules, always computed, never enforced.** Every logged cue (and its history row) carries `wouldWithhold: {perMeeting, perTopic, blockerCueId?}`: per meeting, an owner/date ask after an earlier shown one; per topic, the same with content overlap of at least 0.25. Friday joins these with the verdicts to decide whether either rule should be enforced.
18
+ - **Filter parity.** The port passes the reference's 8 real vectors byte for byte when `COS_LIVE_CUES_FILTER_VECTORS` points at `filter_test_vectors.json` (run for this release). Those vectors hold meeting text, and this repository is public, so the committed fixture is 9 synthetic vectors whose expectations were produced by the same Python reference; the stopword set and the template pattern are pinned to the reference by hash and source.
19
+ - **Not changed.** Cue generation, the gate threshold, the caps and every prompt are 6.78.2's; with neither knob set, behaviour is identical.
20
+ - **Checks.** Full suite 7,776 passed, 3 skipped (507 files; the third skip is the real-vector parity test, which needs `COS_LIVE_CUES_FILTER_VECTORS`); `tsc --noEmit` clean. Mutation gate `server/scripts/live-cues-verdicts-mutation-gate.mjs` (not packaged): 131 of 131 guard mutants killed by named tests against a proven green baseline (125 tests in 6 files). Stage 1 canaries S1 to S14 proven on the packed tarball against a scratch data dir on a spare port, with a 6.78.2 build alongside for the parity rows; S11 also on the real 35-row seed of 10/19 (35 listed, 0 skipped, all ratable).
21
+
1
22
  ## 6.78.2 - Live cues: 25 a meeting instead of 10
2
23
 
3
24
  No companion or COS Control change.
package/README.md CHANGED
@@ -1195,3 +1195,11 @@ A server that boots behind a closed maintenance gate (every managed update) now
1195
1195
  - Until recovery has run, a status for an id with no saved meeting answers `503 {"reason": "recovery_pending", "retryable": true}` with `Retry-After: 5`, instead of a `missing` that would tell the phone the Mac lost the meeting.
1196
1196
  - No new chunk refusal: a chunk for a quarantined or receipted capture starts its own session, because the phone treats a 410 as "already saved" and deletes its copy. The existing 4-hour closed-session 410 still applies, now after a managed boot too. A session that resumed under a receipted id is adopted at the next boot, never split off.
1197
1197
  - Each minute, an `active-sessions/*.json` whose session is not live and has been idle for 4 hours (the earlier of its `lastActivityAt` and its file time) is moved to `data/stale-active-session-files/`, with a `.reason.json` written first. Moved files are removed 7 days after the move. Boot recovery also moves aside, rather than adopts, a file whose capture was already recovered or receipted.
1198
+
1199
+ ### Live cue log and verdicts (6.79.0)
1200
+
1201
+ Every live cue (v2) is written to `<data>/live-cues-log.jsonl` (mode 0600) so it can be rated in COS Control after the
1202
+ meeting, with v2 on or off. Retention: each row holds the cue and up to 400 characters of what was said before it; that
1203
+ excerpt is blanked after 7 days and the row is removed after 30. Ratings live in `<data>/live-cues-verdicts.json` (0600)
1204
+ and go with their cue. Neither file is written to `live-cues-trace.jsonl` or to any diagnostics export. Routes:
1205
+ `POST /api/live-cues/feedback` with `verdict`, and `GET /api/live-cues/history` (see the CHANGELOG for shapes).
@@ -30,6 +30,9 @@
30
30
  "COS_LIVE_CUES_V2_PROMPT",
31
31
  "COS_LIVE_CUES_V2_WEEKLY",
32
32
  "COS_LIVE_CUES_V2_NUMBER_GUARD",
33
+ "COS_LIVE_CUES_V2_COOLDOWN_S",
34
+ "COS_LIVE_CUES_V2_JUDGE_KEEP",
35
+ "COS_LIVE_CUES_V2_REPEAT_OVERLAP",
33
36
  "COS_LIVE_CUES_JEV_DAILY_TOKENS",
34
37
  "COS_LIVE_CUES_SUBQ_DAILY_TOKENS",
35
38
  "COS_DECISION_PROVIDER_CUES",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.78.2",
3
+ "version": "6.79.0",
4
4
  "description": "COS Glasses: self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
5
5
  "type": "module",
6
6
  "bin": {
@@ -72,6 +72,7 @@
72
72
  "!server/scripts/check-gate-anchors.mjs",
73
73
  "!server/scripts/transcript-cleanup-mutation-gate.mjs",
74
74
  "!server/scripts/live-cues-v2-notes-mutation-gate.mjs",
75
+ "!server/scripts/live-cues-verdicts-mutation-gate.mjs",
75
76
  "!server/lib/meeting-engine/__fixtures__/**",
76
77
  "!server/lib/__fixtures__/**",
77
78
  "!shared/**/*.test.ts",
@@ -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 }
@@ -6,6 +6,8 @@
6
6
  // GET /live-cues/status -> engine snapshot + capability
7
7
  // GET /live-cues/v2/settings -> { enabled, source: env|setting|default, capability } (6.76.2)
8
8
  // POST /live-cues/v2/settings { enabled } -> same shape; 409 { error: 'env_forced' } when env forces v2 on
9
+ // POST /live-cues/feedback { sessionId, cueId, verdict, note? } -> { ok, cueId, verdict, vote } (6.79.0)
10
+ // GET /live-cues/history?days=7&sessionId=a,b&unrated=1&limit=200 -> { v: 1, cues, counts, truncated, skipped, maxCuesPerMeeting, meetings? } (6.79.0)
9
11
 
10
12
  import { Router } from 'express'
11
13
  import { errMsg } from '../lib/utils.js'
@@ -16,7 +18,8 @@ import {
16
18
  LiveCuesArmError,
17
19
  } from '../lib/live-cues-engine.js'
18
20
  import { liveCuesCapability } from '../lib/live-cues-capability.js'
19
- import { LiveCuesV2ArmError, liveCuesV2, liveCuesV2Advertised, liveCuesV2Enabled, liveCuesV2SettingsView, peekLiveCuesV2, setLiveCuesV2Enabled } from '../lib/live-cues-v2.js'
21
+ import { LIVE_CUES_V2_LIMITS, LiveCuesV2ArmError, liveCuesV2, liveCuesV2Advertised, liveCuesV2Enabled, liveCuesV2MeetingCounters, liveCuesV2SettingsView, peekLiveCuesV2, setLiveCuesV2Enabled } from '../lib/live-cues-v2.js'
22
+ import { LIVE_CUE_VERDICTS_PROTOCOL, LIVE_CUE_VERDICT_LIMITS, VerdictsUnreadableError, isCueVerdict, liveCueVerdicts } from '../lib/live-cue-verdicts.js'
20
23
 
21
24
  const SESSION_ID = /^[A-Za-z0-9:_-]{3,96}$/
22
25
  const CUE_ID = /^[A-Za-z0-9-]{8,64}$/
@@ -80,6 +83,8 @@ liveCuesRouter.get('/live-cues/status', (req, res) => {
80
83
  capability: liveCuesCapability(),
81
84
  sessions: getLiveCuesStatus(),
82
85
  ...(liveCuesV2Advertised() ? { v2: liveCuesV2Advertised() } : {}),
86
+ // 6.79.0: always, so Control can rate past cues with v2 switched off.
87
+ verdicts: { protocol: LIVE_CUE_VERDICTS_PROTOCOL },
83
88
  })
84
89
  })
85
90
 
@@ -95,17 +100,87 @@ liveCuesRouter.post('/live-cues/pause', (req, res) => {
95
100
  return res.json(liveCuesV2().setPaused(sessionId, paused, seq as number | undefined))
96
101
  })
97
102
 
98
- // POST /live-cues/feedback { sessionId, cueId, vote: 'up'|'down', reason? } -> { ok, knownCue }
103
+ // POST /live-cues/feedback { sessionId, cueId, vote: 'up'|'down', reason? } -> { ok, knownCue } (the lens; unchanged)
104
+ // POST /live-cues/feedback { sessionId, cueId, verdict, note? } -> { ok, cueId, verdict, vote } (COS Control, 6.79.0)
99
105
  liveCuesRouter.post('/live-cues/feedback', (req, res) => {
100
- const body = (req.body ?? {}) as { cueId?: unknown; vote?: unknown; reason?: unknown }
106
+ const body = (req.body ?? {}) as { cueId?: unknown; vote?: unknown; reason?: unknown; verdict?: unknown; note?: unknown }
101
107
  const sessionId = bodySessionId(req.body)
102
108
  const cueId = typeof body.cueId === 'string' ? body.cueId.trim() : ''
109
+ if (body.verdict !== undefined) {
110
+ // Exactly one of vote and verdict: a body carrying both is ambiguous, so neither is applied.
111
+ if (body.vote !== undefined || !SESSION_ID.test(sessionId) || !CUE_ID.test(cueId)
112
+ || !(isCueVerdict(body.verdict) || body.verdict === 'clear') || (body.note !== undefined && typeof body.note !== 'string')) {
113
+ return res.status(400).json({ error: 'sessionId, cueId and verdict (useful|late|repeat|wrong|obvious|clear) required, without vote', reason: 'invalid_request' })
114
+ }
115
+ // Before the v2 gate: last week's cues are rated from the log. peek, never liveCuesV2(): a v2-off server must not build
116
+ // the engine (it constructs the Jev and SubQ clients) just to answer a rating.
117
+ try {
118
+ const result = liveCueVerdicts().rate({ sessionId, cueId, verdict: body.verdict, note: body.note as string | undefined }, liveCueOwner(cueId))
119
+ if (!result.ok && result.error === 'unknown_cue') return res.status(404).json({ error: 'unknown cue', reason: result.error })
120
+ if (!result.ok) return res.status(409).json({ error: 'this cue belongs to another meeting', reason: result.error })
121
+ return res.json(result.response)
122
+ } catch (error) {
123
+ if (error instanceof VerdictsUnreadableError) return res.status(503).json({ error: 'the verdicts file is unreadable', reason: error.code })
124
+ return res.status(500).json({ error: 'verdict_write_failed' })
125
+ }
126
+ }
103
127
  if (!SESSION_ID.test(sessionId) || !CUE_ID.test(cueId) || (body.vote !== 'up' && body.vote !== 'down')
104
128
  || (body.reason !== undefined && typeof body.reason !== 'string')) {
105
129
  return res.status(400).json({ error: 'sessionId, cueId and vote (up|down) required', reason: 'invalid_request' })
106
130
  }
107
131
  if (!liveCuesV2Enabled()) return res.status(404).json({ error: 'live cues v2 is off', reason: 'v2_disabled' })
108
- return res.json(liveCuesV2().recordFeedback({ sessionId, cueId, vote: body.vote, reason: body.reason as string | undefined }))
132
+ const result = liveCuesV2().recordFeedback({ sessionId, cueId, vote: body.vote, reason: body.reason as string | undefined })
133
+ // Kept beside any verdict, never over it, for a cue known under this meeting. Best effort: the lens answer is the same.
134
+ try { liveCueVerdicts().vote(sessionId, cueId, body.vote, liveCueOwner(cueId)) } catch (error) { console.warn(`[live-cues] lens vote not stored: ${errMsg(error)}`) }
135
+ return res.json(result)
136
+ })
137
+
138
+ /** The meeting the running engine holds this cue for, if an engine exists. Never creates one. */
139
+ function liveCueOwner(cueId: string): string | null {
140
+ return peekLiveCuesV2()?.findCue(cueId)?.sessionId ?? null
141
+ }
142
+
143
+ // GET /live-cues/history?days=7&sessionId=a,b&unrated=1&limit=200 (6.79.0). Oldest first, the newest `limit` rows; counts
144
+ // cover the window after the sessionId filter. Not gated on the v2 switch, and no maintenance lease (a 15 s poll must
145
+ // never hold up an update drain). `meetings` (only with sessionId) are the durable counters, so Control never derives them.
146
+ function intParam(raw: unknown, fallback: number, min: number, max: number): number | null {
147
+ if (raw === undefined) return fallback
148
+ if (typeof raw !== 'string' || !/^\d{1,4}$/.test(raw)) return null
149
+ const n = Number(raw)
150
+ return n >= min && n <= max ? n : null
151
+ }
152
+ /** Absent or empty: no filter ([]). Else up to 5 comma-separated ids (a saved meeting can hold several captures). */
153
+ function sessionIdsParam(raw: unknown): string[] | null {
154
+ if (raw === undefined) return []
155
+ if (typeof raw !== 'string') return null
156
+ if (!raw.trim()) return []
157
+ const ids = [...new Set(raw.split(',').map(x => x.trim()))]
158
+ return ids.length <= LIVE_CUE_VERDICT_LIMITS.maxSessionIds && ids.every(id => SESSION_ID.test(id)) ? ids : null
159
+ }
160
+ liveCuesRouter.get('/live-cues/history', (req, res) => {
161
+ res.set('Cache-Control', 'private, no-store')
162
+ const L = LIVE_CUE_VERDICT_LIMITS
163
+ const days = intParam(req.query.days, L.defaultDays, 1, L.maxDays)
164
+ const limit = intParam(req.query.limit, L.defaultLimit, 1, L.maxLimit)
165
+ const sessionIds = sessionIdsParam(req.query.sessionId)
166
+ const unrated = req.query.unrated
167
+ if (days === null || limit === null || sessionIds === null || (unrated !== undefined && unrated !== '1' && unrated !== '0')) {
168
+ return res.status(400).json({ error: 'days 1..30, limit 1..1000, unrated 0|1 and up to 5 valid sessionIds', reason: 'invalid_request' })
169
+ }
170
+ try {
171
+ const verdicts = liveCueVerdicts()
172
+ const history = verdicts.history({ days, limit, unrated: unrated === '1', ...(sessionIds.length ? { sessionIds } : {}) })
173
+ const C = LIVE_CUES_V2_LIMITS
174
+ const meetings = sessionIds.length ? Object.fromEntries(sessionIds.map(id => {
175
+ // The engine's counters expire 24 h after a meeting; past that the log's count stands in and writerCalls is unknown.
176
+ const counters = liveCuesV2MeetingCounters(id)
177
+ return [id, { cues: counters?.cues ?? verdicts.loggedCues(id), cap: C.maxCuesPerMeeting, writerCalls: counters?.writerCalls ?? null, writerCap: C.maxWriterCallsPerMeeting }]
178
+ })) : undefined
179
+ return res.json({ ...history, maxCuesPerMeeting: C.maxCuesPerMeeting, ...(meetings ? { meetings } : {}) })
180
+ } catch (error) {
181
+ if (error instanceof VerdictsUnreadableError) return res.status(503).json({ error: 'the verdicts file is unreadable', reason: error.code })
182
+ return res.status(500).json({ error: 'history_unavailable' })
183
+ }
109
184
  })
110
185
 
111
186
  // COS Control's live cues v2 toggle (6.76.2). Token-authenticated like every /api route. The setting lives in this server's