@gotcos/glasses-server 6.95.0 → 6.98.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.
Files changed (123) hide show
  1. package/CHANGELOG.md +166 -0
  2. package/README.md +4 -0
  3. package/managed-runtime-contract.json +13 -0
  4. package/package.json +3 -1
  5. package/server/index.ts +12 -0
  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/decision-provider.ts +4 -2
  12. package/server/lib/dictation-clean-models.ts +2 -0
  13. package/server/lib/dictation-clean.ts +2 -2
  14. package/server/lib/dictation-polish-pipeline.ts +22 -2
  15. package/server/lib/dictation-prewarm.ts +16 -0
  16. package/server/lib/direct-bridge.ts +14 -2
  17. package/server/lib/direct-sse.ts +6 -1
  18. package/server/lib/display-bus.ts +1 -1
  19. package/server/lib/fork-thread.ts +32 -1
  20. package/server/lib/held-naming-batches.ts +44 -7
  21. package/server/lib/held-voice-groups.ts +5 -4
  22. package/server/lib/job-audit-caller.ts +20 -0
  23. package/server/lib/keyed-sessions.ts +21 -1
  24. package/server/lib/lens-gist-engines.ts +17 -5
  25. package/server/lib/live-cos-ask.ts +29 -6
  26. package/server/lib/live-cue-verdicts.ts +43 -8
  27. package/server/lib/live-cues-engine.ts +4 -1
  28. package/server/lib/live-cues-filter.ts +50 -0
  29. package/server/lib/live-cues-general-prompts.ts +137 -0
  30. package/server/lib/live-cues-general-topic.ts +245 -0
  31. package/server/lib/live-cues-general.ts +715 -0
  32. package/server/lib/live-cues-v2-data.ts +763 -0
  33. package/server/lib/live-cues-v2-grounded.ts +262 -0
  34. package/server/lib/live-cues-v2-judge-b.ts +59 -0
  35. package/server/lib/live-cues-v2-names.ts +49 -0
  36. package/server/lib/live-cues-v2-notes.ts +8 -4
  37. package/server/lib/live-cues-v2-open-items.ts +132 -0
  38. package/server/lib/live-cues-v2-pacing.ts +45 -0
  39. package/server/lib/live-cues-v2-prompts-kinds.ts +9 -4
  40. package/server/lib/live-cues-v2-settings.ts +89 -10
  41. package/server/lib/live-cues-v2.ts +572 -47
  42. package/server/lib/mac-recording-asr.ts +6 -3
  43. package/server/lib/mac-speaker-call-human.ts +371 -0
  44. package/server/lib/mac-speaker-call-interval-human.ts +499 -0
  45. package/server/lib/mac-speaker-call-interval-projection.ts +233 -0
  46. package/server/lib/mac-speaker-call-original-view.ts +328 -0
  47. package/server/lib/mac-speaker-call-producer-worker.ts +615 -0
  48. package/server/lib/mac-speaker-call-producer.ts +539 -0
  49. package/server/lib/mac-speaker-call-store.ts +264 -0
  50. package/server/lib/mac-speaker-call-view.ts +373 -0
  51. package/server/lib/mac-speaker-live-compute.ts +72 -0
  52. package/server/lib/mac-speaker-live-memory.ts +414 -0
  53. package/server/lib/mac-speaker-live-projection.ts +126 -0
  54. package/server/lib/mac-speaker-live-save.ts +90 -0
  55. package/server/lib/mac-speaker-live-store.ts +71 -0
  56. package/server/lib/mac-speaker-live-worker.ts +58 -0
  57. package/server/lib/mac-speaker-live.ts +175 -0
  58. package/server/lib/mac-speaker-profile-extractor.ts +15 -10
  59. package/server/lib/mac-speaker-shadow-repair.ts +307 -0
  60. package/server/lib/mac-voice-assignment.ts +46 -27
  61. package/server/lib/mac-voice-display.ts +195 -0
  62. package/server/lib/mac-voice-naming-batch.ts +1067 -0
  63. package/server/lib/mac-voice-suggestions.ts +548 -0
  64. package/server/lib/maintenance-lifecycle.ts +2 -0
  65. package/server/lib/media-store.ts +2 -1
  66. package/server/lib/meeting-ask-pasted.ts +135 -0
  67. package/server/lib/meeting-ask-review.ts +8 -1
  68. package/server/lib/meeting-ask-runner.ts +4 -1
  69. package/server/lib/meeting-ask-runtime.ts +189 -17
  70. package/server/lib/meeting-ask-store.ts +2 -0
  71. package/server/lib/meeting-ask-web.ts +284 -13
  72. package/server/lib/meeting-batch-progress.ts +51 -5
  73. package/server/lib/meeting-batch-transcribe.ts +35 -2
  74. package/server/lib/meeting-corrections.ts +2 -1
  75. package/server/lib/meeting-fact-check.ts +3 -0
  76. package/server/lib/meeting-finalization-jobs.ts +69 -2
  77. package/server/lib/meeting-public-topic.ts +6 -1
  78. package/server/lib/meeting-relabel-enrolment.ts +2 -1
  79. package/server/lib/meeting-relabel.ts +17 -10
  80. package/server/lib/meeting-speaker-review.ts +187 -21
  81. package/server/lib/meeting-store.ts +6 -1
  82. package/server/lib/parakeet.ts +51 -5
  83. package/server/lib/pending-batch-retention.ts +58 -0
  84. package/server/lib/private-reference.ts +38 -0
  85. package/server/lib/provider-usage.ts +561 -0
  86. package/server/lib/public-https-url.ts +32 -0
  87. package/server/lib/query-job-runtime.ts +3 -0
  88. package/server/lib/rich-media-safety.ts +23 -2
  89. package/server/lib/session-find-ask.ts +288 -0
  90. package/server/lib/session-find-collect.ts +715 -0
  91. package/server/lib/session-find-redact.ts +254 -0
  92. package/server/lib/session-find.ts +345 -0
  93. package/server/lib/speaker-calibration-log.ts +3 -2
  94. package/server/lib/speaker-embeddings.ts +46 -35
  95. package/server/lib/speaker-name-nfc.ts +37 -0
  96. package/server/lib/speaker-rename-fanout.ts +7 -6
  97. package/server/lib/speaker-shadow-decide.ts +59 -22
  98. package/server/lib/speaker-shadow-pair-source.ts +91 -0
  99. package/server/lib/speaker-shadow-paired.ts +268 -0
  100. package/server/lib/speaker-shadow-report.ts +2 -2
  101. package/server/lib/speaker-shadow-worker.ts +50 -15
  102. package/server/lib/speaker-shadow.ts +80 -19
  103. package/server/lib/speaker-trainer.ts +2 -1
  104. package/server/lib/token-audit.ts +36 -6
  105. package/server/lib/transcript-cleanup.ts +3 -2
  106. package/server/lib/voice-directory.ts +10 -5
  107. package/server/lib/voice-profile-store.ts +24 -14
  108. package/server/lib/web-lookup-ledger.ts +151 -0
  109. package/server/lib/work-review-runtime.ts +2 -0
  110. package/server/lib/work-search.ts +3 -1
  111. package/server/lib/xlsx-text.ts +277 -0
  112. package/server/routes/decision-provider.ts +4 -1
  113. package/server/routes/jev.ts +5 -1
  114. package/server/routes/live-cues.ts +36 -14
  115. package/server/routes/meeting.ts +515 -61
  116. package/server/routes/session-find.ts +73 -0
  117. package/server/routes/transcribe-stream.ts +114 -33
  118. package/server/routes/voice.ts +28 -18
  119. package/server/scripts/live-cues-v3-mutation-gate.mjs +311 -0
  120. package/server/scripts/mac-voice-assignment-6930-mutation-gate.mjs +5 -5
  121. package/shared/live-cues-v2.ts +38 -5
  122. package/shared/media-attachment.ts +2 -0
  123. package/shared/meeting-ask.ts +11 -0
