mixdog 0.9.1 → 0.9.2

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 (214) hide show
  1. package/package.json +8 -1
  2. package/scripts/_bench-cwc.json +20 -0
  3. package/scripts/agent-loop-policy-test.mjs +37 -0
  4. package/scripts/agent-parallel-smoke.mjs +54 -10
  5. package/scripts/background-task-meta-smoke.mjs +1 -1
  6. package/scripts/bench-run.mjs +262 -0
  7. package/scripts/compact-smoke.mjs +12 -0
  8. package/scripts/compact-trigger-migration-smoke.mjs +67 -1
  9. package/scripts/ingest-pure-conversation-smoke.mjs +148 -0
  10. package/scripts/internal-comms-bench.mjs +727 -0
  11. package/scripts/internal-comms-smoke.mjs +75 -0
  12. package/scripts/lead-workflow-smoke.mjs +4 -4
  13. package/scripts/live-worker-smoke.mjs +9 -9
  14. package/scripts/output-style-bench.mjs +285 -0
  15. package/scripts/output-style-smoke.mjs +13 -10
  16. package/scripts/patch-replay.mjs +90 -0
  17. package/scripts/provider-stream-stall-test.mjs +276 -0
  18. package/scripts/provider-toolcall-test.mjs +599 -1
  19. package/scripts/routing-corpus.mjs +281 -0
  20. package/scripts/session-bench.mjs +1526 -0
  21. package/scripts/session-diag.mjs +595 -0
  22. package/scripts/task-bench.mjs +207 -0
  23. package/scripts/tool-failures.mjs +6 -6
  24. package/scripts/tool-smoke.mjs +306 -66
  25. package/scripts/toolcall-args-test.mjs +81 -0
  26. package/src/agents/debugger/AGENT.md +4 -4
  27. package/src/agents/heavy-worker/AGENT.md +4 -2
  28. package/src/agents/reviewer/AGENT.md +4 -4
  29. package/src/agents/worker/AGENT.md +4 -2
  30. package/src/app.mjs +10 -6
  31. package/src/defaults/{hidden-roles.json → agents.json} +7 -7
  32. package/src/examples/schedules/SCHEDULE.example.md +32 -0
  33. package/src/examples/webhooks/WEBHOOK.example.md +40 -0
  34. package/src/headless-role.mjs +14 -14
  35. package/src/help.mjs +1 -0
  36. package/src/lib/rules-builder.cjs +32 -54
  37. package/src/mixdog-session-runtime.mjs +710 -318
  38. package/src/output-styles/default.md +12 -7
  39. package/src/output-styles/minimal.md +25 -0
  40. package/src/output-styles/oneline.md +21 -0
  41. package/src/output-styles/simple.md +10 -9
  42. package/src/repl.mjs +12 -2
  43. package/src/rules/agent/00-common.md +7 -5
  44. package/src/rules/agent/30-explorer.md +7 -8
  45. package/src/rules/lead/01-general.md +3 -1
  46. package/src/rules/lead/lead-tool.md +7 -0
  47. package/src/rules/shared/01-tool.md +17 -12
  48. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +90 -32
  49. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +32 -0
  50. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +18 -6
  51. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +23 -20
  52. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +48 -14
  53. package/src/runtime/agent/orchestrator/agent-trace.mjs +87 -12
  54. package/src/runtime/agent/orchestrator/config.mjs +3 -0
  55. package/src/runtime/agent/orchestrator/context/collect.mjs +131 -67
  56. package/src/runtime/agent/orchestrator/{internal-roles.mjs → internal-agents.mjs} +72 -72
  57. package/src/runtime/agent/orchestrator/internal-tools.mjs +13 -26
  58. package/src/runtime/agent/orchestrator/mcp/client.mjs +94 -16
  59. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -0
  60. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +188 -0
  61. package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +444 -0
  62. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +332 -57
  63. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +59 -32
  64. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +27 -20
  65. package/src/runtime/agent/orchestrator/providers/gemini.mjs +184 -17
  66. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +8 -1
  67. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +18 -8
  68. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +210 -21
  69. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +78 -3
  70. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +202 -98
  71. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +183 -20
  72. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -0
  73. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +11 -5
  74. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -1
  75. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +15 -9
  76. package/src/runtime/agent/orchestrator/session/compact.mjs +560 -51
  77. package/src/runtime/agent/orchestrator/session/context-utils.mjs +250 -3
  78. package/src/runtime/agent/orchestrator/session/loop.mjs +394 -132
  79. package/src/runtime/agent/orchestrator/session/manager.mjs +217 -170
  80. package/src/runtime/agent/orchestrator/session/store.mjs +4 -4
  81. package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +61 -0
  82. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -0
  83. package/src/runtime/agent/orchestrator/stall-policy.mjs +63 -15
  84. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +194 -24
  85. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +143 -0
  86. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +34 -18
  87. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +0 -0
  88. package/src/runtime/agent/orchestrator/tools/builtin/list-formatting.mjs +10 -0
  89. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +5 -4
  90. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +15 -0
  91. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +9 -44
  92. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -1
  93. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +13 -4
  94. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +10 -17
  95. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +18 -2
  96. package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -2
  97. package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +10 -0
  98. package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -1
  99. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +5 -5
  100. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4076 -3985
  101. package/src/runtime/agent/orchestrator/tools/patch.mjs +116 -2
  102. package/src/runtime/channels/backends/discord.mjs +99 -9
  103. package/src/runtime/channels/backends/telegram.mjs +501 -0
  104. package/src/runtime/channels/index.mjs +441 -1224
  105. package/src/runtime/channels/lib/config.mjs +54 -2
  106. package/src/runtime/channels/lib/format.mjs +4 -2
  107. package/src/runtime/channels/lib/output-forwarder.mjs +80 -67
  108. package/src/runtime/channels/lib/runtime-paths.mjs +29 -0
  109. package/src/runtime/channels/lib/scheduler.mjs +1 -1
  110. package/src/runtime/channels/lib/telegram-format.mjs +283 -0
  111. package/src/runtime/channels/lib/tool-format.mjs +1 -1
  112. package/src/runtime/channels/lib/transcript-discovery.mjs +19 -1
  113. package/src/runtime/channels/lib/webhook.mjs +59 -31
  114. package/src/runtime/channels/tool-defs.mjs +1 -1
  115. package/src/runtime/memory/index.mjs +184 -19
  116. package/src/runtime/memory/lib/agent-ipc.mjs +2 -2
  117. package/src/runtime/memory/lib/core-memory-store.mjs +1 -1
  118. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
  119. package/src/runtime/memory/lib/memory-cycle2.mjs +9 -6
  120. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
  121. package/src/runtime/memory/lib/memory.mjs +101 -4
  122. package/src/runtime/memory/lib/pg/adapter.mjs +139 -15
  123. package/src/runtime/memory/lib/session-ingest.mjs +107 -0
  124. package/src/runtime/memory/lib/trace-store.mjs +69 -22
  125. package/src/runtime/memory/tool-defs.mjs +6 -3
  126. package/src/runtime/shared/channel-notification-routing.mjs +12 -0
  127. package/src/runtime/shared/channel-notification-routing.test.mjs +45 -0
  128. package/src/runtime/shared/config.mjs +9 -0
  129. package/src/runtime/shared/llm/http-agent.mjs +12 -5
  130. package/src/runtime/shared/schedules-store.mjs +21 -19
  131. package/src/runtime/shared/tool-surface.mjs +98 -13
  132. package/src/runtime/shared/transcript-writer.mjs +129 -0
  133. package/src/runtime/shared/update-checker.mjs +214 -0
  134. package/src/standalone/agent-tool.mjs +255 -109
  135. package/src/standalone/channel-admin.mjs +133 -40
  136. package/src/standalone/channel-worker.mjs +8 -291
  137. package/src/standalone/explore-tool.mjs +2 -2
  138. package/src/standalone/memory-runtime-proxy.mjs +3 -1
  139. package/src/standalone/provider-admin.mjs +11 -0
  140. package/src/standalone/usage-dashboard.mjs +1 -1
  141. package/src/tui/App.jsx +2096 -732
  142. package/src/tui/components/ConfirmBar.jsx +47 -0
  143. package/src/tui/components/ContextPanel.jsx +5 -3
  144. package/src/tui/components/ItemRightHintOverprint.jsx +54 -0
  145. package/src/tui/components/Markdown.jsx +22 -98
  146. package/src/tui/components/Message.jsx +14 -35
  147. package/src/tui/components/Picker.jsx +87 -12
  148. package/src/tui/components/PromptInput.jsx +83 -7
  149. package/src/tui/components/QueuedCommands.jsx +1 -1
  150. package/src/tui/components/SlashCommandPalette.jsx +8 -5
  151. package/src/tui/components/Spinner.jsx +7 -7
  152. package/src/tui/components/StatusLine.jsx +40 -21
  153. package/src/tui/components/TextEntryPanel.jsx +51 -7
  154. package/src/tui/components/ToolExecution.jsx +170 -98
  155. package/src/tui/components/TurnDone.jsx +4 -4
  156. package/src/tui/components/UsagePanel.jsx +1 -1
  157. package/src/tui/components/tool-output-format.mjs +159 -21
  158. package/src/tui/components/tool-output-format.test.mjs +87 -0
  159. package/src/tui/display-width.mjs +69 -0
  160. package/src/tui/display-width.test.mjs +35 -0
  161. package/src/tui/dist/index.mjs +6965 -2391
  162. package/src/tui/engine.mjs +287 -126
  163. package/src/tui/index.jsx +117 -7
  164. package/src/tui/keyboard-protocol.mjs +42 -0
  165. package/src/tui/lib/voice-recorder.mjs +453 -0
  166. package/src/tui/markdown/format-token.mjs +129 -76
  167. package/src/tui/markdown/format-token.test.mjs +61 -19
  168. package/src/tui/markdown/measure-rendered-rows.mjs +85 -0
  169. package/src/tui/markdown/render-ansi.test.mjs +1 -1
  170. package/src/tui/markdown/streaming-markdown.mjs +167 -0
  171. package/src/tui/markdown/streaming-markdown.test.mjs +70 -0
  172. package/src/tui/markdown/table-layout.mjs +9 -9
  173. package/src/tui/paste-attachments.mjs +0 -11
  174. package/src/tui/prompt-history-store.mjs +129 -0
  175. package/src/tui/prompt-history-store.test.mjs +52 -0
  176. package/src/tui/statusline-ansi-bridge.test.mjs +3 -3
  177. package/src/tui/theme.mjs +41 -657
  178. package/src/tui/themes/base.mjs +86 -0
  179. package/src/tui/themes/basic.mjs +85 -0
  180. package/src/tui/themes/catppuccin.mjs +72 -0
  181. package/src/tui/themes/dracula.mjs +70 -0
  182. package/src/tui/themes/everforest.mjs +71 -0
  183. package/src/tui/themes/gruvbox.mjs +71 -0
  184. package/src/tui/themes/index.mjs +71 -0
  185. package/src/tui/themes/indigo.mjs +78 -0
  186. package/src/tui/themes/kanagawa.mjs +80 -0
  187. package/src/tui/themes/light.mjs +81 -0
  188. package/src/tui/themes/nord.mjs +72 -0
  189. package/src/tui/themes/onedark.mjs +16 -0
  190. package/src/tui/themes/rosepine.mjs +70 -0
  191. package/src/tui/themes/teal.mjs +81 -0
  192. package/src/tui/themes/tokyonight.mjs +79 -0
  193. package/src/tui/themes/utils.mjs +106 -0
  194. package/src/tui/themes/warm.mjs +79 -0
  195. package/src/tui/transcript-tool-failures.mjs +13 -2
  196. package/src/ui/markdown.mjs +1 -1
  197. package/src/ui/model-display.mjs +2 -2
  198. package/src/ui/statusline.mjs +26 -27
  199. package/src/vendor/statusline/bin/statusline-route.mjs +5 -12
  200. package/src/vendor/statusline/src/gateway/claude-current.mjs +3 -3
  201. package/src/vendor/statusline/src/gateway/route-meta.mjs +30 -16
  202. package/src/workflows/default/WORKFLOW.md +39 -12
  203. package/src/workflows/sequential/WORKFLOW.md +46 -0
  204. package/src/workflows/solo/WORKFLOW.md +7 -0
  205. package/vendor/ink/build/display-width.js +62 -0
  206. package/vendor/ink/build/ink.js +100 -12
  207. package/vendor/ink/build/measure-text.js +4 -1
  208. package/vendor/ink/build/output.js +115 -9
  209. package/vendor/ink/build/render-node-to-output.js +4 -1
  210. package/vendor/ink/build/render.js +4 -0
  211. package/src/output-styles/extreme-simple.md +0 -20
  212. package/src/rules/lead/04-workflow.md +0 -51
  213. package/src/workflows/default/workflow.json +0 -13
  214. package/src/workflows/solo/workflow.json +0 -7
