@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.
Files changed (123) hide show
  1. package/CHANGELOG.md +157 -0
  2. package/README.md +4 -0
  3. package/managed-runtime-contract.json +13 -0
  4. package/package.json +3 -1
  5. package/server/index.ts +12 -0
  6. package/server/lib/attached-provider-adapter.ts +35 -1
  7. package/server/lib/claude-bridge.ts +47 -0
  8. package/server/lib/codex-bridge.ts +11 -1
  9. package/server/lib/codex-rollout-watermark.ts +42 -0
  10. package/server/lib/cursor-bridge.ts +2 -1
  11. package/server/lib/decision-provider.ts +4 -2
  12. package/server/lib/dictation-clean-models.ts +2 -0
  13. package/server/lib/dictation-clean.ts +2 -2
  14. package/server/lib/dictation-polish-pipeline.ts +22 -2
  15. package/server/lib/dictation-prewarm.ts +16 -0
  16. package/server/lib/direct-bridge.ts +14 -2
  17. package/server/lib/direct-sse.ts +6 -1
  18. package/server/lib/display-bus.ts +1 -1
  19. package/server/lib/fork-thread.ts +32 -1
  20. package/server/lib/held-naming-batches.ts +44 -7
  21. package/server/lib/held-voice-groups.ts +5 -4
  22. package/server/lib/job-audit-caller.ts +20 -0
  23. package/server/lib/keyed-sessions.ts +21 -1
  24. package/server/lib/lens-gist-engines.ts +17 -5
  25. package/server/lib/live-cos-ask.ts +29 -6
  26. package/server/lib/live-cue-verdicts.ts +43 -8
  27. package/server/lib/live-cues-engine.ts +4 -1
  28. package/server/lib/live-cues-filter.ts +50 -0
  29. package/server/lib/live-cues-general-prompts.ts +137 -0
  30. package/server/lib/live-cues-general-topic.ts +245 -0
  31. package/server/lib/live-cues-general.ts +715 -0
  32. package/server/lib/live-cues-v2-data.ts +453 -0
  33. package/server/lib/live-cues-v2-grounded.ts +262 -0
  34. package/server/lib/live-cues-v2-judge-b.ts +59 -0
  35. package/server/lib/live-cues-v2-names.ts +49 -0
  36. package/server/lib/live-cues-v2-notes.ts +8 -4
  37. package/server/lib/live-cues-v2-open-items.ts +132 -0
  38. package/server/lib/live-cues-v2-pacing.ts +45 -0
  39. package/server/lib/live-cues-v2-prompts-kinds.ts +9 -4
  40. package/server/lib/live-cues-v2-settings.ts +89 -10
  41. package/server/lib/live-cues-v2.ts +570 -47
  42. package/server/lib/mac-recording-asr.ts +6 -3
  43. package/server/lib/mac-speaker-call-human.ts +371 -0
  44. package/server/lib/mac-speaker-call-interval-human.ts +499 -0
  45. package/server/lib/mac-speaker-call-interval-projection.ts +233 -0
  46. package/server/lib/mac-speaker-call-original-view.ts +328 -0
  47. package/server/lib/mac-speaker-call-producer-worker.ts +615 -0
  48. package/server/lib/mac-speaker-call-producer.ts +539 -0
  49. package/server/lib/mac-speaker-call-store.ts +264 -0
  50. package/server/lib/mac-speaker-call-view.ts +373 -0
  51. package/server/lib/mac-speaker-live-compute.ts +72 -0
  52. package/server/lib/mac-speaker-live-memory.ts +414 -0
  53. package/server/lib/mac-speaker-live-projection.ts +126 -0
  54. package/server/lib/mac-speaker-live-save.ts +90 -0
  55. package/server/lib/mac-speaker-live-store.ts +71 -0
  56. package/server/lib/mac-speaker-live-worker.ts +58 -0
  57. package/server/lib/mac-speaker-live.ts +175 -0
  58. package/server/lib/mac-speaker-profile-extractor.ts +15 -10
  59. package/server/lib/mac-speaker-shadow-repair.ts +307 -0
  60. package/server/lib/mac-voice-assignment.ts +46 -27
  61. package/server/lib/mac-voice-display.ts +195 -0
  62. package/server/lib/mac-voice-naming-batch.ts +1067 -0
  63. package/server/lib/mac-voice-suggestions.ts +548 -0
  64. package/server/lib/maintenance-lifecycle.ts +2 -0
  65. package/server/lib/media-store.ts +2 -1
  66. package/server/lib/meeting-ask-pasted.ts +135 -0
  67. package/server/lib/meeting-ask-review.ts +8 -1
  68. package/server/lib/meeting-ask-runner.ts +4 -1
  69. package/server/lib/meeting-ask-runtime.ts +189 -17
  70. package/server/lib/meeting-ask-store.ts +2 -0
  71. package/server/lib/meeting-ask-web.ts +284 -13
  72. package/server/lib/meeting-batch-progress.ts +51 -5
  73. package/server/lib/meeting-batch-transcribe.ts +35 -2
  74. package/server/lib/meeting-corrections.ts +2 -1
  75. package/server/lib/meeting-fact-check.ts +3 -0
  76. package/server/lib/meeting-finalization-jobs.ts +69 -2
  77. package/server/lib/meeting-public-topic.ts +6 -1
  78. package/server/lib/meeting-relabel-enrolment.ts +2 -1
  79. package/server/lib/meeting-relabel.ts +17 -10
  80. package/server/lib/meeting-speaker-review.ts +187 -21
  81. package/server/lib/meeting-store.ts +6 -1
  82. package/server/lib/parakeet.ts +51 -5
  83. package/server/lib/pending-batch-retention.ts +58 -0
  84. package/server/lib/private-reference.ts +38 -0
  85. package/server/lib/provider-usage.ts +561 -0
  86. package/server/lib/public-https-url.ts +32 -0
  87. package/server/lib/query-job-runtime.ts +3 -0
  88. package/server/lib/rich-media-safety.ts +23 -2
  89. package/server/lib/session-find-ask.ts +288 -0
  90. package/server/lib/session-find-collect.ts +715 -0
  91. package/server/lib/session-find-redact.ts +254 -0
  92. package/server/lib/session-find.ts +345 -0
  93. package/server/lib/speaker-calibration-log.ts +3 -2
  94. package/server/lib/speaker-embeddings.ts +46 -35
  95. package/server/lib/speaker-name-nfc.ts +37 -0
  96. package/server/lib/speaker-rename-fanout.ts +7 -6
  97. package/server/lib/speaker-shadow-decide.ts +59 -22
  98. package/server/lib/speaker-shadow-pair-source.ts +91 -0
  99. package/server/lib/speaker-shadow-paired.ts +268 -0
  100. package/server/lib/speaker-shadow-report.ts +2 -2
  101. package/server/lib/speaker-shadow-worker.ts +50 -15
  102. package/server/lib/speaker-shadow.ts +80 -19
  103. package/server/lib/speaker-trainer.ts +2 -1
  104. package/server/lib/token-audit.ts +36 -6
  105. package/server/lib/transcript-cleanup.ts +3 -2
  106. package/server/lib/voice-directory.ts +10 -5
  107. package/server/lib/voice-profile-store.ts +24 -14
  108. package/server/lib/web-lookup-ledger.ts +151 -0
  109. package/server/lib/work-review-runtime.ts +2 -0
  110. package/server/lib/work-search.ts +3 -1
  111. package/server/lib/xlsx-text.ts +277 -0
  112. package/server/routes/decision-provider.ts +4 -1
  113. package/server/routes/jev.ts +5 -1
  114. package/server/routes/live-cues.ts +36 -14
  115. package/server/routes/meeting.ts +515 -61
  116. package/server/routes/session-find.ts +73 -0
  117. package/server/routes/transcribe-stream.ts +114 -33
  118. package/server/routes/voice.ts +28 -18
  119. package/server/scripts/live-cues-v3-mutation-gate.mjs +311 -0
  120. package/server/scripts/mac-voice-assignment-6930-mutation-gate.mjs +5 -5
  121. package/shared/live-cues-v2.ts +38 -5
  122. package/shared/media-attachment.ts +2 -0
  123. package/shared/meeting-ask.ts +11 -0
@@ -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 === 'luna-5.6-fast'
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 { DictationCleanModel } from './dictation-clean-models.js'
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
- const usageTotals = { input: 0, output: 0, usd: 0, measured: false }
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,
@@ -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: nonNegative(usage.prompt_tokens),
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
- outcome = await driveChild({ child, deps, provider, sourceNativeThreadId, prompt, timeoutMs, startedAt })
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===name)?.embeddings.length ?? 0}
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===to||!from.trim()||!to.trim())return{copies:[]}
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===from?[i]:[])
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!==from)continue
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].includes(c.doc.chunks[position].speaker))throw new Error('HQ speaker conflicts with an unrelated named chunk')
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].includes(c.doc.chunks[i]?.speaker))))throw new Error('Meeting copies disagree on prior speaker labels')
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 === best!.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 === c.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 === 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 === 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
  }