@gotcos/glasses-server 6.94.4 → 6.98.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.
Files changed (109) hide show
  1. package/CHANGELOG.md +147 -0
  2. package/README.md +52 -0
  3. package/managed-runtime-contract.json +13 -0
  4. package/package.json +3 -1
  5. package/server/index.ts +19 -1
  6. package/server/lib/attached-provider-adapter.ts +35 -1
  7. package/server/lib/claude-bridge.ts +47 -0
  8. package/server/lib/codex-bridge.ts +11 -1
  9. package/server/lib/codex-rollout-watermark.ts +42 -0
  10. package/server/lib/cursor-bridge.ts +2 -1
  11. package/server/lib/dictation-clean-models.ts +2 -0
  12. package/server/lib/dictation-clean.ts +2 -2
  13. package/server/lib/dictation-polish-pipeline.ts +22 -2
  14. package/server/lib/dictation-prewarm.ts +16 -0
  15. package/server/lib/direct-bridge.ts +14 -2
  16. package/server/lib/direct-sse.ts +6 -1
  17. package/server/lib/display-bus.ts +1 -1
  18. package/server/lib/fork-thread.ts +32 -1
  19. package/server/lib/held-naming-batches.ts +36 -0
  20. package/server/lib/job-audit-caller.ts +20 -0
  21. package/server/lib/lens-gist-engines.ts +17 -5
  22. package/server/lib/live-cos-ask.ts +29 -6
  23. package/server/lib/live-cue-verdicts.ts +43 -8
  24. package/server/lib/live-cues-engine.ts +4 -1
  25. package/server/lib/live-cues-filter.ts +50 -0
  26. package/server/lib/live-cues-general-prompts.ts +137 -0
  27. package/server/lib/live-cues-general-topic.ts +245 -0
  28. package/server/lib/live-cues-general.ts +715 -0
  29. package/server/lib/live-cues-v2-data.ts +453 -0
  30. package/server/lib/live-cues-v2-grounded.ts +262 -0
  31. package/server/lib/live-cues-v2-judge-b.ts +59 -0
  32. package/server/lib/live-cues-v2-names.ts +49 -0
  33. package/server/lib/live-cues-v2-notes.ts +8 -4
  34. package/server/lib/live-cues-v2-open-items.ts +132 -0
  35. package/server/lib/live-cues-v2-pacing.ts +45 -0
  36. package/server/lib/live-cues-v2-prompts-kinds.ts +9 -4
  37. package/server/lib/live-cues-v2-settings.ts +89 -10
  38. package/server/lib/live-cues-v2.ts +570 -47
  39. package/server/lib/mac-recording-asr.ts +6 -3
  40. package/server/lib/mac-speaker-call-human.ts +371 -0
  41. package/server/lib/mac-speaker-call-interval-human.ts +499 -0
  42. package/server/lib/mac-speaker-call-interval-projection.ts +233 -0
  43. package/server/lib/mac-speaker-call-original-view.ts +328 -0
  44. package/server/lib/mac-speaker-call-producer-worker.ts +615 -0
  45. package/server/lib/mac-speaker-call-producer.ts +539 -0
  46. package/server/lib/mac-speaker-call-store.ts +264 -0
  47. package/server/lib/mac-speaker-call-view.ts +373 -0
  48. package/server/lib/mac-speaker-live-compute.ts +72 -0
  49. package/server/lib/mac-speaker-live-memory.ts +414 -0
  50. package/server/lib/mac-speaker-live-projection.ts +126 -0
  51. package/server/lib/mac-speaker-live-save.ts +90 -0
  52. package/server/lib/mac-speaker-live-store.ts +71 -0
  53. package/server/lib/mac-speaker-live-worker.ts +58 -0
  54. package/server/lib/mac-speaker-live.ts +175 -0
  55. package/server/lib/mac-speaker-profile-extractor.ts +15 -10
  56. package/server/lib/mac-speaker-shadow-repair.ts +307 -0
  57. package/server/lib/mac-voice-assignment.ts +44 -26
  58. package/server/lib/mac-voice-display.ts +195 -0
  59. package/server/lib/mac-voice-naming-batch.ts +1067 -0
  60. package/server/lib/mac-voice-suggestions.ts +548 -0
  61. package/server/lib/maintenance-lifecycle.ts +2 -0
  62. package/server/lib/media-store.ts +2 -1
  63. package/server/lib/meeting-ask-pasted.ts +135 -0
  64. package/server/lib/meeting-ask-review.ts +8 -1
  65. package/server/lib/meeting-ask-runner.ts +4 -1
  66. package/server/lib/meeting-ask-runtime.ts +189 -17
  67. package/server/lib/meeting-ask-store.ts +2 -0
  68. package/server/lib/meeting-ask-web.ts +284 -13
  69. package/server/lib/meeting-batch-progress.ts +51 -5
  70. package/server/lib/meeting-batch-transcribe.ts +35 -2
  71. package/server/lib/meeting-fact-check.ts +3 -0
  72. package/server/lib/meeting-finalization-jobs.ts +69 -2
  73. package/server/lib/meeting-public-topic.ts +6 -1
  74. package/server/lib/meeting-speaker-review.ts +182 -17
  75. package/server/lib/meeting-store.ts +6 -1
  76. package/server/lib/parakeet.ts +51 -5
  77. package/server/lib/pending-batch-retention.ts +58 -0
  78. package/server/lib/private-reference.ts +38 -0
  79. package/server/lib/provider-usage.ts +561 -0
  80. package/server/lib/public-https-url.ts +32 -0
  81. package/server/lib/query-job-runtime.ts +3 -0
  82. package/server/lib/rich-media-safety.ts +23 -2
  83. package/server/lib/speaker-shadow-decide.ts +503 -0
  84. package/server/lib/speaker-shadow-models.ts +150 -0
  85. package/server/lib/speaker-shadow-numeric.ts +143 -0
  86. package/server/lib/speaker-shadow-pair-source.ts +91 -0
  87. package/server/lib/speaker-shadow-paired.ts +268 -0
  88. package/server/lib/speaker-shadow-report.ts +113 -0
  89. package/server/lib/speaker-shadow-timeline.ts +210 -0
  90. package/server/lib/speaker-shadow-worker.ts +312 -0
  91. package/server/lib/speaker-shadow.ts +788 -0
  92. package/server/lib/token-audit.ts +36 -6
  93. package/server/lib/transcript-cleanup.ts +3 -2
  94. package/server/lib/voice-directory.ts +8 -4
  95. package/server/lib/web-lookup-ledger.ts +151 -0
  96. package/server/lib/work-review-runtime.ts +2 -0
  97. package/server/lib/work-search.ts +3 -1
  98. package/server/lib/xlsx-text.ts +277 -0
  99. package/server/routes/health.ts +5 -0
  100. package/server/routes/live-cues.ts +36 -14
  101. package/server/routes/meeting.ts +506 -49
  102. package/server/routes/speaker-shadow.ts +79 -0
  103. package/server/routes/transcribe-stream.ts +114 -33
  104. package/server/routes/voice.ts +3 -0
  105. package/server/scripts/live-cues-v3-mutation-gate.mjs +311 -0
  106. package/server/scripts/mac-voice-assignment-6930-mutation-gate.mjs +5 -5
  107. package/shared/live-cues-v2.ts +38 -5
  108. package/shared/media-attachment.ts +2 -0
  109. package/shared/meeting-ask.ts +11 -0