@@ -90,6 +90,8 @@ import {
90
90
  stableSessionSourceRef,
91
91
  sessionMessageContent,
92
92
  createIngestTurnAllocator,
93
+ sessionMessageContentForIngest,
94
+ shouldExcludeIngestMessage,
93
95
  } from './lib/session-ingest.mjs'
94
96
  import { configureEmbedding, embedText, embedTexts, getEmbeddingDims, getEmbeddingDtype, getEmbeddingModelId, getKnownDimsForCurrentModel, isEmbeddingModelReady, primeEmbeddingDims, warmupEmbeddingProvider } from './lib/embedding-provider.mjs'
95
97
  import { startLlmWorker, stopLlmWorker } from './lib/llm-worker-host.mjs'
@@ -98,6 +100,7 @@ import { loadConfig as loadAgentConfig } from '../agent/orchestrator/config.mjs'
98
100
  import { initProviders } from '../agent/orchestrator/providers/registry.mjs'
99
101
  import { makeAgentDispatch } from '../agent/orchestrator/agent-runtime/agent-dispatch.mjs'
100
102
  import { getInFlightCycle1 } from './lib/memory-cycle1.mjs'
103
+ import { drainSessionCycle1 } from '../agent/orchestrator/session/compact.mjs'
101
104
  import { claimAndMarkScheduledCycle, makeCycleRequestSignature, resolveCoalesceMaxRetries, scheduleCoalescedCycleRetry } from './lib/memory-cycle-requests.mjs'