@@ -0,0 +1,561 @@
1
+ // 6.97.0: real token usage, read from what a provider CLI already prints.
2
+ //
3
+ // Nothing in this file may reach an answer. Every reader takes data the caller already has, works on
4
+ // its own copy, and never throws: a bad or missing number leaves the audit row estimated and changes
5
+ // nothing else. Answers keep their own parsers, so a change here cannot change an answer.
6
+ //
7
+ // WHAT THE CLIS REPORT (probed 2026-10-31 on claude 2.1.316 and codex-cli 0.166.0-alpha.5.1; the
8
+ // numbers are pinned in provider-usage.test.ts). Two of these are running totals that a naive reader
9
+ // would count again on every turn of a resumed session, which is most G2 turns:
10
+ //
11
+ // Claude result event
12
+ // usage this turn, main loop only. No subagents, no helper calls.
13
+ // modelUsage, total_cost_usd RUNNING TOTALS, across results in one process AND across
14
+ // --resume (a resumed session's first result carries earlier turns).
15
+ // model_usage_since_last_result per model, since this process's previous result. The right field.
16
+ // Printed only with stream-json, not with `--output-format json`.
17
+ // Codex turn.completed.usage RUNNING TOTAL for the thread, across `exec resume`.
18
+ // `input_tokens` includes `cached_input_tokens`.
19
+ //
20
+ // WHAT A ROW'S NUMBERS MEAN, PER PROVIDER. They are not the same everywhere, and a reader pricing them
21
+ // should know why. inputTokens never includes cache reads. Cache writes are another matter:
22
+ // Claude inputTokens excludes reads and writes. cacheWriteTokens is what the CLI reports.
23
+ // Codex inputTokens is input_tokens minus cached. cacheWriteTokens is absent: its own field read 0
24
+ // on all 31,670 rollout rows checked on 2026-10-31, and nothing shows whether a nonzero one
25
+ // would sit inside input_tokens, so any write stays in inputTokens.
26
+ // Direct inputTokens is prompt_tokens minus cached_tokens, when the provider states those. No cache
27
+ // write is read, so any write stays in inputTokens.
28
+ // Ollama no cache at all.
29
+ // resolvedModel is what the provider says ran only on Claude. On Codex, direct and Ollama it is the id
30
+ // COS asked for (`--model`, or the request): Codex's stream names no model.
31
+
32
+ /** Measured usage of one run. Only `normalizeRealUsage` decides whether it counts as measured. */
33
+ export interface RealUsage {
34
+ /** Never includes cache reads. Excludes cache writes only where they are reported: see the header. */
35
+ inputTokens: number
36
+ outputTokens: number
37
+ cacheReadTokens?: number
38
+ cacheWriteTokens?: number
39
+ /** Of cacheWriteTokens, the part cached for an hour, which Claude bills higher. Only when stated. */
40
+ cacheWrite1hTokens?: number
41
+ /** Only when the provider stated it. Never computed here. */
42
+ costUsd?: number
43
+ /** Claude: the id the CLI says it ran. Codex, direct, Ollama: the id COS asked for. See the header. */
44
+ resolvedModel?: string
45
+ }
46
+
47
+ /** What a reader found in one run's output, ready to spread onto a token-audit entry. */
48
+ export interface EngineUsage {
49
+ realUsage?: RealUsage
50
+ cliSessionId?: string
51
+ }
52
+
53
+ // Provider ids and model slugs have no spaces, so text from a prompt or an answer cannot pass here
54
+ // even if a provider ever put it in one of these fields. Two more shapes are refused because the audit
55
+ // file outlives everything that wrote it: anything shaped like a credential (the prefixes
56
+ // lens-gist-engines.ts redacts, and any long unbroken run, which no session id or model slug has:
57
+ // a UUID's longest is 12), and anything shaped like a path (one slash at most, as in vendor/model).
58
+ const PROVIDER_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:/@+[\]-]{0,127}$/
59
+ const KEY_SHAPED_RE = /(?:^|[^A-Za-z0-9])(?:sk|pk|rk|ak|pat)-[A-Za-z0-9_-]{3,}|(?:^|[^A-Za-z0-9])(?:gh[pousr]_|github_pat_|xox[abposr]-|eyJ[A-Za-z0-9_-]{4,}\.)|(?:^|[^A-Za-z0-9])(?:AKIA|ASIA)[0-9A-Z]{8,}|[A-Za-z0-9]{32,}/i
60
+ const SLASH_RE = /\//g
61
+
62
+ export function cleanProviderId(value: unknown): string | undefined {
63
+ if (typeof value !== 'string' || !PROVIDER_ID_RE.test(value)) return undefined
64
+ if (KEY_SHAPED_RE.test(value)) return undefined
65
+ if (value.includes('..') || (value.match(SLASH_RE)?.length ?? 0) > 1) return undefined
66
+ return value
67
+ }
68
+
69
+ function count(value: unknown): number | undefined {
70
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined
71
+ }
72
+
73
+ function dollars(value: unknown): number | undefined {
74
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined
75
+ }
76
+
77
+ function record(value: unknown): Record<string, unknown> | null {
78
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : null
79
+ }
80
+
81
+ /**
82
+ * The one gate for `usage_kind: provider_final`. Null unless input and output are both real counts
83
+ * and something was actually used: a missing field, a negative, a float or an all-zero reading is an
84
+ * estimate, not a measurement. Never throws.
85
+ */
86
+ export function normalizeRealUsage(raw: RealUsage | null | undefined): RealUsage | null {
87
+ try {
88
+ if (!raw || typeof raw !== 'object') return null
89
+ const input = count(raw.inputTokens)
90
+ const output = count(raw.outputTokens)
91
+ const read = raw.cacheReadTokens === undefined ? 0 : count(raw.cacheReadTokens)
92
+ const write = raw.cacheWriteTokens === undefined ? 0 : count(raw.cacheWriteTokens)
93
+ if (input === undefined || output === undefined || read === undefined || write === undefined) return null
94
+ if (input + output + read + write <= 0) return null
95
+ const cost = dollars(raw.costUsd)
96
+ const model = cleanProviderId(raw.resolvedModel)
97
+ // A part larger than its whole is not a reading; the four main counts stand without it.
98
+ const write1h = count(raw.cacheWrite1hTokens)
99
+ const hour = write1h !== undefined && write1h <= write ? write1h : undefined
100
+ return {
101
+ inputTokens: input,
102
+ outputTokens: output,
103
+ ...(raw.cacheReadTokens !== undefined ? { cacheReadTokens: read } : {}),
104
+ ...(raw.cacheWriteTokens !== undefined ? { cacheWriteTokens: write } : {}),
105
+ ...(hour !== undefined ? { cacheWrite1hTokens: hour } : {}),
106
+ ...(cost !== undefined ? { costUsd: cost } : {}),
107
+ ...(model ? { resolvedModel: model } : {}),
108
+ }
109
+ } catch {
110
+ return null
111
+ }
112
+ }
113
+
114
+ // --- Claude ---------------------------------------------------------------------------
115
+
116
+ interface ClaudeTally {
117
+ input: number
118
+ output: number
119
+ cacheRead: number
120
+ cacheWrite: number
121
+ /** Undefined once any part of the tally did not state it, like cost. */
122
+ cacheWrite1h: number | undefined
123
+ /** Undefined once any part of the tally had no stated cost: a partial sum is not a cost. */
124
+ cost: number | undefined
125
+ byModel: Map<string, number>
126
+ }
127
+
128
+ function emptyTally(): ClaudeTally {
129
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cacheWrite1h: 0, cost: 0, byModel: new Map() }
130
+ }
131
+
132
+ function addTally(into: ClaudeTally, more: ClaudeTally): void {
133
+ into.input += more.input
134
+ into.output += more.output
135
+ into.cacheRead += more.cacheRead
136
+ into.cacheWrite += more.cacheWrite
137
+ into.cacheWrite1h = into.cacheWrite1h === undefined || more.cacheWrite1h === undefined ? undefined : into.cacheWrite1h + more.cacheWrite1h
138
+ into.cost = into.cost === undefined || more.cost === undefined ? undefined : into.cost + more.cost
139
+ for (const [model, weight] of more.byModel) into.byModel.set(model, (into.byModel.get(model) ?? 0) + weight)
140
+ }
141
+
142
+ /**
143
+ * A modelUsage-shaped map. One malformed row makes the whole map unusable, never half a reading.
144
+ * An empty map is a real answer, not a missing one: the CLI counted nothing, so it is an empty tally.
145
+ */
146
+ function tallyFromModelMap(map: unknown): ClaudeTally | null {
147
+ const rows = record(map)
148
+ if (!rows) return null
149
+ const tally = emptyTally()
150
+ for (const [model, row] of Object.entries(rows)) {
151
+ const r = record(row)
152
+ if (!r) return null
153
+ const input = count(r.inputTokens)
154
+ const output = count(r.outputTokens)
155
+ const read = r.cacheReadInputTokens === undefined ? 0 : count(r.cacheReadInputTokens)
156
+ const write = r.cacheCreationInputTokens === undefined ? 0 : count(r.cacheCreationInputTokens)
157
+ if (input === undefined || output === undefined || read === undefined || write === undefined) return null
158
+ const cost = dollars(r.costUSD)
159
+ const id = cleanProviderId(model)
160
+ addTally(tally, {
161
+ input, output, cacheRead: read, cacheWrite: write, cacheWrite1h: count(r.cacheCreationEphemeral1hInputTokens), cost,
162
+ byModel: new Map(id ? [[id, input + output + read + write]] : []),
163
+ })
164
+ }
165
+ return tally
166
+ }
167
+
168
+ function hasTokens(tally: ClaudeTally): boolean {
169
+ return tally.input + tally.output + tally.cacheRead + tally.cacheWrite > 0
170
+ }
171
+
172
+ /** The `usage` of a result: one turn, main loop only. It states no cost. */
173
+ function tallyFromTurnUsage(usage: unknown): ClaudeTally | null {
174
+ const u = record(usage)
175
+ if (!u) return null
176
+ const input = count(u.input_tokens)
177
+ const output = count(u.output_tokens)
178
+ const read = u.cache_read_input_tokens === undefined ? 0 : count(u.cache_read_input_tokens)
179
+ const write = u.cache_creation_input_tokens === undefined ? 0 : count(u.cache_creation_input_tokens)
180
+ if (input === undefined || output === undefined || read === undefined || write === undefined) return null
181
+ const creation = record(u.cache_creation)
182
+ return { input, output, cacheRead: read, cacheWrite: write, cacheWrite1h: count(creation?.ephemeral_1h_input_tokens), cost: undefined, byModel: new Map() }
183
+ }
184
+
185
+ /**
186
+ * The usage of one Claude process, whatever number of results it wrote. Feed it every parsed event
187
+ * and `take()` when a row is written; each result counts once, across any number of takes.
188
+ *
189
+ * `resumed` must be true when the process was started with --resume, because the running totals then
190
+ * begin with earlier turns. Without the per-result field (an older CLI), a resumed run reports its
191
+ * main-loop tokens and no cost, since no number it printed says what this run alone cost.
192
+ */
193
+ export class ClaudeRunUsage {
194
+ private readonly seen = new Set<string>()
195
+ private results = 0
196
+ /** Sticky for the run: once the CLI states per-result usage, its running totals are never read. */
197
+ private sawSince = false
198
+ private since = emptyTally()
199
+ private turnCount = 0
200
+ private turns = emptyTally()
201
+ private cumulative: ClaudeTally | null = null
202
+ private cumulativeCost: number | undefined
203
+ private initModel: string | undefined
204
+ /** A running total can be read once; a second read would count the first again. */
205
+ private totalsTaken = false
206
+ /** Set by the first event that names a session; a result's own id wins over an earlier one. */
207
+ cliSessionId: string | undefined
208
+
209
+ constructor(private readonly resumed: boolean) {}
210
+
211
+ /** Never throws: this runs inside the bridges' stdout handlers. */
212
+ observe(event: unknown): void {
213
+ try {
214
+ const e = record(event)
215
+ if (!e) return
216
+ if (e.type === 'system' && e.subtype === 'init') {
217
+ this.initModel = cleanProviderId(e.model) ?? this.initModel
218
+ this.cliSessionId = cleanProviderId(e.session_id) ?? this.cliSessionId
219
+ return
220
+ }
221
+ if (e.type !== 'result') return
222
+ this.cliSessionId = cleanProviderId(e.session_id) ?? this.cliSessionId
223
+ if (typeof e.uuid === 'string') {
224
+ if (this.seen.has(e.uuid)) return
225
+ this.seen.add(e.uuid)
226
+ }
227
+ this.results++
228
+ const since = tallyFromModelMap(e.model_usage_since_last_result)
229
+ if (since) { addTally(this.since, since); this.sawSince = true }
230
+ const turn = tallyFromTurnUsage(e.usage)
231
+ if (turn) { addTally(this.turns, turn); this.turnCount++ }
232
+ const cumulative = tallyFromModelMap(e.modelUsage)
233
+ if (cumulative && hasTokens(cumulative)) this.cumulative = cumulative
234
+ this.cumulativeCost = dollars(e.total_cost_usd) ?? this.cumulativeCost
235
+ } catch {
236
+ /* best effort: the row stays estimated */
237
+ }
238
+ }
239
+
240
+ /** The results seen since the last take, once. Null when there is nothing real to report. */
241
+ take(): RealUsage | null {
242
+ try {
243
+ const out = this.compute()
244
+ this.results = 0
245
+ this.since = emptyTally()
246
+ this.turnCount = 0
247
+ this.turns = emptyTally()
248
+ return out
249
+ } catch {
250
+ return null
251
+ }
252
+ }
253
+
254
+ private compute(): RealUsage | null {
255
+ if (this.results === 0) return null
256
+ if (this.sawSince) return this.toUsage(this.since, this.since.cost)
257
+ // Below this line the running totals or per-turn usage are all there is. They cover a whole run,
258
+ // so they are read at most once; a late result on such a CLI is not counted rather than doubled.
259
+ if (this.totalsTaken) return null
260
+ this.totalsTaken = true
261
+ if (!this.resumed && this.cumulative) return this.toUsage(this.cumulative, this.cumulativeCost ?? this.cumulative.cost)
262
+ if (this.turnCount !== this.results) return null
263
+ return this.toUsage(this.turns, this.resumed ? undefined : this.cumulativeCost)
264
+ }
265
+
266
+ private toUsage(tally: ClaudeTally, cost: number | undefined): RealUsage | null {
267
+ // A zero cost beside real tokens means the CLI could not price them, not that they were free.
268
+ const stated = cost !== undefined && cost > 0 ? cost : undefined
269
+ return normalizeRealUsage({
270
+ inputTokens: tally.input,
271
+ outputTokens: tally.output,
272
+ cacheReadTokens: tally.cacheRead,
273
+ cacheWriteTokens: tally.cacheWrite,
274
+ ...(tally.cacheWrite1h !== undefined ? { cacheWrite1hTokens: tally.cacheWrite1h } : {}),
275
+ ...(stated !== undefined ? { costUsd: stated } : {}),
276
+ ...(this.modelOf(tally) ? { resolvedModel: this.modelOf(tally) } : {}),
277
+ })
278
+ }
279
+
280
+ private modelOf(tally: ClaudeTally): string | undefined {
281
+ if (this.initModel) return this.initModel
282
+ // Per-turn usage names no model; the running totals still say which ones ran.
283
+ const rows = tally.byModel.size > 0 ? tally.byModel : this.cumulative?.byModel ?? tally.byModel
284
+ let best: string | undefined
285
+ let weight = -1
286
+ for (const [model, w] of rows) if (w > weight) { best = model; weight = w }
287
+ return best
288
+ }
289
+ }
290
+
291
+ // --- Codex ----------------------------------------------------------------------------
292
+
293
+ interface CodexTotals { input: number; cached: number; output: number }
294
+
295
+ function codexTotals(event: Record<string, unknown>): CodexTotals | null {
296
+ const u = record(event.usage)
297
+ if (!u) return null
298
+ const input = count(u.input_tokens)
299
+ const output = count(u.output_tokens)
300
+ const cached = u.cached_input_tokens === undefined ? 0 : count(u.cached_input_tokens)
301
+ if (input === undefined || output === undefined || cached === undefined) return null
302
+ return { input, cached, output }
303
+ }
304
+
305
+ /** No cost: Codex does not state one. On `cache_write_input_tokens`, see the header. */
306
+ function codexRealUsage(totals: CodexTotals, model: string | undefined): RealUsage | null {
307
+ return normalizeRealUsage({
308
+ inputTokens: Math.max(0, totals.input - totals.cached),
309
+ outputTokens: totals.output,
310
+ cacheReadTokens: totals.cached,
311
+ ...(model ? { resolvedModel: model } : {}),
312
+ })
313
+ }
314
+
315
+ /** Where a thread's rollout had got to, in bytes, or null when that cannot be read. */
316
+ export type CodexRolloutProbe = (threadId: string) => number | null
317
+
318
+ /**
319
+ * Registered at boot (codex-rollout-watermark.ts), so this file and the bridge do not load the session
320
+ * stores. With none registered, a resumed turn can prove nothing and stays estimated. Not per-run: the
321
+ * seam is the whole point of `registerCodexRolloutProbe`, and tests set it the same way.
322
+ */
323
+ let registeredCodexRolloutProbe: CodexRolloutProbe | null = null
324
+ export function registerCodexRolloutProbe(probe: CodexRolloutProbe | null): void {
325
+ registeredCodexRolloutProbe = probe
326
+ }
327
+
328
+ interface CodexBaseline {
329
+ totals: CodexTotals
330
+ /** The rollout's size once this bridge's turn had closed. Unset until then; null when unreadable. */
331
+ watermark?: number | null
332
+ }
333
+
334
+ /** The last running total this bridge saw per thread, for its next turn. Lost on restart, on purpose. */
335
+ const codexThreadTotals = new Map<string, CodexBaseline>()
336
+ const CODEX_THREAD_TOTALS_MAX = 512
337
+
338
+ function rememberCodexTotals(threadId: string, totals: CodexTotals): void {
339
+ codexThreadTotals.delete(threadId)
340
+ codexThreadTotals.set(threadId, { totals })
341
+ if (codexThreadTotals.size > CODEX_THREAD_TOTALS_MAX) codexThreadTotals.delete(codexThreadTotals.keys().next().value!)
342
+ }
343
+
344
+ /** Something other than this bridge is about to run on the thread: its next difference would be wrong. */
345
+ export function forgetCodexThreadTotals(threadId: string): void {
346
+ codexThreadTotals.delete(threadId)
347
+ }
348
+
349
+ export function _resetCodexThreadTotalsForTest(): void {
350
+ codexThreadTotals.clear()
351
+ }
352
+
353
+ /**
354
+ * One Codex process's usage. A fresh thread's running total IS its first turn. A resumed turn is the
355
+ * difference from the last total, but only when this bridge can show nothing else ran on the thread
356
+ * since. A wrong real number is worse than an estimate, so each of these stays estimated:
357
+ * - no earlier total: the first resume after a restart, or a thread started elsewhere;
358
+ * - an earlier turn that never reported one (wall timeout, failure): `begin` consumes the baseline,
359
+ * so a turn that does not end with a total leaves none behind;
360
+ * - an attached run (`forgetCodexThreadTotals`);
361
+ * - the rollout grew between turns: the desktop app or the CLI, which COS cannot see any other way.
362
+ * The total is kept each time, so the turn after is real again.
363
+ */
364
+ export class CodexRunUsage {
365
+ private last: CodexTotals | null = null
366
+ private prior: CodexTotals | null = null
367
+ private closed = false
368
+ private finishedThread: string | undefined
369
+
370
+ /**
371
+ * `model` is the id COS passed on `--model`: Codex's stream never names the model it ran. `probe`
372
+ * reads the rollout, defaulting to the one registered at boot.
373
+ */
374
+ constructor(private readonly model?: string, private readonly probe: CodexRolloutProbe | null = registeredCodexRolloutProbe) {}
375
+
376
+ /** Before the process starts, when resuming: it writes to the rollout from its first moment. */
377
+ begin(resumedFrom: string | undefined): void {
378
+ try {
379
+ const thread = cleanProviderId(resumedFrom)
380
+ if (!thread) return
381
+ const baseline = codexThreadTotals.get(thread)
382
+ codexThreadTotals.delete(thread)
383
+ if (!baseline || baseline.watermark === undefined || baseline.watermark === null) return
384
+ if (this.probe?.(thread) === baseline.watermark) this.prior = baseline.totals
385
+ } catch {
386
+ this.prior = null
387
+ }
388
+ }
389
+
390
+ /** Never throws: this runs inside the bridge's event handler. */
391
+ observe(event: unknown): void {
392
+ try {
393
+ const e = record(event)
394
+ if (e?.type !== 'turn.completed') return
395
+ this.last = codexTotals(e) ?? this.last
396
+ } catch {
397
+ /* best effort: the row stays estimated */
398
+ }
399
+ }
400
+
401
+ /** Call once, when the row is written. `resumedFrom` is the thread id passed to `exec resume`. */
402
+ finish(threadId: string | undefined, resumedFrom: string | undefined): RealUsage | null {
403
+ try {
404
+ const totals = this.last
405
+ if (!totals) return null
406
+ const thread = cleanProviderId(threadId)
407
+ if (thread) {
408
+ rememberCodexTotals(thread, totals)
409
+ this.finishedThread = thread
410
+ if (this.closed) this.stampWatermark()
411
+ }
412
+ if (!resumedFrom) return codexRealUsage(totals, this.model)
413
+ const prior = this.prior
414
+ if (!prior || thread !== resumedFrom) return null
415
+ const delta = { input: totals.input - prior.input, cached: totals.cached - prior.cached, output: totals.output - prior.output }
416
+ if (delta.input < 0 || delta.cached < 0 || delta.output < 0) return null
417
+ return codexRealUsage(delta, this.model)
418
+ } catch {
419
+ return null
420
+ }
421
+ }
422
+
423
+ /** When the process has closed, so everything it wrote is on disk. Row and close can land in either order. */
424
+ settle(): void {
425
+ this.closed = true
426
+ this.stampWatermark()
427
+ }
428
+
429
+ private stampWatermark(): void {
430
+ try {
431
+ const baseline = this.finishedThread ? codexThreadTotals.get(this.finishedThread) : undefined
432
+ if (baseline && baseline.watermark === undefined) baseline.watermark = this.probe?.(this.finishedThread!) ?? null
433
+ } catch {
434
+ /* no watermark: the next turn stays estimated */
435
+ }
436
+ }
437
+ }
438
+
439
+ // --- One-shot calls: read the output once, after the process is done ---------------------
440
+
441
+ function jsonLines(stdout: string): Record<string, unknown>[] {
442
+ const events: Record<string, unknown>[] = []
443
+ for (const line of stdout.split('\n')) {
444
+ const trimmed = line.trim()
445
+ if (!trimmed.startsWith('{')) continue
446
+ try {
447
+ const event = record(JSON.parse(trimmed))
448
+ if (event) events.push(event)
449
+ } catch {
450
+ /* not a JSON line */
451
+ }
452
+ }
453
+ return events
454
+ }
455
+
456
+ /**
457
+ * Usage of a one-shot, never-resumed call: the lens gist, cues, transcript cleanup and Meeting Ask.
458
+ * Cursor's usage is not trusted (its cache parser has never produced a real row), so Cursor reports
459
+ * its session id only. `model` is the id passed on `--model`, used only for Codex, which names none.
460
+ * Never throws.
461
+ */
462
+ export function readOneShotUsage(provider: 'claude' | 'codex' | 'cursor', stdout: string, model?: string): EngineUsage {
463
+ try {
464
+ const events = jsonLines(stdout)
465
+ if (provider === 'claude') {
466
+ const run = new ClaudeRunUsage(false)
467
+ for (const event of events) run.observe(event)
468
+ const realUsage = run.take()
469
+ return { ...(realUsage ? { realUsage } : {}), ...(run.cliSessionId ? { cliSessionId: run.cliSessionId } : {}) }
470
+ }
471
+ if (provider === 'codex') {
472
+ const run = new CodexRunUsage(model)
473
+ let thread: string | undefined
474
+ for (const event of events) {
475
+ if (event.type === 'thread.started') thread = cleanProviderId(event.thread_id) ?? thread
476
+ run.observe(event)
477
+ }
478
+ // `--ephemeral`: no resume, and no thread total worth keeping.
479
+ const realUsage = run.finish(undefined, undefined)
480
+ return { ...(realUsage ? { realUsage } : {}), ...(thread ? { cliSessionId: thread } : {}) }
481
+ }
482
+ const session = events.map(event => cleanProviderId(event.session_id)).find(Boolean)
483
+ return session ? { cliSessionId: session } : {}
484
+ } catch {
485
+ return {}
486
+ }
487
+ }
488
+
489
+ // --- Attached and fork runs: a passive reader on a live stdout ---------------------------
490
+
491
+ const SIDE_RUN_LINE_MAX = 2_000_000
492
+ // Cheap on every chunk; only lines that can carry usage or a final answer are parsed.
493
+ const SIDE_RUN_LINE_RE = /"type":"result"|"subtype":"init"|"agent_message"/
494
+
495
+ /**
496
+ * Reads one attached or fork run's stdout. Both run on the user's own thread (resumed or copied), and
497
+ * the user can also run that thread from the desktop. So Claude reports only the per-result field or
498
+ * main-loop tokens, and Codex stays estimated: it has no baseline it could trust here.
499
+ */
500
+ export class SideRunTap {
501
+ private tail = ''
502
+ private readonly claude: ClaudeRunUsage | null
503
+ outputChars = 0
504
+ sawEnd = false
505
+
506
+ constructor(private readonly provider: 'claude' | 'codex' | 'cursor') {
507
+ this.claude = provider === 'claude' ? new ClaudeRunUsage(true) : null
508
+ }
509
+
510
+ /** A second `data` listener that can cost only itself, like the job trail's tee. */
511
+ attach(stdout: { on?: (event: string, listener: (chunk: unknown) => void) => unknown } | null | undefined): void {
512
+ try {
513
+ stdout?.on?.('data', chunk => this.feed(chunk))
514
+ } catch {
515
+ /* the run proceeds with an estimated row */
516
+ }
517
+ }
518
+
519
+ feed(chunk: unknown): void {
520
+ try {
521
+ this.tail += typeof chunk === 'string' ? chunk : String(chunk)
522
+ const lines = this.tail.split('\n')
523
+ this.tail = lines.pop() ?? ''
524
+ if (this.tail.length > SIDE_RUN_LINE_MAX) this.tail = ''
525
+ for (const line of lines) this.readLine(line)
526
+ } catch {
527
+ /* the run proceeds with an estimated row */
528
+ }
529
+ }
530
+
531
+ private readLine(line: string): void {
532
+ if (!SIDE_RUN_LINE_RE.test(line)) return
533
+ let event: Record<string, unknown> | null
534
+ try {
535
+ event = record(JSON.parse(line))
536
+ } catch {
537
+ return
538
+ }
539
+ if (!event) return
540
+ this.claude?.observe(event)
541
+ if (event.type === 'result') {
542
+ this.sawEnd = true
543
+ if (typeof event.result === 'string') this.outputChars = event.result.length
544
+ }
545
+ const item = record(event.item)
546
+ if (this.provider === 'codex' && event.type === 'item.completed' && item?.type === 'agent_message' && typeof item.text === 'string') {
547
+ this.sawEnd = true
548
+ this.outputChars += item.text.length
549
+ }
550
+ }
551
+
552
+ /** The stream may end without a newline. Call once, when the run is over. */
553
+ finish(): RealUsage | null {
554
+ if (this.tail) {
555
+ const last = this.tail
556
+ this.tail = ''
557
+ try { this.readLine(last) } catch { /* estimated */ }
558
+ }
559
+ return this.claude ? this.claude.take() : null
560
+ }
561
+ }
@@ -0,0 +1,32 @@
1
+ // A public https address, for the live cue log. The rules of publicWebUrl in meeting-ask-web.ts (no credentials, no localhost
2
+ // or private or link-local host) with three differences, all stricter: https only, because a cue source never links to plain
3
+ // http; one trailing dot is dropped from the host before the local-host checks, and a host with an empty label is refused, so
4
+ // `localhost.` and `printer.local.` are the local hosts they are; and only the default port, because an https page on port 80
5
+ // is not a page a cue should open. It is a copy on purpose: meeting-ask-web.ts pulls in the whole Ask runtime, and the cue log
6
+ // must stay standalone (a v2-off server reads it without building anything). public-https-url.test.ts pins parity with
7
+ // publicWebUrl on a table of URLs and lists the differences by name, so the two cannot drift apart unnoticed.
8
+
9
+ import { isIP } from 'node:net'
10
+
11
+ function publicAddress(host: string): boolean {
12
+ const h = host.toLowerCase().replace(/^\[|\]$/g, '')
13
+ if (!h || h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local') || h.endsWith('.internal')) return false
14
+ if (isIP(h) === 4) {
15
+ const p = h.split('.').map(Number)
16
+ return !(p[0] === 0 || p[0] === 10 || p[0] === 127 || p[0] >= 224 || p[0] === 169 && p[1] === 254 || p[0] === 172 && p[1] >= 16 && p[1] <= 31 || p[0] === 192 && p[1] === 168 || p[0] === 100 && p[1] >= 64 && p[1] <= 127)
17
+ }
18
+ if (isIP(h) === 6) return !(h === '::' || h === '::1' || h.startsWith('fc') || h.startsWith('fd') || h.startsWith('fe80') || h.startsWith('::ffff:'))
19
+ return h.includes('.')
20
+ }
21
+
22
+ /** The normalised href when `value` is an https URL to a public host, else null. Never throws. */
23
+ export function publicHttpsUrl(value: unknown): string | null {
24
+ try {
25
+ if (typeof value !== 'string' || value.length > 2048) return null
26
+ const u = new URL(value)
27
+ const host = u.hostname.replace(/\.$/, '')
28
+ if (host.endsWith('.') || host.includes('..')) return null
29
+ if (u.protocol !== 'https:' || u.username || u.password || !publicAddress(host) || u.port && u.port !== '443') return null
30
+ return u.href
31
+ } catch { return null }
32
+ }
@@ -1,4 +1,5 @@
1
1
  import { routineAuditCaller } from './routines-config.js'
2
+ import { jobAuditCaller } from './job-audit-caller.js'
2
3
  import { carriesBoundTo } from './agent-session-binding-store.js'
3
4
  import { randomUUID } from 'node:crypto'
4
5
  import { resolve } from 'node:path'
@@ -423,6 +424,8 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
423
424
  ...(request.sessionName ? { sessionName: request.sessionName } : {}),
424
425
  // 6.82.0: a routine's run is counted in the token audit under the routine that started it.
425
426
  ...(routineAuditCaller(request) ? { auditCaller: routineAuditCaller(request) } : {}),
427
+ // 6.97.0: so is a dispatched task, and a meeting review.
428
+ ...(jobAuditCaller(request) ? { auditCaller: jobAuditCaller(request) } : {}),
426
429
  ...(request.keyTurnOnly ? { keyTurnOnly: true } : {}),
427
430
  // 6.78.0: only on a run the coordinator decided is steerable.
428
431
  ...(steer ? { steer } : {}),
@@ -20,6 +20,7 @@ import {
20
20
  import { tmpdir } from 'node:os'
21
21
  import { extname, join } from 'node:path'
22
22
  import type { MediaMime } from '../../shared/media-attachment.js'
23
+ import { extractXlsxText, XlsxError, XLSX_LIMITS } from './xlsx-text.js'
23
24
 
24
25
  /** Images and documents — the contract's `otherMaxBytes`. Unchanged. */
25
26
  export const MAX_OTHER_MEDIA_BYTES = 64 * 1024 * 1024 // 67108864
@@ -144,7 +145,7 @@ interface PreparedOriginalFile {
144
145
 
145
146
  export type PreparedDocumentFile = PreparedOriginalFile & {
146
147
  category: 'document'
147
- mime: Extract<MediaMime, 'text/plain' | 'text/markdown' | 'text/csv' | 'application/json' | 'application/pdf'>
148
+ mime: Extract<MediaMime, 'text/plain' | 'text/markdown' | 'text/csv' | 'application/json' | 'application/pdf' | typeof XLSX_MIME>
148
149
  extractedText: string
149
150
  textTruncated: boolean
150
151
  pageImages: Buffer[]
@@ -502,6 +503,22 @@ export function mediaCeilingBytes(isVideo: boolean, transfer: MediaTransferMode
502
503
  return transfer === 'chunked' ? MAX_CHUNKED_MEDIA_BYTES : MAX_VIDEO_MEDIA_BYTES
503
504
  }
504
505
 
506
+ export const XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' as const
507
+
508
+ /** A workbook's text: sheet names and cell values as tab-separated rows, bounded (see xlsx-text.ts). No formula is evaluated. */
509
+ function processXlsx(sourcePath: string, byteLength: number): PreparedDocumentFile {
510
+ if (byteLength > XLSX_LIMITS.maxBytes) throw new RichMediaSafetyError('attachment_too_large', `workbook exceeds ${XLSX_LIMITS.maxBytes} byte limit`)
511
+ let result: ReturnType<typeof extractXlsxText>
512
+ try {
513
+ result = extractXlsxText(readFileSync(sourcePath))
514
+ } catch (e) {
515
+ if (e instanceof XlsxError) throw new RichMediaSafetyError(e.code === 'too_large' ? 'attachment_too_large' : e.code === 'not_a_workbook' ? 'unsupported_attachment_format' : 'corrupt_attachment', `workbook: ${e.message}`)
516
+ throw new RichMediaSafetyError('corrupt_attachment', 'workbook could not be read')
517
+ }
518
+ const capped = capText(result.text)
519
+ return { category: 'document', mime: XLSX_MIME, originalPath: sourcePath, originalBytes: byteLength, extractedText: capped.text, textTruncated: result.truncated || capped.truncated, pageImages: [] }
520
+ }
521
+
505
522
  export async function prepareRichMediaFromFile(
506
523
  sourcePath: string,
507
524
  options: { label?: string; declaredMime?: string; byteLength: number; transfer?: MediaTransferMode },
@@ -516,6 +533,10 @@ export async function prepareRichMediaFromFile(
516
533
  throw new RichMediaSafetyError('attachment_too_large', `attachment exceeds ${cap} byte limit`)
517
534
  }
518
535
  if (isPdf(head)) return processPdf(sourcePath, options.byteLength)
536
+ // 6.97.8: .xlsx. Byte-authoritative like the PDF: the zip signature must be there, and the name or the declared type must say workbook.
537
+ if (head.length >= 4 && head.readUInt32LE(0) === 0x04034b50 && (fileExtension(options.label) === '.xlsx' || (options.declaredMime ?? '').toLowerCase().split(';', 1)[0] === XLSX_MIME)) {
538
+ return processXlsx(sourcePath, options.byteLength)
539
+ }
519
540
  if (isVideo) return processVideo(sourcePath, options.byteLength, options.label, options.declaredMime)
520
541
 
521
542
  const ext = fileExtension(options.label)
@@ -529,7 +550,7 @@ export async function prepareRichMediaFromFile(
529
550
  : ext === '.txt' || declared === 'text/plain'
530
551
  ? 'text/plain'
531
552
  : null
532
- if (!textMime) throw new RichMediaSafetyError('unsupported_attachment_format', 'supported files: TXT, MD, CSV, JSON, PDF, MP4, MOV')
553
+ if (!textMime) throw new RichMediaSafetyError('unsupported_attachment_format', 'supported files: TXT, MD, CSV, JSON, XLSX, PDF, MP4, MOV')
533
554
  // Text has to be decoded to be validated at all, and it is bounded by the
534
555
  // 64 MiB `otherMaxBytes` cap enforced above.
535
556
  const capped = capText(decodeStrictUtf8(readFileSync(sourcePath)))