@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.
Files changed (109) hide show
  1. package/CHANGELOG.md +147 -0
  2. package/README.md +52 -0
  3. package/managed-runtime-contract.json +13 -0
  4. package/package.json +3 -1
  5. package/server/index.ts +19 -1
  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/dictation-clean-models.ts +2 -0
  12. package/server/lib/dictation-clean.ts +2 -2
  13. package/server/lib/dictation-polish-pipeline.ts +22 -2
  14. package/server/lib/dictation-prewarm.ts +16 -0
  15. package/server/lib/direct-bridge.ts +14 -2
  16. package/server/lib/direct-sse.ts +6 -1
  17. package/server/lib/display-bus.ts +1 -1
  18. package/server/lib/fork-thread.ts +32 -1
  19. package/server/lib/held-naming-batches.ts +36 -0
  20. package/server/lib/job-audit-caller.ts +20 -0
  21. package/server/lib/lens-gist-engines.ts +17 -5
  22. package/server/lib/live-cos-ask.ts +29 -6
  23. package/server/lib/live-cue-verdicts.ts +43 -8
  24. package/server/lib/live-cues-engine.ts +4 -1
  25. package/server/lib/live-cues-filter.ts +50 -0
  26. package/server/lib/live-cues-general-prompts.ts +137 -0
  27. package/server/lib/live-cues-general-topic.ts +245 -0
  28. package/server/lib/live-cues-general.ts +715 -0
  29. package/server/lib/live-cues-v2-data.ts +453 -0
  30. package/server/lib/live-cues-v2-grounded.ts +262 -0
  31. package/server/lib/live-cues-v2-judge-b.ts +59 -0
  32. package/server/lib/live-cues-v2-names.ts +49 -0
  33. package/server/lib/live-cues-v2-notes.ts +8 -4
  34. package/server/lib/live-cues-v2-open-items.ts +132 -0
  35. package/server/lib/live-cues-v2-pacing.ts +45 -0
  36. package/server/lib/live-cues-v2-prompts-kinds.ts +9 -4
  37. package/server/lib/live-cues-v2-settings.ts +89 -10
  38. package/server/lib/live-cues-v2.ts +570 -47
  39. package/server/lib/mac-recording-asr.ts +6 -3
  40. package/server/lib/mac-speaker-call-human.ts +371 -0
  41. package/server/lib/mac-speaker-call-interval-human.ts +499 -0
  42. package/server/lib/mac-speaker-call-interval-projection.ts +233 -0
  43. package/server/lib/mac-speaker-call-original-view.ts +328 -0
  44. package/server/lib/mac-speaker-call-producer-worker.ts +615 -0
  45. package/server/lib/mac-speaker-call-producer.ts +539 -0
  46. package/server/lib/mac-speaker-call-store.ts +264 -0
  47. package/server/lib/mac-speaker-call-view.ts +373 -0
  48. package/server/lib/mac-speaker-live-compute.ts +72 -0
  49. package/server/lib/mac-speaker-live-memory.ts +414 -0
  50. package/server/lib/mac-speaker-live-projection.ts +126 -0
  51. package/server/lib/mac-speaker-live-save.ts +90 -0
  52. package/server/lib/mac-speaker-live-store.ts +71 -0
  53. package/server/lib/mac-speaker-live-worker.ts +58 -0
  54. package/server/lib/mac-speaker-live.ts +175 -0
  55. package/server/lib/mac-speaker-profile-extractor.ts +15 -10
  56. package/server/lib/mac-speaker-shadow-repair.ts +307 -0
  57. package/server/lib/mac-voice-assignment.ts +44 -26
  58. package/server/lib/mac-voice-display.ts +195 -0
  59. package/server/lib/mac-voice-naming-batch.ts +1067 -0
  60. package/server/lib/mac-voice-suggestions.ts +548 -0
  61. package/server/lib/maintenance-lifecycle.ts +2 -0
  62. package/server/lib/media-store.ts +2 -1
  63. package/server/lib/meeting-ask-pasted.ts +135 -0
  64. package/server/lib/meeting-ask-review.ts +8 -1
  65. package/server/lib/meeting-ask-runner.ts +4 -1
  66. package/server/lib/meeting-ask-runtime.ts +189 -17
  67. package/server/lib/meeting-ask-store.ts +2 -0
  68. package/server/lib/meeting-ask-web.ts +284 -13
  69. package/server/lib/meeting-batch-progress.ts +51 -5
  70. package/server/lib/meeting-batch-transcribe.ts +35 -2
  71. package/server/lib/meeting-fact-check.ts +3 -0
  72. package/server/lib/meeting-finalization-jobs.ts +69 -2
  73. package/server/lib/meeting-public-topic.ts +6 -1
  74. package/server/lib/meeting-speaker-review.ts +182 -17
  75. package/server/lib/meeting-store.ts +6 -1
  76. package/server/lib/parakeet.ts +51 -5
  77. package/server/lib/pending-batch-retention.ts +58 -0
  78. package/server/lib/private-reference.ts +38 -0
  79. package/server/lib/provider-usage.ts +561 -0
  80. package/server/lib/public-https-url.ts +32 -0
  81. package/server/lib/query-job-runtime.ts +3 -0
  82. package/server/lib/rich-media-safety.ts +23 -2
  83. package/server/lib/speaker-shadow-decide.ts +503 -0
  84. package/server/lib/speaker-shadow-models.ts +150 -0
  85. package/server/lib/speaker-shadow-numeric.ts +143 -0
  86. package/server/lib/speaker-shadow-pair-source.ts +91 -0
  87. package/server/lib/speaker-shadow-paired.ts +268 -0
  88. package/server/lib/speaker-shadow-report.ts +113 -0
  89. package/server/lib/speaker-shadow-timeline.ts +210 -0
  90. package/server/lib/speaker-shadow-worker.ts +312 -0
  91. package/server/lib/speaker-shadow.ts +788 -0
  92. package/server/lib/token-audit.ts +36 -6
  93. package/server/lib/transcript-cleanup.ts +3 -2
  94. package/server/lib/voice-directory.ts +8 -4
  95. package/server/lib/web-lookup-ledger.ts +151 -0
  96. package/server/lib/work-review-runtime.ts +2 -0
  97. package/server/lib/work-search.ts +3 -1
  98. package/server/lib/xlsx-text.ts +277 -0
  99. package/server/routes/health.ts +5 -0
  100. package/server/routes/live-cues.ts +36 -14
  101. package/server/routes/meeting.ts +506 -49
  102. package/server/routes/speaker-shadow.ts +79 -0
  103. package/server/routes/transcribe-stream.ts +114 -33
  104. package/server/routes/voice.ts +3 -0
  105. package/server/scripts/live-cues-v3-mutation-gate.mjs +311 -0
  106. package/server/scripts/mac-voice-assignment-6930-mutation-gate.mjs +5 -5
  107. package/shared/live-cues-v2.ts +38 -5
  108. package/shared/media-attachment.ts +2 -0
  109. package/shared/meeting-ask.ts +11 -0