102
105
  import { searchRelevantHybrid } from './lib/memory-recall-store.mjs'
103
106
  import { fetchEntriesByIdsScoped } from './lib/memory-recall-id-patch.mjs'
@@ -398,6 +401,13 @@ let _startupTimeout = null
398
401
  // getting the real stats and others getting `skippedInFlight: true` from
399
402
  // the inner guard.
400
403
  let _cycle1InFlight = null // shared cycle1 promise (outer coalesce layer)
404
+ // Per-session recall-drain in-flight map. Multiple terminals share one
405
+ // memory daemon (active-instance.json memory_port); concurrent recall calls
406
+ // for the SAME session must share one drain promise (dedup), while DIFFERENT
407
+ // sessions drain in parallel (cycle1's own advisory-lock/coalesce guard in
408
+ // memory-cycle1.mjs still serializes the actual DB work). A drain never
409
+ // blocks recall for sessions other than its own.
410
+ const _recallDrainInFlight = new Map() // sessionId -> Promise
401
411
  let _initialized = false
402
412
  let _initPromise = null
403
413
  let _stopPromise = null
@@ -683,6 +693,28 @@ async function readRawRowsInWindow(db, tsFromMs, tsToMs, hardLimit = 10, { proje
683
693
  } catch { return [] }
684
694
  }
685
695
 
