@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.
- package/CHANGELOG.md +166 -0
- package/README.md +4 -0
- package/managed-runtime-contract.json +13 -0
- package/package.json +3 -1
- package/server/index.ts +12 -0
- package/server/lib/attached-provider-adapter.ts +35 -1
- package/server/lib/claude-bridge.ts +47 -0
- package/server/lib/codex-bridge.ts +11 -1
- package/server/lib/codex-rollout-watermark.ts +42 -0
- package/server/lib/cursor-bridge.ts +2 -1
- package/server/lib/decision-provider.ts +4 -2
- package/server/lib/dictation-clean-models.ts +2 -0
- package/server/lib/dictation-clean.ts +2 -2
- package/server/lib/dictation-polish-pipeline.ts +22 -2
- package/server/lib/dictation-prewarm.ts +16 -0
- package/server/lib/direct-bridge.ts +14 -2
- package/server/lib/direct-sse.ts +6 -1
- package/server/lib/display-bus.ts +1 -1
- package/server/lib/fork-thread.ts +32 -1
- package/server/lib/held-naming-batches.ts +44 -7
- package/server/lib/held-voice-groups.ts +5 -4
- package/server/lib/job-audit-caller.ts +20 -0
- package/server/lib/keyed-sessions.ts +21 -1
- package/server/lib/lens-gist-engines.ts +17 -5
- package/server/lib/live-cos-ask.ts +29 -6
- package/server/lib/live-cue-verdicts.ts +43 -8
- package/server/lib/live-cues-engine.ts +4 -1
- package/server/lib/live-cues-filter.ts +50 -0
- package/server/lib/live-cues-general-prompts.ts +137 -0
- package/server/lib/live-cues-general-topic.ts +245 -0
- package/server/lib/live-cues-general.ts +715 -0
- package/server/lib/live-cues-v2-data.ts +763 -0
- package/server/lib/live-cues-v2-grounded.ts +262 -0
- package/server/lib/live-cues-v2-judge-b.ts +59 -0
- package/server/lib/live-cues-v2-names.ts +49 -0
- package/server/lib/live-cues-v2-notes.ts +8 -4
- package/server/lib/live-cues-v2-open-items.ts +132 -0
- package/server/lib/live-cues-v2-pacing.ts +45 -0
- package/server/lib/live-cues-v2-prompts-kinds.ts +9 -4
- package/server/lib/live-cues-v2-settings.ts +89 -10
- package/server/lib/live-cues-v2.ts +572 -47
- package/server/lib/mac-recording-asr.ts +6 -3
- package/server/lib/mac-speaker-call-human.ts +371 -0
- package/server/lib/mac-speaker-call-interval-human.ts +499 -0
- package/server/lib/mac-speaker-call-interval-projection.ts +233 -0
- package/server/lib/mac-speaker-call-original-view.ts +328 -0
- package/server/lib/mac-speaker-call-producer-worker.ts +615 -0
- package/server/lib/mac-speaker-call-producer.ts +539 -0
- package/server/lib/mac-speaker-call-store.ts +264 -0
- package/server/lib/mac-speaker-call-view.ts +373 -0
- package/server/lib/mac-speaker-live-compute.ts +72 -0
- package/server/lib/mac-speaker-live-memory.ts +414 -0
- package/server/lib/mac-speaker-live-projection.ts +126 -0
- package/server/lib/mac-speaker-live-save.ts +90 -0
- package/server/lib/mac-speaker-live-store.ts +71 -0
- package/server/lib/mac-speaker-live-worker.ts +58 -0
- package/server/lib/mac-speaker-live.ts +175 -0
- package/server/lib/mac-speaker-profile-extractor.ts +15 -10
- package/server/lib/mac-speaker-shadow-repair.ts +307 -0
- package/server/lib/mac-voice-assignment.ts +46 -27
- package/server/lib/mac-voice-display.ts +195 -0
- package/server/lib/mac-voice-naming-batch.ts +1067 -0
- package/server/lib/mac-voice-suggestions.ts +548 -0
- package/server/lib/maintenance-lifecycle.ts +2 -0
- package/server/lib/media-store.ts +2 -1
- package/server/lib/meeting-ask-pasted.ts +135 -0
- package/server/lib/meeting-ask-review.ts +8 -1
- package/server/lib/meeting-ask-runner.ts +4 -1
- package/server/lib/meeting-ask-runtime.ts +189 -17
- package/server/lib/meeting-ask-store.ts +2 -0
- package/server/lib/meeting-ask-web.ts +284 -13
- package/server/lib/meeting-batch-progress.ts +51 -5
- package/server/lib/meeting-batch-transcribe.ts +35 -2
- package/server/lib/meeting-corrections.ts +2 -1
- package/server/lib/meeting-fact-check.ts +3 -0
- package/server/lib/meeting-finalization-jobs.ts +69 -2
- package/server/lib/meeting-public-topic.ts +6 -1
- package/server/lib/meeting-relabel-enrolment.ts +2 -1
- package/server/lib/meeting-relabel.ts +17 -10
- package/server/lib/meeting-speaker-review.ts +187 -21
- package/server/lib/meeting-store.ts +6 -1
- package/server/lib/parakeet.ts +51 -5
- package/server/lib/pending-batch-retention.ts +58 -0
- package/server/lib/private-reference.ts +38 -0
- package/server/lib/provider-usage.ts +561 -0
- package/server/lib/public-https-url.ts +32 -0
- package/server/lib/query-job-runtime.ts +3 -0
- package/server/lib/rich-media-safety.ts +23 -2
- package/server/lib/session-find-ask.ts +288 -0
- package/server/lib/session-find-collect.ts +715 -0
- package/server/lib/session-find-redact.ts +254 -0
- package/server/lib/session-find.ts +345 -0
- package/server/lib/speaker-calibration-log.ts +3 -2
- package/server/lib/speaker-embeddings.ts +46 -35
- package/server/lib/speaker-name-nfc.ts +37 -0
- package/server/lib/speaker-rename-fanout.ts +7 -6
- package/server/lib/speaker-shadow-decide.ts +59 -22
- package/server/lib/speaker-shadow-pair-source.ts +91 -0
- package/server/lib/speaker-shadow-paired.ts +268 -0
- package/server/lib/speaker-shadow-report.ts +2 -2
- package/server/lib/speaker-shadow-worker.ts +50 -15
- package/server/lib/speaker-shadow.ts +80 -19
- package/server/lib/speaker-trainer.ts +2 -1
- package/server/lib/token-audit.ts +36 -6
- package/server/lib/transcript-cleanup.ts +3 -2
- package/server/lib/voice-directory.ts +10 -5
- package/server/lib/voice-profile-store.ts +24 -14
- package/server/lib/web-lookup-ledger.ts +151 -0
- package/server/lib/work-review-runtime.ts +2 -0
- package/server/lib/work-search.ts +3 -1
- package/server/lib/xlsx-text.ts +277 -0
- package/server/routes/decision-provider.ts +4 -1
- package/server/routes/jev.ts +5 -1
- package/server/routes/live-cues.ts +36 -14
- package/server/routes/meeting.ts +515 -61
- package/server/routes/session-find.ts +73 -0
- package/server/routes/transcribe-stream.ts +114 -33
- package/server/routes/voice.ts +28 -18
- package/server/scripts/live-cues-v3-mutation-gate.mjs +311 -0
- package/server/scripts/mac-voice-assignment-6930-mutation-gate.mjs +5 -5
- package/shared/live-cues-v2.ts +38 -5
- package/shared/media-attachment.ts +2 -0
- 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)))
|