mixdog 0.9.18 → 0.9.20

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/LICENSE +21 -0
  2. package/README.md +93 -29
  3. package/package.json +5 -3
  4. package/scripts/build-runtime-windows.ps1 +242 -242
  5. package/scripts/build-tui.mjs +6 -0
  6. package/scripts/hook-bus-test.mjs +8 -0
  7. package/scripts/log-writer-guard-smoke.mjs +131 -0
  8. package/scripts/output-style-smoke.mjs +2 -2
  9. package/scripts/reactive-compact-persist-smoke.mjs +22 -6
  10. package/scripts/recall-bench-cases.json +10 -0
  11. package/scripts/recall-bench.mjs +91 -2
  12. package/scripts/recall-quality-cases.json +1 -2
  13. package/scripts/recall-usecase-cases.json +1 -1
  14. package/scripts/session-ingest-compaction-smoke.mjs +241 -0
  15. package/scripts/smoke-runtime-negative.ps1 +106 -106
  16. package/scripts/tool-efficiency-diag.mjs +5 -2
  17. package/scripts/tool-smoke.mjs +251 -72
  18. package/src/agents/debugger/AGENT.md +3 -3
  19. package/src/agents/heavy-worker/AGENT.md +7 -10
  20. package/src/agents/maintainer/AGENT.md +1 -2
  21. package/src/agents/reviewer/AGENT.md +1 -2
  22. package/src/agents/worker/AGENT.md +5 -8
  23. package/src/defaults/agents.json +3 -0
  24. package/src/help.mjs +2 -5
  25. package/src/lib/mixdog-debug.cjs +13 -0
  26. package/src/mixdog-session-runtime.mjs +7 -3311
  27. package/src/rules/agent/00-core.md +4 -3
  28. package/src/rules/agent/30-explorer.md +53 -22
  29. package/src/rules/lead/02-channels.md +3 -3
  30. package/src/rules/lead/lead-tool.md +3 -2
  31. package/src/rules/shared/01-tool.md +24 -29
  32. package/src/runtime/agent/orchestrator/context/collect.mjs +33 -9
  33. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +1 -1
  34. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +1 -1
  35. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +24 -3
  36. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +3 -3
  37. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +663 -0
  38. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +6 -0
  39. package/src/runtime/agent/orchestrator/session/context-utils.mjs +2 -2
  40. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +153 -0
  41. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +49 -0
  42. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +250 -35
  43. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -1
  44. package/src/runtime/agent/orchestrator/session/loop.mjs +8 -1860
  45. package/src/runtime/agent/orchestrator/session/manager/agent-runtime-singleton.mjs +29 -0
  46. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +686 -0
  47. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +60 -12
  48. package/src/runtime/agent/orchestrator/session/manager/env-utils.mjs +8 -0
  49. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +159 -0
  50. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +143 -0
  51. package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +268 -0
  52. package/src/runtime/agent/orchestrator/session/manager/provider-cache-key.mjs +22 -0
  53. package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +26 -0
  54. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +124 -0
  55. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +258 -0
  56. package/src/runtime/agent/orchestrator/session/manager/session-errors.mjs +20 -0
  57. package/src/runtime/agent/orchestrator/session/manager/session-id.mjs +9 -0
  58. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +475 -0
  59. package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +23 -0
  60. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +6 -4
  61. package/src/runtime/agent/orchestrator/session/manager.mjs +88 -2285
  62. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +440 -0
  63. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +153 -0
  64. package/src/runtime/agent/orchestrator/session/store.mjs +4 -1
  65. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +642 -0
  66. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +23 -3
  67. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +198 -6
  68. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +10 -2
  69. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +13 -15
  70. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +6 -1
  71. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +45 -3
  72. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +5 -2
  73. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +195 -3
  74. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +108 -2
  75. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +15 -3
  76. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +7 -0
  77. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +24 -2
  78. package/src/runtime/agent/orchestrator/tools/builtin.mjs +17 -1
  79. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +38 -0
  80. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -4
  81. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +2 -2
  82. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +72 -8
  83. package/src/runtime/agent/orchestrator/tools/shell-policy-danger-target.mjs +5 -1
  84. package/src/runtime/agent/orchestrator/tools/shell-state.mjs +1 -1
  85. package/src/runtime/channels/backends/discord-gateway.mjs +14 -1
  86. package/src/runtime/channels/backends/discord.mjs +43 -1
  87. package/src/runtime/channels/index.mjs +6 -2096
  88. package/src/runtime/channels/lib/inbound-handler.mjs +328 -0
  89. package/src/runtime/channels/lib/inbound-routing.mjs +19 -12
  90. package/src/runtime/channels/lib/interaction-handlers.mjs +260 -0
  91. package/src/runtime/channels/lib/memory-client.mjs +32 -14
  92. package/src/runtime/channels/lib/network-retry.mjs +23 -0
  93. package/src/runtime/channels/lib/output-forwarder.mjs +38 -7
  94. package/src/runtime/channels/lib/owned-runtime.mjs +547 -0
  95. package/src/runtime/channels/lib/owner-heartbeat.mjs +13 -4
  96. package/src/runtime/channels/lib/runtime-paths.mjs +35 -1
  97. package/src/runtime/channels/lib/tool-dispatch.mjs +17 -7
  98. package/src/runtime/channels/lib/transcript-binding.mjs +336 -0
  99. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +39 -2
  100. package/src/runtime/channels/lib/worker-bootstrap.mjs +97 -0
  101. package/src/runtime/channels/lib/worker-ipc.mjs +201 -0
  102. package/src/runtime/channels/lib/worker-main.mjs +771 -0
  103. package/src/runtime/memory/index.mjs +73 -1725
  104. package/src/runtime/memory/lib/embedding-provider.mjs +4 -2
  105. package/src/runtime/memory/lib/embedding-worker.mjs +47 -6
  106. package/src/runtime/memory/lib/http-router.mjs +772 -0
  107. package/src/runtime/memory/lib/memory-action-handlers.mjs +901 -0
  108. package/src/runtime/memory/lib/memory-embed.mjs +28 -7
  109. package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +8 -2
  110. package/src/runtime/memory/lib/memory-recall-store.mjs +182 -9
  111. package/src/runtime/memory/lib/query-handlers.mjs +38 -6
  112. package/src/runtime/memory/lib/recall-format.mjs +106 -6
  113. package/src/runtime/memory/lib/session-ingest-runtime.mjs +401 -0
  114. package/src/runtime/memory/lib/session-ingest.mjs +1 -1
  115. package/src/runtime/shared/atomic-file.mjs +10 -4
  116. package/src/runtime/shared/background-tasks.mjs +4 -2
  117. package/src/runtime/shared/tool-execution-contract.mjs +1 -1
  118. package/src/runtime/shared/tool-result-summary.mjs +1 -1
  119. package/src/runtime/shared/tool-surface.mjs +30 -1
  120. package/src/session-runtime/boot-profile.mjs +36 -0
  121. package/src/session-runtime/channel-config-api.mjs +70 -0
  122. package/src/session-runtime/config-lifecycle.mjs +1 -1
  123. package/src/session-runtime/context-status.mjs +181 -0
  124. package/src/session-runtime/cwd-plugins.mjs +46 -3
  125. package/src/session-runtime/env.mjs +17 -0
  126. package/src/session-runtime/lifecycle-api.mjs +242 -0
  127. package/src/session-runtime/mcp-glue.mjs +24 -3
  128. package/src/session-runtime/model-route-api.mjs +198 -0
  129. package/src/session-runtime/output-styles.mjs +44 -10
  130. package/src/session-runtime/provider-auth-api.mjs +135 -0
  131. package/src/session-runtime/resource-api.mjs +282 -0
  132. package/src/session-runtime/runtime-core.mjs +2046 -0
  133. package/src/session-runtime/session-turn-api.mjs +274 -0
  134. package/src/session-runtime/tool-catalog.mjs +18 -264
  135. package/src/session-runtime/tool-defs.mjs +2 -2
  136. package/src/session-runtime/workflow-agents-api.mjs +238 -0
  137. package/src/session-runtime/workflow.mjs +16 -1
  138. package/src/standalone/agent-tool.mjs +2 -2
  139. package/src/standalone/channel-worker.mjs +78 -5
  140. package/src/standalone/explore-tool.mjs +1 -1
  141. package/src/standalone/memory-runtime-proxy.mjs +56 -6
  142. package/src/tui/App.jsx +88 -97
  143. package/src/tui/app/channel-pickers.mjs +45 -0
  144. package/src/tui/app/core-memory-picker.mjs +1 -1
  145. package/src/tui/app/slash-commands.mjs +0 -1
  146. package/src/tui/app/slash-dispatch.mjs +0 -16
  147. package/src/tui/app/transcript-window.mjs +44 -1
  148. package/src/tui/app/use-mouse-input.mjs +15 -2
  149. package/src/tui/app/use-prompt-handlers.mjs +7 -94
  150. package/src/tui/app/use-transcript-scroll.mjs +82 -4
  151. package/src/tui/app/use-transcript-window.mjs +65 -5
  152. package/src/tui/components/PromptInput.jsx +33 -64
  153. package/src/tui/components/ToolExecution.jsx +2 -2
  154. package/src/tui/dist/index.mjs +7908 -7558
  155. package/src/tui/engine/context-state.mjs +145 -0
  156. package/src/tui/engine/prompt-history.mjs +27 -0
  157. package/src/tui/engine/session-api-ext.mjs +478 -0
  158. package/src/tui/engine/session-api.mjs +545 -0
  159. package/src/tui/engine/session-flow.mjs +485 -0
  160. package/src/tui/engine/turn.mjs +1078 -0
  161. package/src/tui/engine.mjs +69 -2582
  162. package/src/tui/index.jsx +7 -0
  163. package/src/tui/lib/voice-setup.mjs +166 -0
  164. package/src/tui/paste-attachments.mjs +12 -5
  165. package/src/tui/prompt-history-store.mjs +125 -12
  166. package/vendor/ink/build/ink.js +16 -1
  167. package/vendor/ink/build/output.js +30 -4
  168. package/vendor/ink/build/render.js +5 -0
  169. package/scripts/bench/cache-probe-tasks.json +0 -8
  170. package/scripts/bench/lead-review-tasks-r3.json +0 -20
  171. package/scripts/bench/lead-review-tasks.json +0 -20
  172. package/scripts/bench/r4-mixed-tasks.json +0 -20
  173. package/scripts/bench/r5-orchestrated-task.json +0 -7
  174. package/scripts/bench/review-tasks.json +0 -20
  175. package/scripts/bench/round-codex.json +0 -114
  176. package/scripts/bench/round-mixdog-lead-r3.json +0 -269
  177. package/scripts/bench/round-mixdog-lead.json +0 -269
  178. package/scripts/bench/round-mixdog.json +0 -126
  179. package/scripts/bench/round-r10-bigsample.json +0 -679
  180. package/scripts/bench/round-r11-codexalign.json +0 -257
  181. package/scripts/bench/round-r13-clientmeta.json +0 -464
  182. package/scripts/bench/round-r14-betafeatures.json +0 -466
  183. package/scripts/bench/round-r15-fulldefault.json +0 -462
  184. package/scripts/bench/round-r16-sessionid.json +0 -466
  185. package/scripts/bench/round-r17-wirebytes.json +0 -456
  186. package/scripts/bench/round-r18-prewarm.json +0 -468
  187. package/scripts/bench/round-r19-clean.json +0 -472
  188. package/scripts/bench/round-r20-prewarm-clean.json +0 -475
  189. package/scripts/bench/round-r21-delta-retry.json +0 -473
  190. package/scripts/bench/round-r22-full-probe.json +0 -693
  191. package/scripts/bench/round-r23-itemprobe.json +0 -701
  192. package/scripts/bench/round-r24-shapefix.json +0 -677
  193. package/scripts/bench/round-r25-serial.json +0 -464
  194. package/scripts/bench/round-r26-parallel3.json +0 -671
  195. package/scripts/bench/round-r27-parallel10.json +0 -894
  196. package/scripts/bench/round-r28-parallel10-stagger.json +0 -882
  197. package/scripts/bench/round-r29-parallel10-stagger166.json +0 -886
  198. package/scripts/bench/round-r30-instid.json +0 -253
  199. package/scripts/bench/round-r31-upgradeprobe.json +0 -256
  200. package/scripts/bench/round-r32-vs-codex-lead.json +0 -254
  201. package/scripts/bench/round-r33-vs-codex-codex.json +0 -115
  202. package/scripts/bench/round-r34-orchestrated.json +0 -120
  203. package/scripts/bench/round-r35-orchestrated-codex.json +0 -61
  204. package/scripts/bench/round-r36-orchestrated-capped.json +0 -128
  205. package/scripts/bench/round-r4-codex.json +0 -114
  206. package/scripts/bench/round-r4-mixed.json +0 -225
  207. package/scripts/bench/round-r5-gpt-lead.json +0 -259
  208. package/scripts/bench/round-r6-codex.json +0 -114
  209. package/scripts/bench/round-r6-solo.json +0 -257
  210. package/scripts/bench/round-r7-full.json +0 -254
  211. package/scripts/bench/round-r8-fulldefault.json +0 -255
  212. package/src/tui/components/prompt-input/voice-indicator.mjs +0 -39
  213. package/src/tui/lib/voice-recorder.mjs +0 -469
  214. package/src/workflows/sequential/WORKFLOW.md +0 -51
