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.
Files changed (120) hide show
  1. package/package.json +3 -1
  2. package/scripts/_smoke_wd.mjs +7 -0
  3. package/scripts/agent-terminal-reap-test.mjs +127 -2
  4. package/scripts/compact-file-reattach-test.mjs +88 -0
  5. package/scripts/compact-pressure-test.mjs +45 -21
  6. package/scripts/compact-recall-digest-test.mjs +57 -0
  7. package/scripts/compact-smoke.mjs +35 -39
  8. package/scripts/desktop-session-bridge-test.mjs +271 -9
  9. package/scripts/generate-oc-icons.mjs +11 -0
  10. package/scripts/live-share-test.mjs +148 -0
  11. package/scripts/live-worker-smoke.mjs +1 -1
  12. package/scripts/max-output-recovery-test.mjs +12 -1
  13. package/scripts/mouse-tracking-restore-smoke.mjs +107 -0
  14. package/scripts/provider-admission-scheduler-test.mjs +100 -0
  15. package/scripts/provider-contract-test.mjs +49 -0
  16. package/scripts/resource-admission-test.mjs +5 -2
  17. package/scripts/session-ingest-compaction-smoke.mjs +38 -0
  18. package/scripts/steering-drain-buckets-test.mjs +15 -0
  19. package/scripts/submit-commandbusy-race-test.mjs +54 -3
  20. package/scripts/tool-smoke.mjs +2 -3
  21. package/scripts/tui-ambiguous-width-test.mjs +113 -0
  22. package/scripts/tui-runtime-load-bench.mjs +5 -0
  23. package/scripts/tui-transcript-perf-test.mjs +167 -0
  24. package/src/app.mjs +23 -0
  25. package/src/cli.mjs +6 -0
  26. package/src/lib/keychain-cjs.cjs +70 -20
  27. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +4 -2
  28. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +18 -17
  29. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +18 -0
  30. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +111 -0
  31. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +6 -6
  32. package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +61 -2
  33. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +13 -4
  34. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +43 -2
  35. package/src/runtime/agent/orchestrator/session/compact/file-reattach.mjs +112 -0
  36. package/src/runtime/agent/orchestrator/session/context-utils.mjs +203 -49
  37. package/src/runtime/agent/orchestrator/session/loop/compact-debug.mjs +6 -2
  38. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +110 -15
  39. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +2 -0
  40. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +7 -1
  41. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +109 -2
  42. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +15 -7
  43. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +39 -39
  44. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +108 -4
  45. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +19 -0
  46. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +22 -11
  47. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +132 -24
  48. package/src/runtime/agent/orchestrator/session/manager/status-telemetry.mjs +1 -1
  49. package/src/runtime/agent/orchestrator/session/manager.mjs +16 -1
  50. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +39 -21
  51. package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +18 -0
  52. package/src/runtime/agent/orchestrator/session/store/paths-heartbeat.mjs +91 -1
  53. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +14 -33
  54. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +169 -0
  55. package/src/runtime/agent/orchestrator/session/store.mjs +474 -42
  56. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +17 -2
  57. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +36 -1
  58. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +20 -0
  59. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +5 -1
  60. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +249 -11
  61. package/src/runtime/channels/data/voice-runtime-manifest.json +21 -5
  62. package/src/runtime/channels/lib/scheduler.mjs +8 -6
  63. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +71 -8
  64. package/src/runtime/channels/lib/voice-transcription.mjs +2 -2
  65. package/src/runtime/channels/lib/whisper-language.mjs +4 -0
  66. package/src/runtime/memory/lib/embedding-provider.mjs +7 -0
  67. package/src/runtime/memory/lib/embedding-worker.mjs +20 -0
  68. package/src/runtime/memory/lib/query-handlers.mjs +10 -4
  69. package/src/runtime/memory/lib/recall-format.mjs +43 -2
  70. package/src/runtime/memory/lib/session-ingest-runtime.mjs +90 -64
  71. package/src/runtime/shared/err-text.mjs +4 -1
  72. package/src/runtime/shared/resource-admission.mjs +11 -0
  73. package/src/runtime/shared/schedule-model-ref.mjs +28 -0
  74. package/src/runtime/shared/schedule-session-run.mjs +63 -0
  75. package/src/runtime/shared/schedules-db.mjs +8 -3
  76. package/src/runtime/shared/tool-result-summary.mjs +4 -1
  77. package/src/runtime/shared/tool-surface.mjs +7 -7
  78. package/src/runtime/shared/transcript-writer.mjs +17 -0
  79. package/src/session-runtime/channel-config-api.mjs +11 -0
  80. package/src/session-runtime/config-helpers.mjs +9 -6
  81. package/src/session-runtime/context-status.mjs +80 -3
  82. package/src/session-runtime/lifecycle-api.mjs +154 -40
  83. package/src/session-runtime/provider-auth-api.mjs +21 -2
  84. package/src/session-runtime/provider-usage.mjs +4 -1
  85. package/src/session-runtime/resource-api.mjs +12 -11
  86. package/src/session-runtime/runtime-core.mjs +45 -11
  87. package/src/session-runtime/session-text.mjs +56 -6
  88. package/src/session-runtime/session-turn-api.mjs +70 -2
  89. package/src/session-runtime/tool-catalog.mjs +2 -1
  90. package/src/session-runtime/workflow-agents-api.mjs +2 -2
  91. package/src/standalone/agent-tool/render.mjs +5 -1
  92. package/src/standalone/agent-tool.mjs +63 -3
  93. package/src/standalone/channel-admin.mjs +19 -3
  94. package/src/standalone/channel-daemon.mjs +40 -0
  95. package/src/tui/app/resume-picker.mjs +5 -1
  96. package/src/tui/app/settings-picker.mjs +19 -0
  97. package/src/tui/app/transcript-window.mjs +45 -8
  98. package/src/tui/app/use-mouse-input.mjs +101 -31
  99. package/src/tui/app/use-transcript-window.mjs +53 -9
  100. package/src/tui/components/ToolExecution.jsx +3 -4
  101. package/src/tui/components/TranscriptItem.jsx +3 -0
  102. package/src/tui/dist/index.mjs +17391 -14920
  103. package/src/tui/engine/context-state.mjs +10 -1
  104. package/src/tui/engine/live-share.mjs +390 -0
  105. package/src/tui/engine/queue-helpers.mjs +23 -0
  106. package/src/tui/engine/session-api-ext.mjs +439 -40
  107. package/src/tui/engine/session-api.mjs +7 -1
  108. package/src/tui/engine/session-flow.mjs +21 -7
  109. package/src/tui/engine/tool-card-results.mjs +3 -1
  110. package/src/tui/engine/turn.mjs +57 -4
  111. package/src/tui/engine.mjs +211 -7
  112. package/src/tui/index.jsx +2 -0
  113. package/src/tui/lib/voice-setup.mjs +10 -4
  114. package/src/tui/paste-attachments.mjs +4 -1
  115. package/src/vendor/statusline/src/gateway/session-routes.mjs +24 -1
  116. package/src/workflows/default/WORKFLOW.md +3 -0
  117. package/vendor/ink/build/display-width.js +14 -0
  118. package/vendor/ink/build/output.js +24 -3
  119. package/vendor/ink/build/wrap-text.d.ts +2 -0
  120. 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