@@ -0,0 +1,42 @@
1
+ // 6.97.0: how far a Codex thread's rollout has been written, for the audit only. Codex's usage is a
2
+ // running total for the thread, so charging a resumed turn the difference is only honest if nothing
3
+ // else ran on the thread since. The rollout's size is the one thing that shows a desktop run.
4
+ //
5
+ // Any write moves it, including ones that are not a turn. That direction is safe: the row stays estimated.
6
+ import { statSync } from 'node:fs'
7
+ import { realNativeHeadDeps, transcriptPathFor, type NativeHeadDeps } from './native-head.js'
8
+ import { registerCodexRolloutProbe } from './provider-usage.js'
9
+
10
+ /** A located rollout stays put, so a turn start costs one stat, not a walk of the sessions tree. */
11
+ const rolloutPaths = new Map<string, string>()
12
+ const ROLLOUT_PATHS_MAX = 512
13
+
14
+ /** Bytes written so far, or null for UNKNOWN: no rollout found, or it moved or was compressed. Never throws. */
15
+ export function codexRolloutSize(threadId: string, deps?: NativeHeadDeps): number | null {
16
+ try {
17
+ const known = rolloutPaths.get(threadId)
18
+ if (known) {
19
+ try {
20
+ return statSync(known).size
21
+ } catch {
22
+ rolloutPaths.delete(threadId)
23
+ }
24
+ }
25
+ const path = transcriptPathFor('codex', threadId, deps ?? realNativeHeadDeps())
26
+ if (!path) return null
27
+ rolloutPaths.set(threadId, path)
28
+ if (rolloutPaths.size > ROLLOUT_PATHS_MAX) rolloutPaths.delete(rolloutPaths.keys().next().value!)
29
+ return statSync(path).size
30
+ } catch {
31
+ return null
32
+ }
33
+ }
34
+
35
+ /** Called once at boot, in production only. */
36
+ export function installCodexRolloutProbe(): void {
37
+ registerCodexRolloutProbe(threadId => codexRolloutSize(threadId))
38
+ }
39
+
40
+ export function _resetCodexRolloutPathsForTest(): void {
41
+ rolloutPaths.clear()
42
+ }
@@ -533,7 +533,8 @@ export async function callCursorStreaming(
533
533
  runId: run.runId,
534
534
  sessionId: sid,
535
535
  clientJobId: options?.clientJobId,
536
- usageKind: 'estimated',
536
+ // 6.97.0: the chat id only. Cursor's usage stays estimated: its cache parser has no real rows.
537
+ ...(cursorChatId ? { cliSessionId: cursorChatId } : {}),
537
538
  })
