@gotcos/glasses-server 6.95.0 → 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 +157 -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 +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 +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
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
export const DICTATION_CLEAN_MODELS = ['haiku', 'sonnet', 'luna-5.6-fast'] as const
|
|
3
3
|
export type DictationCleanModel = typeof DICTATION_CLEAN_MODELS[number]
|
|
4
4
|
export const LUNA_DICTATION_CLI_MODEL = 'gpt-5.6-luna-none-fast'
|
|
5
|
+
/** The polish choice that runs through Cursor, whose path has no system prompt. */
|
|
6
|
+
export const LUNA_DICTATION_MODEL = 'luna-5.6-fast'
|
|
5
7
|
export function isDictationCleanModel(value: unknown): value is DictationCleanModel {
|
|
6
8
|
return value === 'sonnet' || value === 'luna-5.6-fast' || value === 'haiku'
|
|
7
9
|
}
|
|
@@ -4,7 +4,7 @@ import { homedir, tmpdir } from 'node:os'
|
|
|
4
4
|
import { dirname, join } from 'node:path'
|
|
5
5
|
import { resolveProviderBinary } from './provider-binary.js'
|
|
6
6
|
import { terminateProviderProcess } from './provider-process-lifecycle.js'
|
|
7
|
-
import { isDictationCleanModel, LUNA_DICTATION_CLI_MODEL } from './dictation-clean-models.js'
|
|
7
|
+
import { isDictationCleanModel, LUNA_DICTATION_CLI_MODEL, LUNA_DICTATION_MODEL } from './dictation-clean-models.js'
|
|
8
8
|
import { sanitizeCustomInstructions } from './dictation-polish-instructions.js'
|
|
9
9
|
import { LEARNED_STYLE_MAX_CHARS } from './dictation-learned-style.js'
|
|
10
10
|
import type { DictationRegister } from './dictation-register.js'
|
|
@@ -311,7 +311,7 @@ export async function autoCleanDictation(
|
|
|
311
311
|
if (opts.signal?.aborted) throw new Error('Auto-clean aborted')
|
|
312
312
|
const model = (opts.model || process.env.COS_DICTATION_AUTOCLEAN_MODEL || 'haiku').toLowerCase()
|
|
313
313
|
if (!isDictationCleanModel(model)) throw new Error('Unsupported dictation cleanup model')
|
|
314
|
-
const cursor = model ===
|
|
314
|
+
const cursor = model === LUNA_DICTATION_MODEL
|
|
315
315
|
const binary = resolveProviderBinary(cursor ? 'cursor' : 'claude')
|
|
316
316
|
if (!binary.ok) throw new Error(`Dictation cleanup ${cursor ? 'Cursor' : 'Claude'} CLI unavailable`)
|
|
317
317
|
const configuredTimeout = Number.parseInt(process.env.COS_DICTATION_AUTOCLEAN_TIMEOUT_MS || '20000', 10)
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { LUNA_DICTATION_MODEL, type DictationCleanModel } from './dictation-clean-models.js'
|
|
2
2
|
import { applyNegativeRules } from './hallucination-filter.js'
|
|
3
3
|
import { applyCorrections } from './whisper-local.js'
|
|
4
4
|
import { applyFuzzyCorrections } from './fuzzy-correct.js'
|
|
5
5
|
import { getAllSpeakerNames } from './speaker-embeddings.js'
|
|
6
|
-
import { autoCleanDictation, AUTOCLEAN_MAX_CHARS } from './dictation-clean.js'
|
|
6
|
+
import { autoCleanDictation, AUTOCLEAN_MAX_CHARS, buildDictationPolishPrompt, DICTATION_POLISH_SYSTEM_PROMPTS, type DictationPromptExtras } from './dictation-clean.js'
|
|
7
7
|
import { dictationPolishPromptVersion, styleAndRegisterApply, type DictationPolishPromptVersion } from './dictation-polish-options.js'
|
|
8
8
|
import { fillerPassEnabled, stripFillersIfEnglish } from './dictation-fillers.js'
|
|
9
9
|
import { polishBudgetExhausted, recordPolishCall, type PolishBucket } from './dictation-polish-budget.js'
|
|
@@ -265,6 +265,21 @@ export function guardPolishAnswer(
|
|
|
265
265
|
return { text: answer, outcome: answer !== cleaned ? 'polished' : 'unchanged' }
|
|
266
266
|
}
|
|
267
267
|
|
|
268
|
+
/**
|
|
269
|
+
* 6.97.0, for the audit row only: what the model is sent, on its own field, because `input_chars` stays
|
|
270
|
+
* the words dictated so old and new rows still compare. Absent if it cannot be worked out. Never throws.
|
|
271
|
+
*/
|
|
272
|
+
function polishPromptChars(
|
|
273
|
+
cleaned: string, model: string, version: DictationPolishPromptVersion, customInstructions: string, extras: DictationPromptExtras,
|
|
274
|
+
): { promptChars?: number } {
|
|
275
|
+
try {
|
|
276
|
+
const system = model === LUNA_DICTATION_MODEL ? 0 : DICTATION_POLISH_SYSTEM_PROMPTS[version].length
|
|
277
|
+
return { promptChars: system + buildDictationPolishPrompt(cleaned, getVocabulary(), version, customInstructions, extras).length }
|
|
278
|
+
} catch {
|
|
279
|
+
return {}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
268
283
|
export async function polishOutboundDictation(
|
|
269
284
|
text: string,
|
|
270
285
|
opts: OutboundOptions,
|
|
@@ -334,6 +349,9 @@ export async function polishOutboundDictation(
|
|
|
334
349
|
...(canaried ? { polishRegister: register } : {}),
|
|
335
350
|
...(used.polishPrompt ? { polishPrompt: used.polishPrompt } : {}),
|
|
336
351
|
polishOutcome: guarded.outcome, ...(guarded.guard ? { polishGuard: guarded.guard } : {}),
|
|
352
|
+
// Real numbers from a warm result. The cold path prints plain text, so its row stays estimated.
|
|
353
|
+
...polishPromptChars(cleaned, model, promptVersion, customInstructions, { learnedStyle: style.lines, register }),
|
|
354
|
+
...warmEntry?.usage,
|
|
337
355
|
})
|
|
338
356
|
return {
|
|
339
357
|
text: guarded.text, ...(prewarm ? { prewarm } : {}),
|
|
@@ -357,6 +375,8 @@ export async function polishOutboundDictation(
|
|
|
357
375
|
...(canaried ? { polishRegister: register } : {}),
|
|
358
376
|
...(used.polishPrompt ? { polishPrompt: used.polishPrompt } : {}),
|
|
359
377
|
polishOutcome: 'aborted',
|
|
378
|
+
...polishPromptChars(cleaned, model, promptVersion, customInstructions, { learnedStyle: style.lines, register }),
|
|
379
|
+
...warmEntry?.usage,
|
|
360
380
|
})
|
|
361
381
|
console.warn('[prompt-draft] auto-clean aborted by the client (glossary-only)')
|
|
362
382
|
} else {
|
|
@@ -5,6 +5,7 @@ import { tmpdir } from 'node:os'
|
|
|
5
5
|
import { join } from 'node:path'
|
|
6
6
|
import { resolveProviderBinary } from './provider-binary.js'
|
|
7
7
|
import { terminateProviderProcess } from './provider-process-lifecycle.js'
|
|
8
|
+
import { ClaudeRunUsage, type EngineUsage } from './provider-usage.js'
|
|
8
9
|
import {
|
|
9
10
|
buildDictationPolishPrompt, claudePolishArgs, DICTATION_POLISH_SYSTEM_PROMPTS, dictationChildEnv, dictationPolishPromptVersion,
|
|
10
11
|
type DictationPolishPromptVersion, type DictationPromptExtras,
|
|
@@ -42,6 +43,8 @@ interface WarmPolish {
|
|
|
42
43
|
timer: NodeJS.Timeout
|
|
43
44
|
onResult: ((event: any) => void) | null
|
|
44
45
|
onExit: (() => void) | null
|
|
46
|
+
/** 6.97.0: what the one message used, for the audit row. Read by the caller after the call. */
|
|
47
|
+
usage?: EngineUsage
|
|
45
48
|
}
|
|
46
49
|
|
|
47
50
|
/** Unclaimed warm processes, by token. */
|
|
@@ -178,6 +181,8 @@ export async function runWarmPolish(
|
|
|
178
181
|
const abort = () => finish(new Error('Auto-clean aborted'))
|
|
179
182
|
const timer = setTimeout(() => finish(new Error(`Auto-clean timed out (${timeoutMs}ms)`)), timeoutMs)
|
|
180
183
|
entry.onResult = (event) => {
|
|
184
|
+
// Before the answer is judged, and unable to affect it: the audit reads its own copy.
|
|
185
|
+
noteWarmUsage(entry, event)
|
|
181
186
|
if (event.subtype !== 'success' || event.is_error || typeof event.result !== 'string') {
|
|
182
187
|
return finish(new Error('Warm polish returned an unsuccessful result'))
|
|
183
188
|
}
|
|
@@ -203,6 +208,17 @@ export async function runWarmPolish(
|
|
|
203
208
|
})
|
|
204
209
|
}
|
|
205
210
|
|
|
211
|
+
function noteWarmUsage(entry: WarmPolish, event: unknown): void {
|
|
212
|
+
try {
|
|
213
|
+
const run = new ClaudeRunUsage(false)
|
|
214
|
+
run.observe(event)
|
|
215
|
+
const realUsage = run.take()
|
|
216
|
+
entry.usage = { ...(realUsage ? { realUsage } : {}), ...(run.cliSessionId ? { cliSessionId: run.cliSessionId } : {}) }
|
|
217
|
+
} catch {
|
|
218
|
+
/* the audit row stays estimated */
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
206
222
|
/** The server is going away: a detached warm process would outlive it. */
|
|
207
223
|
export function killAllPrewarmsSync(): void {
|
|
208
224
|
for (const entry of alive) {
|
|
@@ -551,7 +551,8 @@ export async function callDirectStreaming(
|
|
|
551
551
|
|
|
552
552
|
let fullText = ''
|
|
553
553
|
let finalized = false
|
|
554
|
-
|
|
554
|
+
// statedUsd and allStated: what the provider itself said it cost. `usd` may be priced by COS instead.
|
|
555
|
+
const usageTotals = { input: 0, output: 0, usd: 0, measured: false, statedUsd: 0, allStated: true, cached: 0 }
|
|
555
556
|
const trail = (draft: JobTrailDraft) => {
|
|
556
557
|
if (!callbacks.onTrail) return
|
|
557
558
|
try { callbacks.onTrail(draft) } catch { /* observation only */ }
|
|
@@ -569,11 +570,19 @@ export async function callDirectStreaming(
|
|
|
569
570
|
runId: run.runId,
|
|
570
571
|
sessionId: sid,
|
|
571
572
|
...(options?.clientJobId ? { clientJobId: options.clientJobId } : {}),
|
|
572
|
-
usageKind: usageTotals.measured ? 'provider_final' : 'estimated',
|
|
573
573
|
profile: profile.id,
|
|
574
574
|
lane: 'direct',
|
|
575
575
|
credentialKind: key.kind,
|
|
576
576
|
usd: Math.round(usageTotals.usd * 1e6) / 1e6,
|
|
577
|
+
// 6.97.0: the tokens the provider reported, with its cost only when every call stated one.
|
|
578
|
+
...(usageTotals.measured ? { realUsage: {
|
|
579
|
+
// prompt_tokens includes cached ones; the row keeps them apart.
|
|
580
|
+
inputTokens: usageTotals.input - usageTotals.cached,
|
|
581
|
+
outputTokens: usageTotals.output,
|
|
582
|
+
cacheReadTokens: usageTotals.cached,
|
|
583
|
+
...(usageTotals.allStated ? { costUsd: usageTotals.statedUsd } : {}),
|
|
584
|
+
resolvedModel: profile.model,
|
|
585
|
+
} } : {}),
|
|
577
586
|
})
|
|
578
587
|
}
|
|
579
588
|
|
|
@@ -745,6 +754,9 @@ export async function callDirectStreaming(
|
|
|
745
754
|
usageTotals.output += parser.usage.output
|
|
746
755
|
usageTotals.usd += usd
|
|
747
756
|
usageTotals.measured = true
|
|
757
|
+
if (parser.usage.costUsd === undefined) usageTotals.allStated = false
|
|
758
|
+
else usageTotals.statedUsd += parser.usage.costUsd
|
|
759
|
+
usageTotals.cached += parser.usage.cachedInput ?? 0
|
|
748
760
|
settle({
|
|
749
761
|
input: parser.usage.input,
|
|
750
762
|
output: parser.usage.output,
|
package/server/lib/direct-sse.ts
CHANGED
|
@@ -35,6 +35,8 @@ export interface DirectSseUsage {
|
|
|
35
35
|
output: number
|
|
36
36
|
/** Provider-reported cost in USD (OpenRouter `usage.cost`), when present. */
|
|
37
37
|
costUsd?: number
|
|
38
|
+
/** 6.97.0: the part of `input` read from cache, when the provider says. Only the audit reads it. */
|
|
39
|
+
cachedInput?: number
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
export type DirectSseEvent =
|
|
@@ -213,10 +215,13 @@ export class DirectSseParser {
|
|
|
213
215
|
const usage = chunk.usage as Record<string, unknown> | undefined
|
|
214
216
|
if (usage && typeof usage === 'object') {
|
|
215
217
|
const cost = usage.cost
|
|
218
|
+
const input = nonNegative(usage.prompt_tokens)
|
|
219
|
+
const cached = (usage.prompt_tokens_details as { cached_tokens?: unknown } | null | undefined)?.cached_tokens
|
|
216
220
|
this.usage = {
|
|
217
|
-
input
|
|
221
|
+
input,
|
|
218
222
|
output: nonNegative(usage.completion_tokens),
|
|
219
223
|
...(typeof cost === 'number' && Number.isFinite(cost) && cost >= 0 ? { costUsd: cost } : {}),
|
|
224
|
+
...(typeof cached === 'number' && Number.isSafeInteger(cached) && cached > 0 && cached <= input ? { cachedInput: cached } : {}),
|
|
220
225
|
}
|
|
221
226
|
}
|
|
222
227
|
const choices = Array.isArray(chunk.choices) ? chunk.choices : []
|
|
@@ -10,7 +10,7 @@ bus.setMaxListeners(20) // Multiple glasses clients
|
|
|
10
10
|
export interface DisplayEvent {
|
|
11
11
|
type: 'chunk' | 'done' | 'error' | 'tool_status' | 'start' | 'session_restore' | 'transcript_chunk' | 'prompt_transcript' | 'recording_start' | 'recording_stop' | 'coaching_nudge' | 'coaching_nudge_v2'
|
|
12
12
|
// Control asked the glasses to stop a G2 meeting (routes/meeting.ts stop-request).
|
|
13
|
-
| 'meeting_stop_requested'
|
|
13
|
+
| 'meeting_stop_requested' | 'mac_speaker_live_revision'
|
|
14
14
|
data: Record<string, unknown>
|
|
15
15
|
}
|
|
16
16
|
|
|
@@ -89,6 +89,8 @@ import { PROVIDER_AUTH_RE, PROVIDER_OVERFLOW_RE, PROVIDER_QUOTA_RE } from './pro
|
|
|
89
89
|
import { cursorIsolationUnavailableError, cursorSpawnEnv, isCursorIsolationUnavailable, releaseCursorSpawnOnExit } from './cursor-spawn-env.js'
|
|
90
90
|
import { anyKeyedSessions, isKeyedSession } from './keyed-sessions.js'
|
|
91
91
|
import { claudeKeySecretsGuardArgs } from './key-secrets-guard.js'
|
|
92
|
+
import { SideRunTap } from './provider-usage.js'
|
|
93
|
+
import { logTokenAudit, type TokenAuditEntry } from './token-audit.js'
|
|
92
94
|
|
|
93
95
|
/**
|
|
94
96
|
* Providers that can be forked.
|
|
@@ -496,6 +498,8 @@ export interface ForkDeps {
|
|
|
496
498
|
* always supply it.
|
|
497
499
|
*/
|
|
498
500
|
sourceWatermark?: (provider: ForkProvider, nativeThreadId: string) => string | null
|
|
501
|
+
/** 6.97.0: where the run's audit row goes. Only production wiring sets it, so no test writes a real ledger. */
|
|
502
|
+
auditRun?: (entry: TokenAuditEntry) => void
|
|
499
503
|
/**
|
|
500
504
|
* Argv builder. Optional, defaults to the real one.
|
|
501
505
|
*
|
|
@@ -793,8 +797,13 @@ async function run(request: ForkRequest, deps: ForkDeps, startedAt: number): Pro
|
|
|
793
797
|
}
|
|
794
798
|
|
|
795
799
|
let outcome: ForkResult
|
|
800
|
+
// 6.97.0: a passive reader, wired after the run's own listeners, for one audit row at the end.
|
|
801
|
+
const usageTap = deps.auditRun ? new SideRunTap(provider) : null
|
|
796
802
|
try {
|
|
797
|
-
|
|
803
|
+
const running = driveChild({ child, deps, provider, sourceNativeThreadId, prompt, timeoutMs, startedAt })
|
|
804
|
+
usageTap?.attach(child.stdout as any)
|
|
805
|
+
outcome = await running
|
|
806
|
+
auditForkRun(deps, usageTap, outcome, provider, prompt)
|
|
798
807
|
} finally {
|
|
799
808
|
// EVERY path: success, mismatch, non-zero exit, timeout, throw. A leaked entry
|
|
800
809
|
// outlives the process and lets a RECYCLED pid inherit our self-ownership
|
|
@@ -1058,6 +1067,27 @@ function driveChild(input: DriveInput): Promise<ForkResult> {
|
|
|
1058
1067
|
// Production wiring
|
|
1059
1068
|
// ---------------------------------------------------------------------------
|
|
1060
1069
|
|
|
1070
|
+
/** One row for a fork that reached an end and names its new thread. Never changes the fork's outcome. */
|
|
1071
|
+
function auditForkRun(deps: ForkDeps, tap: SideRunTap | null, outcome: ForkResult, provider: ForkProvider, prompt: string): void {
|
|
1072
|
+
try {
|
|
1073
|
+
if (!tap || !deps.auditRun) return
|
|
1074
|
+
const realUsage = tap.finish()
|
|
1075
|
+
if (!outcome.newNativeThreadId || (!outcome.ok && !tap.sawEnd)) return
|
|
1076
|
+
deps.auditRun({
|
|
1077
|
+
source: 'g2-fork',
|
|
1078
|
+
caller: outcome.ok ? 'fork_thread' : `fork_thread:${outcome.reason}`,
|
|
1079
|
+
model: provider,
|
|
1080
|
+
inputChars: prompt.length,
|
|
1081
|
+
outputChars: tap.outputChars,
|
|
1082
|
+
durationMs: outcome.durationMs,
|
|
1083
|
+
cliSessionId: outcome.newNativeThreadId,
|
|
1084
|
+
...(realUsage ? { realUsage } : {}),
|
|
1085
|
+
})
|
|
1086
|
+
} catch {
|
|
1087
|
+
// The audit never changes a fork's outcome.
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1061
1091
|
/**
|
|
1062
1092
|
* The real dependency set.
|
|
1063
1093
|
*
|
|
@@ -1071,6 +1101,7 @@ export function realForkDeps(
|
|
|
1071
1101
|
): ForkDeps {
|
|
1072
1102
|
return {
|
|
1073
1103
|
now: () => Date.now(),
|
|
1104
|
+
auditRun: entry => logTokenAudit(entry),
|
|
1074
1105
|
resolveBinary: provider => resolveProviderBinary(provider),
|
|
1075
1106
|
spawn: request => {
|
|
1076
1107
|
// 6.62.0 (plan 3.4): the Cursor fork's `--model` must not rewrite the CLI default.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** Durable held naming: server-stored preview, per-copy receipts, explicit recovery. */
|
|
2
|
+
import { nfc, sameSpeakerName } from './speaker-name-nfc.js'
|
|
2
3
|
import { createHash, randomUUID } from 'node:crypto'
|
|
3
4
|
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, unlinkSync } from 'node:fs'
|
|
4
5
|
import { dirname, join, resolve } from 'node:path'
|
|
@@ -14,6 +15,7 @@ import { MeetingStore } from './meeting-store.js'
|
|
|
14
15
|
import { appendCorrection, closeInterruptedCorrections, pendingCorrections, readCorrections, appliedCorrections, type CorrectionRow } from './meeting-corrections.js'
|
|
15
16
|
import { patchValue, relabelSpeakerPositions, undoSpeakerMarkdown, strippedTranscript, validatedRawMap, type SpeakerPatch } from './meeting-speaker-labels.js'
|
|
16
17
|
import { acquireMeetingSyncLock } from './meeting-sync-lock.js'
|
|
18
|
+
import { inspectOriginalViewTransaction } from './mac-speaker-call-original-view.js'
|
|
17
19
|
|
|
18
20
|
export const NAMING_CAPABILITIES = {preview:true,apply:true,undo:true,version:1} as const
|
|
19
21
|
const PREVIEW_TTL = 15 * 60 * 1000
|
|
@@ -27,12 +29,12 @@ export function resolveStoredName(name: string): {name:string;owner:boolean} {
|
|
|
27
29
|
if (stored.length > 1) throw new HeldGroupError(400,'ambiguous_name','Multiple stored voices have that spelling. Rename the duplicate profiles first.',{names:stored.map(p=>p.name)})
|
|
28
30
|
const owner = getOwnerSpeakerLabel()
|
|
29
31
|
const isOwner = foldName(owner) === foldName(name.trim()) || (stored[0] && foldName(stored[0].name) === foldName(owner))
|
|
30
|
-
return {name:isOwner ? owner : stored[0]?.name ?? name.trim(),owner:!!isOwner}
|
|
32
|
+
return {name:isOwner ? owner : stored[0]?.name ?? nfc(name.trim()),owner:!!isOwner}
|
|
31
33
|
}
|
|
32
34
|
function voiceVersion(name: string) {
|
|
33
35
|
const path = dataPath('voice-profiles.json')
|
|
34
36
|
const profiles = readVoiceProfiles().profiles
|
|
35
|
-
return {storeMtime:existsSync(path)?statSync(path).mtimeMs:0,profileCount:profiles.length,targetSamples:profiles.find(p=>p.name
|
|
37
|
+
return {storeMtime:existsSync(path)?statSync(path).mtimeMs:0,profileCount:profiles.length,targetSamples:profiles.find(p=>sameSpeakerName(p.name,name))?.embeddings.length ?? 0}
|
|
36
38
|
}
|
|
37
39
|
function evidenceVersion(refs:HeldSampleRef[]) {
|
|
38
40
|
return hash([...new Set(refs.map(r=>r.sessionId))].sort().map(sessionId=>({sessionId,rows:readChunkEmbeddings(sessionId).rows.map(r=>({i:r.i,embedding:Array.from(r.embedding)})),cache:existsSync(dataPath('held-voice-embeddings',`${sessionId}.json`))?readFileSync(dataPath('held-voice-embeddings',`${sessionId}.json`),'utf8'):''})))
|
|
@@ -179,6 +181,23 @@ function checkedRevision(value:unknown):number {
|
|
|
179
181
|
if(typeof value!=='number'||!Number.isSafeInteger(value)||value<0)throw new Error('Invalid meeting revision')
|
|
180
182
|
return value
|
|
181
183
|
}
|
|
184
|
+
/** A call-wide view is bound to the original BASE and correction ledger. Legacy
|
|
185
|
+
* physical renames (including Undo and profile fan-out) must not silently make
|
|
186
|
+
* that view, or a later human annotation, disappear. Presence is deliberately
|
|
187
|
+
* enough: a malformed or interrupted artifact is not permission to overwrite. */
|
|
188
|
+
const CALL_OVERLAY_REFUSAL='Call-wide speaker review is active or needs recovery. Use the exact reviewed segment naming flow; legacy naming cannot change this meeting.'
|
|
189
|
+
function assertNoCallSpeakerOverlay(sidecarPath:string,sessionId:string):void {
|
|
190
|
+
const stem=sidecarPath.replace(/\.g2-chunks\.json$/,'')
|
|
191
|
+
if(/^mac_[A-Za-z0-9_-]{1,100}$/.test(sessionId)){
|
|
192
|
+
const original=inspectOriginalViewTransaction({sidecarPath,meetingPath:stem+'.md'},sessionId)
|
|
193
|
+
if(original.status==='prepared'||original.status==='recovery-invalid')throw new Error(CALL_OVERLAY_REFUSAL)
|
|
194
|
+
}
|
|
195
|
+
const artifacts=[...['.speaker-call.json','.speaker-call-state.json','.speaker-call-human.json','.speaker-call-human-transaction.json','.speaker-call-interval-human.json','.speaker-call-interval-human-transaction.json'].map(suffix=>stem+suffix),join(dirname(sidecarPath),`.${sessionId}.speaker-call.lock`)]
|
|
196
|
+
for(const artifact of artifacts){
|
|
197
|
+
try{lstatSync(artifact)}catch(e){if((e as NodeJS.ErrnoException).code==='ENOENT')continue;throw e}
|
|
198
|
+
throw new Error(CALL_OVERLAY_REFUSAL)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
182
201
|
function resolveCopies(sessionId:string):ResolvedCopy[] {
|
|
183
202
|
if(sessionBusy(sessionId)||existsSync(dataPath('meeting-finalization-jobs',`${sessionId}.json`)))throw new Error('Meeting is recording or finalizing; retry after it finishes')
|
|
184
203
|
const local = new MeetingStore().findBySessionId(sessionId)
|
|
@@ -197,6 +216,7 @@ function resolveCopies(sessionId:string):ResolvedCopy[] {
|
|
|
197
216
|
throw new Error('Configured operations folder is unavailable; retry after sync')
|
|
198
217
|
}
|
|
199
218
|
return paths.map(p=>{
|
|
219
|
+
assertNoCallSpeakerOverlay(p.sidecarPath,sessionId)
|
|
200
220
|
assertFile(p.sidecarPath)
|
|
201
221
|
const raw=readFileSync(p.sidecarPath,'utf8'),doc=JSON.parse(raw)
|
|
202
222
|
if(doc.sessionId!==sessionId) throw new Error('Meeting identity changed')
|
|
@@ -428,8 +448,22 @@ export async function undoHeldNaming(batchId:string,confirm:boolean){
|
|
|
428
448
|
let lock:Awaited<ReturnType<typeof acquireMeetingSyncLock>>|undefined
|
|
429
449
|
try{
|
|
430
450
|
try{lock=await acquireMeetingSyncLock()}catch(e){throw new HeldGroupError(409,'sync_running',errorText(e))}
|
|
451
|
+
// One meeting that cannot be undone must not stop the others: Undo restores
|
|
452
|
+
// what it can and reports the rest as partial. Each meeting is still checked
|
|
453
|
+
// before its intent row, because that row changes the ledger its copies share,
|
|
454
|
+
// and a ledger change would invalidate a human overlay in a sibling copy.
|
|
455
|
+
const refused:string[]=[]
|
|
456
|
+
let attempted=false
|
|
431
457
|
for(const meeting of batch.meetings){
|
|
432
458
|
if(!meeting.receipts.length)continue
|
|
459
|
+
if(meeting.receipts.some(r=>r.status!=='reverted')){
|
|
460
|
+
try{resolveCopies(meeting.sessionId)}catch(e){
|
|
461
|
+
// Kept in memory until the outcome is known: a full refusal below leaves the batch record untouched.
|
|
462
|
+
meeting.error=errorText(e);refused.push(meeting.error)
|
|
463
|
+
continue
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
attempted=true
|
|
433
467
|
const undoId=randomUUID(),row:CorrectionRow={id:undoId,phase:'intent',at:new Date().toISOString(),from:batch.speaker,to:'prior labels',chunks:[...meeting.namedChunks,...meeting.widerChunks],scope:'meeting',batchId:batch.batchId,source:'undo'}
|
|
434
468
|
try{
|
|
435
469
|
const later=appliedCorrections(meeting.sessionId).filter(r=>r.batchId!==batchId&&r.at>=batch.at)
|
|
@@ -462,6 +496,9 @@ export async function undoHeldNaming(batchId:string,confirm:boolean){
|
|
|
462
496
|
}catch(e){meeting.error=errorText(e);appendCorrection(meeting.sessionId,{...row,phase:'failed',error:meeting.error})}
|
|
463
497
|
writeRecord('batch',batch.batchId,batch)
|
|
464
498
|
}
|
|
499
|
+
// Nothing could be attempted and a call-wide review is why: that is a refusal
|
|
500
|
+
// with a next step, not a partial Undo.
|
|
501
|
+
if(!attempted&&refused.includes(CALL_OVERLAY_REFUSAL))throw new HeldGroupError(409,'call_review_active',CALL_OVERLAY_REFUSAL)
|
|
465
502
|
batch.status=batch.meetings.filter(m=>m.receipts.length).every(m=>m.status==='reverted')?'reverted':'partial'
|
|
466
503
|
writeRecord('batch',batch.batchId,batch)
|
|
467
504
|
return{...batchView(batch),kind:'undone',samplesKept:true,audioRestored:false}
|
|
@@ -472,7 +509,7 @@ export async function undoHeldNaming(batchId:string,confirm:boolean){
|
|
|
472
509
|
* enrolment/audio deletion off. Dry run neither records a preview nor writes a ledger. */
|
|
473
510
|
export async function renameMeetingSpeaker(sessionId:string,from:string,to:string,options:{apply?:boolean;operationsDir?:string;expectedSidecarPath?:string}={}) : Promise<{copies:Array<{sidecarPath:string;meetingPath:string;labels:number;transcript:number;attendees:number;unresolvedTurns:number}>;batchId?:string;error?:string}> {
|
|
474
511
|
if(options.operationsDir && resolveCosOperationsDir()!==realpathSync(options.operationsDir)) throw new Error('Operations root differs from the requested fan-out root')
|
|
475
|
-
if(from
|
|
512
|
+
if(sameSpeakerName(from,to)||!from.trim()||!to.trim())return{copies:[]}
|
|
476
513
|
if(options.apply&&mutationBusy)throw new Error('Another naming is running; retry')
|
|
477
514
|
let lock:Awaited<ReturnType<typeof acquireMeetingSyncLock>>|undefined
|
|
478
515
|
let batch:BatchRecord|undefined
|
|
@@ -486,22 +523,22 @@ export async function renameMeetingSpeaker(sessionId:string,from:string,to:strin
|
|
|
486
523
|
if(options.expectedSidecarPath && (!operations || realpathSync(operations.sidecarPath)!==realpathSync(options.expectedSidecarPath)))throw new Error('Session resolves to a different sidecar; duplicate or archived copy')
|
|
487
524
|
const maps=copies.map(c=>validatedRawMap(c.doc))
|
|
488
525
|
if(maps.some(map=>hash(map)!==hash(maps[0])))throw new Error('Meeting copies disagree on raw mapping')
|
|
489
|
-
const positions:number[]=copies[0].doc.chunks.flatMap((c:any,i:number)=>c.speaker
|
|
526
|
+
const positions:number[]=copies[0].doc.chunks.flatMap((c:any,i:number)=>sameSpeakerName(c.speaker,from)?[i]:[])
|
|
490
527
|
// Repair historical chunks-only renames too: HQ words still carry `from`
|
|
491
528
|
// even when the compacted chunk already says `to` (the 6.27.10 trap).
|
|
492
529
|
for(const c of copies)for(const segment of c.doc.batchSegments??[])for(const word of segment.speakerWords??[]){
|
|
493
|
-
if(word.speaker
|
|
530
|
+
if(!sameSpeakerName(word.speaker,from))continue
|
|
494
531
|
const absolute=Number(segment.startElapsed??0)+Number(word.start)*1000
|
|
495
532
|
let position=-1,distance=Infinity
|
|
496
533
|
const raw=validatedRawMap(c.doc)
|
|
497
534
|
raw.forEach((index,i)=>{if(index<segment.startChunkIdx||index>segment.endChunkIdx)return;const d=Math.abs(Number(c.doc.chunks[i].elapsed)-absolute);if(d<distance){distance=d;position=i}})
|
|
498
535
|
if(distance>3500||position<0)throw new Error('Old HQ speaker has no safe raw chunk mapping')
|
|
499
|
-
if(![from,to].
|
|
536
|
+
if(![from,to].some(n=>sameSpeakerName(n,c.doc.chunks[position].speaker)))throw new Error('HQ speaker conflicts with an unrelated named chunk')
|
|
500
537
|
if(!positions.includes(position))positions.push(position)
|
|
501
538
|
}
|
|
502
539
|
positions.sort((a,b)=>a-b)
|
|
503
540
|
if(!positions.length)return{copies:[]}
|
|
504
|
-
if(copies.some(c=>positions.some((i:number)=>![from,to].
|
|
541
|
+
if(copies.some(c=>positions.some((i:number)=>![from,to].some(n=>sameSpeakerName(n,c.doc.chunks[i]?.speaker)))))throw new Error('Meeting copies disagree on prior speaker labels')
|
|
505
542
|
const batchId=randomUUID()
|
|
506
543
|
const edits=copies.map(c=>relabelSpeakerPositions(c.doc,c.blended?'':c.markdown,positions,to,batchId))
|
|
507
544
|
const results=copies.map((c,i)=>({sidecarPath:c.sidecarPath,meetingPath:c.meetingPath,labels:positions.length,transcript:edits[i].transcript,attendees:edits[i].attendees,unresolvedTurns:edits[i].unresolvedTurns}))
|
|
@@ -62,6 +62,7 @@ import {
|
|
|
62
62
|
greedyDiversitySelect,
|
|
63
63
|
pairwiseSimilarityMatrix,
|
|
64
64
|
} from './voice-enrolment-selection.js'
|
|
65
|
+
import { sameSpeakerName } from './speaker-name-nfc.js'
|
|
65
66
|
|
|
66
67
|
/** Where decoded-on-demand vectors are kept. One JSON file per held session. */
|
|
67
68
|
export const HELD_EMBEDDING_CACHE_DIR = 'held-voice-embeddings'
|
|
@@ -432,12 +433,12 @@ export function suggestProfile(embedding: Float32Array, profiles: VoiceProfile[]
|
|
|
432
433
|
if (!best || candidate.score > best.score) best = candidate
|
|
433
434
|
}
|
|
434
435
|
if (!best || best.anchor === null) return null
|
|
435
|
-
const winner = profiles.find(p => p.name
|
|
436
|
+
const winner = profiles.find(p => sameSpeakerName(p.name, best!.name))!
|
|
436
437
|
// Near-duplicate profile pairs represent an existing identity ambiguity, not
|
|
437
438
|
// independent competing acoustic evidence. Do not let that pair erase the
|
|
438
439
|
// margin against genuinely different voices. Never merge or rename here.
|
|
439
440
|
const runnerUp = candidates.filter(c => c.name !== best!.name).filter(c => {
|
|
440
|
-
const other = profiles.find(p => p.name
|
|
441
|
+
const other = profiles.find(p => sameSpeakerName(p.name, c.name))!
|
|
441
442
|
return profileSimilarity(winner, other) < HELD_NEAR_PROFILE_SIMILARITY
|
|
442
443
|
}).reduce((score, c) => Math.max(score, c.score), 0)
|
|
443
444
|
const margin = best.score - runnerUp
|
|
@@ -754,7 +755,7 @@ export function enrollHeldGroup(name: string, refs: HeldSampleRef[], opts: { dry
|
|
|
754
755
|
const core = expand(coreReps)
|
|
755
756
|
const leftBehind = expand(reps.filter(i => !coreSet.has(i))).map(s => ({ sessionId: s.sessionId, chunkIndex: s.chunkIndex })).sort(compareRefs)
|
|
756
757
|
|
|
757
|
-
const existing = readVoiceProfiles().profiles.find(p => p.name
|
|
758
|
+
const existing = readVoiceProfiles().profiles.find(p => sameSpeakerName(p.name, name))
|
|
758
759
|
// A primitive snapshot, taken BEFORE the loop: the store hands back its live
|
|
759
760
|
// `embeddings` array by reference, and enrolment appends to that same array
|
|
760
761
|
// until the first write invalidates the cache. Reading `.length` afterwards
|
|
@@ -826,7 +827,7 @@ export function projectHeldEnrollment(name: string, refs: HeldSampleRef[]): {
|
|
|
826
827
|
const profiles = readVoiceProfiles().profiles.map(profile => ({
|
|
827
828
|
...profile, embeddings: profile.embeddings.map(row => [...row]), sources: [...(profile.sources ?? [])],
|
|
828
829
|
}))
|
|
829
|
-
let target = profiles.find(profile => profile.name
|
|
830
|
+
let target = profiles.find(profile => sameSpeakerName(profile.name, name))
|
|
830
831
|
if (!target) { target = { name, embeddings: [], sources: [] }; profiles.push(target) }
|
|
831
832
|
const selectedMembers: HeldSampleRef[] = []
|
|
832
833
|
for (const embedding of selected) {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// 6.97.0: the token-audit caller of a dispatched task or a meeting review, which read as `full_query`
|
|
2
|
+
// before. Routines have their own (routines-config.ts). Import-free, so the review runtime can mark a
|
|
3
|
+
// job without loading the job runtime.
|
|
4
|
+
|
|
5
|
+
/** Review jobs in this process. A review resumed after a restart reads as an ordinary query again. */
|
|
6
|
+
const workReviewJobs = new Set<string>()
|
|
7
|
+
const WORK_REVIEW_JOBS_MAX = 256
|
|
8
|
+
|
|
9
|
+
/** Reviews carry no origin (a new kind would reach the phone and COS Control), so they are marked here. */
|
|
10
|
+
export function noteWorkReviewJob(clientJobId: string): void {
|
|
11
|
+
workReviewJobs.delete(clientJobId)
|
|
12
|
+
workReviewJobs.add(clientJobId)
|
|
13
|
+
if (workReviewJobs.size > WORK_REVIEW_JOBS_MAX) workReviewJobs.delete(workReviewJobs.values().next().value!)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** '' for every other job, whose caller stays what the bridge picks. */
|
|
17
|
+
export function jobAuditCaller(request: { clientJobId?: string; origin?: { kind: string } }): string {
|
|
18
|
+
if (request.origin?.kind === 'task') return 'dispatch_task'
|
|
19
|
+
return request.clientJobId && workReviewJobs.has(request.clientJobId) ? 'work_review' : ''
|
|
20
|
+
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// every check below is one failed stat. Reading never creates. Remove all keys (plan 12) keeps
|
|
11
11
|
// this file on purpose: a keyed transcript stays refused after the keys are gone. A manual
|
|
12
12
|
// `claude --resume` in a terminal is documented, not blocked.
|
|
13
|
-
import { statSync } from 'node:fs'
|
|
13
|
+
import { existsSync, lstatSync, readFileSync, statSync } from 'node:fs'
|
|
14
14
|
import { dataPath } from './data-dir.js'
|
|
15
15
|
import { readPrivateJsonFile, writePrivateJsonFile } from './key-store.js'
|
|
16
16
|
import { registerKeyLanesStatusProvider } from './key-lanes.js'
|
|
@@ -83,6 +83,26 @@ export function listKeyedSessions(): KeyedSessionRecord[] {
|
|
|
83
83
|
return [...byId.values()]
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* The same records, read so that a damaged file cannot pass for an empty one. listKeyedSessions reads a corrupt or unreadable
|
|
88
|
+
* file as "none", which is right for the places that refuse a keyed session (they fail open on a file nobody can read) and wrong for
|
|
89
|
+
* a place that must NOT send keyed text anywhere: a corrupt file sent keyed session text to a model in a probe. A missing file is
|
|
90
|
+
* none; a file that exists and is not the store's shape throws. Session find uses this; nothing else does.
|
|
91
|
+
*/
|
|
92
|
+
export function listKeyedSessionsStrict(): KeyedSessionRecord[] {
|
|
93
|
+
const byId = new Map<string, KeyedSessionRecord>()
|
|
94
|
+
const file = keyedSessionsPath()
|
|
95
|
+
if (existsSync(file)) {
|
|
96
|
+
const st = lstatSync(file)
|
|
97
|
+
if (st.isSymbolicLink() || !st.isFile()) throw new Error('keyed sessions file is not a plain file')
|
|
98
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8')) as Partial<KeyedSessionsFile> | null
|
|
99
|
+
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.sessions) || !parsed.sessions.every(isRecord)) throw new Error('keyed sessions file is not readable')
|
|
100
|
+
for (const r of parsed.sessions) byId.set(r.id, r)
|
|
101
|
+
}
|
|
102
|
+
for (const r of memory.values()) if (!byId.has(r.id)) byId.set(r.id, r)
|
|
103
|
+
return [...byId.values()]
|
|
104
|
+
}
|
|
105
|
+
|
|
86
106
|
/** True once anything was ever recorded. The cheap short-circuit every refusing site starts with. */
|
|
87
107
|
export function anyKeyedSessions(): boolean {
|
|
88
108
|
return memory.size > 0 || fileRecords().length > 0
|
|
@@ -27,6 +27,7 @@ import { resolveProviderBinary } from './provider-binary.js'
|
|
|
27
27
|
import { getOllamaCatalog, ollamaFetch, resolveOllamaOrigin } from './ollama-catalog.js'
|
|
28
28
|
import { PROVIDER_QUOTA_RE } from './provider-proof.js'
|
|
29
29
|
import { logTokenAudit } from './token-audit.js'
|
|
30
|
+
import { normalizeRealUsage, readOneShotUsage, type EngineUsage } from './provider-usage.js'
|
|
30
31
|
|
|
31
32
|
/**
|
|
32
33
|
* The provider itself said its plan, session, rate or capacity limit is spent. Raised only
|
|
@@ -341,10 +342,14 @@ async function runCli(
|
|
|
341
342
|
prompt: string,
|
|
342
343
|
opts: LensGistRunOptions,
|
|
343
344
|
parse: (stdout: string) => LensGistEngineRun,
|
|
345
|
+
onStdout?: (provider: 'claude' | 'codex' | 'cursor', stdout: string) => void,
|
|
344
346
|
): Promise<LensGistEngineRun> {
|
|
345
347
|
const binary = binaryFor(provider)
|
|
346
348
|
const result = await withScratchWorkspace(workspace =>
|
|
347
349
|
runProcess(binary, args(workspace), { stdin: prompt, cwd: workspace, timeoutMs: opts.timeoutMs, label: provider, cursorIsolation: provider === 'cursor', maxOutputTokens: opts.maxOutputTokens, signal: opts.signal }))
|
|
350
|
+
// 6.97.0: its own read of the output for the audit, before the answer's parser and independent of
|
|
351
|
+
// it, so a call that failed after using tokens still reports them.
|
|
352
|
+
onStdout?.(provider, result.stdout)
|
|
348
353
|
const providerText = `${result.stdout}\n${result.stderr}`
|
|
349
354
|
if (result.code !== 0) {
|
|
350
355
|
// Codex reports its failure as a JSON event AND a non-zero exit; the event says why.
|
|
@@ -400,14 +405,14 @@ async function runOllama(model: string, prompt: string, opts: LensGistRunOptions
|
|
|
400
405
|
}
|
|
401
406
|
}
|
|
402
407
|
|
|
403
|
-
function dispatch(engine: LensGistEngineName, model: string, prompt: string, opts: LensGistRunOptions): Promise<LensGistEngineRun> {
|
|
408
|
+
function dispatch(engine: LensGistEngineName, model: string, prompt: string, opts: LensGistRunOptions, onStdout: (provider: 'claude' | 'codex' | 'cursor', stdout: string) => void): Promise<LensGistEngineRun> {
|
|
404
409
|
switch (engine) {
|
|
405
410
|
case 'claude':
|
|
406
|
-
return runCli('claude', () => buildClaudeGistArgs(model, opts.system), prompt, opts, parseClaudeGistOutput)
|
|
411
|
+
return runCli('claude', () => buildClaudeGistArgs(model, opts.system), prompt, opts, parseClaudeGistOutput, onStdout)
|
|
407
412
|
case 'codex':
|
|
408
|
-
return runCli('codex', workspace => buildCodexGistArgs(model, workspace), prompt, opts, parseCodexGistOutput)
|
|
413
|
+
return runCli('codex', workspace => buildCodexGistArgs(model, workspace), prompt, opts, parseCodexGistOutput, onStdout)
|
|
409
414
|
case 'cursor':
|
|
410
|
-
return runCli('cursor', workspace => buildCursorGistArgs(model, workspace), prompt, opts, parseCursorGistOutput)
|
|
415
|
+
return runCli('cursor', workspace => buildCursorGistArgs(model, workspace), prompt, opts, parseCursorGistOutput, onStdout)
|
|
411
416
|
case 'ollama':
|
|
412
417
|
return runOllama(model, prompt, opts)
|
|
413
418
|
}
|
|
@@ -423,10 +428,16 @@ export async function runLensGistEngine(
|
|
|
423
428
|
const started = Date.now()
|
|
424
429
|
let outputChars = 0
|
|
425
430
|
let resolvedModel = model
|
|
431
|
+
let usage: EngineUsage = {}
|
|
426
432
|
try {
|
|
427
|
-
const run = await dispatch(engine, model, prompt, opts)
|
|
433
|
+
const run = await dispatch(engine, model, prompt, opts, (provider, stdout) => { usage = readOneShotUsage(provider, stdout, model) })
|
|
428
434
|
outputChars = run.text.length
|
|
429
435
|
resolvedModel = run.model || model
|
|
436
|
+
// Ollama answers over HTTP, so it has no stdout to read; its reply already carries the counts.
|
|
437
|
+
if (engine === 'ollama') {
|
|
438
|
+
const realUsage = normalizeRealUsage({ inputTokens: run.usage?.inputTokens as number, outputTokens: run.usage?.outputTokens as number, resolvedModel: run.model })
|
|
439
|
+
if (realUsage) usage = { realUsage }
|
|
440
|
+
}
|
|
430
441
|
return run
|
|
431
442
|
} finally {
|
|
432
443
|
logTokenAudit({
|
|
@@ -436,6 +447,7 @@ export async function runLensGistEngine(
|
|
|
436
447
|
inputChars: prompt.length + opts.system.length,
|
|
437
448
|
outputChars,
|
|
438
449
|
durationMs: Date.now() - started,
|
|
450
|
+
...usage,
|
|
439
451
|
})
|
|
440
452
|
}
|
|
441
453
|
}
|