- if (!manifest.model?.filename) return null
601
- const p = join(dataDir, 'voice', 'models', manifest.model.filename)
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 = resolveManagedWhisperModel(dataDir)
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
- modelName: managedModelPath ? 'ggml-large-v3-turbo.bin' : '',
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.model
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) return { modelPath, modelId: model.id, size: model.size }
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(limit)
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 < limit) {
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, limit - rows.length)
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
- return { text: renderEntryLines(rows) }
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
- // every later hydrate so its source_ref stays stable and ON CONFLICT dedupes
99
- // it. This is the survivor-vs-new-append signal VERIFIED valid in
100
- // production: ingest_session runs IN-PROCESS (runtime-core executor
101
- // memoryMod.handleToolCall(args), session-runtime/runtime-core.mjs), NOT over
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 drops the array). The subset-reingest ordinal fix below must
129
- // replay cleanMemoryText/shouldExcludeIngestMessage over messages[0, start)
130
- // on every call; without caching that turned an O(limit)-bounded ingest into
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
- if (!ordinalState.seeded) {
261
- // Replay identity/exclude logic over messages[0, start) so occNext reflects
262
- // full-array position (and any surviving recorded ordinals) even when this
263
- // call ingests only a later window. Runs once per fresh/evicted state; a
264
- // warm state already carries the counts, so re-seeding would double-count.
265
- for (let i = 0; i < start; i += 1) {
266
- const m = messages[i]
267
- if (!m || typeof m !== 'object') continue
268
- // Cached (WeakMap-keyed) so a repeated hydrate over the SAME prefix
269
- // objects only pays the clean/normalize cost once per message.
270
- const fields = _ingestIdentityFields(m)
271
- if (!fields) continue
272
- _assignOccurrence(occNext, `${fields.role}\u0000${fields.content}`, m)
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
- ordinalState.seeded = true
292
+ const match = list[p]
293
+ priorPointers.set(key, p + 1)
294
+ priorCursor = match.index + 1
295
+ return match
275
296
  }
276
- for (let i = start; i < messages.length; i += 1) {
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 role = normalizeIngestRole(m.role)
280
- // ingest_session persists user/assistant only; system/developer/tool rows
281
- // are dropped so recall-fasttrack summaries stay conversation-focused and
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
- const floor = (warmCall && untimestamped) ? (ordinalState.durable.get(idHash) ?? 0) : 0
314
- const occurrence = _assignOccurrence(occNext, occKey, m, floor)
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
- return `${provider} quota/rate limit hit${quotaRetry?.[1] ? `; retry after ${quotaRetry[1]}` : ''}.`;
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
+ }