mixdog 0.9.55 → 0.9.59
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/package.json +3 -1
- package/scripts/_smoke_wd.mjs +7 -0
- package/scripts/agent-terminal-reap-test.mjs +127 -2
- package/scripts/compact-file-reattach-test.mjs +88 -0
- package/scripts/compact-pressure-test.mjs +45 -21
- package/scripts/compact-recall-digest-test.mjs +57 -0
- package/scripts/compact-smoke.mjs +35 -39
- package/scripts/desktop-session-bridge-test.mjs +271 -9
- package/scripts/generate-oc-icons.mjs +11 -0
- package/scripts/live-share-test.mjs +148 -0
- package/scripts/live-worker-smoke.mjs +1 -1
- package/scripts/max-output-recovery-test.mjs +12 -1
- package/scripts/mouse-tracking-restore-smoke.mjs +107 -0
- package/scripts/provider-admission-scheduler-test.mjs +100 -0
- package/scripts/provider-contract-test.mjs +49 -0
- package/scripts/resource-admission-test.mjs +5 -2
- package/scripts/session-ingest-compaction-smoke.mjs +38 -0
- package/scripts/steering-drain-buckets-test.mjs +15 -0
- package/scripts/submit-commandbusy-race-test.mjs +54 -3
- package/scripts/tool-smoke.mjs +2 -3
- package/scripts/tui-ambiguous-width-test.mjs +113 -0
- package/scripts/tui-runtime-load-bench.mjs +5 -0
- package/scripts/tui-transcript-perf-test.mjs +167 -0
- package/src/app.mjs +23 -0
- package/src/cli.mjs +6 -0
- package/src/lib/keychain-cjs.cjs +70 -20
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +4 -2
- package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +18 -17
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +18 -0
- package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +111 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +6 -6
- package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +61 -2
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +13 -4
- package/src/runtime/agent/orchestrator/session/compact/engine.mjs +43 -2
- package/src/runtime/agent/orchestrator/session/compact/file-reattach.mjs +112 -0
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +203 -49
- package/src/runtime/agent/orchestrator/session/loop/compact-debug.mjs +6 -2
- package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +110 -15
- package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +7 -1
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +109 -2
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +15 -7
- package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +39 -39
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +108 -4
- package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +19 -0
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +22 -11
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +132 -24
- package/src/runtime/agent/orchestrator/session/manager/status-telemetry.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/manager.mjs +16 -1
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +39 -21
- package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +18 -0
- package/src/runtime/agent/orchestrator/session/store/paths-heartbeat.mjs +91 -1
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +14 -33
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +169 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +474 -42
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +17 -2
- package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +36 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +20 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +5 -1
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +249 -11
- package/src/runtime/channels/data/voice-runtime-manifest.json +21 -5
- package/src/runtime/channels/lib/scheduler.mjs +8 -6
- package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +71 -8
- package/src/runtime/channels/lib/voice-transcription.mjs +2 -2
- package/src/runtime/channels/lib/whisper-language.mjs +4 -0
- package/src/runtime/memory/lib/embedding-provider.mjs +7 -0
- package/src/runtime/memory/lib/embedding-worker.mjs +20 -0
- package/src/runtime/memory/lib/query-handlers.mjs +10 -4
- package/src/runtime/memory/lib/recall-format.mjs +43 -2
- package/src/runtime/memory/lib/session-ingest-runtime.mjs +90 -64
- package/src/runtime/shared/err-text.mjs +4 -1
- package/src/runtime/shared/resource-admission.mjs +11 -0
- package/src/runtime/shared/schedule-model-ref.mjs +28 -0
- package/src/runtime/shared/schedule-session-run.mjs +63 -0
- package/src/runtime/shared/schedules-db.mjs +8 -3
- package/src/runtime/shared/tool-result-summary.mjs +4 -1
- package/src/runtime/shared/tool-surface.mjs +7 -7
- package/src/runtime/shared/transcript-writer.mjs +17 -0
- package/src/session-runtime/channel-config-api.mjs +11 -0
- package/src/session-runtime/config-helpers.mjs +9 -6
- package/src/session-runtime/context-status.mjs +80 -3
- package/src/session-runtime/lifecycle-api.mjs +154 -40
- package/src/session-runtime/provider-auth-api.mjs +21 -2
- package/src/session-runtime/provider-usage.mjs +4 -1
- package/src/session-runtime/resource-api.mjs +12 -11
- package/src/session-runtime/runtime-core.mjs +45 -11
- package/src/session-runtime/session-text.mjs +56 -6
- package/src/session-runtime/session-turn-api.mjs +70 -2
- package/src/session-runtime/tool-catalog.mjs +2 -1
- package/src/session-runtime/workflow-agents-api.mjs +2 -2
- package/src/standalone/agent-tool/render.mjs +5 -1
- package/src/standalone/agent-tool.mjs +63 -3
- package/src/standalone/channel-admin.mjs +19 -3
- package/src/standalone/channel-daemon.mjs +40 -0
- package/src/tui/app/resume-picker.mjs +5 -1
- package/src/tui/app/settings-picker.mjs +19 -0
- package/src/tui/app/transcript-window.mjs +45 -8
- package/src/tui/app/use-mouse-input.mjs +101 -31
- package/src/tui/app/use-transcript-window.mjs +53 -9
- package/src/tui/components/ToolExecution.jsx +3 -4
- package/src/tui/components/TranscriptItem.jsx +3 -0
- package/src/tui/dist/index.mjs +17391 -14920
- package/src/tui/engine/context-state.mjs +10 -1
- package/src/tui/engine/live-share.mjs +390 -0
- package/src/tui/engine/queue-helpers.mjs +23 -0
- package/src/tui/engine/session-api-ext.mjs +439 -40
- package/src/tui/engine/session-api.mjs +7 -1
- package/src/tui/engine/session-flow.mjs +21 -7
- package/src/tui/engine/tool-card-results.mjs +3 -1
- package/src/tui/engine/turn.mjs +57 -4
- package/src/tui/engine.mjs +211 -7
- package/src/tui/index.jsx +2 -0
- package/src/tui/lib/voice-setup.mjs +10 -4
- package/src/tui/paste-attachments.mjs +4 -1
- package/src/vendor/statusline/src/gateway/session-routes.mjs +24 -1
- package/src/workflows/default/WORKFLOW.md +3 -0
- package/vendor/ink/build/display-width.js +14 -0
- package/vendor/ink/build/output.js +24 -3
- package/vendor/ink/build/wrap-text.d.ts +2 -0
- package/vendor/ink/build/wrap-text.js +45 -4
|
@@ -40,6 +40,30 @@ import { spawnSync } from 'child_process'
|
|
|
40
40
|
import { createGunzip } from 'zlib'
|
|
41
41
|
import { renameWithRetrySync, writeFileAtomicSync } from '../../shared/atomic-file.mjs'
|
|
42
42
|
import { windowsProgramRoots, windowsSystemRoot } from '../../agent/orchestrator/tools/builtin/windows-roots.mjs'
|
|
43
|
+
import { detectDeviceLanguage, normalizeWhisperLanguage } from './whisper-language.mjs'
|
|
44
|
+
|
|
45
|
+
// Model catalog: `models.standard` / `models.korean` in the manifest, with the
|
|
46
|
+
// legacy single `model` section as the standard fallback for older manifests.
|
|
47
|
+
// Selection: explicit voice.model config wins; 'auto' picks the Korean
|
|
48
|
+
// fine-tune only when the device language resolves to Korean.
|
|
49
|
+
export function selectVoiceModelId(voiceConfig = null) {
|
|
50
|
+
const pref = String(voiceConfig?.model || 'auto').trim().toLowerCase()
|
|
51
|
+
if (pref === 'standard' || pref === 'korean') return pref
|
|
52
|
+
// voice.language is the strongest auto signal: Node's ICU default locale
|
|
53
|
+
// often resolves to en-US even on a Korean Windows install, so an explicit
|
|
54
|
+
// transcription language wins over device-locale detection.
|
|
55
|
+
const configured = normalizeWhisperLanguage(voiceConfig?.language)
|
|
56
|
+
if (configured === 'ko') return 'korean'
|
|
57
|
+
if (configured) return 'standard'
|
|
58
|
+
return detectDeviceLanguage() === 'ko' ? 'korean' : 'standard'
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function manifestModelEntry(manifest, modelId = 'standard') {
|
|
62
|
+
const entry = manifest?.models && typeof manifest.models === 'object'
|
|
63
|
+
? manifest.models[modelId]
|
|
64
|
+
: null
|
|
65
|
+
return entry || manifest?.model || null
|
|
66
|
+
}
|
|
43
67
|
|
|
44
68
|
const BUNDLED_MANIFEST_PATH = fileURLToPath(new URL('../data/voice-runtime-manifest.json', import.meta.url))
|
|
45
69
|
const MANIFEST_URL = 'https://raw.githubusercontent.com/tribgames/mixdog/main/src/runtime/channels/data/voice-runtime-manifest.json'
|
|
@@ -597,11 +621,45 @@ export function resolveManagedWhisperCmd(dataDir) {
|
|
|
597
621
|
export function resolveManagedWhisperModel(dataDir) {
|
|
598
622
|
if (!existsSync(BUNDLED_MANIFEST_PATH)) return null
|
|
599
623
|
const manifest = JSON.parse(readFileSync(BUNDLED_MANIFEST_PATH, 'utf8'))
|
|
600
|
-
|
|
601
|
-
|
|
624
|
+
return resolveManagedWhisperModelForId(manifest, dataDir, 'standard')
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
export function resolveManagedWhisperModelById(dataDir, modelId = 'standard') {
|
|
628
|
+
if (!existsSync(BUNDLED_MANIFEST_PATH)) return null
|
|
629
|
+
const manifest = JSON.parse(readFileSync(BUNDLED_MANIFEST_PATH, 'utf8'))
|
|
630
|
+
return resolveManagedWhisperModelForId(manifest, dataDir, modelId)
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function resolveManagedWhisperModelForId(manifest, dataDir, modelId) {
|
|
634
|
+
const entry = manifestModelEntry(manifest, modelId)
|
|
635
|
+
if (!entry?.filename) return null
|
|
636
|
+
const modelDir = join(dataDir, 'voice', 'models')
|
|
637
|
+
const p = join(modelDir, entry.filename)
|
|
602
638
|
return existsSync(p) ? p : null
|
|
603
639
|
}
|
|
604
640
|
|
|
641
|
+
// Clean-install policy: the models dir holds ONLY filenames declared by the
|
|
642
|
+
// current manifest (standard/korean). Anything else — the pre-Q8 F16 weight,
|
|
643
|
+
// interrupted renames, hand-copied experiments — is deleted so a model swap
|
|
644
|
+
// never leaves a 1.6GB orphan behind and resolution never silently falls back
|
|
645
|
+
// to a stale weight.
|
|
646
|
+
function gcUnknownModelFiles(manifest, modelDir) {
|
|
647
|
+
const keep = new Set()
|
|
648
|
+
for (const entry of [manifest?.model, ...Object.values(manifest?.models || {})]) {
|
|
649
|
+
if (entry?.filename) keep.add(entry.filename)
|
|
650
|
+
}
|
|
651
|
+
if (keep.size === 0) return
|
|
652
|
+
let names = []
|
|
653
|
+
try { names = readdirSync(modelDir) } catch { return }
|
|
654
|
+
for (const name of names) {
|
|
655
|
+
if (!name.endsWith('.bin') || keep.has(name)) continue
|
|
656
|
+
try {
|
|
657
|
+
rmSync(join(modelDir, name), { force: true })
|
|
658
|
+
process.stderr.write(`[voice-runtime] removed stale model file ${name}\n`)
|
|
659
|
+
} catch {}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
605
663
|
// Single managed ffmpeg binary used by transcribe (ogg→wav). Layout mirrors
|
|
606
664
|
// whisper-runtime: one binary per OS×arch fetched once, sha256-verified, atomic
|
|
607
665
|
// stage→rename, GC of stale ffmpeg-* dirs. Source binaries are gz-compressed
|
|
@@ -701,9 +759,9 @@ export function resolveManagedFfmpegPath(dataDir) {
|
|
|
701
759
|
return null
|
|
702
760
|
}
|
|
703
761
|
|
|
704
|
-
export function resolveVoiceRuntime(dataDir) {
|
|
762
|
+
export function resolveVoiceRuntime(dataDir, { modelId = 'standard' } = {}) {
|
|
705
763
|
const managedWhisperCmd = resolveManagedWhisperCmd(dataDir)
|
|
706
|
-
const managedModelPath =
|
|
764
|
+
const managedModelPath = resolveManagedWhisperModelById(dataDir, modelId)
|
|
707
765
|
const managedFfmpegPath = resolveManagedFfmpegPath(dataDir)
|
|
708
766
|
const ext = process.platform === 'win32' ? '.exe' : ''
|
|
709
767
|
const managedServerCmd = managedWhisperCmd
|
|
@@ -720,7 +778,8 @@ export function resolveVoiceRuntime(dataDir) {
|
|
|
720
778
|
whisperCmd: managedWhisperCmd,
|
|
721
779
|
serverCmd,
|
|
722
780
|
modelPath: managedModelPath,
|
|
723
|
-
|
|
781
|
+
modelId,
|
|
782
|
+
modelName: managedModelPath ? managedModelPath.split(/[\\/]/).pop() : '',
|
|
724
783
|
ffmpegPath: managedFfmpegPath,
|
|
725
784
|
}
|
|
726
785
|
}
|
|
@@ -729,9 +788,9 @@ export function resolveVoiceRuntime(dataDir) {
|
|
|
729
788
|
// the resolved file exists and matches the manifest sha256, return without
|
|
730
789
|
// re-downloading. Atomic install via stage-then-rename so a partial download
|
|
731
790
|
// on a kill/crash never leaves the model dir holding a corrupted .bin.
|
|
732
|
-
export async function ensureWhisperModel(dataDir, onProgress = null) {
|
|
791
|
+
export async function ensureWhisperModel(dataDir, onProgress = null, modelId = 'standard') {
|
|
733
792
|
const manifest = await loadManifest(dataDir)
|
|
734
|
-
const model = manifest
|
|
793
|
+
const model = manifestModelEntry(manifest, modelId)
|
|
735
794
|
if (!model) {
|
|
736
795
|
throw new Error('[voice-runtime] manifest is missing the `model` section — cannot resolve whisper model')
|
|
737
796
|
}
|
|
@@ -751,7 +810,10 @@ export async function ensureWhisperModel(dataDir, onProgress = null) {
|
|
|
751
810
|
return _withInstallLock(modelDir, 'install', async () => {
|
|
752
811
|
if (existsSync(modelPath)) {
|
|
753
812
|
const actual = await sha256File(modelPath)
|
|
754
|
-
if (actual === model.sha256)
|
|
813
|
+
if (actual === model.sha256) {
|
|
814
|
+
gcUnknownModelFiles(manifest, modelDir)
|
|
815
|
+
return { modelPath, modelId: model.id, size: model.size }
|
|
816
|
+
}
|
|
755
817
|
try { rmSync(modelPath, { force: true }) } catch {}
|
|
756
818
|
}
|
|
757
819
|
gcStagingPartials(modelDir)
|
|
@@ -764,6 +826,7 @@ export async function ensureWhisperModel(dataDir, onProgress = null) {
|
|
|
764
826
|
})
|
|
765
827
|
await verifySha256(stagingPath, model.sha256)
|
|
766
828
|
renameWithRetrySync(stagingPath, modelPath)
|
|
829
|
+
gcUnknownModelFiles(manifest, modelDir)
|
|
767
830
|
process.stderr.write(`[voice-runtime] model ${model.id} ready at ${modelPath}\n`)
|
|
768
831
|
return { modelPath, modelId: model.id, size: model.size }
|
|
769
832
|
} catch (err) {
|
|
@@ -3,7 +3,7 @@ import * as fs from "fs";
|
|
|
3
3
|
import * as os from "os";
|
|
4
4
|
import * as path from "path";
|
|
5
5
|
import { createRequire } from "module";
|
|
6
|
-
import { resolveVoiceRuntime } from "./voice-runtime-fetcher.mjs";
|
|
6
|
+
import { resolveVoiceRuntime, selectVoiceModelId } from "./voice-runtime-fetcher.mjs";
|
|
7
7
|
import { ensureReady, transcribe } from "./whisper-server.mjs";
|
|
8
8
|
import { normalizeWhisperLanguage, detectDeviceLanguage } from "./whisper-language.mjs";
|
|
9
9
|
|
|
@@ -119,7 +119,7 @@ function createVoiceTranscription({ getConfig, dataDir }) {
|
|
|
119
119
|
async function _doTranscribeVoice(audioPath, attachmentId) {
|
|
120
120
|
const config = getConfig();
|
|
121
121
|
try {
|
|
122
|
-
const runtime = resolveVoiceRuntime(dataDir);
|
|
122
|
+
const runtime = resolveVoiceRuntime(dataDir, { modelId: selectVoiceModelId(config.voice) });
|
|
123
123
|
if (!runtime?.installed) {
|
|
124
124
|
const missing = [runtime?.binary ? null : 'binary', runtime?.model ? null : 'model', runtime?.ffmpeg ? null : 'ffmpeg'].filter(Boolean).join(' + ');
|
|
125
125
|
throw new Error(`voice runtime not installed (missing: ${missing}) — open the setup wizard and click "Install voice"`);
|
|
@@ -6,6 +6,10 @@ let resolvedWhisperLanguage = null;
|
|
|
6
6
|
function normalizeWhisperLanguage(value) {
|
|
7
7
|
const raw = String(value ?? "").trim().toLowerCase();
|
|
8
8
|
if (!raw || raw === "auto") return null;
|
|
9
|
+
// POSIX pseudo-locales (LANG=C / C.UTF-8 / POSIX) carry no language signal;
|
|
10
|
+
// treating them as a language poisoned detection ('c.utf-8' reached whisper
|
|
11
|
+
// and blocked the ko-KR Intl fallback from ever being consulted).
|
|
12
|
+
if (raw === "c" || raw === "posix" || raw.startsWith("c.") || raw.startsWith("posix.")) return null;
|
|
9
13
|
if (raw.startsWith("ko")) return "ko";
|
|
10
14
|
if (raw.startsWith("ja")) return "ja";
|
|
11
15
|
if (raw.startsWith("en")) return "en";
|
|
@@ -74,6 +74,13 @@ function ensureWorker() {
|
|
|
74
74
|
const execArgv = process.execArgv.filter((arg) => !String(arg).startsWith('--input-type'))
|
|
75
75
|
worker = new Worker(WORKER_PATH, { env: { ...process.env }, execArgv })
|
|
76
76
|
worker.on('message', (msg) => {
|
|
77
|
+
if (msg.type === 'log') {
|
|
78
|
+
// Worker-thread stdio forwarded via IPC (see embedding-worker.mjs
|
|
79
|
+
// header): route through the parent's guardable stderr so TUI runs
|
|
80
|
+
// file it instead of tearing the terminal frame.
|
|
81
|
+
try { process.stderr.write(String(msg.chunk ?? '')) } catch { /* best-effort */ }
|
|
82
|
+
return
|
|
83
|
+
}
|
|
77
84
|
if (msg.type === 'profile') {
|
|
78
85
|
writeProfilePoint(msg.record)
|
|
79
86
|
return
|
|
@@ -21,6 +21,26 @@ import {
|
|
|
21
21
|
} from './embedding-model-config.mjs'
|
|
22
22
|
|
|
23
23
|
const MODEL_ID = getConfiguredEmbeddingModelId()
|
|
24
|
+
|
|
25
|
+
// Reroute ALL worker-thread stdio through parentPort. Without stdout:true/
|
|
26
|
+
// stderr:true the worker's stream writes are copied straight into the
|
|
27
|
+
// process's REAL fds, bypassing the TUI stderr guard and painting over the
|
|
28
|
+
// alternate-screen frame (third-party noise: transformers.js progress,
|
|
29
|
+
// ORT JS warnings, stray console.*). Piped stdio (stdout:true) is NOT an
|
|
30
|
+
// option here — reading a worker's piped stdio refs the MessagePort for the
|
|
31
|
+
// worker's lifetime and blocks process exit (see save-session-worker.mjs,
|
|
32
|
+
// same pattern). __mixdogMemoryLog above intentionally keeps the raw bound
|
|
33
|
+
// writer: it is already gated by MIXDOG_QUIET_MEMORY_LOG in TUI runs.
|
|
34
|
+
const __forwardWorkerWrite = (chunk, encoding, callback) => {
|
|
35
|
+
const done = typeof encoding === 'function' ? encoding : callback
|
|
36
|
+
try {
|
|
37
|
+
parentPort?.postMessage({ type: 'log', chunk: typeof chunk === 'string' ? chunk : String(chunk ?? '') })
|
|
38
|
+
} catch { /* drop — logging must never break embedding */ }
|
|
39
|
+
if (typeof done === 'function') { try { done() } catch { /* ignore */ } }
|
|
40
|
+
return true
|
|
41
|
+
}
|
|
42
|
+
process.stdout.write = __forwardWorkerWrite
|
|
43
|
+
process.stderr.write = __forwardWorkerWrite
|
|
24
44
|
const DEFAULT_DEVICE = getDefaultEmbeddingDevice(MODEL_ID)
|
|
25
45
|
const DEFAULT_DTYPE = getDefaultEmbeddingDtype(MODEL_ID)
|
|
26
46
|
const MODEL_LOAD_OPTIONS = getEmbeddingModelLoadOptions(MODEL_ID)
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
renderEntryLines,
|
|
20
20
|
renderSessionGroupedLines,
|
|
21
21
|
collapseNearDuplicateRows,
|
|
22
|
+
compactDigestRows,
|
|
22
23
|
} from './recall-format.mjs'
|
|
23
24
|
import { searchRelevantHybrid } from './memory-recall-store.mjs'
|
|
24
25
|
import { fetchEntriesByIdsScoped } from './memory-recall-id-patch.mjs'
|
|
@@ -101,6 +102,10 @@ export function createQueryHandlers({
|
|
|
101
102
|
const sessionId = String(args.sessionId || args.session_id || '').trim()
|
|
102
103
|
if (!sessionId) return { text: '(no current session)' }
|
|
103
104
|
const limit = Math.max(1, Math.min(100, Number(args.limit) || 20))
|
|
105
|
+
const compactDigest = args.compactDigest === true
|
|
106
|
+
// Over-fetch before compact-only dedupe so duplicated legacy rows cannot
|
|
107
|
+
// consume the requested page and hide distinct older context.
|
|
108
|
+
const fetchLimit = compactDigest ? Math.min(100, Math.max(limit, limit * 4)) : limit
|
|
104
109
|
const terms = sessionRecallTerms(args.query)
|
|
105
110
|
const params = [sessionId]
|
|
106
111
|
// Roots + not-yet-chunked leaves only. Once cycle1 turns raw leaves into
|
|
@@ -147,7 +152,7 @@ export function createQueryHandlers({
|
|
|
147
152
|
})
|
|
148
153
|
where.push(`(${clauses.join(' OR ')})`)
|
|
149
154
|
}
|
|
150
|
-
params.push(
|
|
155
|
+
params.push(fetchLimit)
|
|
151
156
|
let rows = (await db.query(`
|
|
152
157
|
SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root,
|
|
153
158
|
element, category, summary, status, score, last_seen_at, project_id
|
|
@@ -156,9 +161,9 @@ export function createQueryHandlers({
|
|
|
156
161
|
ORDER BY ts DESC, id DESC
|
|
157
162
|
LIMIT $${params.length}
|
|
158
163
|
`, params)).rows
|
|
159
|
-
if (rows.length <
|
|
164
|
+
if (rows.length < fetchLimit) {
|
|
160
165
|
const seen = new Set(rows.map((row) => Number(row.id)).filter((id) => Number.isFinite(id)))
|
|
161
|
-
const fillLimit = Math.max(0,
|
|
166
|
+
const fillLimit = Math.max(0, fetchLimit - rows.length)
|
|
162
167
|
const fillWhere = ['session_id = $1', 'id <> ALL($2::bigint[])', '(is_root = 1 OR chunk_root IS NULL OR chunk_root = id)']
|
|
163
168
|
const fillParams = [sessionId, [...seen]]
|
|
164
169
|
if (Number.isFinite(excludeSourceTurnId)) {
|
|
@@ -201,7 +206,8 @@ export function createQueryHandlers({
|
|
|
201
206
|
}
|
|
202
207
|
}
|
|
203
208
|
}
|
|
204
|
-
|
|
209
|
+
if (compactDigest) rows = compactDigestRows(rows, limit)
|
|
210
|
+
return { text: renderEntryLines(rows, { pendingMarks: !compactDigest }) }
|
|
205
211
|
}
|
|
206
212
|
|
|
207
213
|
async function recallCoreRows(query, { projectScope, category, limit } = {}) {
|
|
@@ -149,7 +149,7 @@ export function interleaveRawRows(hybridRows, rawRows) {
|
|
|
149
149
|
return out
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
-
export function renderEntryLines(rows, { recencyOrder = false } = {}) {
|
|
152
|
+
export function renderEntryLines(rows, { recencyOrder = false, pendingMarks = true } = {}) {
|
|
153
153
|
if (!rows || rows.length === 0) return '(no results)'
|
|
154
154
|
// Each emitted line is tracked as a { ts, text } unit so the recencyOrder
|
|
155
155
|
// path can sort the WHOLE stream (roots + their members, plus leaf/raw rows)
|
|
@@ -202,7 +202,7 @@ export function renderEntryLines(rows, { recencyOrder = false } = {}) {
|
|
|
202
202
|
: cleanMemoryText(String(r.content ?? '')).slice(0, 8000)
|
|
203
203
|
// Unchunked raw leaf (cycle1 hasn't classified it yet): mark it so
|
|
204
204
|
// callers can tell fresh-but-unprocessed rows from chunked memory.
|
|
205
|
-
const pendingMark = (r.is_root === 0 && r.chunk_root == null) ? ' [pending]' : ''
|
|
205
|
+
const pendingMark = pendingMarks && (r.is_root === 0 && r.chunk_root == null) ? ' [pending]' : ''
|
|
206
206
|
units.push({ ts: Number(r.ts) || 0, text: `[${ts}] ${rolePrefix}${body.slice(0, 8000)}${pendingMark} #${r.id}` })
|
|
207
207
|
}
|
|
208
208
|
}
|
|
@@ -240,6 +240,37 @@ function normalizedRowText(r) {
|
|
|
240
240
|
return String(body).toLowerCase()
|
|
241
241
|
}
|
|
242
242
|
|
|
243
|
+
function exactDuplicateKey(r) {
|
|
244
|
+
if (Array.isArray(r?.members) && r.members.length > 0) {
|
|
245
|
+
return JSON.stringify(['members', r.members.map((m) => [
|
|
246
|
+
m?.role ?? '',
|
|
247
|
+
cleanMemoryText(String(m?.content ?? '')),
|
|
248
|
+
])])
|
|
249
|
+
}
|
|
250
|
+
const element = r?.element ?? ''
|
|
251
|
+
const summary = r?.summary ?? ''
|
|
252
|
+
const body = (element || summary)
|
|
253
|
+
? `${element}${summary ? ' — ' + summary : ''}`
|
|
254
|
+
: cleanMemoryText(String(r?.content ?? ''))
|
|
255
|
+
return JSON.stringify([r?.role ?? '', body])
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Legacy object-identity-only ingest could mint duplicate rows on JSON reload.
|
|
259
|
+
// Keep the newest/highest-ranked exact body once, including short acknowledgments
|
|
260
|
+
// that shingle dedupe intentionally ignores.
|
|
261
|
+
export function collapseExactDuplicateRows(rows) {
|
|
262
|
+
if (!Array.isArray(rows) || rows.length < 2) return rows
|
|
263
|
+
const seen = new Set()
|
|
264
|
+
const out = []
|
|
265
|
+
for (const row of rows) {
|
|
266
|
+
const key = exactDuplicateKey(row)
|
|
267
|
+
if (seen.has(key)) continue
|
|
268
|
+
seen.add(key)
|
|
269
|
+
out.push(row)
|
|
270
|
+
}
|
|
271
|
+
return out
|
|
272
|
+
}
|
|
273
|
+
|
|
243
274
|
export function collapseNearDuplicateRows(rows, {
|
|
244
275
|
stubOverlap = 0.65,
|
|
245
276
|
dropOverlap = 0.9,
|
|
@@ -290,6 +321,16 @@ export function collapseNearDuplicateRows(rows, {
|
|
|
290
321
|
return out
|
|
291
322
|
}
|
|
292
323
|
|
|
324
|
+
// Compact handoffs are authoritative state, not browsable search output.
|
|
325
|
+
// Remove exact and near-duplicate bodies completely (no id stubs) so polluted
|
|
326
|
+
// legacy rows cannot crowd distinct recent work out of the digest.
|
|
327
|
+
export function compactDigestRows(rows, limit = 30) {
|
|
328
|
+
const cap = Math.max(1, Math.floor(Number(limit) || 30))
|
|
329
|
+
return collapseNearDuplicateRows(collapseExactDuplicateRows(rows))
|
|
330
|
+
.filter((row) => !row?._dupStub)
|
|
331
|
+
.slice(0, cap)
|
|
332
|
+
}
|
|
333
|
+
|
|
293
334
|
// Compact session label for group headers: keep short ids verbatim, shorten
|
|
294
335
|
// long ones to a recognizable tail (ids are typically unique in the suffix —
|
|
295
336
|
// timestamp/counter — not the prefix).
|
|
@@ -85,7 +85,7 @@ export function createSessionIngestRuntime({
|
|
|
85
85
|
_sessionIngestOrdinalState.set(sessionId, existing)
|
|
86
86
|
return existing
|
|
87
87
|
}
|
|
88
|
-
const st = { occNext: new Map(), seeded: false }
|
|
88
|
+
const st = { occNext: new Map(), seeded: false, snapshot: [] }
|
|
89
89
|
_sessionIngestOrdinalState.set(sessionId, st)
|
|
90
90
|
while (_sessionIngestOrdinalState.size > _ORDINAL_STATE_MAX_SESSIONS) {
|
|
91
91
|
const oldest = _sessionIngestOrdinalState.keys().next().value
|
|
@@ -95,14 +95,10 @@ export function createSessionIngestRuntime({
|
|
|
95
95
|
return st
|
|
96
96
|
}
|
|
97
97
|
// Ordinal assigned to a given session-message OBJECT at first sight, reused on
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
// an HTTP/JSON boundary, so the SAME message objects arrive across calls for a
|
|
103
|
-
// live session. WeakMap: entries vanish when a message is GC'd (compaction
|
|
104
|
-
// drops it from the array). Across a process restart (or LRU eviction) the
|
|
105
|
-
// signal is gone; the walk then falls back to positional ordinals.
|
|
98
|
+
// later hydrates while the live array survives. Resume/reopen JSON-parses a new
|
|
99
|
+
// object graph even when this memory runtime remains warm, so object identity
|
|
100
|
+
// is only the fast/strong signal; the ordered snapshot fallback below handles
|
|
101
|
+
// cloned transcript replays.
|
|
106
102
|
const _ingestedMessageOrdinal = new WeakMap()
|
|
107
103
|
// Assign/reuse the occurrence ordinal for message `m` under identity `occKey`.
|
|
108
104
|
// A re-presented object reuses its recorded ordinal AND advances the session
|
|
@@ -123,17 +119,22 @@ export function createSessionIngestRuntime({
|
|
|
123
119
|
_ingestedMessageOrdinal.set(m, ord)
|
|
124
120
|
return ord
|
|
125
121
|
}
|
|
122
|
+
// Reuse an occurrence recovered from the previous ingest's content/order
|
|
123
|
+
// snapshot. Session resume/reopen JSON-parses the transcript, so every row is
|
|
124
|
+
// a fresh object even while the long-lived memory runtime (and its WeakMaps)
|
|
125
|
+
// stays warm. Object identity alone therefore misclassified a cloned replay
|
|
126
|
+
// as newly appended history and minted a new source_ref for every old turn.
|
|
127
|
+
function _reuseOccurrence(occNext, occKey, m, occurrence) {
|
|
128
|
+
const ord = Math.max(0, Math.floor(Number(occurrence) || 0))
|
|
129
|
+
if (ord + 1 > (occNext.get(occKey) ?? 0)) occNext.set(occKey, ord + 1)
|
|
130
|
+
_ingestedMessageOrdinal.set(m, ord)
|
|
131
|
+
return ord
|
|
132
|
+
}
|
|
126
133
|
// Cache of (role, cleaned content) per session message OBJECT, keyed by
|
|
127
134
|
// identity (WeakMap — entries vanish once the message is GC'd, e.g. after
|
|
128
|
-
// compaction
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
// an O(full transcript) regex pass on every hydrate. Message objects are
|
|
132
|
-
// immutable transcript entries reused by reference across calls (recall-
|
|
133
|
-
// fasttrack re-ingests the same in-memory array as the session grows), so
|
|
134
|
-
// once a message's identity fields are computed they never change — caching
|
|
135
|
-
// makes every call after the first pay only for genuinely NEW messages,
|
|
136
|
-
// restoring the limit-bounded cost in steady state.
|
|
135
|
+
// compaction or JSON reload replaces the array). Live-array steady-state calls
|
|
136
|
+
// reuse the cache; cloned replays recompute once and then reconcile against the
|
|
137
|
+
// ordered snapshot.
|
|
137
138
|
const _ingestIdentityFieldsCache = new WeakMap()
|
|
138
139
|
// Return { role, content } for a session message's dedup identity, or null if
|
|
139
140
|
// the message would be skipped by ingest (no role / excluded / empty content
|
|
@@ -257,61 +258,72 @@ export function createSessionIngestRuntime({
|
|
|
257
258
|
} catch { /* absent/corrupt state file → in-memory only (invariant 2 safe) */ }
|
|
258
259
|
ordinalState.durableLoaded = true
|
|
259
260
|
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
261
|
+
// Content/order fallback for JSON-cloned transcript replays. The previous
|
|
262
|
+
// eligible sequence is indexed by a stable base source key; a greedy
|
|
263
|
+
// forward subsequence match recognizes unchanged prefixes, compacted
|
|
264
|
+
// survivors, and appended suffixes in O(n). Same-object rows keep the
|
|
265
|
+
// stronger WeakMap occurrence and advance the cursor to that exact prior
|
|
266
|
+
// occurrence. Unmatched rows alone consume the historical high-water, so a
|
|
267
|
+
// genuine append remains distinct while a reopen/reload stays idempotent.
|
|
268
|
+
const priorSnapshot = Array.isArray(ordinalState.snapshot) ? ordinalState.snapshot : []
|
|
269
|
+
const priorByKey = new Map()
|
|
270
|
+
for (let i = 0; i < priorSnapshot.length; i += 1) {
|
|
271
|
+
const item = priorSnapshot[i]
|
|
272
|
+
if (!item?.key) continue
|
|
273
|
+
if (!priorByKey.has(item.key)) priorByKey.set(item.key, [])
|
|
274
|
+
priorByKey.get(item.key).push({ index: i, occurrence: item.occurrence })
|
|
275
|
+
}
|
|
276
|
+
const priorPointers = new Map()
|
|
277
|
+
let priorCursor = 0
|
|
278
|
+
const takePrior = (key, wantedOccurrence = null) => {
|
|
279
|
+
const list = priorByKey.get(key)
|
|
280
|
+
if (!list?.length) return null
|
|
281
|
+
let p = priorPointers.get(key) ?? 0
|
|
282
|
+
while (p < list.length && list[p].index < priorCursor) p += 1
|
|
283
|
+
if (wantedOccurrence != null) {
|
|
284
|
+
while (p < list.length
|
|
285
|
+
&& list[p].index >= priorCursor
|
|
286
|
+
&& Number(list[p].occurrence) !== Number(wantedOccurrence)) p += 1
|
|
287
|
+
}
|
|
288
|
+
if (p >= list.length) {
|
|
289
|
+
priorPointers.set(key, p)
|
|
290
|
+
return null
|
|
273
291
|
}
|
|
274
|
-
|
|
292
|
+
const match = list[p]
|
|
293
|
+
priorPointers.set(key, p + 1)
|
|
294
|
+
priorCursor = match.index + 1
|
|
295
|
+
return match
|
|
275
296
|
}
|
|
276
|
-
|
|
297
|
+
const currentSnapshot = []
|
|
298
|
+
const prepared = []
|
|
299
|
+
for (let i = 0; i < messages.length; i += 1) {
|
|
277
300
|
const m = messages[i]
|
|
278
301
|
if (!m || typeof m !== 'object') continue
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
// do not re-inject content already in the protected system prefix.
|
|
283
|
-
if (!role) continue
|
|
284
|
-
// Exclude synthetic / non-conversation rows entirely (reference-files
|
|
285
|
-
// injections, compaction summaries, protected-context `.` acks, internal
|
|
286
|
-
// runtime nudges). These are mechanical noise, not conversation.
|
|
287
|
-
if (shouldExcludeIngestMessage(m)) continue
|
|
288
|
-
// Pure-conversation shaping: only the human/model prose text. The ingest
|
|
289
|
-
// shaper NEVER inlines tool_call / tool_result traces and strips the
|
|
290
|
-
// deterministic manager.mjs user-turn prefix envelopes (# Session /
|
|
291
|
-
// # Additional context / # Prefetch / # Task) so only the real human
|
|
292
|
-
// prompt remains. cleanMemoryText then removes the <system-reminder> block
|
|
293
|
-
// and residual transcript noise.
|
|
294
|
-
const content = cleanMemoryText(sessionMessageContentForIngest(m))
|
|
295
|
-
if (!content || !content.trim()) continue
|
|
296
|
-
considered += 1
|
|
297
|
-
const tsMs = parseTsToMs(m.ts ?? m.timestamp ?? (Date.now() - (messages.length - i)))
|
|
298
|
-
// Assign the next monotonic turn BEFORE building the source_ref so identical
|
|
299
|
-
// untimestamped repeats get distinct identities (peekNext is stable until a
|
|
300
|
-
// row is actually inserted → next()).
|
|
301
|
-
const assignedTurn = turnAllocator.peekNext()
|
|
302
|
-
// Stable occurrence index for this identity (pre-hash; role+content is a
|
|
303
|
-
// sufficient discriminator for the duplicate case the ordinal disambiguates).
|
|
304
|
-
// See the distinction-rule comment above and _assignOccurrence. For an
|
|
305
|
-
// UNTIMESTAMPED turn a WARM first-seen assignment is floored by the durable
|
|
306
|
-
// per-identity high-water so a genuine post-restart/eviction append lands
|
|
307
|
-
// above every persisted copy; the durable high-water is then advanced.
|
|
302
|
+
const fields = _ingestIdentityFields(m)
|
|
303
|
+
if (!fields) continue
|
|
304
|
+
const { role, content } = fields
|
|
308
305
|
const occKey = `${role}\u0000${content}`
|
|
309
306
|
const rawTs = m.ts ?? m.timestamp
|
|
310
307
|
const untimestamped = !((typeof rawTs === 'number' && Number.isFinite(rawTs))
|
|
311
308
|
|| (typeof rawTs === 'string' && rawTs.trim()))
|
|
312
309
|
const idHash = untimestamped ? _identityHash(occKey) : null
|
|
313
|
-
|
|
314
|
-
|
|
310
|
+
// occurrence=0 makes a stable match key: timestamp/tool ids remain part
|
|
311
|
+
// of the key, while repeated untimestamped text shares a key and is
|
|
312
|
+
// disambiguated by ordered snapshot occurrences.
|
|
313
|
+
const snapshotKey = stableSessionSourceRef(sessionId, m, role, content, 0)
|
|
314
|
+
let occurrence
|
|
315
|
+
if (_ingestedMessageOrdinal.has(m)) {
|
|
316
|
+
occurrence = _assignOccurrence(occNext, occKey, m)
|
|
317
|
+
takePrior(snapshotKey, occurrence)
|
|
318
|
+
} else {
|
|
319
|
+
const prior = takePrior(snapshotKey)
|
|
320
|
+
if (prior) {
|
|
321
|
+
occurrence = _reuseOccurrence(occNext, occKey, m, prior.occurrence)
|
|
322
|
+
} else {
|
|
323
|
+
const floor = (warmCall && untimestamped) ? (ordinalState.durable.get(idHash) ?? 0) : 0
|
|
324
|
+
occurrence = _assignOccurrence(occNext, occKey, m, floor)
|
|
325
|
+
}
|
|
326
|
+
}
|
|
315
327
|
if (untimestamped && occurrence >= 1) {
|
|
316
328
|
// Duplicate untimestamped identity (occurrence>0): record/raise its durable
|
|
317
329
|
// next-ordinal (write-behind persisted below). Monotonic — never regresses
|
|
@@ -319,6 +331,20 @@ export function createSessionIngestRuntime({
|
|
|
319
331
|
const cur = ordinalState.durable.get(idHash) ?? 0
|
|
320
332
|
if (occurrence + 1 > cur) { ordinalState.durable.set(idHash, occurrence + 1); ordinalState.dirty = true }
|
|
321
333
|
}
|
|
334
|
+
currentSnapshot.push({ key: snapshotKey, occurrence })
|
|
335
|
+
if (i >= start) {
|
|
336
|
+
considered += 1
|
|
337
|
+
prepared.push({ m, role, content, occurrence, index: i })
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
ordinalState.snapshot = currentSnapshot
|
|
341
|
+
ordinalState.seeded = true
|
|
342
|
+
for (const { m, role, content, occurrence, index } of prepared) {
|
|
343
|
+
const tsMs = parseTsToMs(m.ts ?? m.timestamp ?? (Date.now() - (messages.length - index)))
|
|
344
|
+
// Assign the next monotonic turn BEFORE building the source_ref so identical
|
|
345
|
+
// untimestamped repeats get distinct identities (peekNext is stable until a
|
|
346
|
+
// row is actually inserted → next()).
|
|
347
|
+
const assignedTurn = turnAllocator.peekNext()
|
|
322
348
|
// Stable per-message identity. The previous `session:${id}#${i+1}` key was
|
|
323
349
|
// positional, so after compaction shrinks/reindexes session.messages a
|
|
324
350
|
// later turn could reuse an old index and be silently skipped by
|
|
@@ -152,7 +152,10 @@ export function presentErrorText(error, options = {}) {
|
|
|
152
152
|
const quotaRetry = /retryAfter=([^\s:]+)/i.exec(text);
|
|
153
153
|
if (/\b429\b|rate[_ -]?limit|quota|too many requests|resource exhausted|insufficient_quota|quota_exceeded/i.test(text)) {
|
|
154
154
|
const provider = /Anthropic OAuth/i.test(text) ? 'Anthropic' : 'Provider';
|
|
155
|
-
|
|
155
|
+
// Cooldown refusals are recoverable right now by switching accounts —
|
|
156
|
+
// surface that path instead of leaving only the wait option.
|
|
157
|
+
const hint = /cooldown/i.test(text) ? ' Re-login or switch the provider account to continue now.' : '';
|
|
158
|
+
return `${provider} quota/rate limit hit${quotaRetry?.[1] ? `; retry after ${quotaRetry[1]}` : ''}.${hint}`;
|
|
156
159
|
}
|
|
157
160
|
|
|
158
161
|
const firstResponse = /(?:agent\s+)?first response stale\s*\((\d+)ms\)/i.exec(text);
|
|
@@ -86,6 +86,8 @@ export class ResourceAdmissionController {
|
|
|
86
86
|
this.now = now;
|
|
87
87
|
this.active = { agent: 0, shell: 0 };
|
|
88
88
|
this.queue = [];
|
|
89
|
+
// Live leases for saturation diagnostics only (labels/ages in snapshot()).
|
|
90
|
+
this.activeLeases = new Set();
|
|
89
91
|
this.context = new AsyncLocalStorage();
|
|
90
92
|
}
|
|
91
93
|
|
|
@@ -183,10 +185,12 @@ export class ResourceAdmissionController {
|
|
|
183
185
|
restorePending: null,
|
|
184
186
|
parent,
|
|
185
187
|
releasePromise: null,
|
|
188
|
+
startedAt: this.now(),
|
|
186
189
|
queuedMs: queuedAt == null ? 0 : Math.max(0, this.now() - queuedAt),
|
|
187
190
|
release: () => {
|
|
188
191
|
if (lease.released) return lease.releasePromise || Promise.resolve();
|
|
189
192
|
lease.released = true;
|
|
193
|
+
this.activeLeases.delete(lease);
|
|
190
194
|
if (lease.restorePending) {
|
|
191
195
|
const item = lease.restorePending.item;
|
|
192
196
|
const index = this.queue.indexOf(item);
|
|
@@ -212,6 +216,7 @@ export class ResourceAdmissionController {
|
|
|
212
216
|
return restored;
|
|
213
217
|
},
|
|
214
218
|
};
|
|
219
|
+
this.activeLeases.add(lease);
|
|
215
220
|
return lease;
|
|
216
221
|
}
|
|
217
222
|
|
|
@@ -353,10 +358,16 @@ export class ResourceAdmissionController {
|
|
|
353
358
|
}
|
|
354
359
|
|
|
355
360
|
snapshot() {
|
|
361
|
+
const now = this.now();
|
|
356
362
|
return {
|
|
357
363
|
active: { ...this.active },
|
|
358
364
|
queued: this.queue.length,
|
|
359
365
|
limits: { ...this.limits },
|
|
366
|
+
activeLeases: [...this.activeLeases].map((lease) => ({
|
|
367
|
+
kind: lease.kind,
|
|
368
|
+
label: lease.label,
|
|
369
|
+
ageMs: Math.max(0, now - (lease.startedAt || now)),
|
|
370
|
+
})),
|
|
360
371
|
};
|
|
361
372
|
}
|
|
362
373
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// schedule.model wire format shared by the channels-worker scheduler and the
|
|
2
|
+
// engine-side run-now dispatch: either a config.presets id/name (legacy) or a
|
|
3
|
+
// direct "provider/model[@effort][+fast]" route string written by the desktop
|
|
4
|
+
// schedule editor. Slash-form values become {provider,model,effort?,fast?}
|
|
5
|
+
// route objects, which agent-dispatch consumes without a presets lookup.
|
|
6
|
+
export function parseScheduleModelRef(ref) {
|
|
7
|
+
const raw = String(ref || '');
|
|
8
|
+
const slash = raw.indexOf('/');
|
|
9
|
+
if (slash <= 0) return raw;
|
|
10
|
+
let rest = raw.slice(slash + 1);
|
|
11
|
+
let fast = false;
|
|
12
|
+
if (rest.endsWith('+fast')) {
|
|
13
|
+
fast = true;
|
|
14
|
+
rest = rest.slice(0, -5);
|
|
15
|
+
}
|
|
16
|
+
let effort = '';
|
|
17
|
+
const at = rest.lastIndexOf('@');
|
|
18
|
+
if (at > 0) {
|
|
19
|
+
effort = rest.slice(at + 1);
|
|
20
|
+
rest = rest.slice(0, at);
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
provider: raw.slice(0, slash),
|
|
24
|
+
model: rest,
|
|
25
|
+
...(effort ? { effort } : {}),
|
|
26
|
+
...(fast ? { fast: true } : {}),
|
|
27
|
+
};
|
|
28
|
+
}
|