@@ -8,12 +8,17 @@ import { homedir } from 'node:os'
8
8
 
9
9
  import { COS_SCRIPTS_DIR } from './python-bridge.js'
10
10
  import type { TokenAuditKeyFields } from './key-relay-contract.js'
11
+ import { cleanProviderId, normalizeRealUsage, type RealUsage } from './provider-usage.js'
11
12
 
12
13
  // Shared JSONL file — same path as Python's llm_client.py uses
13
14
  // Uses COS_SCRIPTS_DIR when available (server mode), falls back to user home
14
- const AUDIT_FILE = COS_SCRIPTS_DIR
15
- ? resolve(COS_SCRIPTS_DIR, '.cos_token_audit.jsonl')
16
- : resolve(homedir(), '.cos-glasses', 'token-audit.jsonl')
15
+ // 6.97.0: COS_TOKEN_AUDIT_FILE is set only by the test config, so no test writes a made-up row to a
16
+ // real ledger now that a row can claim to be measured.
17
+ const AUDIT_FILE = process.env.COS_TOKEN_AUDIT_FILE
18
+ ? resolve(process.env.COS_TOKEN_AUDIT_FILE)
19
+ : COS_SCRIPTS_DIR
20
+ ? resolve(COS_SCRIPTS_DIR, '.cos_token_audit.jsonl')
21
+ : resolve(homedir(), '.cos-glasses', 'token-audit.jsonl')
17
22
 
18
23
  // Rough char-to-token ratio (1 token ~ 4 chars for English)
19
24
  const CHARS_PER_TOKEN = 4