696
+ // Spread raw (unchunked) rows evenly across a hybrid-ranked list at a fixed
697
+ // stride so every offset page draws its proportional share of raw rows,
698
+ // instead of them all landing after the hybrid page-0 window (the old
699
+ // append-only behaviour). Stride is derived from the ratio of hybrid:raw
700
+ // counts so a small raw set doesn't get pushed arbitrarily deep, and a large
701
+ // raw set doesn't crowd out every hybrid hit near the top.
702
+ function interleaveRawRows(hybridRows, rawRows) {
703
+ if (!Array.isArray(rawRows) || rawRows.length === 0) return hybridRows
704
+ const out = []
705
+ const stride = Math.max(1, Math.round(hybridRows.length / (rawRows.length + 1)))
706
+ let rawIdx = 0
707
+ for (let i = 0; i < hybridRows.length; i += 1) {
708
+ out.push(hybridRows[i])
709
+ if ((i + 1) % stride === 0 && rawIdx < rawRows.length) {
710
+ out.push(rawRows[rawIdx])
711
+ rawIdx += 1
712
+ }
713
+ }
714
+ while (rawIdx < rawRows.length) out.push(rawRows[rawIdx++])
715
+ return out
716
+ }
717
+
686
718
  function sessionRecallTerms(query) {
687
719
  return [...new Set(String(query || '').toLowerCase().match(/[\p{L}\p{N}_./:-]{2,}/gu) || [])]
688
720
  .filter((term) => !CORE_RECALL_STOPWORDS.has(term))
@@ -792,7 +824,17 @@ async function ingestSessionMessages(args = {}) {
792
824
  // are dropped so recall-fasttrack summaries stay conversation-focused and
793
825
  // do not re-inject content already in the protected system prefix.
794
826
  if (!role) continue
795
- const content = cleanMemoryText(sessionMessageContent(m))
827
+ // Exclude synthetic / non-conversation rows entirely (reference-files
828
+ // injections, compaction summaries, protected-context `.` acks, internal
829
+ // runtime nudges). These are mechanical noise, not conversation.
830
+ if (shouldExcludeIngestMessage(m)) continue
831
+ // Pure-conversation shaping: only the human/model prose text. The ingest
832
+ // shaper NEVER inlines tool_call / tool_result traces and strips the
833
+ // deterministic manager.mjs user-turn prefix envelopes (# Session /
834
+ // # Additional context / # Prefetch / # Task) so only the real human
835
+ // prompt remains. cleanMemoryText then removes the <system-reminder> block
836
+ // and residual transcript noise.
837
+ const content = cleanMemoryText(sessionMessageContentForIngest(m))
796
838
  if (!content || !content.trim()) continue
797
839
  considered += 1
798
840
  const tsMs = parseTsToMs(m.ts ?? m.timestamp ?? (Date.now() - (messages.length - i)))
@@ -1178,7 +1220,7 @@ let _cycle1AgentDispatch = null
1178
1220
  function getCycle1CallLlm() {
1179
1221
  if (!_cycle1AgentDispatch) {
1180
1222
  _cycle1AgentDispatch = makeAgentDispatch({
1181
- role: 'cycle1-agent',
1223
+ agent: 'cycle1-agent',
1182
1224
  taskType: 'maintenance',
1183
1225
  sourceType: 'memory-cycle',
1184
1226
  // cycle1 parses the full raw line-format response; the agent brief cap
@@ -1206,7 +1248,7 @@ let _cycle2AgentDispatch = null
1206
1248
  function getCycle2CallLlm() {
1207
1249
  if (!_cycle2AgentDispatch) {
1208
1250
  _cycle2AgentDispatch = makeAgentDispatch({
1209
- role: 'cycle2-agent',
1251
+ agent: 'cycle2-agent',
1210
1252
  taskType: 'maintenance',
1211
1253
  sourceType: 'memory-cycle',
1212
1254
  brief: false,
@@ -1226,7 +1268,7 @@ let _cycle3AgentDispatch = null
1226
1268
  function getCycle3CallLlm() {
1227
1269
  if (!_cycle3AgentDispatch) {
1228
1270
  _cycle3AgentDispatch = makeAgentDispatch({
1229
- role: 'cycle3-agent',
1271
+ agent: 'cycle3-agent',
1230
1272
  taskType: 'maintenance',
1231
1273
  sourceType: 'memory-cycle',
1232
1274
  brief: false,
@@ -1815,10 +1857,94 @@ async function recallCoreRows(query, { projectScope, category, limit } = {}) {
1815
1857
  }))
1816
1858
  }
1817
1859
 
1860
+ // Session-scoped full drain before a recall(query) search. Chunking is
1861
+ // partitioned by session_id (memory-cycle1.mjs:409-431 selected_sessions
1862
+ // GROUP BY session_id + ROW_NUMBER PARTITION BY session_id), so this drains
1863
+ // ONLY the calling session's backlog via the same session_id override
1864
+ // (memory-cycle1.mjs:394) — other sessions' pending rows are left for the
1865
+ // periodic cycle1 sweep. Reuses recall-fasttrack's drainSessionCycle1
1866
+ // (session/compact.mjs:504) which loops cycle1 until raw rows hit 0 or a
1867
+ // no-progress pass. Safety limits: a 2,000-row hard cap (both in the pending
1868
+ // pre-check and as the per-call cycle1 batch ceiling) and cooperative
1869
+ // abort-signal propagation through handleMemoryAction — no time budget.
1870
+ //
1871
+ // Multi-terminal safety: the memory daemon is one shared instance across
1872
+ // terminals (active-instance.json memory_port). Concurrent recall(query)
1873
+ // calls for the SAME session share one in-flight drain promise (dedup);
1874
+ // different sessions drain independently in parallel (cycle1's own
1875
+ // advisory-lock/coalesce guard in memory-cycle1.mjs still serializes actual
1876
+ // DB writes). A drain only gates recall for its OWN session — every other
1877
+ // terminal's recall (different session, or no session) proceeds unblocked.
1878
+ const RECALL_DRAIN_HARD_CAP_ROWS = 2000
1879
+ async function _drainSessionForRecallQuery(sessionId, signal) {
1880
+ if (!sessionId || signal?.aborted) return
1881
+ const existing = _recallDrainInFlight.get(sessionId)
1882
+ if (existing) { try { await existing } catch {} return }
1883
+ let pendingCount = 0
1884
+ try {
1885
+ const r = await db.query(
1886
+ `SELECT COUNT(*) c FROM entries WHERE chunk_root IS NULL AND session_id = $1`,
1887
+ [sessionId],
1888
+ )
1889
+ pendingCount = Number(r.rows?.[0]?.c ?? 0)
1890
+ } catch { return }
1891
+ if (pendingCount <= 0) return
1892
+ const runTool = async (_name, toolArgs) => {
1893
+ const result = await handleMemoryAction(toolArgs, signal)
1894
+ return result?.text ?? ''
1895
+ }
1896
+ const dumpArgs = {
1897
+ action: 'dump_session_roots',
1898
+ sessionId,
1899
+ includeRaw: true,
1900
+ limit: Math.min(RECALL_DRAIN_HARD_CAP_ROWS, Math.max(1000, pendingCount)),
1901
+ }
1902
+ const drainPromise = (async () => {
1903
+ try {
1904
+ return await drainSessionCycle1(runTool, {
1905
+ sessionId,
1906
+ dumpArgs,
1907
+ deadlineMs: 0, // no time budget — drive the session's backlog to 0
1908
+ maxPasses: 20, // safety stopper; drainSessionCycle1 also self-stops on no-progress
1909
+ cycleArgs: {
1910
+ min_batch: 1,
1911
+ session_cap: 1,
1912
+ batch_size: RECALL_DRAIN_HARD_CAP_ROWS,
1913
+ rows_per_session: RECALL_DRAIN_HARD_CAP_ROWS,
1914
+ window_size: 20,
1915
+ concurrency: 4,
1916
+ },
1917
+ })
1918
+ } catch (err) {
1919
+ __mixdogMemoryLog(`[recall] session drain aborted/failed (sess=${sessionId}): ${err?.message || err}\n`)
1920
+ return null
1921
+ }
1922
+ })()
1923
+ _recallDrainInFlight.set(sessionId, drainPromise)
1924
+ try {
1925
+ await drainPromise
1926
+ } finally {
1927
+ if (_recallDrainInFlight.get(sessionId) === drainPromise) _recallDrainInFlight.delete(sessionId)
1928
+ }
1929
+ }
1930
+
1818
1931
  async function handleSearch(args, signal) {
1819
1932
  // Cooperative abort check: throw early if the caller already aborted
1820
1933
  // (IPC cancel handler signals the AbortController before re-entry).
1821
1934
  if (signal?.aborted) throw signal.reason ?? new Error('aborted')
1935
+ // Drain this session's unchunked backlog to zero BEFORE searching, so a
1936
+ // query recall never returns raw/unclassified transcript tail instead of
1937
+ // the chunked summary. Only runs when both a session and a query are
1938
+ // present; a bare recent-browsing call (no query) skips this entirely.
1939
+ {
1940
+ const _drainSessionId = String(args?.sessionId || args?.session_id || '').trim()
1941
+ const _drainHasQuery = Array.isArray(args?.query)
1942
+ ? args.query.some((v) => String(v || '').trim())
1943
+ : String(args?.query ?? '').trim() !== ''
1944
+ if (_drainSessionId && _drainHasQuery) {
1945
+ await _drainSessionForRecallQuery(_drainSessionId, signal)
1946
+ }
1947
+ }
1822
1948
  if (args?.currentSession === true || args?.sessionId || args?.session_id) {
1823
1949
  return await recallSessionRows(args)
1824
1950
  }
@@ -1935,8 +2061,6 @@ async function handleSearch(args, signal) {
1935
2061
  })),
1936
2062
  deadlineRace,
1937
2063
  ])
1938
- } catch (err) {
1939
- throw err
1940
2064
  } finally {
1941
2065
  clearTimeout(deadlineTimer)
1942
2066
  }
@@ -2071,13 +2195,16 @@ async function handleSearch(args, signal) {
2071
2195
  })
2072
2196
  }
2073
2197
  if (includeRaw) {
2074
- // Reserve slots for raw rows under sort=importance: hybrid rows are
2075
- // already score-sorted descending, so a full hybrid page (limit rows)
2076
- // would shut out raw rows entirely after slice(offset, offset+limit).
2077
- // Reserve up to RAW_RESERVE slots near the top of the post-slice
2078
- // window by trimming the hybrid prefix before merging, then re-sort
2079
- // for sort=date or otherwise append (already ranked) for importance.
2080
- const RAW_FETCH = 20
2198
+ // Raw rows (chunk_root IS NULL) carry no retrievalScore, so a naive
2199
+ // append-after-hybrid under sort=importance always lands them past
2200
+ // slice(offset, offset+limit) once the hybrid pool exceeds one page —
2201
+ // every page beyond the first silently drops them. Fetch a wider raw
2202
+ // window (bounded like the hybrid candidate pool) and spread the
2203
+ // fetched raw rows evenly across the WHOLE hybrid list before slicing,
2204
+ // so every offset page gets its proportional share instead of only
2205
+ // page 0. Same projectScope/ts window as the hybrid leg — filter
2206
+ // parity (item 3) is deliberate, not accidental.
2207
+ const RAW_FETCH = Math.min(500, Math.max(20, limit + offset))
2081
2208
  const rawRows = await readRawRowsInWindow(
2082
2209
  db,
2083
2210
  temporal?.startMs ?? null,
@@ -2091,10 +2218,10 @@ async function handleSearch(args, signal) {
2091
2218
  for (const r of newRaw) filtered.push(r)
2092
2219
  filtered.sort((a, b) => (Number(b.ts) || 0) - (Number(a.ts) || 0))
2093
2220
  } else {
2094
- // sort=importance: append raw rows after the hybrid page (mostly
2095
- // ineffective slice(offset, offset+limit) typically shuts them
2096
- // out). Proper includeRaw paging fix deferred (needs fetching extra rows / paging redesign).
2097
- for (const r of newRaw) filtered.push(r)
2221
+ // sort=importance: interleave raw rows at a fixed stride through the
2222
+ // full (pre-slice) hybrid list instead of appending at the tail, so
2223
+ // offset > 0 pages also draw from the raw pool proportionally.
2224
+ filtered = interleaveRawRows(filtered, newRaw)
2098
2225
  }
2099
2226
  }
2100
2227
  const coreRows = await recallCoreRows(query, { projectScope, category, limit })
@@ -3920,8 +4047,46 @@ export async function stop() {
3920
4047
  // postmaster keeps running after the memory service exits.
3921
4048
  if (!memorySecondaryMode()) {
3922
4049
  try {
3923
- const { stopPgForShutdown } = await import('./lib/pg/supervisor.mjs')
3924
- await stopPgForShutdown()
4050
+ // Conservative check: only skip stopPgForShutdown when the owner
4051
+ // record is unambiguously (a) a memory-runtime-daemon owner record
4052
+ // (kind check — guards against a stale/foreign pid reusing this pid
4053
+ // number for an unrelated process) and (b) that pid is alive AND not
4054
+ // this process. Any read/parse failure or ambiguous state falls back
4055
+ // to stopping PG (previous unconditional behavior) rather than
4056
+ // risking an orphaned PG postmaster.
4057
+ const anotherOwnerAlive = await (async () => {
4058
+ try {
4059
+ const { readSingletonOwner } = await import('../shared/singleton-owner.mjs')
4060
+ const ownerPath = path.join(DATA_DIR, 'memory-runtime-owner.json')
4061
+ const { owner, alive } = readSingletonOwner(ownerPath)
4062
+ if (!alive) return false
4063
+ if (owner?.kind !== 'memory-runtime-daemon') return false
4064
+ const ownerPid = Number(owner?.pid)
4065
+ if (!Number.isInteger(ownerPid) || ownerPid === process.pid) return false
4066
+ // Best-effort process-name check (mirrors supervisor.mjs's
4067
+ // isPostgresPid pattern) — confirms the pid is actually a node
4068
+ // process before trusting it as a live sibling memory owner.
4069
+ // Falls back to true (trust the owner file) when the platform
4070
+ // check is unavailable or inconclusive.
4071
+ try {
4072
+ if (process.platform === 'win32') {
4073
+ const { execFileSync } = await import('node:child_process')
4074
+ const out = execFileSync('tasklist', ['/FI', `PID eq ${ownerPid}`, '/FO', 'CSV', '/NH'], { encoding: 'utf8', windowsHide: true })
4075
+ if (!String(out || '').toLowerCase().includes('node')) return false
4076
+ } else if (process.platform === 'linux') {
4077
+ const comm = fs.readFileSync(`/proc/${ownerPid}/comm`, 'utf8').trim()
4078
+ if (!comm.includes('node')) return false
4079
+ }
4080
+ } catch { /* inconclusive — trust the owner file (already alive+kind-matched) */ }
4081
+ return true
4082
+ } catch { return false }
4083
+ })()
4084
+ if (anotherOwnerAlive) {
4085
+ __mixdogMemoryLog('[memory-service] shutdown: another live memory owner holds memory-runtime-owner.json — leaving PG running\n')
4086
+ } else {
4087
+ const { stopPgForShutdown } = await import('./lib/pg/supervisor.mjs')
4088
+ await stopPgForShutdown()
4089
+ }
3925
4090
  } catch {}
3926
4091
  } else {
3927
4092
  __mixdogMemoryLog('[memory-service] secondary mode; leaving shared PG running\n')
@@ -40,7 +40,7 @@ function nextCallId() {
40
40
  * unavailable (worker not forked) or the parent reports an error.
41
41
  *
42
42
  * @param {object} opts agent-dispatch construction options
43
- * @param {string} [opts.role]
43
+ * @param {string} [opts.agent]
44
44
  * @param {string} [opts.taskType]
45
45
  * @param {string} [opts.mode]
46
46
  * @param {string} [opts.preset] preset id/name (passed at call time)
@@ -86,7 +86,7 @@ export function callAgentDispatch(opts = {}, prompt) {
86
86
  callId,
87
87
  tool: 'agent_dispatch',
88
88
  params: {
89
- role: opts.role || null,
89
+ agent: opts.agent || null,
90
90
  taskType: opts.taskType || null,
91
91
  mode: opts.mode || null,
92
92
  preset: opts.preset || null,
@@ -170,7 +170,7 @@ async function _llmJudgeMerge(existing, incoming) {
170
170
  `INCOMING: ${incoming.element} — ${String(incoming.summary || '')}`
171
171
  try {
172
172
  const raw = await callAgentDispatch({
173
- role: 'cycle2-agent',
173
+ agent: 'cycle2-agent',
174
174
  taskType: 'maintenance',
175
175
  mode: 'core-merge-judge',
176
176
  preset: resolveMaintenancePreset('memory'),
@@ -520,7 +520,7 @@ async function _runCycle1Impl(db, config = {}, options = {}, _dataDir = null) {
520
520
  const _tLlm = Date.now()
521
521
  try {
522
522
  raw = await llmCall({
523
- role: 'cycle1-agent',
523
+ agent: 'cycle1-agent',
524
524
  taskType: 'maintenance',
525
525
  mode: 'cycle1',
526
526
  preset,
@@ -47,7 +47,7 @@ function resourceDir() {
47
47
 
48
48
  async function invokeLlm(prompt, mode, preset, timeout, llmCall = callAgentDispatch) {
49
49
  return await llmCall({
50
- role: 'cycle2-agent',
50
+ agent: 'cycle2-agent',
51
51
  taskType: 'maintenance',
52
52
  mode,
53
53
  preset,
@@ -347,7 +347,7 @@ async function _llmJudgePair(summaryA, summaryB, siblingContext = [], options =
347
347
  `Two memory entries below. Are they restating the same principle? Reply ONE WORD: merge or distinct.\n\nA: ${summaryA}\nB: ${summaryB}${siblings}`
348
348
  try {
349
349
  const raw = await llmCall({
350
- role: 'cycle2-agent',
350
+ agent: 'cycle2-agent',
351
351
  taskType: 'maintenance',
352
352
  mode: 'cycle2-phase_merge_judge',
353
353
  preset: 'HAIKU',
@@ -808,7 +808,7 @@ async function sonnetCascade(candidates, rulesDigest, options = {}) {
808
808
  let raw
809
809
  try {
810
810
  raw = await llmCall({
811
- role: 'cycle2-agent',
811
+ agent: 'cycle2-agent',
812
812
  taskType: 'maintenance',
813
813
  mode: 'cycle2-cascade',
814
814
  preset,
@@ -1305,13 +1305,16 @@ async function _runCycle2Impl(db, config = {}, options = {}, dataDir = null) {
1305
1305
  )
1306
1306
  }
1307
1307
  } else if (rows.length > 0) {
1308
- // Parse failure — bump error_count, do not advance reviewed_at.
1308
+ // Parse failure — bump error_count and advance reviewed_at so the
1309
+ // failing row sorts to the back of reviewed_at ASC NULLS FIRST (see
1310
+ // ~:1067-1073), matching CYCLE1_OMITTED_COOLDOWN_MS-style cooldown
1311
+ // semantics instead of spinning forever on the same row.
1309
1312
  for (const r of rows) {
1310
1313
  throwIfAborted(signal)
1311
1314
  try {
1312
1315
  await db.query(
1313
- `UPDATE entries SET error_count = COALESCE(error_count, 0) + 1 WHERE id = $1`,
1314
- [r.id],
1316
+ `UPDATE entries SET error_count = COALESCE(error_count, 0) + 1, reviewed_at = $1 WHERE id = $2`,
1317
+ [sweepCursor, r.id],
1315
1318
  )
1316
1319
  } catch {}
1317
1320
  }
@@ -36,7 +36,7 @@ function resourceDir() {
36
36
 
37
37
  async function invokeLlm(prompt, mode, preset, timeout, llmCall = callAgentDispatch) {
38
38
  return await llmCall({
39
- role: 'cycle3-agent',
39
+ agent: 'cycle3-agent',
40
40
  taskType: 'maintenance',
41
41
  mode,
42
42
  preset,
@@ -136,9 +136,18 @@ export async function init(db, dims) {
136
136
  await db.exec(`CREATE INDEX IF NOT EXISTS idx_entries_phase_sweep ON entries(status, is_root, error_count, reviewed_at, id)`)
137
137
  await db.exec(`CREATE INDEX IF NOT EXISTS idx_entries_promoted_at ON entries(promoted_at) WHERE promoted_at IS NOT NULL`)
138
138
  await db.exec(`CREATE INDEX IF NOT EXISTS idx_entries_tsv ON entries USING GIN (search_tsv)`)
139
- await db.exec(`CREATE INDEX IF NOT EXISTS idx_entries_content_trgm ON entries USING GIN (content gin_trgm_ops) WHERE is_root = 1`)
140
- await db.exec(`CREATE INDEX IF NOT EXISTS idx_entries_element_trgm ON entries USING GIN (element gin_trgm_ops) WHERE is_root = 1 AND element IS NOT NULL`)
141
- await db.exec(`CREATE INDEX IF NOT EXISTS idx_entries_embedding_hnsw ON entries USING hnsw (embedding halfvec_cosine_ops) WHERE is_root = 1 AND embedding IS NOT NULL`)
139
+ // Recall CTEs (memory-recall-store.mjs dense/trgm legs) intentionally match
140
+ // BOTH root and leaf/chunk rows, so their SQL has NO `is_root = 1` predicate
141
+ // (only `embedding IS NOT NULL` / content|element|summary text filters).
142
+ // The old root-only PARTIAL indexes therefore could not be used by those
143
+ // queries — the planner fell back to a Seq Scan + top-N heapsort over every
144
+ // embedding (verified via EXPLAIN ANALYZE). Broaden the predicates to match
145
+ // the query shape so HNSW/GIN are actually used. A `summary` trgm index is
146
+ // added because the trgm leg also filters on `summary` but had no index.
147
+ await db.exec(`CREATE INDEX IF NOT EXISTS idx_entries_content_trgm ON entries USING GIN (content gin_trgm_ops)`)
148
+ await db.exec(`CREATE INDEX IF NOT EXISTS idx_entries_element_trgm ON entries USING GIN (element gin_trgm_ops) WHERE element IS NOT NULL`)
149
+ await db.exec(`CREATE INDEX IF NOT EXISTS idx_entries_summary_trgm ON entries USING GIN (summary gin_trgm_ops) WHERE summary IS NOT NULL`)
150
+ await db.exec(`CREATE INDEX IF NOT EXISTS idx_entries_embedding_hnsw ON entries USING hnsw (embedding halfvec_cosine_ops) WHERE embedding IS NOT NULL`)
142
151
 
143
152
  // BEFORE INSERT/UPDATE trigger keeps score in sync with category + last_seen_at
144
153
  // automatically; cycle code no longer needs to UPDATE entries SET score = ...
@@ -333,13 +342,101 @@ export async function validateEmbeddingDims(db, dimCount) {
333
342
  }
334
343
  }
335
344
 
345
+ // One-time migration: broaden the recall indexes that were created as root-only
346
+ // PARTIAL indexes (`WHERE is_root = 1 ...`) so they match the recall CTE query
347
+ // predicates (which do NOT restrict is_root — recall matches leaf/chunk rows
348
+ // too). A stale root-only index is unusable by those queries and forces a Seq
349
+ // Scan. This runs on every boot (via ensureCurrentSchemaExtensions), so it is
350
+ // carefully guarded: it only DROP+CREATEs an index when the CURRENT definition
351
+ // still contains the `is_root = 1` predicate. Once broadened, every check is a
352
+ // cheap catalog read and no rebuild happens. Best-effort: catalog-read or DDL
353
+ // failures are logged and swallowed so a boot is never blocked by this.
354
+ async function _migrateRecallIndexesIfStale(db) {
355
+ // Each entry: [indexName, newDefTailPredicate] where the presence of
356
+ // "is_root" in the live indexdef signals the stale root-only shape.
357
+ const targets = [
358
+ { name: 'idx_entries_content_trgm', create: `CREATE INDEX idx_entries_content_trgm ON entries USING GIN (content gin_trgm_ops)` },
359
+ { name: 'idx_entries_element_trgm', create: `CREATE INDEX idx_entries_element_trgm ON entries USING GIN (element gin_trgm_ops) WHERE element IS NOT NULL` },
360
+ { name: 'idx_entries_embedding_hnsw', create: `CREATE INDEX idx_entries_embedding_hnsw ON entries USING hnsw (embedding halfvec_cosine_ops) WHERE embedding IS NOT NULL` },
361
+ ]
362
+ try {
363
+ for (const t of targets) {
364
+ let def = null
365
+ try {
366
+ const r = await db.query(`SELECT indexdef FROM pg_indexes WHERE indexname = $1`, [t.name])
367
+ def = r.rows?.[0]?.indexdef ?? null
368
+ } catch { def = null }
369
+ // Missing index → post-loop IF-NOT-EXISTS ensures below self-heal trgm
370
+ // indexes; embedding_hnsw is also re-ensured by ensureCurrentSchemaExtensions.
371
+ if (def == null) continue
372
+ // Already broadened (no is_root predicate) → no-op, no rebuild.
373
+ if (!/is_root/i.test(def)) continue
374
+ try {
375
+ await db.exec(`DROP INDEX IF EXISTS ${t.name}`)
376
+ await db.exec(t.create)
377
+ __mixdogMemoryLog(`[memory] migrated stale root-only index ${t.name} → broadened to match recall query predicates\n`)
378
+ } catch (err) {
379
+ __mixdogMemoryLog(`[memory] recall index migration for ${t.name} failed: ${err?.message || err}\n`)
380
+ }
381
+ }
382
+ // Self-heal trgm recall indexes on already-bootstrapped DBs: init() will
383
+ // not re-run, and a missing index is not handled by the stale-shape loop
384
+ // above (def == null → continue). These IF-NOT-EXISTS ensures recreate any
385
+ // trgm index that never existed or whose migrate-create failed. No-op (cheap
386
+ // catalog check) when the broadened index is already present.
387
+ try {
388
+ await db.exec(`CREATE INDEX IF NOT EXISTS idx_entries_content_trgm ON entries USING GIN (content gin_trgm_ops)`)
389
+ } catch (err) {
390
+ __mixdogMemoryLog(`[memory] idx_entries_content_trgm ensure failed: ${err?.message || err}\n`)
391
+ }
392
+ try {
393
+ await db.exec(`CREATE INDEX IF NOT EXISTS idx_entries_element_trgm ON entries USING GIN (element gin_trgm_ops) WHERE element IS NOT NULL`)
394
+ } catch (err) {
395
+ __mixdogMemoryLog(`[memory] idx_entries_element_trgm ensure failed: ${err?.message || err}\n`)
396
+ }
397
+ try {
398
+ await db.exec(`CREATE INDEX IF NOT EXISTS idx_entries_summary_trgm ON entries USING GIN (summary gin_trgm_ops) WHERE summary IS NOT NULL`)
399
+ } catch (err) {
400
+ __mixdogMemoryLog(`[memory] idx_entries_summary_trgm ensure failed: ${err?.message || err}\n`)
401
+ }
402
+ } catch (err) {
403
+ __mixdogMemoryLog(`[memory] _migrateRecallIndexesIfStale failed: ${err?.message || err}\n`)
404
+ }
405
+ }
406
+
336
407
  export async function ensureCurrentSchemaExtensions(db, dims) {
408
+ // One-time cleanup: attachment-only placeholder rows ('(attachment)' user
409
+ // content, e.g. Discord backend discord.mjs:724) predate the
410
+ // shouldExcludeIngestMessage() ingest-time filter (session-ingest.mjs).
411
+ // Delete any already-persisted rows so they stop polluting recall/cycle1.
412
+ // Idempotent (no-op once cleaned); best-effort so a failure never blocks boot.
413
+ try {
414
+ const cleaned = await db.query(
415
+ `DELETE FROM entries WHERE content = '(attachment)' AND role = 'user'`,
416
+ )
417
+ const n = Number(cleaned?.rowCount ?? 0)
418
+ if (n > 0) {
419
+ __mixdogMemoryLog(`[memory] ensureCurrentSchemaExtensions: removed ${n} attachment-only placeholder rows\n`)
420
+ }
421
+ } catch (err) {
422
+ __mixdogMemoryLog(`[memory] attachment-placeholder cleanup failed: ${err?.message || err}\n`)
423
+ }
337
424
  // core_entries gained an embedding column for cross-table semantic dedup
338
425
  // between user-curated rows and cycle2-promoted entries. ALTER + index are
339
426
  // idempotent and define the current runtime schema.
340
427
  if (Number.isInteger(dims) && dims > 0) {
341
428
  await db.exec(`ALTER TABLE core_entries ADD COLUMN IF NOT EXISTS embedding halfvec(${dims})`)
342
- await db.exec(`CREATE INDEX IF NOT EXISTS idx_entries_embedding_hnsw ON entries USING hnsw (embedding halfvec_cosine_ops) WHERE is_root = 1 AND embedding IS NOT NULL`)
429
+ // One-time migration for EXISTING deployments (bootstrap-complete DBs never
430
+ // re-run init(), so the broadened index definitions there would otherwise
431
+ // never reach them). This path runs on EVERY boot, so we must NOT
432
+ // unconditionally DROP+CREATE an HNSW index (that would rebuild it on every
433
+ // startup). Only rebuild when the current index still carries the stale
434
+ // root-only `is_root = 1` predicate; once broadened, the check is a no-op.
435
+ await _migrateRecallIndexesIfStale(db)
436
+ // Residual (low risk, no action needed): cycle2's root-active embedding
437
+ // scans (memory-cycle2.mjs) now share this broader all-embedding HNSW
438
+ // instead of a root-only partial index; acceptable at current scale.
439
+ await db.exec(`CREATE INDEX IF NOT EXISTS idx_entries_embedding_hnsw ON entries USING hnsw (embedding halfvec_cosine_ops) WHERE embedding IS NOT NULL`)
343
440
  await db.exec(`CREATE INDEX IF NOT EXISTS core_entries_embedding_hnsw ON core_entries USING hnsw (embedding halfvec_cosine_ops) WHERE embedding IS NOT NULL`)
344
441
  }
345
442
  await db.exec(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS core_summary text`)