@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.
- package/CHANGELOG.md +147 -0
- package/README.md +52 -0
- package/managed-runtime-contract.json +13 -0
- package/package.json +3 -1
- package/server/index.ts +19 -1
- 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/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 +36 -0
- package/server/lib/job-audit-caller.ts +20 -0
- 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 +453 -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 +570 -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 +44 -26
- 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-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-speaker-review.ts +182 -17
- 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/speaker-shadow-decide.ts +503 -0
- package/server/lib/speaker-shadow-models.ts +150 -0
- package/server/lib/speaker-shadow-numeric.ts +143 -0
- 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 +113 -0
- package/server/lib/speaker-shadow-timeline.ts +210 -0
- package/server/lib/speaker-shadow-worker.ts +312 -0
- package/server/lib/speaker-shadow.ts +788 -0
- package/server/lib/token-audit.ts +36 -6
- package/server/lib/transcript-cleanup.ts +3 -2
- package/server/lib/voice-directory.ts +8 -4
- 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/health.ts +5 -0
- package/server/routes/live-cues.ts +36 -14
- package/server/routes/meeting.ts +506 -49
- package/server/routes/speaker-shadow.ts +79 -0
- package/server/routes/transcribe-stream.ts +114 -33
- package/server/routes/voice.ts +3 -0
- 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
|
@@ -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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
309
|
-
?
|
|
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
|
+
}
|
package/server/routes/health.ts
CHANGED
|
@@ -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
|
})
|