mixdog 0.9.19 → 0.9.21

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