@gotcos/glasses-server 6.76.1 → 6.78.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/.env.example +40 -0
- package/CHANGELOG.md +56 -0
- package/README.md +12 -1
- package/managed-runtime-contract.json +17 -1
- package/package.json +10 -1
- package/server/index.ts +98 -10
- package/server/lib/agent-session-binding-registry.ts +623 -9
- package/server/lib/boot-resume.ts +552 -0
- package/server/lib/claude-bridge.ts +152 -3
- package/server/lib/claude-cli-support.ts +39 -0
- package/server/lib/claude-steer.ts +259 -0
- package/server/lib/cos-bridge-notes.ts +73 -0
- package/server/lib/decision-provider.ts +9 -0
- package/server/lib/env-switch.ts +17 -0
- package/server/lib/fuzzy-correct.ts +77 -49
- package/server/lib/live-cos-ask.ts +41 -7
- package/server/lib/live-cues-v2-notes.ts +152 -0
- package/server/lib/live-cues-v2-settings.ts +63 -0
- package/server/lib/live-cues-v2.ts +198 -38
- package/server/lib/managed-restart.ts +8 -0
- package/server/lib/provider-actions.ts +18 -0
- package/server/lib/query-job-coordinator.ts +300 -0
- package/server/lib/query-job-feature.ts +39 -0
- package/server/lib/query-job-resume.ts +269 -0
- package/server/lib/query-job-runtime.ts +23 -3
- package/server/lib/query-job-store.ts +516 -7
- package/server/lib/query-job-types.ts +210 -1
- package/server/lib/queue-turn-evidence.ts +213 -0
- package/server/lib/thread-turn-queue-deliver.ts +6 -1
- package/server/lib/thread-turn-queue-store.ts +13 -4
- package/server/lib/thread-turn-queue.ts +151 -1
- package/server/lib/transcript-cleanup-context.ts +234 -0
- package/server/lib/transcript-cleanup-ledger.ts +304 -0
- package/server/lib/transcript-cleanup-model.ts +217 -0
- package/server/lib/transcript-cleanup-screen.ts +249 -0
- package/server/lib/transcript-cleanup-spans.ts +358 -0
- package/server/lib/transcript-cleanup-state.ts +201 -0
- package/server/lib/transcript-cleanup-suggestions.ts +126 -0
- package/server/lib/transcript-cleanup-text.ts +200 -0
- package/server/lib/transcript-cleanup.ts +427 -0
- package/server/routes/agent-session-bindings.ts +169 -11
- package/server/routes/glossary.ts +38 -19
- package/server/routes/health.ts +29 -1
- package/server/routes/live-cues.ts +32 -3
- package/server/routes/maintenance.ts +34 -0
- package/server/routes/meeting.ts +92 -9
- package/server/routes/query-jobs.ts +107 -13
- package/server/routes/recovery.ts +4 -0
- package/server/routes/thread-turn-queue.ts +371 -10
- package/server/routes/transcribe.ts +3 -0
- package/server/routes/transcript-cleanup.ts +237 -0
- package/shared/live-cues-v2.ts +9 -4
|
@@ -62,6 +62,10 @@ import { terminateProviderProcess } from './provider-process-lifecycle.js'
|
|
|
62
62
|
import { teeJobTrail, type JobTrailDraft } from './job-trail.js'
|
|
63
63
|
import { claudeSupportsSessionName } from './claude-cli-support.js'
|
|
64
64
|
import { applyTurnRequestEnv, createTurnRequestBinding, spawnWithTurnRequest } from './turn-request.js'
|
|
65
|
+
import { STEER_REAPER_MS, STEER_SETTLE_MS, SteerTracker, composeFirstContent, encodeUserLine, steerClock, type SteerPort } from './claude-steer.js'
|
|
66
|
+
import { randomUUID } from 'node:crypto'
|
|
67
|
+
|
|
68
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
65
69
|
|
|
66
70
|
// Inactivity = no stdout data for this long → kill (catches stalls)
|
|
67
71
|
const INACTIVITY_BY_MODEL: Record<ClaudeModelPreference, number> = {
|
|
@@ -407,6 +411,12 @@ export interface CallOptions {
|
|
|
407
411
|
dispatch?: { restricted: true; tools: readonly string[] }
|
|
408
412
|
/** 6.58.2: `claude --name` for a session this COS session starts fresh (QueryJobRequest.sessionName). */
|
|
409
413
|
sessionName?: string
|
|
414
|
+
/**
|
|
415
|
+
* 6.78.0 Messages steer: the job's steer port. Present only when the coordinator decided the
|
|
416
|
+
* run is steerable (lib/claude-steer.ts). Absent, every argument and every stdin byte is the
|
|
417
|
+
* 6.77.0 one. A dispatch never steers.
|
|
418
|
+
*/
|
|
419
|
+
steer?: SteerPort
|
|
410
420
|
}
|
|
411
421
|
|
|
412
422
|
export async function callClaudeStreaming(
|
|
@@ -599,6 +609,10 @@ export async function callClaudeStreaming(
|
|
|
599
609
|
if (startsOwnCliSession && !options?.dispatch && options?.sessionName && await claudeSupportsSessionName()) {
|
|
600
610
|
args.push(`--name=${options.sessionName}`)
|
|
601
611
|
}
|
|
612
|
+
// 6.78.0 steer: the prompt becomes one stream-json line and stdin stays open for steers.
|
|
613
|
+
// These two flags and nothing else (plan P4); the CLI probe passed before the port existed.
|
|
614
|
+
const steerPort = options?.dispatch ? undefined : options?.steer
|
|
615
|
+
if (steerPort) args.push('--input-format', 'stream-json', '--replay-user-messages')
|
|
602
616
|
|
|
603
617
|
// Strip CLAUDECODE env var so claude -p doesn't think it's nested
|
|
604
618
|
const env = { ...process.env }
|
|
@@ -612,6 +626,9 @@ export async function callClaudeStreaming(
|
|
|
612
626
|
: defaultWallMax
|
|
613
627
|
const wallMax = isExtendedQuery(query) ? Math.max(WALL_MAX_EXTENDED_MS, effortWallMax) : effortWallMax
|
|
614
628
|
const startTime = Date.now()
|
|
629
|
+
// 6.78.0 steer (P7 S7): the wall clock's start on the monotonic steer clock, next to the
|
|
630
|
+
// wall timer it describes, so the coordinator's cut-off reads the same clock after a sleep.
|
|
631
|
+
const steerStartedAt = steerClock()
|
|
615
632
|
const run = startClaudeRun({
|
|
616
633
|
cosSessionId: sid,
|
|
617
634
|
model: resolvedModel,
|
|
@@ -627,9 +644,12 @@ export async function callClaudeStreaming(
|
|
|
627
644
|
|
|
628
645
|
// Ultracode is a CLI-only orchestration keyword; history/chat keeps the
|
|
629
646
|
// original user text so the keyword never appears on the lens.
|
|
630
|
-
const
|
|
647
|
+
const promptText = resolvedEffort === 'ultracode'
|
|
631
648
|
? `${fullQuery}\n\n${ULTRACODE_KEYWORD}`
|
|
632
649
|
: fullQuery
|
|
650
|
+
// 6.78.0 steer: steers that arrived before the first line ride in it (never in request.query
|
|
651
|
+
// or the fingerprint). They are journaled `included` once the line is written.
|
|
652
|
+
const cliQuery = steerPort ? composeFirstContent(promptText, steerPort.drainForCompose()) : promptText
|
|
633
653
|
// The COS UserPromptSubmit hook sees `cliQuery` (image preamble, attachment block and
|
|
634
654
|
// all). This private file hands it the user's own words, bound by the hash of exactly
|
|
635
655
|
// what goes on stdin; the file dies with the child. See lib/turn-request.ts.
|
|
@@ -651,6 +671,57 @@ export async function callClaudeStreaming(
|
|
|
651
671
|
let terminalTextError: string | null = null
|
|
652
672
|
let lastActivity = Date.now() // Tracks last stdout data for inactivity timeout
|
|
653
673
|
let receivedStreamEvents = false // Track if CLI emits stream_event (vs older assistant-only format)
|
|
674
|
+
// 6.78.0 steer state (all inert without a port).
|
|
675
|
+
// The prompt line's uuid is the job's turn id when it is one (the plan's wire shape), so a
|
|
676
|
+
// result's user_message_uuids names the turn; anything else gets a fresh uuid.
|
|
677
|
+
const steerTracker = steerPort
|
|
678
|
+
? new SteerTracker(typeof options?.turnId === 'string' && UUID_RE.test(options.turnId) ? options.turnId.toLowerCase() : randomUUID())
|
|
679
|
+
: null
|
|
680
|
+
let steerSettling = false // a result left a steer uncovered: the settle window is open
|
|
681
|
+
let steerSettleTimer: ReturnType<typeof setTimeout> | null = null
|
|
682
|
+
let steerNeedsSeparator = false // text after such a result is a second turn
|
|
683
|
+
let steerCheckpoint: string | null = null
|
|
684
|
+
let steerStdinEnded = false
|
|
685
|
+
let steerReaper: ReturnType<typeof setTimeout> | null = null
|
|
686
|
+
let steerKilled = false
|
|
687
|
+
let childClosed = false
|
|
688
|
+
function endSteerStdin() {
|
|
689
|
+
if (!steerPort || steerStdinEnded) return
|
|
690
|
+
steerStdinEnded = true
|
|
691
|
+
steerPort.close()
|
|
692
|
+
try { proc.stdin.end() } catch { /* the child is gone */ }
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* 6.78.0 (P7 S2): the run ended with steer lines written that no result covered. Ending
|
|
696
|
+
* stdin would let the CLI run those queued lines unwatched (up to the 15 s reaper), and a
|
|
697
|
+
* later --resume of its session would inherit them while the job reads them not_applied,
|
|
698
|
+
* so the client sends them again. Instead: the port closes, the child is killed, and the
|
|
699
|
+
* CLI session mapping is dropped so the next Ask starts a fresh session.
|
|
700
|
+
*/
|
|
701
|
+
function abandonUncoveredSteers() {
|
|
702
|
+
steerStdinEnded = true
|
|
703
|
+
steerPort!.close()
|
|
704
|
+
if (cliSessionMap.delete(resolvedCliKey)) scheduleCliSessionSave()
|
|
705
|
+
if (childClosed || terminationRequested || steerKilled) return
|
|
706
|
+
steerKilled = true
|
|
707
|
+
void terminateProviderProcess(proc)
|
|
708
|
+
}
|
|
709
|
+
/** A finished steer-mode run whose child has not exited by now is terminated (stdin is closed). */
|
|
710
|
+
function armSteerReaper() {
|
|
711
|
+
if (!steerPort || steerReaper || childClosed || steerKilled) return
|
|
712
|
+
steerReaper = setTimeout(() => {
|
|
713
|
+
if (!childClosed && !terminationRequested) void terminateProviderProcess(proc)
|
|
714
|
+
}, STEER_REAPER_MS)
|
|
715
|
+
steerReaper.unref?.()
|
|
716
|
+
}
|
|
717
|
+
/** Second-turn text keeps the partial a prefix of the answer: one separator first. */
|
|
718
|
+
function steerSeparator() {
|
|
719
|
+
if (!steerNeedsSeparator) return
|
|
720
|
+
steerNeedsSeparator = false
|
|
721
|
+
if (!fullText) return
|
|
722
|
+
fullText += '\n\n'
|
|
723
|
+
callbacks.onChunk('\n\n')
|
|
724
|
+
}
|
|
654
725
|
const toolInputs = new Map<number, { name: string; json: string }>()
|
|
655
726
|
|
|
656
727
|
function cleanupImages() {
|
|
@@ -683,6 +754,7 @@ export async function callClaudeStreaming(
|
|
|
683
754
|
}
|
|
684
755
|
finalized = true
|
|
685
756
|
cleanup()
|
|
757
|
+
armSteerReaper()
|
|
686
758
|
cleanupImages()
|
|
687
759
|
|
|
688
760
|
// The coordinator persists the final provider text before conversation
|
|
@@ -796,6 +868,7 @@ export async function callClaudeStreaming(
|
|
|
796
868
|
if (finalized) return
|
|
797
869
|
finalized = true
|
|
798
870
|
cleanup()
|
|
871
|
+
armSteerReaper()
|
|
799
872
|
cleanupImages()
|
|
800
873
|
outputImagePublisher?.cleanup()
|
|
801
874
|
removeExchange(sid, pendingUserExchange)
|
|
@@ -860,6 +933,11 @@ export async function callClaudeStreaming(
|
|
|
860
933
|
function resetInactivity() {
|
|
861
934
|
lastActivity = Date.now()
|
|
862
935
|
clearTimeout(inactivityTimer)
|
|
936
|
+
// 6.78.0 steer: the inactivity timer pauses during the settle window; output resets the settle.
|
|
937
|
+
if (steerSettling) {
|
|
938
|
+
armSteerSettle()
|
|
939
|
+
return
|
|
940
|
+
}
|
|
863
941
|
inactivityTimer = setTimeout(() => {
|
|
864
942
|
const elapsed = Math.round((Date.now() - startTime) / 1000)
|
|
865
943
|
void terminateForTerminal('inactivity timeout', result => finalizeError(
|
|
@@ -869,10 +947,33 @@ export async function callClaudeStreaming(
|
|
|
869
947
|
}, inactivityMs)
|
|
870
948
|
}
|
|
871
949
|
|
|
950
|
+
/**
|
|
951
|
+
* 6.78.0 steer: a result covered the prompt but not every steer. Wait for the next result,
|
|
952
|
+
* at most STEER_SETTLE_MS of quiet; then close stdin (the CLI runs anything queued and
|
|
953
|
+
* exits) and let the close finish the run with what it has.
|
|
954
|
+
*/
|
|
955
|
+
function armSteerSettle() {
|
|
956
|
+
if (steerSettleTimer) clearTimeout(steerSettleTimer)
|
|
957
|
+
steerSettleTimer = setTimeout(() => {
|
|
958
|
+
steerSettleTimer = null
|
|
959
|
+
steerSettling = false
|
|
960
|
+
endSteerStdin()
|
|
961
|
+
if (!finalized) resetInactivity()
|
|
962
|
+
}, STEER_SETTLE_MS)
|
|
963
|
+
steerSettleTimer.unref?.()
|
|
964
|
+
}
|
|
965
|
+
function enterSteerSettle() {
|
|
966
|
+
steerSettling = true
|
|
967
|
+
clearTimeout(inactivityTimer)
|
|
968
|
+
armSteerSettle()
|
|
969
|
+
}
|
|
970
|
+
|
|
872
971
|
// ─── Wall clock max: absolute cap ───
|
|
873
972
|
|
|
874
973
|
const wallTimer = setTimeout(() => {
|
|
875
974
|
void terminateForTerminal('wall timeout', result => {
|
|
975
|
+
// 6.78.0 steer: a steered run past its first whole answer completes with that answer.
|
|
976
|
+
if (steerCheckpoint !== null) return finalize(steerCheckpoint)
|
|
876
977
|
if (fullText) {
|
|
877
978
|
// Got partial output — deliver only after the process tree is closed.
|
|
878
979
|
return finalize(fullText)
|
|
@@ -886,6 +987,15 @@ export async function callClaudeStreaming(
|
|
|
886
987
|
clearTimeout(inactivityTimer)
|
|
887
988
|
clearTimeout(wallTimer)
|
|
888
989
|
options?.abortSignal?.removeEventListener('abort', handleAbort)
|
|
990
|
+
// 6.78.0 steer: every terminal path closes the port and stdin.
|
|
991
|
+
if (steerSettleTimer) clearTimeout(steerSettleTimer)
|
|
992
|
+
steerSettleTimer = null
|
|
993
|
+
steerSettling = false
|
|
994
|
+
if (steerTracker && steerTracker.uncovered().length > 0) {
|
|
995
|
+
abandonUncoveredSteers()
|
|
996
|
+
return
|
|
997
|
+
}
|
|
998
|
+
endSteerStdin()
|
|
889
999
|
}
|
|
890
1000
|
|
|
891
1001
|
// ─── Process stdout ───
|
|
@@ -924,6 +1034,7 @@ export async function callClaudeStreaming(
|
|
|
924
1034
|
const inner = event.event
|
|
925
1035
|
if (inner?.type === 'content_block_delta' && inner.delta?.type === 'text_delta' && inner.delta.text) {
|
|
926
1036
|
phase = 'generating'
|
|
1037
|
+
steerSeparator()
|
|
927
1038
|
fullText += inner.delta.text
|
|
928
1039
|
callbacks.onChunk(inner.delta.text)
|
|
929
1040
|
} else if (inner?.type === 'content_block_start' && inner.content_block?.type === 'thinking') {
|
|
@@ -982,12 +1093,16 @@ export async function callClaudeStreaming(
|
|
|
982
1093
|
if (authFailure) terminalTextError = authFailure
|
|
983
1094
|
else {
|
|
984
1095
|
phase = 'generating'
|
|
1096
|
+
steerSeparator()
|
|
985
1097
|
fullText += text
|
|
986
1098
|
callbacks.onChunk(text)
|
|
987
1099
|
}
|
|
988
1100
|
}
|
|
989
1101
|
}
|
|
990
1102
|
} else if (event.type === 'user') {
|
|
1103
|
+
// 6.78.0 steer: the CLI replaying a steer line means it has it.
|
|
1104
|
+
const delivered = steerTracker?.onUserEvent(event)
|
|
1105
|
+
if (delivered) steerPort!.events.onDelivered?.(delivered)
|
|
991
1106
|
for (const preview of claudeToolResultPreviewLines(event)) callbacks.onActivityLine?.(preview)
|
|
992
1107
|
} else if (event.type === 'result') {
|
|
993
1108
|
const resultError = claudeResultErrorMessage(event)
|
|
@@ -1001,6 +1116,25 @@ export async function callClaudeStreaming(
|
|
|
1001
1116
|
scheduleCliSessionSave()
|
|
1002
1117
|
updateClaudeRun(run.runId, { cliSessionId: event.session_id })
|
|
1003
1118
|
}
|
|
1119
|
+
// 6.78.0 steer: a result finishes the run only when it (with the ones before it)
|
|
1120
|
+
// covers every line written. A result that leaves a steer uncovered checkpoints the
|
|
1121
|
+
// answer the first time it covers the prompt, and the run waits.
|
|
1122
|
+
if (steerTracker && !finalized) {
|
|
1123
|
+
const verdict = steerTracker.onResult(event)
|
|
1124
|
+
if (verdict.folded.length > 0) steerPort!.events.onFolded?.(verdict.folded)
|
|
1125
|
+
if (verdict.action === 'wait') {
|
|
1126
|
+
if (verdict.checkpoint) {
|
|
1127
|
+
const checkpoint: string = fullText || event.result || ''
|
|
1128
|
+
steerCheckpoint = checkpoint
|
|
1129
|
+
steerPort!.events.onCheckpoint?.(checkpoint)
|
|
1130
|
+
}
|
|
1131
|
+
steerNeedsSeparator = true
|
|
1132
|
+
enterSteerSettle()
|
|
1133
|
+
continue
|
|
1134
|
+
}
|
|
1135
|
+
// finish, or finish with steering off (the CLI named no uuids): no more steers.
|
|
1136
|
+
steerPort!.close()
|
|
1137
|
+
}
|
|
1004
1138
|
// Final result — use accumulated text (more reliable than result.result)
|
|
1005
1139
|
void finalize(fullText || event.result || '')
|
|
1006
1140
|
}
|
|
@@ -1025,6 +1159,9 @@ export async function callClaudeStreaming(
|
|
|
1025
1159
|
})
|
|
1026
1160
|
|
|
1027
1161
|
proc.on('close', (code) => {
|
|
1162
|
+
childClosed = true
|
|
1163
|
+
if (steerReaper) clearTimeout(steerReaper)
|
|
1164
|
+
steerPort?.close()
|
|
1028
1165
|
if (terminationRequested) return
|
|
1029
1166
|
// Process any remaining buffer
|
|
1030
1167
|
if (buffer.trim()) {
|
|
@@ -1090,8 +1227,20 @@ export async function callClaudeStreaming(
|
|
|
1090
1227
|
return sid
|
|
1091
1228
|
}
|
|
1092
1229
|
if (finalized) return sid
|
|
1093
|
-
|
|
1094
|
-
|
|
1230
|
+
if (steerPort && steerTracker) {
|
|
1231
|
+
// 6.78.0 steer: the prompt is one stream-json line; stdin stays open. Steers offered
|
|
1232
|
+
// after the compose are written right behind it, then every later one as it comes.
|
|
1233
|
+
proc.stdin.write(encodeUserLine(steerTracker.promptUuid, cliQuery))
|
|
1234
|
+
steerPort.open((line, steerId) => {
|
|
1235
|
+
if (finalized || steerStdinEnded || !proc.stdin.writable) return false
|
|
1236
|
+
proc.stdin.write(line)
|
|
1237
|
+
steerTracker.noteWritten(steerId)
|
|
1238
|
+
return true
|
|
1239
|
+
}, { startedAt: steerStartedAt, wallMaxMs: wallMax })
|
|
1240
|
+
} else {
|
|
1241
|
+
proc.stdin.write(cliQuery)
|
|
1242
|
+
proc.stdin.end()
|
|
1243
|
+
}
|
|
1095
1244
|
} catch (err) {
|
|
1096
1245
|
const message = err instanceof Error ? err.message : String(err)
|
|
1097
1246
|
if (!finalized) {
|
|
@@ -32,6 +32,45 @@ export function helpListsNameOption(help: string): boolean {
|
|
|
32
32
|
return /(^|[\s,])--name[\s=<[]/m.test(help)
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* 6.78.0 Messages steer: does the installed Claude CLI take `--input-format stream-json` and
|
|
37
|
+
* `--replay-user-messages` (probed on 2.1.302)? Asked once, like `--name`. Any failure is a no,
|
|
38
|
+
* and a run then streams exactly as before. `claudeSteerSupportKnown()` is the answer once it is
|
|
39
|
+
* in, for the synchronous /api/models capability (null until the first probe finishes).
|
|
40
|
+
*/
|
|
41
|
+
let steerSupport: Promise<boolean> | null = null
|
|
42
|
+
let steerSupportKnown: boolean | null = null
|
|
43
|
+
|
|
44
|
+
export function claudeSupportsSteer(): Promise<boolean> {
|
|
45
|
+
if (!steerSupport) {
|
|
46
|
+
steerSupport = new Promise<boolean>(resolve => {
|
|
47
|
+
try {
|
|
48
|
+
execFile('claude', ['--help'], { timeout: HELP_TIMEOUT_MS, maxBuffer: 1024 * 1024 }, (error, stdout, stderr) => {
|
|
49
|
+
const supported = !error && helpListsSteerOptions(`${stdout}\n${stderr}`)
|
|
50
|
+
if (!supported) console.warn('[claude-bridge] this Claude CLI does not list --input-format and --replay-user-messages; Messages steer is off')
|
|
51
|
+
steerSupportKnown = supported
|
|
52
|
+
resolve(supported)
|
|
53
|
+
})
|
|
54
|
+
} catch {
|
|
55
|
+
steerSupportKnown = false
|
|
56
|
+
resolve(false)
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
return steerSupport
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function claudeSteerSupportKnown(): boolean | null {
|
|
64
|
+
return steerSupportKnown
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Both options, as Claude Code 2.1.302 prints them (`--input-format <format>`, `--replay-user-messages`). */
|
|
68
|
+
export function helpListsSteerOptions(help: string): boolean {
|
|
69
|
+
return /(^|[\s,])--input-format[\s=<[]/m.test(help) && /(^|[\s,])--replay-user-messages([\s=<[]|$)/m.test(help)
|
|
70
|
+
}
|
|
71
|
+
|
|
35
72
|
export function resetClaudeCliSupportForTests(): void {
|
|
36
73
|
sessionNameSupport = null
|
|
74
|
+
steerSupport = null
|
|
75
|
+
steerSupportKnown = null
|
|
37
76
|
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// 6.78.0 Messages steer (plan v3.1 P4): everything about steering a running Claude answer that
|
|
2
|
+
// can be decided without a process, a file or the network. The bridge holds thin wiring only;
|
|
3
|
+
// the coordinator owns the journal. Imports nothing that reaches a provider, so tests and the
|
|
4
|
+
// Pi Durable canaries load it directly.
|
|
5
|
+
//
|
|
6
|
+
// THE PROTOCOL (probed on Claude Code 2.1.302, pi_durable_build/probes). With
|
|
7
|
+
// `-p --input-format stream-json --replay-user-messages`, the prompt is one stream-json user
|
|
8
|
+
// line and stdin stays open. A later user line at the DEFAULT priority (never "now") folds into
|
|
9
|
+
// the running turn at the next tool boundary: the CLI replays it (`type: user`, its own uuid,
|
|
10
|
+
// `isReplay: true`), and the turn's one `result` lists every folded line in
|
|
11
|
+
// `user_message_uuids`. A line that arrives after the turn's last step starts a second turn,
|
|
12
|
+
// which ends in a second result. The run is finished only when the results cover every line
|
|
13
|
+
// written; the CLI exits about 300 ms after stdin closes.
|
|
14
|
+
//
|
|
15
|
+
// WHAT THIS MODULE DECIDES:
|
|
16
|
+
// encodeUserLine one stdin line
|
|
17
|
+
// composeFirstContent the first prompt plus steers that arrived before it was written
|
|
18
|
+
// (pending ones, and ones carried from an interrupted generation)
|
|
19
|
+
// SteerPort one per job, created before the runner starts: a pending buffer the
|
|
20
|
+
// first line drains, then direct writes, then closed (too late)
|
|
21
|
+
// SteerTracker sent and covered uuids; what a result means (finish, wait, steering off)
|
|
22
|
+
// steerCutoffMs how close to a deadline a steer is refused as too late
|
|
23
|
+
// resolveRunModel the model a run will use, exactly as model-router resolves it
|
|
24
|
+
import { DEFAULT_MODEL, isCodexModel, isCursorModel, isOllamaModel, normalizeModelPreference, type ModelPreference } from '../../shared/model-preference.js'
|
|
25
|
+
|
|
26
|
+
/** Quiet after a result that left a steer uncovered, before stdin is closed (plan P4). */
|
|
27
|
+
export const STEER_SETTLE_MS = 20_000
|
|
28
|
+
/** After a steer-mode run is finalized, a child that has not exited by now is terminated. */
|
|
29
|
+
export const STEER_REAPER_MS = 15_000
|
|
30
|
+
const CUTOFF_FLOOR_MS = 10_000
|
|
31
|
+
const CUTOFF_FRACTION = 0.2
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 6.78.0 (P7 S7): the clock every steer cut-off is measured on, the bridge's wall clock and the
|
|
35
|
+
* coordinator's deadline alike. Monotonic, like the timers that actually end the run
|
|
36
|
+
* (setTimeout runs on libuv's monotonic clock), so a Mac that slept does not make a steer read
|
|
37
|
+
* too late against a timer that did not advance. `Date.now()` jumps across a sleep.
|
|
38
|
+
*/
|
|
39
|
+
export function steerClock(): number {
|
|
40
|
+
return performance.now()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A steer arriving with less than this left on the wall clock or the deadline is too late. */
|
|
44
|
+
export function steerCutoffMs(wallMaxMs: number): number {
|
|
45
|
+
return Math.max(CUTOFF_FLOOR_MS, Math.round(CUTOFF_FRACTION * Math.max(0, wallMaxMs)))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** One stdin line of `--input-format stream-json`: a user message with its own uuid. */
|
|
49
|
+
export function encodeUserLine(uuid: string, content: string): string {
|
|
50
|
+
return `${JSON.stringify({ type: 'user', uuid, parent_tool_use_id: null, message: { role: 'user', content } })}\n`
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The first prompt, with the steers that are already known when it is written. Never part of
|
|
55
|
+
* `request.query` or the fingerprint (Resume and rollback hash the request): composed here, at
|
|
56
|
+
* execution time only. Pending steers (sent before the run started) and steers carried from an
|
|
57
|
+
* earlier generation read alike.
|
|
58
|
+
*/
|
|
59
|
+
export function composeFirstContent(prompt: string, steers: ReadonlyArray<{ text: string }>): string {
|
|
60
|
+
const lines = steers.map(steer => steer.text.trim()).filter(Boolean)
|
|
61
|
+
if (lines.length === 0) return prompt
|
|
62
|
+
return `${prompt}\n\nThe user added this while the answer was being prepared:\n${lines.map(line => `- ${line}`).join('\n')}`
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface PendingSteer {
|
|
66
|
+
steerId: string
|
|
67
|
+
text: string
|
|
68
|
+
/** The generation it was first sent to, when it is carried into a later one. */
|
|
69
|
+
carriedFrom?: number
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** What the port tells its owner (the coordinator journals each). */
|
|
73
|
+
export interface SteerPortEvents {
|
|
74
|
+
/** Composed into the first line (pending before the start, or carried). */
|
|
75
|
+
onIncluded?: (steers: PendingSteer[]) => void
|
|
76
|
+
/** The CLI replayed the line: it has it. */
|
|
77
|
+
onDelivered?: (steerId: string) => void
|
|
78
|
+
/** A result covered it: the answer includes it. */
|
|
79
|
+
onFolded?: (steerIds: string[]) => void
|
|
80
|
+
/** The first result covered the prompt while a steer was still uncovered: the answer so far. */
|
|
81
|
+
onCheckpoint?: (text: string) => void
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export type SteerOffer = 'pending' | 'written' | 'closed' | 'duplicate'
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* One per job, on the coordinator's ActiveRun BEFORE the runner starts, so no steer can fall
|
|
88
|
+
* between "accepted" and a sink that does not exist yet.
|
|
89
|
+
*
|
|
90
|
+
* before the first line: `offer` buffers; the bridge drains the buffer into the first line
|
|
91
|
+
* after `open(sink)`: `offer` writes a line at once (the sink must say it is writable)
|
|
92
|
+
* after `close()`: `offer` is refused, and the caller journals too_late
|
|
93
|
+
*/
|
|
94
|
+
export class SteerPort {
|
|
95
|
+
readonly events: SteerPortEvents
|
|
96
|
+
private readonly pending: PendingSteer[] = []
|
|
97
|
+
private readonly known = new Set<string>()
|
|
98
|
+
private sink: ((line: string, steerId: string) => boolean) | null = null
|
|
99
|
+
private composed: PendingSteer[] = []
|
|
100
|
+
private closed = false
|
|
101
|
+
private wallDeadlineAt: number | null = null
|
|
102
|
+
private wallMaxMs: number | null = null
|
|
103
|
+
/** Every steer uuid written to stdin, in order (the tracker's `sent`, less the prompt). */
|
|
104
|
+
readonly written: string[] = []
|
|
105
|
+
|
|
106
|
+
constructor(events: SteerPortEvents = {}) {
|
|
107
|
+
this.events = events
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
get isClosed(): boolean { return this.closed }
|
|
111
|
+
get isOpen(): boolean { return this.sink !== null && !this.closed }
|
|
112
|
+
|
|
113
|
+
/** A steer to apply. Idempotent by id. */
|
|
114
|
+
offer(steer: PendingSteer): SteerOffer {
|
|
115
|
+
if (this.known.has(steer.steerId)) return 'duplicate'
|
|
116
|
+
if (this.closed) return 'closed'
|
|
117
|
+
this.known.add(steer.steerId)
|
|
118
|
+
if (!this.sink) {
|
|
119
|
+
this.pending.push(steer)
|
|
120
|
+
return 'pending'
|
|
121
|
+
}
|
|
122
|
+
if (!this.sink(encodeUserLine(steer.steerId, steer.text), steer.steerId)) {
|
|
123
|
+
this.closed = true
|
|
124
|
+
this.sink = null
|
|
125
|
+
return 'closed'
|
|
126
|
+
}
|
|
127
|
+
this.written.push(steer.steerId)
|
|
128
|
+
return 'written'
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The bridge, composing the first line: every pending steer, in order, once. They are
|
|
132
|
+
* reported `onIncluded` when the line is actually written (`open`). */
|
|
133
|
+
drainForCompose(): PendingSteer[] {
|
|
134
|
+
this.composed = this.pending.splice(0)
|
|
135
|
+
return [...this.composed]
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The first line is written: later steers go straight to stdin. Steers offered after the
|
|
140
|
+
* compose and before this call are written now, in order. Returns the ones written here.
|
|
141
|
+
*/
|
|
142
|
+
open(sink: (line: string, steerId: string) => boolean, timing: { startedAt: number; wallMaxMs: number }): string[] {
|
|
143
|
+
if (this.closed) return []
|
|
144
|
+
this.wallMaxMs = timing.wallMaxMs
|
|
145
|
+
this.wallDeadlineAt = timing.startedAt + timing.wallMaxMs
|
|
146
|
+
const composed = this.composed.splice(0)
|
|
147
|
+
if (composed.length > 0) this.events.onIncluded?.(composed)
|
|
148
|
+
this.sink = sink
|
|
149
|
+
const late = this.pending.splice(0)
|
|
150
|
+
const out: string[] = []
|
|
151
|
+
for (const steer of late) {
|
|
152
|
+
if (!sink(encodeUserLine(steer.steerId, steer.text), steer.steerId)) {
|
|
153
|
+
this.closed = true
|
|
154
|
+
this.sink = null
|
|
155
|
+
break
|
|
156
|
+
}
|
|
157
|
+
this.written.push(steer.steerId)
|
|
158
|
+
out.push(steer.steerId)
|
|
159
|
+
}
|
|
160
|
+
return out
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Steers that were offered and never written (they read not_applied when the run ends). */
|
|
164
|
+
unwritten(): PendingSteer[] {
|
|
165
|
+
return [...this.composed, ...this.pending]
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** No more writes: the run is finishing or finished. */
|
|
169
|
+
close(): void {
|
|
170
|
+
this.closed = true
|
|
171
|
+
this.sink = null
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Time left on the bridge's wall clock, or null before the first line. */
|
|
175
|
+
wallRemainingMs(now: number): number | null {
|
|
176
|
+
return this.wallDeadlineAt === null ? null : this.wallDeadlineAt - now
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
wallMax(): number | null {
|
|
180
|
+
return this.wallMaxMs
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export type ResultVerdict =
|
|
185
|
+
/** Every line written is covered: finish now with the whole answer. */
|
|
186
|
+
| { action: 'finish'; folded: string[] }
|
|
187
|
+
/** Some line is not covered yet: keep stdin open and wait (settle timer). */
|
|
188
|
+
| { action: 'wait'; folded: string[]; checkpoint: boolean }
|
|
189
|
+
/** The CLI did not say which lines a result covers: steering is off for this job, finish now. */
|
|
190
|
+
| { action: 'finish_steering_off'; folded: string[] }
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Sent and covered uuids for one run. The prompt's uuid is sent first; each steer written is
|
|
194
|
+
* sent after it. A replay echo marks a steer delivered; a result adds its `user_message_uuids`
|
|
195
|
+
* to `covered`.
|
|
196
|
+
*/
|
|
197
|
+
export class SteerTracker {
|
|
198
|
+
private readonly sent: string[]
|
|
199
|
+
private readonly covered = new Set<string>()
|
|
200
|
+
private readonly delivered = new Set<string>()
|
|
201
|
+
private promptCovered = false
|
|
202
|
+
|
|
203
|
+
constructor(readonly promptUuid: string) {
|
|
204
|
+
this.sent = [promptUuid]
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
noteWritten(uuid: string): void {
|
|
208
|
+
if (!this.sent.includes(uuid)) this.sent.push(uuid)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** A `type: user` stream event. Returns a steer id the first time the CLI replays it. */
|
|
212
|
+
onUserEvent(event: { uuid?: unknown; isReplay?: unknown }): string | null {
|
|
213
|
+
const uuid = typeof event.uuid === 'string' ? event.uuid : null
|
|
214
|
+
if (!uuid || uuid === this.promptUuid || !this.sent.includes(uuid) || this.delivered.has(uuid)) return null
|
|
215
|
+
if (event.isReplay !== true) return null
|
|
216
|
+
this.delivered.add(uuid)
|
|
217
|
+
return uuid
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** A successful `result`. Errors are decided by the bridge before this is called. */
|
|
221
|
+
onResult(event: { user_message_uuids?: unknown }): ResultVerdict {
|
|
222
|
+
const uuids = event.user_message_uuids
|
|
223
|
+
if (!Array.isArray(uuids)) {
|
|
224
|
+
return { action: 'finish_steering_off', folded: [] }
|
|
225
|
+
}
|
|
226
|
+
const folded: string[] = []
|
|
227
|
+
for (const uuid of uuids) {
|
|
228
|
+
if (typeof uuid !== 'string' || this.covered.has(uuid)) continue
|
|
229
|
+
this.covered.add(uuid)
|
|
230
|
+
if (uuid !== this.promptUuid && this.sent.includes(uuid)) folded.push(uuid)
|
|
231
|
+
}
|
|
232
|
+
if (this.sent.every(uuid => this.covered.has(uuid))) return { action: 'finish', folded }
|
|
233
|
+
const firstCover = !this.promptCovered && this.covered.has(this.promptUuid)
|
|
234
|
+
if (firstCover) this.promptCovered = true
|
|
235
|
+
return { action: 'wait', folded, checkpoint: firstCover }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Steers written and not covered (they will read not_applied when the run ends). */
|
|
239
|
+
uncovered(): string[] {
|
|
240
|
+
return this.sent.filter(uuid => uuid !== this.promptUuid && !this.covered.has(uuid))
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export type QueryProvider = 'claude' | 'codex' | 'cursor' | 'ollama'
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The model a run will use, EXACTLY as model-router.ts resolves it (a test pins the two):
|
|
248
|
+
* the request's model, else the session's, else COS_G2_DEFAULT_MODEL, else the default.
|
|
249
|
+
*/
|
|
250
|
+
export function resolveRunModel(model: unknown, sessionModel: ModelPreference | null | undefined, envDefault: unknown): ModelPreference {
|
|
251
|
+
return normalizeModelPreference(model) ?? sessionModel ?? normalizeModelPreference(envDefault) ?? DEFAULT_MODEL
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** The provider of a resolved model, with model-router's fall-through: anything else is Claude. */
|
|
255
|
+
export function providerOfModel(model: ModelPreference): QueryProvider {
|
|
256
|
+
if (isOllamaModel(model)) return 'ollama'
|
|
257
|
+
if (isCursorModel(model)) return 'cursor'
|
|
258
|
+
return isCodexModel(model) ? 'codex' : 'claude'
|
|
259
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// The owner's notes as the memory bridge lists them, searched by keyword, and reciprocal rank fusion (6.76.2). Shared by
|
|
2
|
+
// the live cues notes fallback (live-cues-v2-notes.ts) and the Live COS Ask (searchAskHistory in live-cos-ask.ts).
|
|
3
|
+
//
|
|
4
|
+
// Why the listing: the public memory runtime 0.1.0 serves the owner's notes ONLY through `cos_api_bridge.py memory` (what
|
|
5
|
+
// COS Control's Memory list shows). Its bot_memory.py has no `search` command, so searchMemories' meaning arm answers
|
|
6
|
+
// memory_parse_error, and its keyword arm reads only markdown memory folders: on such a Mac, searchMemories finds none of
|
|
7
|
+
// the owner's notes. Listing and scoring is local: no model call, no query leaves the Mac. Nothing here logs.
|
|
8
|
+
|
|
9
|
+
import { callPython } from './python-bridge.js'
|
|
10
|
+
import { scoreKeywordMatch, tokenizeMeetingQuery } from './meeting-library-search.js'
|
|
11
|
+
|
|
12
|
+
export const BRIDGE_DAYS = 3650
|
|
13
|
+
export const BRIDGE_LIMIT = 200
|
|
14
|
+
/** The listing never takes longer than cue_memory.py's dense arm may (DENSE_BUDGET_S). */
|
|
15
|
+
export const BRIDGE_TIMEOUT_MS = 3_000
|
|
16
|
+
export const RRF_K = 60
|
|
17
|
+
|
|
18
|
+
export interface BridgeMemory { id: string; content: string; summary?: string; created_at?: string; domain?: string }
|
|
19
|
+
|
|
20
|
+
/** Rows the bridge listed: an id and some content, at most BRIDGE_LIMIT. Anything else (an error object) is no rows. */
|
|
21
|
+
export function normalizeBridgeMemories(value: unknown): BridgeMemory[] {
|
|
22
|
+
if (!Array.isArray(value)) return []
|
|
23
|
+
const out: BridgeMemory[] = []
|
|
24
|
+
for (const row of value.slice(0, BRIDGE_LIMIT)) {
|
|
25
|
+
if (!row || typeof row !== 'object' || Array.isArray(row)) continue
|
|
26
|
+
const r = row as Record<string, unknown>
|
|
27
|
+
const id = typeof r.id === 'string' ? r.id.trim().slice(0, 170) : ''
|
|
28
|
+
const content = typeof r.content === 'string' ? r.content : ''
|
|
29
|
+
if (!id || !content.trim()) continue
|
|
30
|
+
out.push({ id, content,
|
|
31
|
+
...(typeof r.summary === 'string' ? { summary: r.summary } : {}),
|
|
32
|
+
...(typeof r.created_at === 'string' ? { created_at: r.created_at } : {}),
|
|
33
|
+
...(typeof r.domain === 'string' && r.domain ? { domain: r.domain } : {}) })
|
|
34
|
+
}
|
|
35
|
+
return out
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The bridge listing. Rejects when the bridge fails or the signal aborts; callers decide what that means. */
|
|
39
|
+
export async function listBridgeMemories(signal?: AbortSignal, timeoutMs: number = BRIDGE_TIMEOUT_MS): Promise<BridgeMemory[]> {
|
|
40
|
+
return normalizeBridgeMemories(await callPython(['memory', '--days', String(BRIDGE_DAYS), '--limit', String(BRIDGE_LIMIT)], timeoutMs, undefined, signal))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Rows that match `query`, best first, scored by the library's own keyword scorer (score > 0 only). */
|
|
44
|
+
export function scoreBridgeMemories(rows: BridgeMemory[], query: string): Array<{ row: BridgeMemory; score: number }> {
|
|
45
|
+
const tokens = tokenizeMeetingQuery(query)
|
|
46
|
+
return rows.map(row => ({ row, score: scoreKeywordMatch(tokens, row.summary || '', `${row.summary || ''}\n${row.content}`).score }))
|
|
47
|
+
.filter(x => x.score > 0).sort((a, b) => b.score - a.score)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A note's LOCAL calendar day: a date bound is a local day (live cues use "tomorrow"), and a memory written at 22:00 Central
|
|
51
|
+
* carries a UTC timestamp dated the next day. A bare YYYY-MM-DD is already a local day. */
|
|
52
|
+
export function localDateOf(v: unknown): string {
|
|
53
|
+
if (typeof v !== 'string') return ''
|
|
54
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(v)) return v
|
|
55
|
+
if (!/^\d{4}-\d{2}-\d{2}T/.test(v)) return ''
|
|
56
|
+
const ms = Date.parse(v)
|
|
57
|
+
return Number.isFinite(ms) ? new Date(ms).toLocaleDateString('en-CA') : ''
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Reciprocal rank fusion over ranked lists (k=60, as cue_memory.py): an item in several lists, or high in one, ranks first.
|
|
61
|
+
* The first item seen for a key is kept; ties keep the order items were first added. */
|
|
62
|
+
export class RankFusion<T> {
|
|
63
|
+
private readonly items = new Map<string, { item: T; score: number; order: number }>()
|
|
64
|
+
add(list: ReadonlyArray<{ key: string; item: T }>): void {
|
|
65
|
+
list.forEach(({ key, item }, rank) => {
|
|
66
|
+
const prev = this.items.get(key)
|
|
67
|
+
this.items.set(key, { item: prev?.item ?? item, score: (prev?.score ?? 0) + 1 / (RRF_K + rank), order: prev?.order ?? this.items.size })
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
ranked(limit = Infinity): T[] {
|
|
71
|
+
return [...this.items.values()].sort((a, b) => b.score - a.score || a.order - b.order).slice(0, limit).map(x => x.item)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -82,6 +82,13 @@ export class SubqDecisionClient implements DecisionClient {
|
|
|
82
82
|
private readonly cap = () => { const n = Number(process.env.COS_SUBQ_DAILY_TOKENS); return Number.isFinite(n) && n > 0 ? Math.floor(n) : SUBQ_LIMITS.dailyTokens },
|
|
83
83
|
private readonly timeoutMs: number = SUBQ_LIMITS.timeoutMs) {}
|
|
84
84
|
resetForNewKey(): void { this.failures = 0; this.breakerUntil = 0 }
|
|
85
|
+
/** For an honest capability (6.76.2, live cues). An unreadable ledger reads as the cap reached: it fails toward not spending. */
|
|
86
|
+
status(): { configured: boolean; usedToday: number; dailyCap: number; breakerOpenUntil: number | null } {
|
|
87
|
+
const dailyCap = this.cap()
|
|
88
|
+
let usedToday = dailyCap
|
|
89
|
+
try { usedToday = Number(this.ledger()[this.day()]) || 0 } catch { /* unreadable ledger: saturated */ }
|
|
90
|
+
return { configured: !!this.key(), usedToday, dailyCap, breakerOpenUntil: this.breakerUntil > this.now().getTime() ? this.breakerUntil : null }
|
|
91
|
+
}
|
|
85
92
|
private ledger(): Record<string, number> {
|
|
86
93
|
try {
|
|
87
94
|
const stat = lstatSync(this.usageFile)
|
|
@@ -144,6 +151,8 @@ export class SubqDecisionClient implements DecisionClient {
|
|
|
144
151
|
|
|
145
152
|
const configuredSubqClients = new Set<SubqDecisionClient>()
|
|
146
153
|
export function resetSubqDecisionClients(): void { configuredSubqClients.forEach(client => client.resetForNewKey()) }
|
|
154
|
+
/** A SubQ client built outside createDecisionClient (live cues, 6.76.2) still starts clean when a new key is saved. */
|
|
155
|
+
export function trackSubqDecisionClient(client: SubqDecisionClient): SubqDecisionClient { configuredSubqClients.add(client); return client }
|
|
147
156
|
/** Snapshot selection per workflow request. Separate clients and cache identities prevent stale cross-provider answers. */
|
|
148
157
|
export function createDecisionClient(useCase: DecisionUseCase, jev: Pick<JevClient, 'ask'> = new JevClient(), env: NodeJS.ProcessEnv = process.env): DecisionClient {
|
|
149
158
|
const clients: Partial<Record<DecisionProvider, DecisionClient>> = {}
|