@@ -0,0 +1,772 @@
1
+ // HTTP request router extracted from index.mjs.
2
+ //
3
+ // Owns the memory service's HTTP surface: health/admin/trace/session-start
4
+ // routes, the /api/tool + /api/cancel owner-side call plumbing, the /mcp
5
+ // StreamableHTTP bridge, the dev-only cycle1 bench, and the /entry +
6
+ // /ingest-transcript tail routes. Pure wire helpers, core store, trace store,
7
+ // and MCP SDK pieces are imported directly; live DB handle, data dir, the
8
+ // cycle scheduler, lifecycle getters, trace-DB slot, and the action/tool
9
+ // handlers are injected so the facade keeps ownership of `db`, `_traceDb`,
10
+ // `_bootTimestamp`, and the init/stop lifecycle.
11
+
12
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js'
13
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
14
+ import {
15
+ ListToolsRequestSchema,
16
+ CallToolRequestSchema,
17
+ } from '@modelcontextprotocol/sdk/types.js'
18
+ import fs from 'node:fs'
19
+ import path from 'node:path'
20
+
21
+ import { TOOL_DEFS } from '../tool-defs.mjs'
22
+ import { readBody, sendJson, sendError, isLocalOrigin, normalizeCoreProjectId } from './http-wire.mjs'
23
+ import { listCore, addCore, deleteCore } from './core-memory-store.mjs'
24
+ import { isBootstrapComplete, cleanMemoryText } from './memory.mjs'
25
+ import { resolveProjectScope } from './project-id-resolver.mjs'
26
+ import { openTraceDatabase, insertAgentCalls, enqueueTraceEvents, registerTraceExitDrain } from './trace-store.mjs'
27
+
28
+ const MEMORY_INSTRUCTIONS_TEXT = ''
29
+
30
+ export function createHttpRouter({
31
+ getDb,
32
+ dataDir,
33
+ log,
34
+ pluginVersion,
35
+ bootPromotionCodeFingerprint,
36
+ touchDaemonIdleTimer,
37
+ entryStats,
38
+ cycleScheduler,
39
+ getInitialized,
40
+ getInitPromise,
41
+ setBootTimestamp,
42
+ handleMemoryAction,
43
+ handleToolCall,
44
+ stop,
45
+ getCycle1CallLlm,
46
+ getTraceDb,
47
+ setTraceDb,
48
+ ingestTranscriptFile,
49
+ getTranscriptOffset,
50
+ parseTsToMs,
51
+ }) {
52
+ const DATA_DIR = dataDir
53
+ const PLUGIN_VERSION = pluginVersion
54
+ const BOOT_PROMOTION_CODE_FINGERPRINT = bootPromotionCodeFingerprint
55
+
56
+ function createHttpMcpServer() {
57
+ const s = new Server(
58
+ { name: 'mixdog-memory', version: PLUGIN_VERSION },
59
+ { capabilities: { tools: {} }, instructions: MEMORY_INSTRUCTIONS_TEXT },
60
+ )
61
+ s.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOL_DEFS }))
62
+ s.setRequestHandler(CallToolRequestSchema, (req) => handleToolCall(req.params.name, req.params.arguments ?? {}))
63
+ return s
64
+ }
65
+
66
+ async function awaitRuntimeReadyForHttp(res) {
67
+ if (getInitialized()) return true
68
+ const initPromise = getInitPromise()
69
+ if (!initPromise) {
70
+ sendJson(res, { error: 'memory runtime is starting' }, 503)
71
+ return false
72
+ }
73
+ try {
74
+ await initPromise
75
+ return true
76
+ } catch (e) {
77
+ sendJson(res, { error: `memory runtime failed: ${e?.message || e}` }, 503)
78
+ return false
79
+ }
80
+ }
81
+
82
+ async function buildSessionCoreMemoryPayload(cwd) {
83
+ const db = getDb()
84
+ const projectId = resolveProjectScope(typeof cwd === 'string' && cwd ? cwd : null)
85
+ const generatedScopeClause = projectId !== null
86
+ ? `project_id IS NULL OR project_id = $1`
87
+ : `project_id IS NULL`
88
+ const dbRows = (await db.query(`
89
+ SELECT core_summary
90
+ FROM entries
91
+ WHERE is_root = 1
92
+ AND status = 'active'
93
+ AND core_summary IS NOT NULL
94
+ AND (${generatedScopeClause})
95
+ ORDER BY score DESC, last_seen_at DESC
96
+ `, projectId !== null ? [projectId] : [])).rows
97
+ const commonRows = (await db.query(
98
+ `SELECT summary FROM core_entries WHERE project_id IS NULL AND (status IS NULL OR status = 'active') ORDER BY id ASC`
99
+ )).rows
100
+ const scopedRows = projectId !== null
101
+ ? (await db.query(
102
+ `SELECT summary FROM core_entries WHERE project_id = $1 AND (status IS NULL OR status = 'active') ORDER BY id ASC`,
103
+ [projectId]
104
+ )).rows
105
+ : []
106
+ return {
107
+ projectId,
108
+ dbLines: dbRows.map(r => String(r.core_summary || '').trim()).filter(Boolean),
109
+ userLines: [
110
+ ...commonRows.map(r => String(r.summary || '').trim()).filter(Boolean),
111
+ ...scopedRows.map(r => String(r.summary || '').trim()).filter(Boolean),
112
+ ],
113
+ }
114
+ }
115
+
116
+ // Owner-side /api/tool in-flight controllers keyed by caller-supplied
117
+ // X-Mixdog-Call-Id. /api/cancel aborts the matching AbortSignal so the
118
+ // upstream handleToolCall actually stops when the fork-proxy parent cancels.
119
+ const _ownerInFlightHttpCalls = new Map()
120
+
121
+ const requestHandler = async (req, res) => {
122
+ touchDaemonIdleTimer(`${req.method || 'HTTP'} ${req.url || '/'}`)
123
+ if (req.method === 'POST' && req.url === '/session-reset') {
124
+ const ts = Date.now()
125
+ setBootTimestamp(ts)
126
+ sendJson(res, { ok: true, bootTimestamp: ts })
127
+ return
128
+ }
129
+ if (req.method === 'POST' && req.url === '/rebind') {
130
+ setBootTimestamp(Date.now())
131
+ sendJson(res, { ok: true })
132
+ return
133
+ }
134
+
135
+ if (req.method === 'GET' && req.url === '/health') {
136
+ if (!getInitialized()) {
137
+ sendJson(res, { status: 'starting' }, 503)
138
+ return
139
+ }
140
+ try {
141
+ const db = getDb()
142
+ const stats = await entryStats()
143
+ sendJson(res, {
144
+ status: 'ok',
145
+ worker_pid: process.pid,
146
+ server_pid: Number(process.env.MIXDOG_SERVER_PID) || null,
147
+ owner_lead_pid: Number(process.env.MIXDOG_OWNER_LEAD_PID) || null,
148
+ code_fingerprint: BOOT_PROMOTION_CODE_FINGERPRINT,
149
+ bootstrap: await isBootstrapComplete(db),
150
+ entries: stats.total,
151
+ roots: stats.roots,
152
+ active_roots: stats.active_roots,
153
+ archived_roots: stats.archived_roots,
154
+ unchunked_leaves: stats.unchunked_leaves,
155
+ cycle2_pending_roots: stats.cycle2_pending_roots,
156
+ core_entries: stats.core_entries,
157
+ core_embed_null: stats.core_embed_null,
158
+ active_core_summaries: stats.active_core_summaries,
159
+ active_core_summary_missing: stats.active_core_summary_missing,
160
+ mv_hot_active_populated: stats.mv_hot_active_populated,
161
+ cycle_running: cycleScheduler.getCycleRunning(),
162
+ cycle_health: cycleScheduler.getCycleHealth(),
163
+ cycle_backlog: cycleScheduler.getCycleBacklogSnapshot(),
164
+ })
165
+ } catch (e) { sendError(res, e.message) }
166
+ return
167
+ }
168
+
169
+ if (!await awaitRuntimeReadyForHttp(res)) return
170
+
171
+ if (req.method === 'GET' && req.url === '/admin/entries/active') {
172
+ try {
173
+ const db = getDb()
174
+ const { rows } = await db.query(`
175
+ SELECT id, element, category, summary, score, last_seen_at
176
+ FROM entries
177
+ WHERE is_root = 1 AND status = 'active'
178
+ ORDER BY score DESC
179
+ `)
180
+ sendJson(res, { ok: true, items: rows })
181
+ } catch (e) { sendJson(res, { ok: false, error: e.message }, 500) }
182
+ return
183
+ }
184
+
185
+ if (req.method === 'GET' && req.url === '/admin/core/entries') {
186
+ try {
187
+ const rows = await listCore(DATA_DIR, '*')
188
+ sendJson(res, { ok: true, items: rows })
189
+ } catch (e) { sendJson(res, { ok: false, error: e.message }, 500) }
190
+ return
191
+ }
192
+
193
+ if (req.method === 'POST' && req.url === '/admin/core/entries') {
194
+ if (!isLocalOrigin(req)) {
195
+ sendJson(res, { ok: false, error: 'forbidden: cross-origin' }, 403)
196
+ return
197
+ }
198
+ try {
199
+ const body = await readBody(req)
200
+ const projectId = normalizeCoreProjectId(body.project_id)
201
+ const entry = await addCore(DATA_DIR, body, projectId)
202
+ sendJson(res, { ok: true, item: entry })
203
+ } catch (e) { sendJson(res, { ok: false, error: e.message }, 500) }
204
+ return
205
+ }
206
+
207
+ if (req.method === 'POST' && req.url === '/admin/core/entries/delete') {
208
+ if (!isLocalOrigin(req)) {
209
+ sendJson(res, { ok: false, error: 'forbidden: cross-origin' }, 403)
210
+ return
211
+ }
212
+ try {
213
+ const body = await readBody(req)
214
+ const removed = await deleteCore(DATA_DIR, body.id)
215
+ sendJson(res, { ok: true, item: removed })
216
+ } catch (e) { sendJson(res, { ok: false, error: e.message }, 500) }
217
+ return
218
+ }
219
+
220
+ if (req.method === 'POST' && req.url === '/admin/entries/status') {
221
+ if (!isLocalOrigin(req)) {
222
+ sendJson(res, { ok: false, error: 'forbidden: cross-origin' }, 403)
223
+ return
224
+ }
225
+ try {
226
+ const db = getDb()
227
+ const body = await readBody(req)
228
+ const id = Number(body.id)
229
+ const status = String(body.status ?? '').trim().toLowerCase()
230
+ const VALID = ['pending', 'active', 'archived']
231
+ if (!Number.isInteger(id) || id <= 0 || !VALID.includes(status)) {
232
+ sendJson(res, { ok: false, error: 'valid id and status required' }, 400)
233
+ return
234
+ }
235
+ const result = await db.query(
236
+ `UPDATE entries SET status = $1 WHERE id = $2 AND is_root = 1`,
237
+ [status, id]
238
+ )
239
+ sendJson(res, { ok: true, changes: Number(result.rowCount ?? result.affectedRows ?? 0) })
240
+ } catch (e) { sendJson(res, { ok: false, error: e.message }, 500) }
241
+ return
242
+ }
243
+
244
+ if (req.method === 'POST' && req.url === '/admin/entries/add') {
245
+ if (!isLocalOrigin(req)) {
246
+ sendJson(res, { ok: false, error: 'forbidden: cross-origin' }, 403)
247
+ return
248
+ }
249
+ try {
250
+ const body = await readBody(req)
251
+ const result = await handleMemoryAction({
252
+ action: 'manage',
253
+ op: 'add',
254
+ element: body.element,
255
+ summary: body.summary,
256
+ category: body.category,
257
+ cwd: body.cwd,
258
+ })
259
+ if (result.isError) {
260
+ sendJson(res, { ok: false, error: result.text }, 400)
261
+ return
262
+ }
263
+ const idMatch = String(result.text || '').match(/id=(\d+)/)
264
+ const newId = idMatch ? Number(idMatch[1]) : null
265
+ sendJson(res, { ok: true, id: newId, text: result.text })
266
+ } catch (e) { sendJson(res, { ok: false, error: e.message }, 500) }
267
+ return
268
+ }
269
+
270
+ if (req.method === 'POST' && req.url === '/admin/backfill') {
271
+ if (!isLocalOrigin(req)) {
272
+ sendJson(res, { ok: false, error: 'forbidden: cross-origin' }, 403)
273
+ return
274
+ }
275
+ let body
276
+ try { body = await readBody(req) }
277
+ catch (e) { sendJson(res, { ok: false, error: e.message }, Number(e?.statusCode) || 500); return }
278
+ try {
279
+ const result = await handleMemoryAction({
280
+ action: 'backfill',
281
+ window: body.window,
282
+ scope: body.scope,
283
+ limit: body.limit,
284
+ })
285
+ if (result.isError) {
286
+ // 'backfill already in progress' → 409, other failures → 500
287
+ const status = result.text === 'backfill already in progress' ? 409 : 500
288
+ sendJson(res, { ok: false, error: result.text }, status)
289
+ return
290
+ }
291
+ sendJson(res, { ok: true, text: result.text })
292
+ } catch (e) {
293
+ sendJson(res, { ok: false, error: e.message }, 500)
294
+ }
295
+ return
296
+ }
297
+
298
+ if (req.method === 'POST' && req.url === '/admin/purge') {
299
+ if (!isLocalOrigin(req)) {
300
+ sendJson(res, { ok: false, error: 'forbidden: cross-origin' }, 403)
301
+ return
302
+ }
303
+ try {
304
+ const db = getDb()
305
+ const body = await readBody(req)
306
+ if (body?.confirm !== 'DELETE ALL MEMORY') {
307
+ sendJson(res, { ok: false, error: 'confirm must be exactly "DELETE ALL MEMORY"' }, 400)
308
+ return
309
+ }
310
+ const { rows: countRows } = await db.query(`SELECT COUNT(*) AS c FROM entries`)
311
+ const preCount = Number(countRows[0].c)
312
+ const { rows: coreCountRows } = await db.query(`SELECT COUNT(*) AS c FROM core_entries`)
313
+ const coreCount = Number(coreCountRows[0].c)
314
+ await db.transaction(async (tx) => {
315
+ await tx.query(`DELETE FROM entries`)
316
+ })
317
+ sendJson(res, { ok: true, deleted: preCount, core_preserved: coreCount })
318
+ } catch (e) { sendJson(res, { ok: false, error: e.message }, 500) }
319
+ return
320
+ }
321
+
322
+ if (req.method === 'POST' && req.url === '/admin/trace-record') {
323
+ if (!isLocalOrigin(req)) {
324
+ sendJson(res, { ok: false, error: 'forbidden: cross-origin' }, 403)
325
+ return
326
+ }
327
+ let body
328
+ try { body = await readBody(req) }
329
+ catch (e) { sendJson(res, { ok: false, error: e.message }, 400); return }
330
+ if (!Array.isArray(body?.events)) {
331
+ sendJson(res, { ok: false, error: 'body.events must be an array' }, 400)
332
+ return
333
+ }
334
+ if (body.events.length > 500) {
335
+ sendJson(res, { ok: false, error: 'too many events (max 500)' }, 413)
336
+ return
337
+ }
338
+ let traceDb = getTraceDb()
339
+ if (!traceDb) {
340
+ try {
341
+ traceDb = await openTraceDatabase(DATA_DIR)
342
+ setTraceDb(traceDb)
343
+ registerTraceExitDrain(traceDb)
344
+ } catch (e) {
345
+ sendJson(res, { ok: false, error: `trace DB unavailable: ${e.message}` }, 503)
346
+ return
347
+ }
348
+ }
349
+ try {
350
+ // Enqueue for async batched flush (100ms / 500-row window).
351
+ enqueueTraceEvents(traceDb, body.events)
352
+ // Use `queued` — events are async; `inserted` would imply durability.
353
+ sendJson(res, { ok: true, queued: body.events.length })
354
+ // Fire-and-forget into focused agent analytic tables.
355
+ insertAgentCalls(traceDb, body.events).catch(e =>
356
+ log(`[trace] insertAgentCalls error: ${e?.message}\n`)
357
+ )
358
+ } catch (e) {
359
+ sendJson(res, { ok: false, error: e.message }, 500)
360
+ }
361
+ return
362
+ }
363
+
364
+ if (req.method === 'POST' && req.url === '/session-start/core-memory') {
365
+ try {
366
+ const body = await readBody(req)
367
+ const { projectId, dbLines, userLines } = await buildSessionCoreMemoryPayload(body.cwd)
368
+ sendJson(res, { ok: true, projectId, dbLines, userLines })
369
+ } catch (e) { sendError(res, e.message) }
370
+ return
371
+ }
372
+
373
+ if (req.method === 'POST' && req.url === '/admin/shutdown') {
374
+ if (!isLocalOrigin(req)) {
375
+ sendJson(res, { ok: false, error: 'forbidden: cross-origin' }, 403)
376
+ return
377
+ }
378
+ sendJson(res, { shutting_down: true }, 202)
379
+ setImmediate(() => {
380
+ const watchdog = setTimeout(() => {
381
+ log('[shutdown] watchdog fired — forcing exit after 8s\n')
382
+ process.exit(1)
383
+ }, 8000)
384
+ watchdog.unref?.()
385
+ stop()
386
+ .then(() => { clearTimeout(watchdog); process.exit(0) })
387
+ .catch(e => {
388
+ log(`[shutdown] error ${e.message}\n`)
389
+ clearTimeout(watchdog)
390
+ process.exit(1)
391
+ })
392
+ })
393
+ return
394
+ }
395
+
396
+ // DEV-ONLY cycle1 chunking bench. Gated by env MIXDOG_DEV_BENCH=1 so
397
+ // production is untouched (route returns 404 when unset). Mirrors cycle1's
398
+ // exact fetch query + per-session windowing, then runs each window through
399
+ // buildCycle1ChunkPrompt + callAgentDispatch + parseCycle1LineFormat. STRICT
400
+ // read-only — no UPDATE, no transaction, no commit.
401
+ if (req.method === 'POST' && req.url === '/dev/cycle1-bench') {
402
+ const db = getDb()
403
+ // Gate: env MIXDOG_DEV_BENCH=1 OR a runtime flag file, so it can be
404
+ // toggled without restarting the host agent (env only reaches the worker
405
+ // on a full CC restart, not via dev-sync full-restart).
406
+ const _devBenchOn = process.env.MIXDOG_DEV_BENCH === '1'
407
+ || (DATA_DIR && fs.existsSync(path.join(DATA_DIR, '.dev-bench-enabled')))
408
+ if (!_devBenchOn) {
409
+ sendJson(res, { error: 'not found' }, 404)
410
+ return
411
+ }
412
+ if (!isLocalOrigin(req)) {
413
+ sendJson(res, { ok: false, error: 'forbidden: cross-origin' }, 403)
414
+ return
415
+ }
416
+ try {
417
+ const body = await readBody(req)
418
+ const sets = Math.max(1, Number(body?.sets ?? 5))
419
+ const repeat = Math.max(1, Number(body?.repeat ?? 1))
420
+ // Optional variant matrix. Each variant: {name, rules}. rules=null → default prompt.
421
+ const rawVariants = Array.isArray(body?.variants) ? body.variants : null
422
+ const variants = rawVariants && rawVariants.length > 0
423
+ ? rawVariants.map((v, i) => ({
424
+ name: typeof v?.name === 'string' && v.name ? v.name : `variant-${i + 1}`,
425
+ rules: Array.isArray(v?.rules) ? v.rules : null,
426
+ }))
427
+ : null
428
+
429
+ // Lazy-load LLM + chunking helpers so production boot pays nothing.
430
+ // Use the same in-process agent dispatch adapter as real cycle1 — the legacy
431
+ // agent-ipc callAgentDispatch() path is dead in the detached standalone
432
+ // memory daemon (no connected IPC), so the dev bench must mirror prod.
433
+ const [{ buildCycle1ChunkPrompt, parseCycle1LineFormat }, { resolveMaintenancePreset }] = await Promise.all([
434
+ import('./memory-cycle1.mjs'),
435
+ import('../../shared/llm/index.mjs'),
436
+ ])
437
+ const benchCallLlm = getCycle1CallLlm()
438
+
439
+ const CYCLE1_MIN_BATCH = 3
440
+ const CYCLE1_SESSION_CAP = 10
441
+ const BATCH_SIZE = 100
442
+ const TIMEOUT_MS = 180_000
443
+ const fetchLimit = CYCLE1_SESSION_CAP * BATCH_SIZE
444
+
445
+ const fetchResult = await db.query(
446
+ `SELECT id, ts, role, content, session_id, source_ref, project_id
447
+ FROM entries
448
+ WHERE chunk_root IS NULL AND session_id IS NOT NULL
449
+ ORDER BY ts DESC, id DESC
450
+ LIMIT $1`,
451
+ [fetchLimit],
452
+ )
453
+ const rowsDesc = fetchResult.rows
454
+
455
+ if (rowsDesc.length < CYCLE1_MIN_BATCH) {
456
+ sendJson(res, {
457
+ ok: true,
458
+ sets, repeat,
459
+ windowsAvailable: 0,
460
+ note: `not enough pending rows (need >= ${CYCLE1_MIN_BATCH}, got ${rowsDesc.length})`,
461
+ results: [],
462
+ })
463
+ return
464
+ }
465
+
466
+ // Partition by session_id — same as memory-cycle1.mjs _runCycle1Impl L207-233.
467
+ const sessionMap = new Map()
468
+ for (const row of rowsDesc.slice().reverse()) {
469
+ const sid = row.session_id
470
+ if (!sessionMap.has(sid)) sessionMap.set(sid, [])
471
+ sessionMap.get(sid).push(row)
472
+ }
473
+ const windows = []
474
+ for (const [sid, sessionRows] of sessionMap) {
475
+ if (sessionRows.length < CYCLE1_MIN_BATCH) continue
476
+ const windowCount = Math.max(1, Math.ceil(sessionRows.length / BATCH_SIZE))
477
+ const baseSize = Math.floor(sessionRows.length / windowCount)
478
+ const remainder = sessionRows.length % windowCount
479
+ let _offset = 0
480
+ for (let i = 0; i < windowCount; i++) {
481
+ const size = baseSize + (i < remainder ? 1 : 0)
482
+ windows.push({ sid, rows: sessionRows.slice(_offset, _offset + size) })
483
+ _offset += size
484
+ }
485
+ }
486
+ const chosen = windows.slice(0, sets)
487
+
488
+ const preset = resolveMaintenancePreset('memory')
489
+
490
+ function summariseChunks(chunks, totalEntries) {
491
+ const usedIdx = new Set()
492
+ for (const c of chunks) for (const i of (c._idxList || [])) usedIdx.add(i)
493
+ const omitted = []
494
+ for (let i = 1; i <= totalEntries; i++) if (!usedIdx.has(i)) omitted.push(i)
495
+ return { covered: usedIdx.size, omitted }
496
+ }
497
+
498
+ // When variants are absent, fall back to a single implicit baseline so the
499
+ // pre-variant call shape (single rows × repeat) keeps producing the same
500
+ // {runs:[…]} payload the trigger already knows how to print.
501
+ const variantList = variants ?? [{ name: 'baseline', rules: null }]
502
+
503
+ async function runOnce(rows, customRules) {
504
+ const userMessage = buildCycle1ChunkPrompt(rows, customRules)
505
+ const t0 = Date.now()
506
+ let raw, error
507
+ try {
508
+ raw = await benchCallLlm({
509
+ preset,
510
+ timeout: TIMEOUT_MS,
511
+ }, userMessage)
512
+ } catch (e) {
513
+ error = e?.message ?? String(e)
514
+ }
515
+ const llmMs = Date.now() - t0
516
+ if (error) return { ok: false, llmMs, error }
517
+ const parsed = parseCycle1LineFormat(raw)
518
+ const chunks = Array.isArray(parsed?.chunks) ? parsed.chunks : []
519
+ const { covered, omitted } = summariseChunks(chunks, rows.length)
520
+ const ratio = chunks.length > 0
521
+ ? parseFloat((rows.length / chunks.length).toFixed(2))
522
+ : null
523
+ return {
524
+ ok: true,
525
+ llmMs,
526
+ entries: rows.length,
527
+ chunks: chunks.length,
528
+ ratio,
529
+ covered,
530
+ omitted,
531
+ chunkList: chunks.map(c => ({
532
+ idx: c._idxList,
533
+ element: c.element,
534
+ category: c.category,
535
+ summary: c.summary,
536
+ })),
537
+ }
538
+ }
539
+
540
+ const results = []
541
+ for (let s = 0; s < chosen.length; s++) {
542
+ const { sid, rows } = chosen[s]
543
+ const sidShort = String(sid).slice(0, 8)
544
+ if (variants) {
545
+ // Variant mode: same rows, one run per variant per repeat.
546
+ const variantResults = []
547
+ for (const v of variantList) {
548
+ const runs = []
549
+ for (let r = 0; r < repeat; r++) {
550
+ const run = await runOnce(rows, v.rules)
551
+ runs.push({ repIdx: r + 1, ...run })
552
+ }
553
+ variantResults.push({ name: v.name, runs })
554
+ }
555
+ results.push({
556
+ setIdx: s + 1,
557
+ sessionIdShort: sidShort,
558
+ entries: rows.length,
559
+ variants: variantResults,
560
+ })
561
+ } else {
562
+ // Legacy single-baseline payload shape.
563
+ const runs = []
564
+ for (let r = 0; r < repeat; r++) {
565
+ const run = await runOnce(rows, null)
566
+ runs.push({ repIdx: r + 1, ...run })
567
+ }
568
+ results.push({
569
+ setIdx: s + 1,
570
+ sessionIdShort: sidShort,
571
+ entries: rows.length,
572
+ runs,
573
+ })
574
+ }
575
+ }
576
+ sendJson(res, {
577
+ ok: true,
578
+ sets, repeat,
579
+ windowsAvailable: windows.length,
580
+ variants: variants ? variantList.map(v => v.name) : null,
581
+ results,
582
+ })
583
+ } catch (e) {
584
+ sendError(res, e?.message || String(e))
585
+ }
586
+ return
587
+ }
588
+
589
+ if (req.method === 'POST' && req.url === '/api/tool') {
590
+ if (!isLocalOrigin(req)) {
591
+ sendJson(res, { content: [{ type: 'text', text: 'forbidden: cross-origin' }], isError: true }, 403)
592
+ return
593
+ }
594
+ // Owner-side cancel plumbing: the fork-proxy worker forwards parent
595
+ // 'cancel' IPC by issuing POST /api/cancel with the same callId. Track
596
+ // each in-flight /api/tool by its caller-supplied X-Mixdog-Call-Id so
597
+ // the cancel endpoint can abort the AbortSignal threaded into
598
+ // handleToolCall. Without this the proxy-side fetch aborts but the
599
+ // owner keeps running the upstream tool to completion.
600
+ const callId = String(req.headers['x-mixdog-call-id'] || '').trim() || null
601
+ const ac = new AbortController()
602
+ // Abort only on a genuine mid-flight client disconnect. The req 'close'
603
+ // event fires on every normal request once the request body is consumed
604
+ // (before handleToolCall resolves), so gating on it would mark normal
605
+ // completions as aborted. Use the response side instead: when the
606
+ // socket closes, res.writableFinished is true iff the response was
607
+ // fully written — a real client disconnect closes the socket before
608
+ // the response finishes, leaving writableFinished===false.
609
+ res.on('close', () => {
610
+ if (res.writableFinished) return
611
+ try { ac.abort() } catch {}
612
+ })
613
+ if (callId) _ownerInFlightHttpCalls.set(callId, ac)
614
+ try {
615
+ const body = await readBody(req)
616
+ const result = await handleToolCall(body.name, body.arguments ?? {}, ac.signal)
617
+ sendJson(res, result)
618
+ } catch (e) {
619
+ sendJson(res, { content: [{ type: 'text', text: `api/tool error: ${e.message}` }], isError: true }, Number(e?.statusCode) || 500)
620
+ } finally {
621
+ if (callId) _ownerInFlightHttpCalls.delete(callId)
622
+ }
623
+ return
624
+ }
625
+
626
+ if (req.method === 'POST' && req.url === '/api/cancel') {
627
+ if (!isLocalOrigin(req)) {
628
+ sendJson(res, { ok: false, error: 'forbidden: cross-origin' }, 403)
629
+ return
630
+ }
631
+ try {
632
+ const body = await readBody(req)
633
+ const id = String(body.callId || '').trim()
634
+ if (!id) { sendJson(res, { ok: false, error: 'callId required' }, 400); return }
635
+ const ac = _ownerInFlightHttpCalls.get(id)
636
+ if (ac) {
637
+ try { ac.abort() } catch {}
638
+ _ownerInFlightHttpCalls.delete(id)
639
+ sendJson(res, { ok: true, cancelled: true })
640
+ } else {
641
+ sendJson(res, { ok: true, cancelled: false })
642
+ }
643
+ } catch (e) {
644
+ sendJson(res, { ok: false, error: e.message }, Number(e?.statusCode) || 500)
645
+ }
646
+ return
647
+ }
648
+
649
+ if (req.url === '/mcp') {
650
+ if (!isLocalOrigin(req)) {
651
+ sendJson(res, { error: 'forbidden: cross-origin' }, 403)
652
+ return
653
+ }
654
+ try {
655
+ if (req.method === 'POST') {
656
+ const httpMcp = createHttpMcpServer()
657
+ const httpTransport = new StreamableHTTPServerTransport({
658
+ sessionIdGenerator: undefined,
659
+ enableJsonResponse: true,
660
+ })
661
+ res.on('close', () => {
662
+ httpTransport.close()
663
+ void httpMcp.close()
664
+ })
665
+ await httpMcp.connect(httpTransport)
666
+ const body = await readBody(req)
667
+ await httpTransport.handleRequest(req, res, body)
668
+ } else {
669
+ sendJson(res, { error: 'Method not allowed' }, 405)
670
+ }
671
+ } catch (e) {
672
+ log(`[memory-service] /mcp error: ${e.stack || e.message}\n`)
673
+ if (!res.headersSent) sendError(res, e.message, Number(e?.statusCode) || 500)
674
+ }
675
+ return
676
+ }
677
+
678
+ if (req.method !== 'POST') {
679
+ sendJson(res, { error: 'Method not allowed' }, 405)
680
+ return
681
+ }
682
+
683
+ // Tail block handles /entry and /ingest-transcript — both mutate the DB,
684
+ // so apply the same cross-origin guard as /admin/* routes.
685
+ if (!isLocalOrigin(req)) {
686
+ sendError(res, 'forbidden: cross-origin', 403)
687
+ return
688
+ }
689
+
690
+ let body
691
+ try { body = await readBody(req) }
692
+ catch (e) { sendError(res, e.message, Number(e?.statusCode) || 500); return }
693
+
694
+ try {
695
+ if (req.url === '/entry') {
696
+ const db = getDb()
697
+ const role = String(body.role ?? 'user')
698
+ const content = String(body.content ?? '')
699
+ const sourceRef = String(body.sourceRef ?? `manual:${Date.now()}-${process.pid}`)
700
+ const sessionId = body.sessionId ?? null
701
+ const tsMs = parseTsToMs(body.ts ?? Date.now())
702
+ if (!content) { sendJson(res, { error: 'content required' }, 400); return }
703
+ // Run the same scrubber used by ingestTranscriptFile so noise markers
704
+ // like "[Request interrupted by user]" and whitespace-only payloads
705
+ // are rejected before they reach the entries table. Match the
706
+ // existing 400 / { error } convention for invalid payloads.
707
+ const cleaned = cleanMemoryText(content)
708
+ if (!cleaned || !cleaned.trim()) {
709
+ sendJson(res, { error: 'empty after clean' }, 400)
710
+ return
711
+ }
712
+ const entryProjectId = resolveProjectScope(typeof body.cwd === 'string' && body.cwd ? body.cwd : null)
713
+ try {
714
+ const result = await db.query(`
715
+ INSERT INTO entries(ts, role, content, source_ref, session_id, project_id)
716
+ VALUES ($1, $2, $3, $4, $5, $6)
717
+ ON CONFLICT DO NOTHING
718
+ RETURNING id
719
+ `, [tsMs, role, cleaned, sourceRef, sessionId, entryProjectId])
720
+ const insertedId = result.rows[0]?.id ?? null
721
+ sendJson(res, { ok: true, id: insertedId !== null ? Number(insertedId) : null, changes: Number(result.rowCount ?? result.affectedRows ?? 0) })
722
+ } catch (e) {
723
+ sendJson(res, { error: e.message }, 500)
724
+ }
725
+ return
726
+ }
727
+
728
+ if (req.url === '/ingest-transcript') {
729
+ const filePath = body.filePath
730
+ if (!filePath) { sendJson(res, { error: 'filePath required' }, 400); return }
731
+ try {
732
+ const n = await ingestTranscriptFile(filePath, { cwd: body.cwd })
733
+ sendJson(res, { ok: true, ingested: n })
734
+ } catch (e) {
735
+ sendJson(res, { error: e.message }, 500)
736
+ }
737
+ return
738
+ }
739
+
740
+ if (req.url === '/transcript/ingest-sync') {
741
+ const filePath = body.path
742
+ if (!filePath || typeof filePath !== 'string') {
743
+ sendJson(res, { error: 'path required' }, 400)
744
+ return
745
+ }
746
+ try {
747
+ let stat
748
+ try { stat = await fs.promises.stat(filePath) } catch {
749
+ sendJson(res, { ok: true, complete: true, fileSize: 0, offsetBytes: 0 })
750
+ return
751
+ }
752
+ const fileSize = stat.size
753
+ await ingestTranscriptFile(filePath, { cwd: body.cwd })
754
+ const off = getTranscriptOffset(filePath)
755
+ const offsetBytes = off && Number.isFinite(off.bytes) ? off.bytes : 0
756
+ const complete = offsetBytes >= fileSize
757
+ sendJson(res, { ok: true, offsetBytes, fileSize, complete })
758
+ } catch (e) {
759
+ sendJson(res, { error: e.message }, 500)
760
+ }
761
+ return
762
+ }
763
+
764
+ sendJson(res, { error: 'Not found' }, 404)
765
+ } catch (e) {
766
+ log(`[memory-service] ${req.url} error: ${e.stack || e.message}\n`)
767
+ sendError(res, e.message)
768
+ }
769
+ }
770
+
771
+ return { requestHandler, buildSessionCoreMemoryPayload, createHttpMcpServer }
772
+ }