538
539
  saveEngineSessionBestEffort()
539
540
 
@@ -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.
@@ -15,6 +15,7 @@ import { MeetingStore } from './meeting-store.js'
15
15
  import { appendCorrection, closeInterruptedCorrections, pendingCorrections, readCorrections, appliedCorrections, type CorrectionRow } from './meeting-corrections.js'
16
16
  import { patchValue, relabelSpeakerPositions, undoSpeakerMarkdown, strippedTranscript, validatedRawMap, type SpeakerPatch } from './meeting-speaker-labels.js'
17
17
  import { acquireMeetingSyncLock } from './meeting-sync-lock.js'
18
+ import { inspectOriginalViewTransaction } from './mac-speaker-call-original-view.js'
18
19
 
19
20
  export const NAMING_CAPABILITIES = {preview:true,apply:true,undo:true,version:1} as const
20
21
  const PREVIEW_TTL = 15 * 60 * 1000
@@ -180,6 +181,23 @@ function checkedRevision(value:unknown):number {
180
181
  if(typeof value!=='number'||!Number.isSafeInteger(value)||value<0)throw new Error('Invalid meeting revision')
181
182
  return value
182
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
+ }
183
201
  function resolveCopies(sessionId:string):ResolvedCopy[] {
184
202
  if(sessionBusy(sessionId)||existsSync(dataPath('meeting-finalization-jobs',`${sessionId}.json`)))throw new Error('Meeting is recording or finalizing; retry after it finishes')
185
203
  const local = new MeetingStore().findBySessionId(sessionId)
@@ -198,6 +216,7 @@ function resolveCopies(sessionId:string):ResolvedCopy[] {
198
216
  throw new Error('Configured operations folder is unavailable; retry after sync')
199
217
  }
200
218
  return paths.map(p=>{
219
+ assertNoCallSpeakerOverlay(p.sidecarPath,sessionId)
201
220
  assertFile(p.sidecarPath)
202
221
  const raw=readFileSync(p.sidecarPath,'utf8'),doc=JSON.parse(raw)
203
222
  if(doc.sessionId!==sessionId) throw new Error('Meeting identity changed')
@@ -429,8 +448,22 @@ export async function undoHeldNaming(batchId:string,confirm:boolean){
429
448
  let lock:Awaited<ReturnType<typeof acquireMeetingSyncLock>>|undefined
430
449
  try{
431
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
432
457
  for(const meeting of batch.meetings){
433
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
434
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'}
435
468
  try{
436
469
  const later=appliedCorrections(meeting.sessionId).filter(r=>r.batchId!==batchId&&r.at>=batch.at)
@@ -463,6 +496,9 @@ export async function undoHeldNaming(batchId:string,confirm:boolean){
463
496
  }catch(e){meeting.error=errorText(e);appendCorrection(meeting.sessionId,{...row,phase:'failed',error:meeting.error})}
464
497
  writeRecord('batch',batch.batchId,batch)
465
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)
466
502
  batch.status=batch.meetings.filter(m=>m.receipts.length).every(m=>m.status==='reverted')?'reverted':'partial'
467
503
  writeRecord('batch',batch.batchId,batch)
468
504
  return{...batchView(batch),kind:'undone',samplesKept:true,audioRestored:false}
@@ -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
+ }
@@ -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
  }
@@ -122,6 +122,12 @@ export interface LiveAskDeps {
122
122
  explicitWebQuery?: string | null
123
123
  publicTopicQuery?: boolean
124
124
  benchmarkWebQuery?: string | null
125
+ /** 6.97.8: a public factual comparison. These queries (at most three, each already built from the user's own words) are searched together
126
+ * before the answer, the planner may not search again, and the answer takes the comparison shape (COMPARISON_FINAL). */
127
+ comparisonQueries?: string[]
128
+ /** 6.97.8: read when each prompt is built. True lets the answer carry one line starting "Not checked against a source:" with the general
129
+ * knowledge answer when no page was read (search withheld, failed or empty). False for a privacy withhold. */
130
+ uncheckedLine?: () => boolean
125
131
  /** The wearer's name, so "You owe" can match a line that names them. null when unknown. */
126
132
  wearer?: () => string | null
127
133
  /** Who owns this COS (6.76.0), from the profile. null or absent: no card, and nothing about the Ask changes. */
@@ -131,6 +137,11 @@ export interface LiveAskDeps {
131
137
  /** Wall clock for timings only; now() keeps the cache clock. */
132
138
  clock?: () => number
133
139
  }
140
+ /** The answer shape for a public factual comparison. Rows stay So what and Details; a row is one line, so figures are short sentences, each cited. */
141
+ export const COMPARISON_FINAL = 'This is a public factual comparison. Return two rows plus the confidence object. So what: ONE sentence, the verdict that answers the question directly; when the question is about something a person in the room said, name who was right or wrong, using the speaker label only from the transcript sources. Details: three to five short sentences, one key figure each, with its unit and scope, each cited with the [W#] of a page that was read (pageRead true); then ONE sentence with the single real caveat. Use only figures that appear in a cited page. Up to 190 words in all. Do not hedge the verdict when the cited figures are clear; do not list every number a page has.';
142
+ /** When no page was read, the answer still answers, labeled, from general knowledge. */
143
+ export const UNCHECKED_FINAL = 'If no web page was read to answer this (the search was withheld, failed or returned nothing usable), do not stop at "Unverified". Answer the question in one short line that starts exactly "Not checked against a source:" and gives the answer from general knowledge with hedged ranges, no citation and no invented precise figures. Never present it as verified.';
144
+ export const REVIEW_COMPARISON_ADDENDUM = ' When comparison is true: keep the labels So what and Details, a one-sentence verdict first in So what, one cited figure per Details sentence and one caveat; up to maxWords. Every figure must come from a cited page with pageRead=true and appear in the support quote. When uncheckedLineAllowed is true and no page was read for a point, keep or add ONE line starting exactly "Not checked against a source:" that answers from general knowledge with hedged ranges and no citation; it is not a verified verdict, so do not replace it with "unverified" and do not add a citation to it.';
134
145
  const PADDING = /^(?:none|n\/?a|nothing|nothing yet|not yet|nothing to report|-+)\.?$/i
135
146
  /**
136
147
  * The model's sections, validated for one action. Labels match case-insensitively (a trailing colon is
@@ -360,7 +371,18 @@ export function createLiveAsk(overrides: Partial<LiveAskDeps> = {}) {
360
371
  // Do this before the answer planner so it cannot substitute a benchmark for
361
372
  // the original article. One further lookup remains for a comparison.
362
373
  const preloadedWeb = input.action === 'ask' && !!deps.publicTopicQuery && !!deps.explicitWebQuery && !!deps.webAvailable
374
+ const comparison = input.action === 'ask' && !!deps.webAvailable && !!deps.comparisonQueries?.length && !preloadedWeb
363
375
  if(preloadedWeb){webAsked=true;const queries=[deps.explicitWebQuery!,...(deps.benchmarkWebQuery?[deps.benchmarkWebQuery]:[])];for(const found of await Promise.all(queries.map(q=>lookup(q,'web'))))absorb(found)}
376
+ // A comparison runs its searches together, so the wait is the slowest one; the answer hop below may not search again.
377
+ if (comparison) {
378
+ webAsked = true
379
+ // allSettled, not all: when one search throws (the Ask was aborted, a child could not be confirmed stopped) the others are waited for,
380
+ // never left running behind a thrown error. The first failure is rethrown once every search has ended.
381
+ const settled = await Promise.allSettled(deps.comparisonQueries!.slice(0, 3).map(q => lookup(q, 'web')))
382
+ for (const r of settled) if (r.status === 'fulfilled') absorb(r.value)
383
+ const failed = settled.find((r): r is PromiseRejectedResult => r.status === 'rejected')
384
+ if (failed) throw failed.reason
385
+ }
364
386
  // 6.76.0: a question about who someone is, or about the owner's own history, always looks in COS
365
387
  // first (memories and earlier meetings, every domain), whatever the planner would have chosen. It
366
388
  // takes one of the two lookups and saves the planner hop. Never the web: that stays explicit-only.
@@ -380,9 +402,10 @@ export function createLiveAsk(overrides: Partial<LiveAskDeps> = {}) {
380
402
  for (let hop = 0; hop < 3; hop++) {
381
403
  if (signal?.aborted) throw new AskError('cancelled', 499)
382
404
  // A typed question may look up twice (COS or web). What to ask may look up once, in COS only.
383
- const searchAllowed = input.action === 'ask' ? !(emptyCosLookup&&!deps.explicitWebQuery) && hop < (preloadedWeb ? deps.evidenceQuestion || deps.benchmarkWebQuery ? 0 : 1 : forcedLookup ? 1 : 2) : input.action === 'questions' && hop < 1
384
- const detailed = input.action === 'ask' && /\b(?:detail(?:ed)?|comprehensive|thorough|deep dive)\b/i.test(input.question)
385
- const prompt = JSON.stringify({ responsePolicy: { maxWords: detailed ? 220 : input.action==='ask'?80:120, targetWords: input.action === 'ask' ? 50 : 90, confidenceMaxWords: 16, detailsOptional: true, avoidRepeatedWarnings: true, userEntityClarificationIsOrientation: true }, question: input.action === 'ask' ? input.question : TASKS[input.action], labels: input.action === 'questions' ? ['So what', 'Ask'] : input.action === 'ask' && !detailed ? ['So what'] : MEETING_ASK_LABELS[input.action], wearer: deps.wearer?.() ?? null, ...(input.action === 'questions' ? { recentSinceMs: recentSinceMs(input) } : {}), history: input.action === 'ask' ? followup.history : [], searchAllowed, orientation: deps.orientation ?? null, ...(card ? { owner: { name: card.name, sourceId: cardId } } : {}), ...(ownerMatch && card ? { ownerMatch: { heard: ownerMatch.heard, readAs: card.name } } : {}), ...(lookups.some(l => l.scope === 'cos') ? { cosLookups: lookups.filter(l => l.scope === 'cos').map(l => ({ query: l.query, searched: 'COS memories and earlier meetings', results: l.results })) } : {}), evidence: evidence.sources, groundingHints: moneyGroundingHints(evidence.sources), finalInstructions: detailed ? 'Give the requested detail with concise confidence.' : input.action === 'ask' ? 'Return only one So what row, 40–80 words (up to 100 for many updates), plus the confidence object with at most 16 words. Do not repeat the confidence in the row. Respect the user supplied account/project name; never equate it with another transcript name without evidence. Summarize direct speaker updates, not unrelated earlier client work. Do not infer done from worked on it or a behavior description. Keep an ambiguous deadline with its nearest feature or omit the deadline; never attach it to an earlier feature. If a count is garbled, state it is unclear rather than guessing a range.' : 'Use only essential nonempty rows, one short sentence per row. Aim for 90 words total; keep the entire response including confidence under 120 words.' })
405
+ const searchAllowed = input.action === 'ask' ? !comparison && !(emptyCosLookup&&!deps.explicitWebQuery) && hop < (preloadedWeb ? deps.evidenceQuestion || deps.benchmarkWebQuery ? 0 : 1 : forcedLookup ? 1 : 2) : input.action === 'questions' && hop < 1
406
+ const detailed = input.action === 'ask' && (comparison || /\b(?:detail(?:ed)?|comprehensive|thorough|deep dive)\b/i.test(input.question))
407
+ const unchecked = input.action === 'ask' && !!deps.uncheckedLine?.()
408
+ const prompt = JSON.stringify({ responsePolicy: { maxWords: detailed ? 220 : input.action==='ask'?80:120, targetWords: input.action === 'ask' ? (comparison ? 140 : 50) : 90, confidenceMaxWords: 16, detailsOptional: true, avoidRepeatedWarnings: true, userEntityClarificationIsOrientation: true }, question: input.action === 'ask' ? input.question : TASKS[input.action], labels: input.action === 'questions' ? ['So what', 'Ask'] : input.action === 'ask' && !detailed ? ['So what'] : MEETING_ASK_LABELS[input.action], wearer: deps.wearer?.() ?? null, ...(input.action === 'questions' ? { recentSinceMs: recentSinceMs(input) } : {}), history: input.action === 'ask' ? followup.history : [], searchAllowed, orientation: deps.orientation ?? null, ...(card ? { owner: { name: card.name, sourceId: cardId } } : {}), ...(ownerMatch && card ? { ownerMatch: { heard: ownerMatch.heard, readAs: card.name } } : {}), ...(lookups.some(l => l.scope === 'cos') ? { cosLookups: lookups.filter(l => l.scope === 'cos').map(l => ({ query: l.query, searched: 'COS memories and earlier meetings', results: l.results })) } : {}), evidence: evidence.sources, groundingHints: moneyGroundingHints(evidence.sources), finalInstructions: comparison ? COMPARISON_FINAL + (unchecked ? ' ' + UNCHECKED_FINAL : '') : detailed ? 'Give the requested detail with concise confidence.' + (unchecked ? ' ' + UNCHECKED_FINAL : '') : input.action === 'ask' ? (unchecked ? UNCHECKED_FINAL + ' ' : '') + 'Return only one So what row, 40–80 words (up to 100 for many updates), plus the confidence object with at most 16 words. Do not repeat the confidence in the row. Respect the user supplied account/project name; never equate it with another transcript name without evidence. Summarize direct speaker updates, not unrelated earlier client work. Do not infer done from worked on it or a behavior description. Keep an ambiguous deadline with its nearest feature or omit the deadline; never attach it to an earlier feature. If a count is garbled, state it is unclear rather than guessing a range.' : 'Use only essential nonempty rows, one short sentence per row. Aim for 90 words total; keep the entire response including confidence under 120 words.' })
386
409
  const runStarted = clock()
387
410
  let run: { text: string; model?: string }
388
411
  try { run = await deps.run(prompt, signal) } catch (e) { phases.push({ phase: 'answer', ms: Math.max(0, clock() - runStarted), failed: true }); throw e }
@@ -448,16 +471,16 @@ export function createLiveAsk(overrides: Partial<LiveAskDeps> = {}) {
448
471
  }
449
472
  if (deps.review) {
450
473
  const maxWords = detailed ? 220 : input.action === 'ask' ? 80 : 120
451
- const reviewInput={question:input.action==='ask'?input.question:TASKS[input.action],labels:input.action==='ask'&&!detailed?['So what']:MEETING_ASK_LABELS[input.action],maxWords:maxWords-18,targetWords:Math.max(30,maxWords-30),omitUnrequestedMoney:!!deps.evidenceQuestion || input.action==='ask' && /401|403|retirement/i.test(input.question) && !/\b(?:balance|amount|fee|cost|money|savings)\b/i.test(input.question),draft:{sections:normalized,confidence},sources:evidence.sources.map(s=>({...s,pageRead:pageWasRead(s)}))}
474
+ const reviewInput={...(comparison?{comparison:true}:{}),...(unchecked?{uncheckedLineAllowed:true}:{}),question:input.action==='ask'?input.question:TASKS[input.action],labels:input.action==='ask'&&!detailed?['So what']:MEETING_ASK_LABELS[input.action],maxWords:maxWords-18,targetWords:Math.max(30,maxWords-30),omitUnrequestedMoney:!!deps.evidenceQuestion || input.action==='ask' && /401|403|retirement/i.test(input.question) && !/\b(?:balance|amount|fee|cost|money|savings)\b/i.test(input.question),draft:{sections:normalized,confidence},sources:evidence.sources.map(s=>({...s,pageRead:pageWasRead(s)}))}
452
475
  let reviewed:any,problem:string|null='invalid_answer'
453
476
  for(let attempt=0;attempt<2;attempt++){
454
477
  const reviewStarted=clock()
455
478
  let checked:{text:string;model?:string}
456
- try{checked=await deps.review(JSON.stringify({...reviewInput,...(attempt?{repairReason:problem,repairDiagnostics:reviewRepairDiagnostics(reviewed,evidence.sources),previousDraft:reviewed}: {})}),REVIEW_SYSTEM,signal)}catch(e){phases.push({phase:'review',ms:Math.max(0,clock()-reviewStarted),failed:true});throw e}
479
+ try{checked=await deps.review(JSON.stringify({...reviewInput,...(attempt?{repairReason:problem,repairDiagnostics:reviewRepairDiagnostics(reviewed,evidence.sources),previousDraft:reviewed}: {})}),REVIEW_SYSTEM+(comparison||unchecked?REVIEW_COMPARISON_ADDENDUM:''),signal)}catch(e){phases.push({phase:'review',ms:Math.max(0,clock()-reviewStarted),failed:true});throw e}
457
480
  phases.push({phase:'review',ms:Math.max(0,clock()-reviewStarted)})
458
481
  if(checked.model&&checked.model!==model){evidence.notices.push(`Grounding review used ${checked.model}; the requested answer draft used ${model}.`);model=checked.model}
459
482
  try { reviewed=normalizeReviewConfidence(parseAskReply(checked.text));reviewed.sections=normalizeSections(input.action,reviewed.sections,allowed);if(deps.evidenceQuestion && reviewed?.confidence)reviewed.confidence.reason="Only general rules can be checked; this plan's terms remain unverified." } catch { reviewed=null }
460
- problem=reviewProblem(reviewed,evidence.sources,maxWords)
483
+ problem=reviewProblem(reviewed,evidence.sources,maxWords,comparison||unchecked?{uncitedFigures:true}:undefined)
461
484
  if(!problem&&reviewInput.omitUnrequestedMoney&&/\$\s*\d|\b[0-9,]+\s+(?:dollars|grand)\b/i.test(JSON.stringify(reviewed.sections)))problem='unrequested_money'
462
485
  if(!problem&&input.action==='ask'&&!detailed&&reviewed.sections.some((s:any)=>s.label!=='So what'))problem='extra_sections'
463
486
  if(!problem)break