@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.
- package/CHANGELOG.md +75 -0
- package/README.md +28 -0
- package/managed-runtime-contract.json +10 -1
- package/package.json +4 -1
- package/server/lib/dictation-clean.ts +64 -11
- package/server/lib/dictation-fillers.ts +109 -0
- package/server/lib/dictation-polish-budget.ts +87 -0
- package/server/lib/live-cue-verdicts.ts +413 -0
- package/server/lib/live-cues-filter.ts +72 -0
- package/server/lib/live-cues-v2.ts +74 -2
- package/server/routes/live-cues.ts +79 -4
- package/server/routes/prompt-drafts.ts +27 -33
|
@@ -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 <
|
|
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) >=
|
|
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 }
|