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.
- package/LICENSE +21 -0
- package/README.md +93 -29
- package/package.json +5 -3
- package/scripts/build-runtime-windows.ps1 +242 -242
- package/scripts/build-tui.mjs +6 -0
- package/scripts/hook-bus-test.mjs +8 -0
- package/scripts/log-writer-guard-smoke.mjs +131 -0
- package/scripts/output-style-smoke.mjs +2 -2
- package/scripts/reactive-compact-persist-smoke.mjs +22 -6
- package/scripts/recall-bench-cases.json +10 -0
- package/scripts/recall-bench.mjs +91 -2
- package/scripts/recall-quality-cases.json +1 -2
- package/scripts/recall-usecase-cases.json +1 -1
- package/scripts/session-ingest-compaction-smoke.mjs +241 -0
- package/scripts/smoke-runtime-negative.ps1 +106 -106
- package/scripts/tool-efficiency-diag.mjs +5 -2
- package/scripts/tool-smoke.mjs +251 -72
- package/src/agents/debugger/AGENT.md +3 -3
- package/src/agents/heavy-worker/AGENT.md +7 -10
- package/src/agents/maintainer/AGENT.md +1 -2
- package/src/agents/reviewer/AGENT.md +1 -2
- package/src/agents/worker/AGENT.md +5 -8
- package/src/defaults/agents.json +3 -0
- package/src/help.mjs +2 -5
- package/src/lib/mixdog-debug.cjs +13 -0
- package/src/mixdog-session-runtime.mjs +7 -3311
- package/src/rules/agent/00-core.md +4 -3
- package/src/rules/agent/30-explorer.md +53 -22
- package/src/rules/lead/02-channels.md +3 -3
- package/src/rules/lead/lead-tool.md +3 -2
- package/src/rules/shared/01-tool.md +24 -29
- package/src/runtime/agent/orchestrator/context/collect.mjs +33 -9
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +24 -3
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +3 -3
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +663 -0
- package/src/runtime/agent/orchestrator/session/compact/engine.mjs +6 -0
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +2 -2
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +153 -0
- package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +49 -0
- package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +250 -35
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -1
- package/src/runtime/agent/orchestrator/session/loop.mjs +8 -1860
- package/src/runtime/agent/orchestrator/session/manager/agent-runtime-singleton.mjs +29 -0
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +686 -0
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +60 -12
- package/src/runtime/agent/orchestrator/session/manager/env-utils.mjs +8 -0
- package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +159 -0
- package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +143 -0
- package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +268 -0
- package/src/runtime/agent/orchestrator/session/manager/provider-cache-key.mjs +22 -0
- package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +26 -0
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +124 -0
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +258 -0
- package/src/runtime/agent/orchestrator/session/manager/session-errors.mjs +20 -0
- package/src/runtime/agent/orchestrator/session/manager/session-id.mjs +9 -0
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +475 -0
- package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +23 -0
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +6 -4
- package/src/runtime/agent/orchestrator/session/manager.mjs +88 -2285
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +440 -0
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +153 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +4 -1
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +642 -0
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +23 -3
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +198 -6
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +10 -2
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +13 -15
- package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +6 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +45 -3
- package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +5 -2
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +195 -3
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +108 -2
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +15 -3
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +7 -0
- package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +24 -2
- package/src/runtime/agent/orchestrator/tools/builtin.mjs +17 -1
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +38 -0
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -4
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +72 -8
- package/src/runtime/agent/orchestrator/tools/shell-policy-danger-target.mjs +5 -1
- package/src/runtime/agent/orchestrator/tools/shell-state.mjs +1 -1
- package/src/runtime/channels/backends/discord-gateway.mjs +14 -1
- package/src/runtime/channels/backends/discord.mjs +43 -1
- package/src/runtime/channels/index.mjs +6 -2096
- package/src/runtime/channels/lib/inbound-handler.mjs +328 -0
- package/src/runtime/channels/lib/inbound-routing.mjs +19 -12
- package/src/runtime/channels/lib/interaction-handlers.mjs +260 -0
- package/src/runtime/channels/lib/memory-client.mjs +32 -14
- package/src/runtime/channels/lib/network-retry.mjs +23 -0
- package/src/runtime/channels/lib/output-forwarder.mjs +38 -7
- package/src/runtime/channels/lib/owned-runtime.mjs +547 -0
- package/src/runtime/channels/lib/owner-heartbeat.mjs +13 -4
- package/src/runtime/channels/lib/runtime-paths.mjs +35 -1
- package/src/runtime/channels/lib/tool-dispatch.mjs +17 -7
- package/src/runtime/channels/lib/transcript-binding.mjs +336 -0
- package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +39 -2
- package/src/runtime/channels/lib/worker-bootstrap.mjs +97 -0
- package/src/runtime/channels/lib/worker-ipc.mjs +201 -0
- package/src/runtime/channels/lib/worker-main.mjs +771 -0
- package/src/runtime/memory/index.mjs +73 -1725
- package/src/runtime/memory/lib/embedding-provider.mjs +4 -2
- package/src/runtime/memory/lib/embedding-worker.mjs +47 -6
- package/src/runtime/memory/lib/http-router.mjs +772 -0
- package/src/runtime/memory/lib/memory-action-handlers.mjs +901 -0
- package/src/runtime/memory/lib/memory-embed.mjs +28 -7
- package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +8 -2
- package/src/runtime/memory/lib/memory-recall-store.mjs +182 -9
- package/src/runtime/memory/lib/query-handlers.mjs +38 -6
- package/src/runtime/memory/lib/recall-format.mjs +106 -6
- package/src/runtime/memory/lib/session-ingest-runtime.mjs +401 -0
- package/src/runtime/memory/lib/session-ingest.mjs +1 -1
- package/src/runtime/shared/atomic-file.mjs +10 -4
- package/src/runtime/shared/background-tasks.mjs +4 -2
- package/src/runtime/shared/tool-execution-contract.mjs +1 -1
- package/src/runtime/shared/tool-result-summary.mjs +1 -1
- package/src/runtime/shared/tool-surface.mjs +30 -1
- package/src/session-runtime/boot-profile.mjs +36 -0
- package/src/session-runtime/channel-config-api.mjs +70 -0
- package/src/session-runtime/config-lifecycle.mjs +1 -1
- package/src/session-runtime/context-status.mjs +181 -0
- package/src/session-runtime/cwd-plugins.mjs +46 -3
- package/src/session-runtime/env.mjs +17 -0
- package/src/session-runtime/lifecycle-api.mjs +242 -0
- package/src/session-runtime/mcp-glue.mjs +24 -3
- package/src/session-runtime/model-route-api.mjs +198 -0
- package/src/session-runtime/output-styles.mjs +44 -10
- package/src/session-runtime/provider-auth-api.mjs +135 -0
- package/src/session-runtime/resource-api.mjs +282 -0
- package/src/session-runtime/runtime-core.mjs +2046 -0
- package/src/session-runtime/session-turn-api.mjs +274 -0
- package/src/session-runtime/tool-catalog.mjs +18 -264
- package/src/session-runtime/tool-defs.mjs +2 -2
- package/src/session-runtime/workflow-agents-api.mjs +238 -0
- package/src/session-runtime/workflow.mjs +16 -1
- package/src/standalone/agent-tool.mjs +2 -2
- package/src/standalone/channel-worker.mjs +78 -5
- package/src/standalone/explore-tool.mjs +1 -1
- package/src/standalone/memory-runtime-proxy.mjs +56 -6
- package/src/tui/App.jsx +88 -97
- package/src/tui/app/channel-pickers.mjs +45 -0
- package/src/tui/app/core-memory-picker.mjs +1 -1
- package/src/tui/app/slash-commands.mjs +0 -1
- package/src/tui/app/slash-dispatch.mjs +0 -16
- package/src/tui/app/transcript-window.mjs +44 -1
- package/src/tui/app/use-mouse-input.mjs +15 -2
- package/src/tui/app/use-prompt-handlers.mjs +7 -94
- package/src/tui/app/use-transcript-scroll.mjs +82 -4
- package/src/tui/app/use-transcript-window.mjs +65 -5
- package/src/tui/components/PromptInput.jsx +33 -64
- package/src/tui/components/ToolExecution.jsx +2 -2
- package/src/tui/dist/index.mjs +7908 -7558
- package/src/tui/engine/context-state.mjs +145 -0
- package/src/tui/engine/prompt-history.mjs +27 -0
- package/src/tui/engine/session-api-ext.mjs +478 -0
- package/src/tui/engine/session-api.mjs +545 -0
- package/src/tui/engine/session-flow.mjs +485 -0
- package/src/tui/engine/turn.mjs +1078 -0
- package/src/tui/engine.mjs +69 -2582
- package/src/tui/index.jsx +7 -0
- package/src/tui/lib/voice-setup.mjs +166 -0
- package/src/tui/paste-attachments.mjs +12 -5
- package/src/tui/prompt-history-store.mjs +125 -12
- package/vendor/ink/build/ink.js +16 -1
- package/vendor/ink/build/output.js +30 -4
- package/vendor/ink/build/render.js +5 -0
- package/scripts/bench/cache-probe-tasks.json +0 -8
- package/scripts/bench/lead-review-tasks-r3.json +0 -20
- package/scripts/bench/lead-review-tasks.json +0 -20
- package/scripts/bench/r4-mixed-tasks.json +0 -20
- package/scripts/bench/r5-orchestrated-task.json +0 -7
- package/scripts/bench/review-tasks.json +0 -20
- package/scripts/bench/round-codex.json +0 -114
- package/scripts/bench/round-mixdog-lead-r3.json +0 -269
- package/scripts/bench/round-mixdog-lead.json +0 -269
- package/scripts/bench/round-mixdog.json +0 -126
- package/scripts/bench/round-r10-bigsample.json +0 -679
- package/scripts/bench/round-r11-codexalign.json +0 -257
- package/scripts/bench/round-r13-clientmeta.json +0 -464
- package/scripts/bench/round-r14-betafeatures.json +0 -466
- package/scripts/bench/round-r15-fulldefault.json +0 -462
- package/scripts/bench/round-r16-sessionid.json +0 -466
- package/scripts/bench/round-r17-wirebytes.json +0 -456
- package/scripts/bench/round-r18-prewarm.json +0 -468
- package/scripts/bench/round-r19-clean.json +0 -472
- package/scripts/bench/round-r20-prewarm-clean.json +0 -475
- package/scripts/bench/round-r21-delta-retry.json +0 -473
- package/scripts/bench/round-r22-full-probe.json +0 -693
- package/scripts/bench/round-r23-itemprobe.json +0 -701
- package/scripts/bench/round-r24-shapefix.json +0 -677
- package/scripts/bench/round-r25-serial.json +0 -464
- package/scripts/bench/round-r26-parallel3.json +0 -671
- package/scripts/bench/round-r27-parallel10.json +0 -894
- package/scripts/bench/round-r28-parallel10-stagger.json +0 -882
- package/scripts/bench/round-r29-parallel10-stagger166.json +0 -886
- package/scripts/bench/round-r30-instid.json +0 -253
- package/scripts/bench/round-r31-upgradeprobe.json +0 -256
- package/scripts/bench/round-r32-vs-codex-lead.json +0 -254
- package/scripts/bench/round-r33-vs-codex-codex.json +0 -115
- package/scripts/bench/round-r34-orchestrated.json +0 -120
- package/scripts/bench/round-r35-orchestrated-codex.json +0 -61
- package/scripts/bench/round-r36-orchestrated-capped.json +0 -128
- package/scripts/bench/round-r4-codex.json +0 -114
- package/scripts/bench/round-r4-mixed.json +0 -225
- package/scripts/bench/round-r5-gpt-lead.json +0 -259
- package/scripts/bench/round-r6-codex.json +0 -114
- package/scripts/bench/round-r6-solo.json +0 -257
- package/scripts/bench/round-r7-full.json +0 -254
- package/scripts/bench/round-r8-fulldefault.json +0 -255
- package/src/tui/components/prompt-input/voice-indicator.mjs +0 -39
- package/src/tui/lib/voice-recorder.mjs +0 -469
- 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
|
+
}
|