@gotcos/glasses-server 6.94.4 → 6.98.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +147 -0
- package/README.md +52 -0
- package/managed-runtime-contract.json +13 -0
- package/package.json +3 -1
- package/server/index.ts +19 -1
- package/server/lib/attached-provider-adapter.ts +35 -1
- package/server/lib/claude-bridge.ts +47 -0
- package/server/lib/codex-bridge.ts +11 -1
- package/server/lib/codex-rollout-watermark.ts +42 -0
- package/server/lib/cursor-bridge.ts +2 -1
- package/server/lib/dictation-clean-models.ts +2 -0
- package/server/lib/dictation-clean.ts +2 -2
- package/server/lib/dictation-polish-pipeline.ts +22 -2
- package/server/lib/dictation-prewarm.ts +16 -0
- package/server/lib/direct-bridge.ts +14 -2
- package/server/lib/direct-sse.ts +6 -1
- package/server/lib/display-bus.ts +1 -1
- package/server/lib/fork-thread.ts +32 -1
- package/server/lib/held-naming-batches.ts +36 -0
- package/server/lib/job-audit-caller.ts +20 -0
- package/server/lib/lens-gist-engines.ts +17 -5
- package/server/lib/live-cos-ask.ts +29 -6
- package/server/lib/live-cue-verdicts.ts +43 -8
- package/server/lib/live-cues-engine.ts +4 -1
- package/server/lib/live-cues-filter.ts +50 -0
- package/server/lib/live-cues-general-prompts.ts +137 -0
- package/server/lib/live-cues-general-topic.ts +245 -0
- package/server/lib/live-cues-general.ts +715 -0
- package/server/lib/live-cues-v2-data.ts +453 -0
- package/server/lib/live-cues-v2-grounded.ts +262 -0
- package/server/lib/live-cues-v2-judge-b.ts +59 -0
- package/server/lib/live-cues-v2-names.ts +49 -0
- package/server/lib/live-cues-v2-notes.ts +8 -4
- package/server/lib/live-cues-v2-open-items.ts +132 -0
- package/server/lib/live-cues-v2-pacing.ts +45 -0
- package/server/lib/live-cues-v2-prompts-kinds.ts +9 -4
- package/server/lib/live-cues-v2-settings.ts +89 -10
- package/server/lib/live-cues-v2.ts +570 -47
- package/server/lib/mac-recording-asr.ts +6 -3
- package/server/lib/mac-speaker-call-human.ts +371 -0
- package/server/lib/mac-speaker-call-interval-human.ts +499 -0
- package/server/lib/mac-speaker-call-interval-projection.ts +233 -0
- package/server/lib/mac-speaker-call-original-view.ts +328 -0
- package/server/lib/mac-speaker-call-producer-worker.ts +615 -0
- package/server/lib/mac-speaker-call-producer.ts +539 -0
- package/server/lib/mac-speaker-call-store.ts +264 -0
- package/server/lib/mac-speaker-call-view.ts +373 -0
- package/server/lib/mac-speaker-live-compute.ts +72 -0
- package/server/lib/mac-speaker-live-memory.ts +414 -0
- package/server/lib/mac-speaker-live-projection.ts +126 -0
- package/server/lib/mac-speaker-live-save.ts +90 -0
- package/server/lib/mac-speaker-live-store.ts +71 -0
- package/server/lib/mac-speaker-live-worker.ts +58 -0
- package/server/lib/mac-speaker-live.ts +175 -0
- package/server/lib/mac-speaker-profile-extractor.ts +15 -10
- package/server/lib/mac-speaker-shadow-repair.ts +307 -0
- package/server/lib/mac-voice-assignment.ts +44 -26
- package/server/lib/mac-voice-display.ts +195 -0
- package/server/lib/mac-voice-naming-batch.ts +1067 -0
- package/server/lib/mac-voice-suggestions.ts +548 -0
- package/server/lib/maintenance-lifecycle.ts +2 -0
- package/server/lib/media-store.ts +2 -1
- package/server/lib/meeting-ask-pasted.ts +135 -0
- package/server/lib/meeting-ask-review.ts +8 -1
- package/server/lib/meeting-ask-runner.ts +4 -1
- package/server/lib/meeting-ask-runtime.ts +189 -17
- package/server/lib/meeting-ask-store.ts +2 -0
- package/server/lib/meeting-ask-web.ts +284 -13
- package/server/lib/meeting-batch-progress.ts +51 -5
- package/server/lib/meeting-batch-transcribe.ts +35 -2
- package/server/lib/meeting-fact-check.ts +3 -0
- package/server/lib/meeting-finalization-jobs.ts +69 -2
- package/server/lib/meeting-public-topic.ts +6 -1
- package/server/lib/meeting-speaker-review.ts +182 -17
- package/server/lib/meeting-store.ts +6 -1
- package/server/lib/parakeet.ts +51 -5
- package/server/lib/pending-batch-retention.ts +58 -0
- package/server/lib/private-reference.ts +38 -0
- package/server/lib/provider-usage.ts +561 -0
- package/server/lib/public-https-url.ts +32 -0
- package/server/lib/query-job-runtime.ts +3 -0
- package/server/lib/rich-media-safety.ts +23 -2
- package/server/lib/speaker-shadow-decide.ts +503 -0
- package/server/lib/speaker-shadow-models.ts +150 -0
- package/server/lib/speaker-shadow-numeric.ts +143 -0
- package/server/lib/speaker-shadow-pair-source.ts +91 -0
- package/server/lib/speaker-shadow-paired.ts +268 -0
- package/server/lib/speaker-shadow-report.ts +113 -0
- package/server/lib/speaker-shadow-timeline.ts +210 -0
- package/server/lib/speaker-shadow-worker.ts +312 -0
- package/server/lib/speaker-shadow.ts +788 -0
- package/server/lib/token-audit.ts +36 -6
- package/server/lib/transcript-cleanup.ts +3 -2
- package/server/lib/voice-directory.ts +8 -4
- package/server/lib/web-lookup-ledger.ts +151 -0
- package/server/lib/work-review-runtime.ts +2 -0
- package/server/lib/work-search.ts +3 -1
- package/server/lib/xlsx-text.ts +277 -0
- package/server/routes/health.ts +5 -0
- package/server/routes/live-cues.ts +36 -14
- package/server/routes/meeting.ts +506 -49
- package/server/routes/speaker-shadow.ts +79 -0
- package/server/routes/transcribe-stream.ts +114 -33
- package/server/routes/voice.ts +3 -0
- package/server/scripts/live-cues-v3-mutation-gate.mjs +311 -0
- package/server/scripts/mac-voice-assignment-6930-mutation-gate.mjs +5 -5
- package/shared/live-cues-v2.ts +38 -5
- package/shared/media-attachment.ts +2 -0
- package/shared/meeting-ask.ts +11 -0
|
@@ -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
|
-
|
|
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 ===
|
|
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.
|
|
@@ -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
|
|
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
|