@@ -33,7 +38,12 @@ export interface TokenAuditEntry extends TokenAuditKeyFields {
33
38
  runId?: string
34
39
  sessionId?: string
35
40
  clientJobId?: string
36
- usageKind?: 'provider_final' | 'estimated'
41
+ /** The provider's own session id: Claude's session, Codex's thread, Cursor's chat. `sessionId` is COS's. */
42
+ cliSessionId?: string
43
+ /** 6.97.0: what the provider reported. The row says `provider_final` only when these are real numbers. */
44
+ realUsage?: RealUsage
45
+ /** 6.97.0, dictation polish only: what the model is sent, system prompt included. `inputChars` stays the words dictated. */
46
+ promptChars?: number
37
47
  /** 6.85.0, dictation polish only: whether the user's polish instructions were in the prompt, and their hash. */
38
48
  polishInstructions?: { on: boolean; hash: string }
39
49
  /** 6.91.0, dictation polish only: whether the learned style was in the prompt, and its hash. */
@@ -56,6 +66,8 @@ export interface TokenAuditEntry extends TokenAuditKeyFields {
56
66
  * (subscription) row is byte-identical to 6.79.0. Callers set them only on keyed turns.
57
67
  */
58
68
  export function buildTokenAuditRecord(entry: TokenAuditEntry, now: Date = new Date()): Record<string, unknown> {
69
+ const real = normalizeRealUsage(entry.realUsage)
70
+ const cliSessionId = cleanProviderId(entry.cliSessionId)
59
71
  return {
60
72
  ts: now.toISOString(),
61
73
  source: entry.source,
@@ -66,7 +78,7 @@ export function buildTokenAuditRecord(entry: TokenAuditEntry, now: Date = new Da
66
78
  est_output_tokens: estimateTokens(entry.outputChars),
67
79
  duration_ms: entry.durationMs,
68
80
  caller: entry.caller,
69
- usage_kind: entry.usageKind ?? 'estimated',
81
+ usage_kind: real ? 'provider_final' : 'estimated',
70
82
  ...(entry.turnId ? { turn_id: entry.turnId } : {}),
71
83
  ...(entry.runId ? { run_id: entry.runId } : {}),
72
84
  ...(entry.sessionId ? { session_id: entry.sessionId } : {}),
@@ -91,12 +103,30 @@ export function buildTokenAuditRecord(entry: TokenAuditEntry, now: Date = new Da
91
103
  ...(entry.polishGuard ? { polish_guard: entry.polishGuard } : {}),
92
104
  // 6.92.0: set only by the prompt-edit caller.
93
105
  ...(entry.promptEditOutcome ? { prompt_edit_outcome: entry.promptEditOutcome } : {}),
106
+ // 6.97.0: last, and only when set, so a row without them is byte-identical to 6.96.4. Numbers and
107
+ // ids only. COS Control reads these names. All four counts are always written. input_tokens never
108
+ // includes cache reads; it excludes cache writes only on Claude, the one provider that reports
109
+ // them, so on Codex and direct cache_write_tokens is 0 and any write stays in input_tokens.
110
+ // resolved_model is the CLI's word on Claude, and what COS asked for elsewhere. provider-usage.ts
111
+ // says exactly what each means. cache_write_1h_tokens is part of cache_write_tokens; it and cost_usd
112
+ // appear only when stated.
113
+ ...(typeof entry.promptChars === 'number' ? { prompt_chars: entry.promptChars } : {}),
114
+ ...(cliSessionId ? { cli_session_id: cliSessionId } : {}),
115
+ ...(real ? {
116
+ input_tokens: real.inputTokens,
117
+ output_tokens: real.outputTokens,
118
+ cache_read_tokens: real.cacheReadTokens ?? 0,
119
+ cache_write_tokens: real.cacheWriteTokens ?? 0,
120
+ ...(real.cacheWrite1hTokens !== undefined ? { cache_write_1h_tokens: real.cacheWrite1hTokens } : {}),
121
+ ...(real.costUsd !== undefined ? { cost_usd: real.costUsd } : {}),
122
+ ...(real.resolvedModel ? { resolved_model: real.resolvedModel } : {}),
123
+ } : {}),
94
124
  }
95
125
  }
96
126
 
97
127
  export function logTokenAudit(entry: TokenAuditEntry): void {
98
- const record = buildTokenAuditRecord(entry)
99
128
  try {
129
+ const record = buildTokenAuditRecord(entry)
100
130
  appendFileSync(AUDIT_FILE, JSON.stringify(record) + '\n', { encoding: 'utf8', mode: 0o600 })
101
131
  chmodSync(AUDIT_FILE, 0o600)
102
132
  } catch {
@@ -169,8 +169,9 @@ const notesSearchDefault: CleanupDeps['searchNotes'] = async (queries, sessionId
169
169
 
170
170
  const filesDefault: CleanupDeps['files'] = async sessionId => {
171
171
  try {
172
- const { MeetingAskStore } = await import('./meeting-ask-store.js')
173
- return new MeetingAskStore().files(sessionId).filter(f => f.status === 'ready' && typeof f.text === 'string' && f.text.trim()).map(f => ({ name: f.name, text: f.text }))
172
+ const { MeetingAskStore, isPastedFileId } = await import('./meeting-ask-store.js')
173
+ // Pasted text and read pages (paste_, web_) are article words, not the room's vocabulary: they must not rewrite the saved transcript.
174
+ return new MeetingAskStore().files(sessionId).filter(f => !isPastedFileId(f.id) && f.status === 'ready' && typeof f.text === 'string' && f.text.trim()).map(f => ({ name: f.name, text: f.text }))
174
175
  } catch { return [] }
175
176
  }
176
177
 
@@ -14,6 +14,7 @@ import { lstat, readFile, readdir, realpath } from 'node:fs/promises'
14
14
  import { basename, dirname, join } from 'node:path'
15
15
  import { setImmediate as yieldToEventLoop } from 'node:timers/promises'
16
16
  import { readMacSpeakerMachineEvidence } from './mac-speaker-machine-evidence.js'
17
+ import { readMacSpeakerCallView } from './mac-speaker-call-view.js'
17
18
  import { dataPath } from './data-dir.js'
18
19
  import { confirmedLabels, confirmedChunks } from './meeting-corrections.js'
19
20
  import { resolveCosOperationsDir, resolveMeetingLibrary } from './cos-operations-meetings.js'
@@ -278,7 +279,8 @@ export async function buildVoiceDirectorySnapshot(): Promise<VoiceDirectorySnaps
278
279
  continue
279
280
  }
280
281
  totalBytes += link.size
281
- const raw = JSON.parse(await readFile(real, 'utf8')) as Record<string, unknown> | ReviewChunk[]
282
+ const baseJSON = await readFile(real, 'utf8')
283
+ const raw = JSON.parse(baseJSON) as Record<string, unknown> | ReviewChunk[]
282
284
  const chunks = Array.isArray(raw) ? raw : raw.chunks
283
285
  const sessionId = Array.isArray(raw) ? '' : String(raw.sessionId ?? '')
284
286
  if (!Array.isArray(chunks) || !SESSION.test(sessionId) || seenSessions.has(sessionId)) {
@@ -297,7 +299,9 @@ export async function buildVoiceDirectorySnapshot(): Promise<VoiceDirectorySnaps
297
299
  )
298
300
 
299
301
  const machineEvidence = readMacSpeakerMachineEvidence(record)
300
- const review = reviewMeetingSpeakers(chunks as ReviewChunk[], {
302
+ const callView = readMacSpeakerCallView(real, baseJSON, candidate.markdownPath)
303
+ const review = reviewMeetingSpeakers((callView.doc.chunks ?? chunks) as ReviewChunk[], {
304
+ callViewAnonymousLabels: callView.metadata.groups.map(g => g.label),
301
305
  owner,
302
306
  phrasesPerVoice: 1,
303
307
  confirmed: confirmedLabels(sessionId),
@@ -305,8 +309,8 @@ export async function buildVoiceDirectorySnapshot(): Promise<VoiceDirectorySnaps
305
309
  machineQualifiedPositions: machineEvidence.positions,
306
310
  machineEvidenceStatus: machineEvidence.status,
307
311
  durationMs: typeof record.durationMs === 'number' ? record.durationMs : undefined,
308
- batchSegments: Array.isArray(record.batchSegments)
309
- ? record.batchSegments as SpeakerWordSegment[]
312
+ batchSegments: Array.isArray(callView.doc.batchSegments)
313
+ ? callView.doc.batchSegments as SpeakerWordSegment[]
310
314
  : undefined,
311
315
  })
312
316
  meetingsScanned++
@@ -0,0 +1,151 @@
1
+ // An honest daily count of every public web lookup COS makes, by feature, and of every lookup it held back and why.
2
+ //
3
+ // WHY. The settings line "Today: N topic checks, N web searches" counts only the general cues. The meeting assistant and the
4
+ // fact check also search and read pages, and nothing counted them, so the number read as the whole story when it was a part.
5
+ // This ledger is written where each lookup actually happens (searchMeetingWeb for a search, the page reader for a page, the
6
+ // gate that refuses for a withheld one), never inferred from a result or a log.
7
+ //
8
+ // It is a LEDGER, not a gate. No code path waits on it or is refused by it, and recording never throws: a lookup that cannot
9
+ // be counted still runs. The file is local-day keyed like the general cue state (same zone and rollover rule): a new local day
10
+ // starts from zero, a day in the future (a clock set back) is not trusted. A file that cannot be read says so in the view
11
+ // (`readable: false`) instead of reading as zero, and a corrupt one is moved aside so counting resumes the next time.
12
+ import { readFileSync, mkdirSync, renameSync } from 'node:fs'
13
+ import { dirname } from 'node:path'
14
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
15
+ import { dataPath } from './data-dir.js'
16
+
17
+ export type WebFeature = 'cues' | 'ask' | 'factCheck'
18
+ export const WEB_FEATURES: readonly WebFeature[] = ['cues', 'ask', 'factCheck']
19
+ /** Distinct withheld reasons kept per feature; the rest count under 'other' so a bad caller cannot grow the file. */
20
+ export const WEB_LEDGER_MAX_REASONS = 40
21
+
22
+ /** `pagesRead` is pages whose text was used; `pagesFailed` is page fetches that were attempted and returned nothing (blocked, too big, not an article, too slow). */
23
+ export interface WebFeatureToday { searches: number; pagesRead: number; pagesFailed: number }
24
+ export interface WebTodayView {
25
+ day: string
26
+ /** False when the file could not be read: the counts below are then zero and do not describe today. */
27
+ readable: boolean
28
+ cues: WebFeatureToday
29
+ ask: WebFeatureToday
30
+ factCheck: WebFeatureToday
31
+ withheld: { total: number; byReason: Record<string, number>; byFeature: Record<WebFeature, number> }
32
+ }
33
+
34
+ interface FeatureState { searches: number; pagesRead: number; pagesFailed: number; withheld: Record<string, number> }
35
+ interface LedgerState { v: 1; zone: string; day: string; features: Record<WebFeature, FeatureState> }
36
+ type LedgerRead = { ok: true; state: LedgerState } | { ok: false; future: boolean }
37
+
38
+ const isRecord = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
39
+ const whole = (v: unknown): v is number => typeof v === 'number' && Number.isSafeInteger(v) && v >= 0
40
+ function validZone(zone: unknown): string | null {
41
+ if (typeof zone !== 'string' || !zone) return null
42
+ try { new Intl.DateTimeFormat('en-CA', { timeZone: zone }); return zone } catch { return null }
43
+ }
44
+ /** Local calendar day in `zone`. Intl, never toISOString: a UTC key flips the day at 19:00 Central (the rule live-cues-general.ts follows). */
45
+ function localDay(ms: number, zone: string): string {
46
+ return new Intl.DateTimeFormat('en-CA', { timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(ms))
47
+ }
48
+ const emptyFeature = (): FeatureState => ({ searches: 0, pagesRead: 0, pagesFailed: 0, withheld: {} })
49
+ const REASON = /^[a-z0-9][a-z0-9_:.-]{0,47}$/
50
+
51
+ export class WebLookupLedger {
52
+ constructor(private readonly file: string, private readonly now: () => number = Date.now) {}
53
+ private systemZone(): string { return validZone(Intl.DateTimeFormat().resolvedOptions().timeZone) ?? 'UTC' }
54
+ private fresh(nowMs: number): LedgerState {
55
+ const zone = this.systemZone()
56
+ return { v: 1, zone, day: localDay(nowMs, zone), features: { cues: emptyFeature(), ask: emptyFeature(), factCheck: emptyFeature() } }
57
+ }
58
+ private read(): LedgerRead {
59
+ const nowMs = this.now()
60
+ let raw: unknown
61
+ try { raw = JSON.parse(readFileSync(this.file, 'utf8')) } catch (e) {
62
+ if ((e as NodeJS.ErrnoException)?.code === 'ENOENT') return { ok: true, state: this.fresh(nowMs) }
63
+ return { ok: false, future: false }
64
+ }
65
+ if (!isRecord(raw) || raw.v !== 1 || !isRecord(raw.features)) return { ok: false, future: false }
66
+ const zone = validZone(raw.zone), today = zone ? localDay(nowMs, zone) : ''
67
+ if (!zone || typeof raw.day !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(raw.day)) return { ok: false, future: false }
68
+ if (raw.day > today) return { ok: false, future: true }
69
+ const rolled = raw.day < today
70
+ const features = {} as Record<WebFeature, FeatureState>
71
+ for (const f of WEB_FEATURES) {
72
+ const s = raw.features[f]
73
+ // pagesFailed arrived after the first files were written: a file without it reads as zero.
74
+ if (!isRecord(s) || !whole(s.searches) || !whole(s.pagesRead) || !(s.pagesFailed === undefined || whole(s.pagesFailed)) || !isRecord(s.withheld)) return { ok: false, future: false }
75
+ const withheld: Record<string, number> = {}
76
+ for (const [reason, n] of Object.entries(s.withheld)) {
77
+ if (!whole(n) || !REASON.test(reason)) return { ok: false, future: false }
78
+ withheld[reason] = n
79
+ }
80
+ features[f] = rolled ? emptyFeature() : { searches: s.searches, pagesRead: s.pagesRead, pagesFailed: (s.pagesFailed as number | undefined) ?? 0, withheld }
81
+ }
82
+ return { ok: true, state: { v: 1, zone, day: today, features } }
83
+ }
84
+ private write(s: LedgerState): boolean {
85
+ try {
86
+ mkdirSync(dirname(this.file), { recursive: true, mode: 0o700 })
87
+ durableAtomicWriteFileSync(this.file, JSON.stringify(s) + '\n', { mode: 0o600 })
88
+ return true
89
+ } catch { return false }
90
+ }
91
+ /** One read-modify-write. A file that cannot be parsed is moved aside once so counting resumes; a future day is left alone
92
+ * (the day catches up) and nothing is counted until then. Never throws. */
93
+ private update(change: (s: LedgerState) => void): boolean {
94
+ try {
95
+ let r = this.read()
96
+ if (!r.ok && !r.future) {
97
+ try { renameSync(this.file, `${this.file}.unreadable`) } catch { return false }
98
+ r = this.read()
99
+ }
100
+ if (!r.ok) return false
101
+ change(r.state)
102
+ return this.write(r.state)
103
+ } catch { return false }
104
+ }
105
+ /** One web search is starting now. */
106
+ search(feature: WebFeature): boolean { return this.update(s => { s.features[feature].searches += 1 }) }
107
+ /** `count` result or pasted pages were read. */
108
+ pagesRead(feature: WebFeature, count = 1): boolean {
109
+ if (!whole(count) || count === 0) return false
110
+ return this.update(s => { s.features[feature].pagesRead += count })
111
+ }
112
+ /** `count` page fetches were attempted and returned nothing. */
113
+ pagesFailed(feature: WebFeature, count = 1): boolean {
114
+ if (!whole(count) || count === 0) return false
115
+ return this.update(s => { s.features[feature].pagesFailed += count })
116
+ }
117
+ /** One lookup was proposed and refused, for `reason` (a gate reason code such as no_public_intent). */
118
+ withheld(feature: WebFeature, reason: string): boolean {
119
+ const key = REASON.test(reason) ? reason : 'other'
120
+ return this.update(s => {
121
+ const w = s.features[feature].withheld
122
+ const name = key in w || Object.keys(w).length < WEB_LEDGER_MAX_REASONS ? key : 'other'
123
+ w[name] = (w[name] ?? 0) + 1
124
+ })
125
+ }
126
+ today(): WebTodayView {
127
+ const r = this.read()
128
+ const zeros = (): WebTodayView => ({ day: localDay(this.now(), this.systemZone()), readable: false, cues: { searches: 0, pagesRead: 0, pagesFailed: 0 }, ask: { searches: 0, pagesRead: 0, pagesFailed: 0 }, factCheck: { searches: 0, pagesRead: 0, pagesFailed: 0 },
129
+ withheld: { total: 0, byReason: {}, byFeature: { cues: 0, ask: 0, factCheck: 0 } } })
130
+ if (!r.ok) return zeros()
131
+ const f = r.state.features
132
+ const byReason: Record<string, number> = {}
133
+ const byFeature = {} as Record<WebFeature, number>
134
+ for (const name of WEB_FEATURES) {
135
+ byFeature[name] = Object.values(f[name].withheld).reduce((a, b) => a + b, 0)
136
+ for (const [reason, n] of Object.entries(f[name].withheld)) byReason[reason] = (byReason[reason] ?? 0) + n
137
+ }
138
+ const view = (s: FeatureState): WebFeatureToday => ({ searches: s.searches, pagesRead: s.pagesRead, pagesFailed: s.pagesFailed })
139
+ return { day: r.state.day, readable: true, cues: view(f.cues), ask: view(f.ask), factCheck: view(f.factCheck),
140
+ withheld: { total: WEB_FEATURES.reduce((n, name) => n + byFeature[name], 0), byReason, byFeature } }
141
+ }
142
+ }
143
+
144
+ let shared: WebLookupLedger | null = null
145
+ /** The server's one ledger, in the data dir. Created on first use so importing this file touches nothing. */
146
+ export function webLedger(): WebLookupLedger {
147
+ if (!shared) shared = new WebLookupLedger(dataPath('web-lookup-ledger.json'))
148
+ return shared
149
+ }
150
+ /** Tests only: replace (or clear, with null) the shared ledger. */
151
+ export function setWebLedgerForTests(ledger: WebLookupLedger | null): void { shared = ledger }
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto'
2
+ import { noteWorkReviewJob } from './job-audit-caller.js'
2
3
  import type { MeetingDetail } from './meeting-store.js'
3
4
  import type { QueryJobSnapshot } from './query-job-types.js'
4
5
  import { WorkReviewError, WorkReviewStore, parseMeetingDescriptor, publicReview, reviewHash,
@@ -143,6 +144,7 @@ export class WorkReviewRuntime {
143
144
  const request = { clientJobId: row.clientJobId, generation: 1, query: row.prompt, model: row.model,
144
145
  messageEra: row.messageEra, activityToolMode: 'status', cursorExecutionMode: 'ask',
145
146
  ...(row.provider === 'claude' ? { dispatch: { restricted: true, tools: ['Read','Grep','Glob'] } } : {}) }
147
+ noteWorkReviewJob(row.clientJobId)
146
148
  job = (await this.deps.jobs.submit(request)).job
147
149
  }
148
150
  if (!job) {
@@ -131,9 +131,11 @@ export function searchOptionText(text: string): string {
131
131
  * drain for an update or restart would wait up to 20 s (the Jev timeout) for a search, and a drain would answer every
132
132
  * search 503. Outside it, a restart mid-search loses at most that search's token count.
133
133
  * Live COS Ask is also read-only and cancellable, with no durable tasks or conversation writes.
134
+ * Voice suggestions (6.96.3) write nothing, answer at once and run detached. The route checks admissions itself so a
135
+ * drain answers its own `refused` state rather than a 503, and a running job stops at its next unit when a drain starts.
134
136
  */
135
137
  export function isReadOnlyApiPost(method: string, path: string): boolean {
136
- return method === 'POST' && (path === '/work/search' || path === '/meetings/live-ask')
138
+ return method === 'POST' && (path === '/work/search' || path === '/meetings/live-ask' || /^\/meeting\/[A-Za-z0-9:_-]{3,96}\/voice-suggestions$/.test(path))
137
139
  }
138
140
 
139
141
  /** A query's length as people count it: code points, not UTF-16 units, so 150 emoji are 150 (Control counts characters). */
@@ -0,0 +1,277 @@
1
+ // Plain text from an .xlsx workbook, for meeting context. No dependency: an .xlsx is a zip of XML parts, read here with
2
+ // node:zlib and a small bounded zip reader.
3
+ //
4
+ // What comes out. For each sheet, in workbook order: a "Sheet: <name>" line, then each non-empty row as tab-separated cell
5
+ // values. Shared strings are resolved. NO FORMULA IS EVALUATED: a formula cell shows the value Excel last saved with it, and
6
+ // numbers and dates are shown exactly as stored (a date is its serial number). Charts, images, comments and macros are not read.
7
+ //
8
+ // Limits, all enforced while reading, never after:
9
+ // XLSX_LIMITS.maxBytes 10 MB workbook
10
+ // XLSX_LIMITS.maxSheets 12 sheets (later sheets are named in a notice, not read)
11
+ // XLSX_LIMITS.maxRowsPerSheet 200 non-empty rows per sheet
12
+ // XLSX_LIMITS.maxColumns 50 columns per row
13
+ // XLSX_LIMITS.maxCellChars 200 characters per cell
14
+ // XLSX_LIMITS.maxChars 100,000 characters of text in all (the same cap as the other document types)
15
+ // inflate caps 8 MB for shared strings, 16 MB per sheet, 4 MB for the other parts: a zip bomb stops at the cap
16
+ // total inflated 48 MB across all parts of one workbook
17
+ // work bound 2 s of scanning in all (checked as the scanners advance); then XlsxError 'too_complex'
18
+ // Every scanner below is a linear indexOf pass that stops at a missing close tag. Nothing here uses a lazy [\s\S]*? regex over
19
+ // untrusted XML: with unclosed tags those go quadratic and an 8 to 16 MB part would hold the server for hours.
20
+ // Anything past a cap is cut and said so in a bracketed line in the text.
21
+ import { inflateRawSync } from 'node:zlib';
22
+
23
+ export const XLSX_LIMITS = {
24
+ maxBytes: 10_000_000,
25
+ maxSheets: 12,
26
+ maxRowsPerSheet: 200,
27
+ maxColumns: 50,
28
+ maxCellChars: 200,
29
+ maxChars: 100_000,
30
+ maxSharedStringsBytes: 8_000_000,
31
+ maxSheetBytes: 16_000_000,
32
+ maxPartBytes: 4_000_000,
33
+ maxTotalInflatedBytes: 48_000_000,
34
+ maxWorkMs: 2_000,
35
+ } as const
36
+
37
+ export type XlsxErrorCode = 'too_large' | 'not_a_workbook' | 'encrypted' | 'unsupported_zip' | 'corrupt' | 'too_complex'
38
+ export class XlsxError extends Error {
39
+ constructor(readonly code: XlsxErrorCode, message: string) {
40
+ super(message)
41
+ this.name = 'XlsxError'
42
+ }
43
+ }
44
+
45
+ interface ZipEntry { name: string; method: number; compressedSize: number; size: number; offset: number }
46
+
47
+ function readEntries(zip: Buffer): Map<string, ZipEntry> {
48
+ if (zip.length < 22 || zip.readUInt32LE(0) !== 0x04034b50) throw new XlsxError('not_a_workbook', 'not a zip file')
49
+ let eocd = -1
50
+ for (let i = zip.length - 22; i >= Math.max(0, zip.length - 22 - 65_535); i--) {
51
+ if (zip.readUInt32LE(i) === 0x06054b50) { eocd = i; break }
52
+ }
53
+ if (eocd < 0) throw new XlsxError('corrupt', 'no end of central directory')
54
+ const count = zip.readUInt16LE(eocd + 10)
55
+ const cdSize = zip.readUInt32LE(eocd + 12)
56
+ const cdOffset = zip.readUInt32LE(eocd + 16)
57
+ if (count === 0xffff || cdSize === 0xffffffff || cdOffset === 0xffffffff) throw new XlsxError('unsupported_zip', 'zip64 is not supported')
58
+ if (cdOffset + cdSize > zip.length || count > 5_000) throw new XlsxError('corrupt', 'central directory out of range')
59
+ const entries = new Map<string, ZipEntry>()
60
+ let p = cdOffset
61
+ for (let n = 0; n < count; n++) {
62
+ if (p + 46 > zip.length || zip.readUInt32LE(p) !== 0x02014b50) throw new XlsxError('corrupt', 'bad central directory entry')
63
+ const flags = zip.readUInt16LE(p + 8)
64
+ if (flags & 1) throw new XlsxError('encrypted', 'the workbook is encrypted')
65
+ const nameLength = zip.readUInt16LE(p + 28), extraLength = zip.readUInt16LE(p + 30), commentLength = zip.readUInt16LE(p + 32)
66
+ if (p + 46 + nameLength > zip.length) throw new XlsxError('corrupt', 'entry name out of range')
67
+ const name = zip.toString('utf8', p + 46, p + 46 + nameLength)
68
+ entries.set(name, { name, method: zip.readUInt16LE(p + 10), compressedSize: zip.readUInt32LE(p + 20), size: zip.readUInt32LE(p + 24), offset: zip.readUInt32LE(p + 42) })
69
+ p += 46 + nameLength + extraLength + commentLength
70
+ }
71
+ return entries
72
+ }
73
+
74
+ /** One part, inflated with a hard output cap. Returns null past the cap or when the part is damaged. */
75
+ function readPart(zip: Buffer, entry: ZipEntry, cap: number): string | null {
76
+ const at = entry.offset
77
+ if (at + 30 > zip.length || zip.readUInt32LE(at) !== 0x04034b50) return null
78
+ const start = at + 30 + zip.readUInt16LE(at + 26) + zip.readUInt16LE(at + 28)
79
+ if (start + entry.compressedSize > zip.length || entry.size > cap) return null
80
+ const raw = zip.subarray(start, start + entry.compressedSize)
81
+ try {
82
+ const bytes = entry.method === 0 ? raw : entry.method === 8 ? inflateRawSync(raw, { maxOutputLength: cap }) : null
83
+ return bytes ? bytes.toString('utf8') : null
84
+ } catch { return null }
85
+ }
86
+
87
+ /** The work bound: scanners call it as they advance; it reads the clock every 4,096 calls and throws once the deadline has passed. */
88
+ class Work {
89
+ private n = 0
90
+ private readonly deadline: number
91
+ constructor(private readonly now: () => number) { this.deadline = now() + XLSX_LIMITS.maxWorkMs }
92
+ tick(): void {
93
+ if ((++this.n & 4095) === 0 && this.now() > this.deadline) throw new XlsxError('too_complex', `workbook took longer than ${XLSX_LIMITS.maxWorkMs} ms to scan`)
94
+ }
95
+ }
96
+ interface Block { attrs: string; body: string | null }
97
+ /**
98
+ * The <tag ...>body</tag> and <tag .../> elements of `xml`, in order, found with indexOf only. A tag without its closing tag ends the
99
+ * scan (nothing after it is trusted), so every position is passed over at most a constant number of times. Nested elements of the same
100
+ * name are not supported (none of the parts read here nest them).
101
+ */
102
+ function* blocks(xml: string, tag: string, work: Work): Generator<Block> {
103
+ const open = `<${tag}`, close = `</${tag}>`
104
+ let at = 0
105
+ for (;;) {
106
+ const start = xml.indexOf(open, at)
107
+ if (start < 0) return
108
+ const after = start + open.length
109
+ const c = xml.charCodeAt(after)
110
+ // <row but not <rowBreaks: the name must end here.
111
+ if (!(c === 62 || c === 47 || c === 32 || c === 9 || c === 10 || c === 13)) { at = after; continue }
112
+ const gt = xml.indexOf('>', after)
113
+ if (gt < 0) return
114
+ work.tick()
115
+ if (xml.charCodeAt(gt - 1) === 47) {
116
+ yield { attrs: xml.slice(after, gt - 1), body: null }
117
+ at = gt + 1
118
+ continue
119
+ }
120
+ const end = xml.indexOf(close, gt + 1)
121
+ if (end < 0) return
122
+ yield { attrs: xml.slice(after, gt), body: xml.slice(gt + 1, end) }
123
+ at = end + close.length
124
+ }
125
+ }
126
+ const ENTITIES: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" }
127
+ function unescapeXml(s: string): string {
128
+ return s.replace(/&(?:#x([0-9a-f]+)|#(\d+)|(amp|lt|gt|quot|apos));/gi, (_, hex: string | undefined, dec: string | undefined, name: string | undefined) => {
129
+ if (name) return ENTITIES[name.toLowerCase()]
130
+ const code = hex ? parseInt(hex, 16) : Number(dec)
131
+ return code > 0 && code <= 0x10ffff && !(code >= 0xd800 && code <= 0xdfff) ? String.fromCodePoint(code) : ''
132
+ })
133
+ }
134
+ /** `fragment` without its <rPh>...</rPh> phonetic guides; an unclosed <rPh> drops the rest. */
135
+ function withoutGuides(fragment: string): string {
136
+ let out = '', at = 0
137
+ for (;;) {
138
+ const start = fragment.indexOf('<rPh', at)
139
+ if (start < 0) return out + fragment.slice(at)
140
+ out += fragment.slice(at, start)
141
+ const end = fragment.indexOf('</rPh>', start)
142
+ if (end < 0) return out
143
+ at = end + 6
144
+ }
145
+ }
146
+ /** The text of every <t> in a fragment, phonetic guides left out. */
147
+ function textOf(fragment: string, work: Work): string {
148
+ let out = ''
149
+ for (const t of blocks(withoutGuides(fragment), 't', work)) if (t.body) out += unescapeXml(t.body)
150
+ return out
151
+ }
152
+ const ATTR_PATTERNS = new Map<string, RegExp>()
153
+ function attr(tag: string, name: string): string | undefined {
154
+ let pattern = ATTR_PATTERNS.get(name)
155
+ if (!pattern) ATTR_PATTERNS.set(name, pattern = new RegExp(`\\b${name}="([^"]*)"`))
156
+ const m = pattern.exec(tag)
157
+ return m ? unescapeXml(m[1]) : undefined
158
+ }
159
+ /** Column letters to a zero-based index: A is 0, AA is 26. */
160
+ function columnIndex(ref: string): number {
161
+ const letters = /^[A-Z]+/i.exec(ref)?.[0].toUpperCase() ?? ''
162
+ let n = 0
163
+ for (const ch of letters.slice(0, 4)) n = n * 26 + (ch.charCodeAt(0) - 64)
164
+ return n - 1
165
+ }
166
+ const clean = (value: string): string => value.replace(/[\t\r\n]+/g, ' ').trim().slice(0, XLSX_LIMITS.maxCellChars)
167
+
168
+ function sharedStrings(xml: string | null, work: Work): string[] {
169
+ if (!xml) return []
170
+ const out: string[] = []
171
+ for (const si of blocks(xml, 'si', work)) out.push(si.body ? textOf(si.body, work) : '')
172
+ return out
173
+ }
174
+
175
+ function firstBody(xml: string, tag: string, work: Work): string | undefined {
176
+ for (const b of blocks(xml, tag, work)) return b.body ?? undefined
177
+ return undefined
178
+ }
179
+ function cellValue(attrs: string, body: string, strings: readonly string[], work: Work): string {
180
+ const type = attr(attrs, 't')
181
+ if (type === 'inlineStr') return textOf(firstBody(body, 'is', work) ?? '', work)
182
+ const v = firstBody(body, 'v', work)
183
+ if (v === undefined) return ''
184
+ const value = unescapeXml(v)
185
+ if (type === 's') return strings[Number(value)] ?? ''
186
+ if (type === 'b') return value === '1' ? 'TRUE' : 'FALSE'
187
+ return value
188
+ }
189
+
190
+ /** Rows of one sheet as tab-separated text, at most maxRowsPerSheet non-empty rows. */
191
+ function sheetRows(xml: string, strings: readonly string[], work: Work): { lines: string[]; cut: boolean } {
192
+ const lines: string[] = []
193
+ for (const row of blocks(xml, 'row', work)) {
194
+ if (!row.body) continue
195
+ const cells: string[] = []
196
+ let next = 0
197
+ for (const c of blocks(row.body, 'c', work)) {
198
+ const ref = attr(c.attrs, 'r')
199
+ const at = ref ? columnIndex(ref) : next
200
+ if (at >= XLSX_LIMITS.maxColumns) break
201
+ const value = clean(cellValue(c.attrs, c.body ?? '', strings, work))
202
+ while (cells.length < at) cells.push('')
203
+ cells[at] = value
204
+ next = at + 1
205
+ }
206
+ while (cells.length && !cells[cells.length - 1]) cells.pop()
207
+ if (!cells.length) continue
208
+ if (lines.length >= XLSX_LIMITS.maxRowsPerSheet) return { lines, cut: true }
209
+ lines.push(cells.join('\t'))
210
+ }
211
+ return { lines, cut: false }
212
+ }
213
+
214
+ const CUT_NOTE = `[Text cut at ${XLSX_LIMITS.maxChars} characters.]`
215
+ export interface XlsxText { text: string; truncated: boolean; sheets: number }
216
+
217
+ /** The text of an .xlsx workbook. Throws XlsxError for anything that is not a readable, unencrypted workbook within the limits. */
218
+ export function extractXlsxText(bytes: Buffer, options: { now?: () => number } = {}): XlsxText {
219
+ if (bytes.length > XLSX_LIMITS.maxBytes) throw new XlsxError('too_large', `workbook exceeds ${XLSX_LIMITS.maxBytes} bytes`)
220
+ const work = new Work(options.now ?? Date.now)
221
+ const entries = readEntries(bytes)
222
+ let inflated = 0
223
+ const part = (name: string, cap: number): string | null => {
224
+ const entry = entries.get(name)
225
+ const text = entry ? readPart(bytes, entry, cap) : null
226
+ inflated += text?.length ?? 0
227
+ if (inflated > XLSX_LIMITS.maxTotalInflatedBytes) throw new XlsxError('too_complex', `workbook inflates past ${XLSX_LIMITS.maxTotalInflatedBytes} bytes`)
228
+ return text
229
+ }
230
+ const workbook = part('xl/workbook.xml', XLSX_LIMITS.maxPartBytes)
231
+ if (!workbook) throw new XlsxError('not_a_workbook', 'no workbook part')
232
+ const rels = new Map<string, string>()
233
+ for (const m of blocks(part('xl/_rels/workbook.xml.rels', XLSX_LIMITS.maxPartBytes) ?? '', 'Relationship', work)) {
234
+ const id = attr(m.attrs, 'Id'), target = attr(m.attrs, 'Target')
235
+ if (id && target) rels.set(id, target.startsWith('/') ? target.slice(1) : `xl/${target.replace(/^\.\//, '')}`)
236
+ }
237
+ const sheets: Array<{ name: string; path: string }> = []
238
+ let ordinal = 0
239
+ for (const m of blocks(workbook, 'sheet', work)) {
240
+ ordinal += 1
241
+ const rid = /\br:id="([^"]*)"/.exec(m.attrs)?.[1]
242
+ sheets.push({ name: clean(attr(m.attrs, 'name') ?? `Sheet${ordinal}`) || `Sheet${ordinal}`, path: (rid && rels.get(rid)) || `xl/worksheets/sheet${ordinal}.xml` })
243
+ }
244
+ if (!sheets.length) throw new XlsxError('not_a_workbook', 'no sheets')
245
+ const strings = sharedStrings(part('xl/sharedStrings.xml', XLSX_LIMITS.maxSharedStringsBytes), work)
246
+ const texts: string[] = []
247
+ let truncated = false, used = 0
248
+ // The cut marker is added after the cap is reached, so the budget leaves room for it and the whole text stays within maxChars.
249
+ const budget = XLSX_LIMITS.maxChars - CUT_NOTE.length - 2
250
+ let capped = false
251
+ const push = (block: string): boolean => {
252
+ if (used + block.length + 2 > budget) {
253
+ capped = true
254
+ const room = budget - used - 2
255
+ if (room > 0) texts.push(block.slice(0, room))
256
+ truncated = true
257
+ return false
258
+ }
259
+ texts.push(block)
260
+ used += block.length + 2
261
+ return true
262
+ }
263
+ for (const [i, sheet] of sheets.entries()) {
264
+ if (i >= XLSX_LIMITS.maxSheets) {
265
+ push(`[${sheets.length - XLSX_LIMITS.maxSheets} more sheet${sheets.length - XLSX_LIMITS.maxSheets === 1 ? '' : 's'} not read: ${sheets.slice(XLSX_LIMITS.maxSheets).map(s => s.name).join(', ')}.]`)
266
+ truncated = true
267
+ break
268
+ }
269
+ const xml = part(sheet.path, XLSX_LIMITS.maxSheetBytes)
270
+ if (xml === null) { if (!push(`Sheet: ${sheet.name}\n[This sheet could not be read.]`)) break; continue }
271
+ const { lines, cut } = sheetRows(xml, strings, work)
272
+ if (cut) truncated = true
273
+ if (!push([`Sheet: ${sheet.name}`, ...lines, ...(cut ? [`[Only the first ${XLSX_LIMITS.maxRowsPerSheet} rows of this sheet are read.]`] : [])].join('\n'))) break
274
+ }
275
+ const text = texts.join('\n\n').trim()
276
+ return { text: capped ? `${text}\n\n${CUT_NOTE}` : text, truncated, sheets: sheets.length }
277
+ }
@@ -16,6 +16,7 @@ import { localFirstMeetingsCapability } from '../lib/local-first-meetings-contra
16
16
  import { isSileroAvailable } from '../lib/vad-silero.js'
17
17
  import { profileProvenanceSummary, speakerModelState, speakerReadiness } from '../lib/speaker-embeddings.js'
18
18
  import { nemotronLive } from '../lib/nemotron-live.js'
19
+ import { speakerShadowHealthFields } from '../lib/speaker-shadow.js'
19
20
  import { finalPassSnapshot } from '../lib/nemotron-final.js'
20
21
  import { chunkEmbeddingStoreStats } from '../lib/chunk-embedding-store.js'
21
22
  import { mintDisplayTicket, DISPLAY_TICKET_TTL_SECONDS } from '../lib/display-ticket.js'
@@ -579,6 +580,10 @@ healthRouter.get('/health', async (_req, res) => {
579
580
  // count only (health is public); the items and their Retry are behind the token at
580
581
  // GET /api/maintenance/status.
581
582
  crash_loop_parking: crashLoopParkingHealth(),
583
+ // 6.95.0: speaker merge shadow mode, only while it is on (absent when off). Codes and counts
584
+ // only (health is public): the proposals and the last error's text are behind the token at
585
+ // /api/speaker-shadow/*.
586
+ ...speakerShadowHealthFields(),
582
587
  // Key lanes (plan v4 3.1): `details.keyLanes` only while enabled. Absent, never null, when off.
583
588
  ...keyLanesHealthFields(),
584
589
  })