mixdog 0.7.1
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/.claude-plugin/marketplace.json +31 -0
- package/.claude-plugin/plugin.json +20 -0
- package/.gitattributes +34 -0
- package/.mcp.json +14 -0
- package/ARCHITECTURE.md +77 -0
- package/CHANGELOG.md +7 -0
- package/CONTRIBUTING.md +45 -0
- package/DATA-FLOW.md +79 -0
- package/LICENSE +21 -0
- package/README.md +389 -0
- package/SECURITY.md +138 -0
- package/UNINSTALL.md +112 -0
- package/agents/maintenance.md +5 -0
- package/agents/memory-classification.md +30 -0
- package/agents/scheduler-task.md +18 -0
- package/agents/webhook-handler.md +27 -0
- package/agents/worker.md +24 -0
- package/bin/bridge +133 -0
- package/bin/statusline-launcher.mjs +78 -0
- package/bin/statusline-lib.mjs +550 -0
- package/bin/statusline.mjs +607 -0
- package/bun.lock +802 -0
- package/commands/config.md +16 -0
- package/commands/doctor.md +13 -0
- package/commands/setup.md +17 -0
- package/defaults/cycle3-review-prompt.md +90 -0
- package/defaults/hidden-roles.json +65 -0
- package/defaults/memory-chunk-prompt.md +63 -0
- package/defaults/memory-promote-prompt.md +135 -0
- package/defaults/mixdog-config.template.json +27 -0
- package/defaults/user-workflow.json +8 -0
- package/defaults/user-workflow.md +12 -0
- package/hooks/hooks.json +73 -0
- package/hooks/lib/active-instance.cjs +77 -0
- package/hooks/lib/permission-evaluator.cjs +411 -0
- package/hooks/lib/permission-route.cjs +63 -0
- package/hooks/lib/permission-rules.cjs +170 -0
- package/hooks/lib/settings-loader.cjs +116 -0
- package/hooks/post-tool-use.cjs +84 -0
- package/hooks/pre-mcp-sandbox.cjs +158 -0
- package/hooks/pre-tool-subagent.cjs +253 -0
- package/hooks/session-start.cjs +1372 -0
- package/hooks/turn-timer.cjs +82 -0
- package/lib/claude-md-writer.cjs +386 -0
- package/lib/config-cjs.cjs +61 -0
- package/lib/hook-pipe-path.cjs +10 -0
- package/lib/keychain-cjs.cjs +263 -0
- package/lib/plugin-paths.cjs +61 -0
- package/lib/rules-builder.cjs +241 -0
- package/lib/text-utils.cjs +61 -0
- package/native/README.md +117 -0
- package/native/prebuilt/linux-aarch64/mixdog-shim +0 -0
- package/native/prebuilt/linux-x86_64/mixdog-shim +0 -0
- package/native/prebuilt/macos-aarch64/mixdog-shim +0 -0
- package/native/prebuilt/macos-x86_64/mixdog-shim +0 -0
- package/native/prebuilt/windows-x86_64/mixdog-shim.exe +0 -0
- package/package.json +107 -0
- package/prompts/code-review.txt +16 -0
- package/prompts/security-audit.txt +17 -0
- package/rules/bridge/00-common.md +39 -0
- package/rules/bridge/20-skip-protocol.md +18 -0
- package/rules/bridge/30-explorer.md +33 -0
- package/rules/bridge/40-cycle1-agent.md +52 -0
- package/rules/bridge/41-cycle2-agent.md +62 -0
- package/rules/bridge/42-cycle3-agent.md +44 -0
- package/rules/lead/00-tool-lead.md +61 -0
- package/rules/lead/01-general.md +23 -0
- package/rules/lead/02-channels.md +49 -0
- package/rules/lead/03-team.md +27 -0
- package/rules/lead/04-workflow.md +20 -0
- package/rules/shared/00-language.md +14 -0
- package/rules/shared/01-tool.md +138 -0
- package/scripts/bootstrap.mjs +184 -0
- package/scripts/bridge-unify-smoke.mjs +308 -0
- package/scripts/build-runtime-linux.sh +348 -0
- package/scripts/build-runtime-macos.sh +217 -0
- package/scripts/build-runtime-windows.ps1 +242 -0
- package/scripts/builtin-utils-smoke.mjs +392 -0
- package/scripts/check-json.mjs +45 -0
- package/scripts/check-syntax-changed.mjs +102 -0
- package/scripts/check-syntax.mjs +58 -0
- package/scripts/code-graph-batch.test.mjs +33 -0
- package/scripts/config-preserve-smoke.mjs +180 -0
- package/scripts/doctor.mjs +484 -0
- package/scripts/edit-normalize-fuzz.mjs +130 -0
- package/scripts/edit-normalize-smoke.mjs +401 -0
- package/scripts/edit-operation-smoke.mjs +369 -0
- package/scripts/edit2-smoke.mjs +63 -0
- package/scripts/fuzzy-e2e.mjs +28 -0
- package/scripts/fuzzy-smoke.mjs +26 -0
- package/scripts/generate-runtime-manifest.mjs +166 -0
- package/scripts/guard-smoke.mjs +66 -0
- package/scripts/hidden-role-schema-smoke.mjs +162 -0
- package/scripts/hook-routing-smoke.mjs +29 -0
- package/scripts/inject-input.ps1 +204 -0
- package/scripts/io-complex-smoke.mjs +667 -0
- package/scripts/io-explore-bench.mjs +424 -0
- package/scripts/io-guardrails-smoke.mjs +205 -0
- package/scripts/io-mini-bench-baseline.json +11 -0
- package/scripts/io-mini-bench.mjs +216 -0
- package/scripts/io-route-harness.mjs +933 -0
- package/scripts/io-telemetry-report.mjs +691 -0
- package/scripts/mutation-bench.mjs +564 -0
- package/scripts/mutation-io-smoke.mjs +1081 -0
- package/scripts/native-patch-bridge-smoke.mjs +288 -0
- package/scripts/native-patch-smoke.mjs +304 -0
- package/scripts/patch-interior-context-smoke.mjs +49 -0
- package/scripts/patch-newline-utf8-smoke.mjs +157 -0
- package/scripts/perf-hook-smoke.mjs +71 -0
- package/scripts/permission-eval-smoke.mjs +426 -0
- package/scripts/prep-patch.mjs +53 -0
- package/scripts/prep-shim.mjs +96 -0
- package/scripts/provider-cache-smoke.mjs +687 -0
- package/scripts/report-runtime-health.mjs +132 -0
- package/scripts/run-mcp.mjs +1547 -0
- package/scripts/salvage-v4a-shatter.test.mjs +58 -0
- package/scripts/scoped-cache-io-smoke.mjs +103 -0
- package/scripts/shell-policy-round3-smoke.mjs +46 -0
- package/scripts/smoke-runtime-negative.ps1 +100 -0
- package/scripts/smoke-runtime-negative.sh +95 -0
- package/scripts/stall-policy-smoke.mjs +50 -0
- package/scripts/start-memory-worker.mjs +23 -0
- package/scripts/statusline-launcher-smoke.mjs +82 -0
- package/scripts/stress-atomic-write.mjs +1028 -0
- package/scripts/test-config-rmw-restore.mjs +122 -0
- package/scripts/test-fault-inject.mjs +164 -0
- package/scripts/test-large-file.mjs +174 -0
- package/scripts/tool-edge-smoke.mjs +209 -0
- package/scripts/uninstall.mjs +201 -0
- package/scripts/webhook-selfheal-smoke.mjs +29 -0
- package/scripts/write-overwrite-guard-smoke.mjs +56 -0
- package/server-main.mjs +3055 -0
- package/server.mjs +468 -0
- package/setup/config-merge.mjs +254 -0
- package/setup/install.mjs +120 -0
- package/setup/launch-core.mjs +507 -0
- package/setup/launch.mjs +101 -0
- package/setup/setup-server.mjs +3206 -0
- package/setup/setup.html +3693 -0
- package/skills/retro-skill-proposer/SKILL.md +92 -0
- package/skills/schedule-add/SKILL.md +77 -0
- package/skills/setup/SKILL.md +346 -0
- package/skills/webhook-add/SKILL.md +81 -0
- package/src/agent/bridge-stall-watchdog.mjs +337 -0
- package/src/agent/index.mjs +2138 -0
- package/src/agent/orchestrator/activity-bus.mjs +38 -0
- package/src/agent/orchestrator/ai-wrapped-dispatch.mjs +1010 -0
- package/src/agent/orchestrator/bridge-retry.mjs +220 -0
- package/src/agent/orchestrator/bridge-trace.mjs +583 -0
- package/src/agent/orchestrator/cache-mtime.mjs +58 -0
- package/src/agent/orchestrator/config.mjs +358 -0
- package/src/agent/orchestrator/context/collect.mjs +651 -0
- package/src/agent/orchestrator/dispatch-persist.mjs +549 -0
- package/src/agent/orchestrator/drain-registry.mjs +50 -0
- package/src/agent/orchestrator/explore-validator.mjs +8 -0
- package/src/agent/orchestrator/internal-roles.mjs +118 -0
- package/src/agent/orchestrator/internal-tools.mjs +88 -0
- package/src/agent/orchestrator/jobs.mjs +116 -0
- package/src/agent/orchestrator/mcp/client.mjs +364 -0
- package/src/agent/orchestrator/providers/anthropic-betas.mjs +21 -0
- package/src/agent/orchestrator/providers/anthropic-oauth.mjs +1745 -0
- package/src/agent/orchestrator/providers/anthropic.mjs +437 -0
- package/src/agent/orchestrator/providers/gemini.mjs +1175 -0
- package/src/agent/orchestrator/providers/grok-oauth.mjs +782 -0
- package/src/agent/orchestrator/providers/model-catalog.mjs +241 -0
- package/src/agent/orchestrator/providers/openai-compat.mjs +1467 -0
- package/src/agent/orchestrator/providers/openai-oauth-ws.mjs +1890 -0
- package/src/agent/orchestrator/providers/openai-oauth.mjs +1307 -0
- package/src/agent/orchestrator/providers/openai-ws.mjs +104 -0
- package/src/agent/orchestrator/providers/registry.mjs +192 -0
- package/src/agent/orchestrator/providers/retry-classifier.mjs +325 -0
- package/src/agent/orchestrator/session/abort-lookup.mjs +13 -0
- package/src/agent/orchestrator/session/cache/post-edit-marks.mjs +42 -0
- package/src/agent/orchestrator/session/cache/prefetch-cache.mjs +142 -0
- package/src/agent/orchestrator/session/cache/read-cache.mjs +319 -0
- package/src/agent/orchestrator/session/cache/scoped-cache-outcome.mjs +11 -0
- package/src/agent/orchestrator/session/cache/scoped-cache.mjs +361 -0
- package/src/agent/orchestrator/session/cache/util.mjs +49 -0
- package/src/agent/orchestrator/session/loop.mjs +1478 -0
- package/src/agent/orchestrator/session/manager.mjs +1975 -0
- package/src/agent/orchestrator/session/read-dedup.mjs +6 -0
- package/src/agent/orchestrator/session/result-classification.mjs +65 -0
- package/src/agent/orchestrator/session/save-session-worker.mjs +18 -0
- package/src/agent/orchestrator/session/store.mjs +624 -0
- package/src/agent/orchestrator/session/stream-watchdog.mjs +130 -0
- package/src/agent/orchestrator/session/tool-result-offload.mjs +166 -0
- package/src/agent/orchestrator/session/trim.mjs +491 -0
- package/src/agent/orchestrator/smart-bridge/CACHE-SHARD.md +115 -0
- package/src/agent/orchestrator/smart-bridge/bridge-llm.mjs +327 -0
- package/src/agent/orchestrator/smart-bridge/cache-obs.mjs +150 -0
- package/src/agent/orchestrator/smart-bridge/cache-strategy.mjs +228 -0
- package/src/agent/orchestrator/smart-bridge/index.mjs +215 -0
- package/src/agent/orchestrator/smart-bridge/profiles.mjs +37 -0
- package/src/agent/orchestrator/smart-bridge/registry.mjs +348 -0
- package/src/agent/orchestrator/smart-bridge/session-builder.mjs +116 -0
- package/src/agent/orchestrator/stall-policy.mjs +195 -0
- package/src/agent/orchestrator/tool-loop-guard.mjs +75 -0
- package/src/agent/orchestrator/tools/bash-policy-scan.mjs +77 -0
- package/src/agent/orchestrator/tools/bash-session.mjs +721 -0
- package/src/agent/orchestrator/tools/builtin/advisory-lock.mjs +171 -0
- package/src/agent/orchestrator/tools/builtin/arg-guard.mjs +455 -0
- package/src/agent/orchestrator/tools/builtin/atomic-write.mjs +236 -0
- package/src/agent/orchestrator/tools/builtin/bash-tool.mjs +480 -0
- package/src/agent/orchestrator/tools/builtin/binary-file.mjs +76 -0
- package/src/agent/orchestrator/tools/builtin/builtin-tools.mjs +256 -0
- package/src/agent/orchestrator/tools/builtin/cache-layers.mjs +386 -0
- package/src/agent/orchestrator/tools/builtin/cwd-utils.mjs +37 -0
- package/src/agent/orchestrator/tools/builtin/device-paths.mjs +154 -0
- package/src/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +292 -0
- package/src/agent/orchestrator/tools/builtin/diff-utils.mjs +109 -0
- package/src/agent/orchestrator/tools/builtin/edit-base-guard.mjs +58 -0
- package/src/agent/orchestrator/tools/builtin/edit-byte-plan.mjs +240 -0
- package/src/agent/orchestrator/tools/builtin/edit-byte-utils.mjs +113 -0
- package/src/agent/orchestrator/tools/builtin/edit-commit.mjs +74 -0
- package/src/agent/orchestrator/tools/builtin/edit-context-utils.mjs +242 -0
- package/src/agent/orchestrator/tools/builtin/edit-diagnostics.mjs +211 -0
- package/src/agent/orchestrator/tools/builtin/edit-engine.mjs +1364 -0
- package/src/agent/orchestrator/tools/builtin/edit-failure-context.mjs +126 -0
- package/src/agent/orchestrator/tools/builtin/edit-hint.mjs +141 -0
- package/src/agent/orchestrator/tools/builtin/edit-match-utils.mjs +194 -0
- package/src/agent/orchestrator/tools/builtin/edit-partial-write.mjs +60 -0
- package/src/agent/orchestrator/tools/builtin/edit-stale-refresh.mjs +168 -0
- package/src/agent/orchestrator/tools/builtin/edit-tool.mjs +173 -0
- package/src/agent/orchestrator/tools/builtin/edit-utf8-guard.mjs +48 -0
- package/src/agent/orchestrator/tools/builtin/fs-reachability.mjs +48 -0
- package/src/agent/orchestrator/tools/builtin/fuzzy-match.mjs +99 -0
- package/src/agent/orchestrator/tools/builtin/glob-walk.mjs +170 -0
- package/src/agent/orchestrator/tools/builtin/grep-formatting.mjs +113 -0
- package/src/agent/orchestrator/tools/builtin/hash-utils.mjs +6 -0
- package/src/agent/orchestrator/tools/builtin/list-formatting.mjs +7 -0
- package/src/agent/orchestrator/tools/builtin/list-tool.mjs +593 -0
- package/src/agent/orchestrator/tools/builtin/native-edit-runner.mjs +89 -0
- package/src/agent/orchestrator/tools/builtin/notebook-edit-tool.mjs +300 -0
- package/src/agent/orchestrator/tools/builtin/open-config-tool.mjs +26 -0
- package/src/agent/orchestrator/tools/builtin/path-diagnostics.mjs +152 -0
- package/src/agent/orchestrator/tools/builtin/path-locks.mjs +35 -0
- package/src/agent/orchestrator/tools/builtin/path-utils.mjs +201 -0
- package/src/agent/orchestrator/tools/builtin/read-args.mjs +103 -0
- package/src/agent/orchestrator/tools/builtin/read-batch.mjs +172 -0
- package/src/agent/orchestrator/tools/builtin/read-constants.mjs +40 -0
- package/src/agent/orchestrator/tools/builtin/read-formatting.mjs +118 -0
- package/src/agent/orchestrator/tools/builtin/read-image-resize.mjs +189 -0
- package/src/agent/orchestrator/tools/builtin/read-image.mjs +88 -0
- package/src/agent/orchestrator/tools/builtin/read-lines.mjs +12 -0
- package/src/agent/orchestrator/tools/builtin/read-mode-tool.mjs +455 -0
- package/src/agent/orchestrator/tools/builtin/read-open.mjs +190 -0
- package/src/agent/orchestrator/tools/builtin/read-range-index.mjs +271 -0
- package/src/agent/orchestrator/tools/builtin/read-ranges.mjs +26 -0
- package/src/agent/orchestrator/tools/builtin/read-single-tool.mjs +728 -0
- package/src/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +173 -0
- package/src/agent/orchestrator/tools/builtin/read-special-files.mjs +268 -0
- package/src/agent/orchestrator/tools/builtin/read-streaming.mjs +602 -0
- package/src/agent/orchestrator/tools/builtin/read-tool.mjs +530 -0
- package/src/agent/orchestrator/tools/builtin/read-windows.mjs +107 -0
- package/src/agent/orchestrator/tools/builtin/rename-tool.mjs +196 -0
- package/src/agent/orchestrator/tools/builtin/rg-runner.mjs +422 -0
- package/src/agent/orchestrator/tools/builtin/search-builders.mjs +158 -0
- package/src/agent/orchestrator/tools/builtin/search-tool.mjs +869 -0
- package/src/agent/orchestrator/tools/builtin/shell-analysis.mjs +653 -0
- package/src/agent/orchestrator/tools/builtin/shell-jobs.mjs +936 -0
- package/src/agent/orchestrator/tools/builtin/shell-output.mjs +36 -0
- package/src/agent/orchestrator/tools/builtin/shell-runtime.mjs +214 -0
- package/src/agent/orchestrator/tools/builtin/snapshot-helpers.mjs +143 -0
- package/src/agent/orchestrator/tools/builtin/snapshot-store.mjs +206 -0
- package/src/agent/orchestrator/tools/builtin/snapshot-validation.mjs +98 -0
- package/src/agent/orchestrator/tools/builtin/text-stats.mjs +69 -0
- package/src/agent/orchestrator/tools/builtin/windows-roots.mjs +23 -0
- package/src/agent/orchestrator/tools/builtin/write-tool.mjs +401 -0
- package/src/agent/orchestrator/tools/builtin.mjs +500 -0
- package/src/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +39 -0
- package/src/agent/orchestrator/tools/code-graph-tool-defs.mjs +24 -0
- package/src/agent/orchestrator/tools/code-graph.mjs +4095 -0
- package/src/agent/orchestrator/tools/cwd-tool.mjs +298 -0
- package/src/agent/orchestrator/tools/destructive-warning.mjs +323 -0
- package/src/agent/orchestrator/tools/edit-normalize.mjs +603 -0
- package/src/agent/orchestrator/tools/env-scrub.mjs +100 -0
- package/src/agent/orchestrator/tools/graph-binary-fetcher.mjs +144 -0
- package/src/agent/orchestrator/tools/graph-manifest.json +26 -0
- package/src/agent/orchestrator/tools/host-input.mjs +204 -0
- package/src/agent/orchestrator/tools/mutation-content-cache.mjs +67 -0
- package/src/agent/orchestrator/tools/mutation-planner.mjs +75 -0
- package/src/agent/orchestrator/tools/next-call-utils.mjs +48 -0
- package/src/agent/orchestrator/tools/patch-binary-fetcher.mjs +133 -0
- package/src/agent/orchestrator/tools/patch-manifest.json +26 -0
- package/src/agent/orchestrator/tools/patch-tool-defs.mjs +20 -0
- package/src/agent/orchestrator/tools/patch.mjs +2754 -0
- package/src/agent/orchestrator/tools/progress-message.mjs +118 -0
- package/src/agent/orchestrator/tools/result-compression.mjs +279 -0
- package/src/agent/orchestrator/tools/shell-command.mjs +865 -0
- package/src/agent/orchestrator/tools/shell-exec-policy.mjs +89 -0
- package/src/agent/orchestrator/tools/shell-policy-danger-target.mjs +27 -0
- package/src/agent/orchestrator/tools/shell-policy-imports.mjs +7 -0
- package/src/agent/orchestrator/tools/shell-policy.mjs +345 -0
- package/src/agent/orchestrator/tools/shell-snapshot.mjs +313 -0
- package/src/agent/orchestrator/workflow-store.mjs +93 -0
- package/src/agent/tool-defs.mjs +103 -0
- package/src/channels/backends/discord.mjs +784 -0
- package/src/channels/data/voice-runtime-manifest.json +138 -0
- package/src/channels/index.mjs +3229 -0
- package/src/channels/lib/cli-worker-host.mjs +12 -0
- package/src/channels/lib/config-lock.mjs +13 -0
- package/src/channels/lib/config.mjs +292 -0
- package/src/channels/lib/drop-trace.mjs +71 -0
- package/src/channels/lib/event-pipeline.mjs +81 -0
- package/src/channels/lib/event-queue.mjs +345 -0
- package/src/channels/lib/executor.mjs +168 -0
- package/src/channels/lib/format.mjs +188 -0
- package/src/channels/lib/holidays.mjs +138 -0
- package/src/channels/lib/hook-pipe-server.mjs +802 -0
- package/src/channels/lib/interaction-workflows.mjs +184 -0
- package/src/channels/lib/memory-client.mjs +149 -0
- package/src/channels/lib/output-forwarder.mjs +765 -0
- package/src/channels/lib/runtime-paths.mjs +479 -0
- package/src/channels/lib/scheduler.mjs +723 -0
- package/src/channels/lib/session-control.mjs +36 -0
- package/src/channels/lib/session-discovery.mjs +103 -0
- package/src/channels/lib/settings.mjs +11 -0
- package/src/channels/lib/state-file.mjs +68 -0
- package/src/channels/lib/status-snapshot.mjs +219 -0
- package/src/channels/lib/tool-format.mjs +140 -0
- package/src/channels/lib/transcript-discovery.mjs +195 -0
- package/src/channels/lib/voice-runtime-fetcher.mjs +734 -0
- package/src/channels/lib/webhook.mjs +1179 -0
- package/src/channels/lib/whisper-server.mjs +477 -0
- package/src/channels/tool-defs.mjs +170 -0
- package/src/daemon/host.mjs +118 -0
- package/src/daemon/mcp-transport.mjs +47 -0
- package/src/daemon/session.mjs +100 -0
- package/src/daemon/thin-client.mjs +71 -0
- package/src/daemon/transport.mjs +163 -0
- package/src/memory/data/runtime-manifest.json +40 -0
- package/src/memory/index.mjs +3305 -0
- package/src/memory/lib/agent-ipc.mjs +93 -0
- package/src/memory/lib/bridge-trace-queries.mjs +120 -0
- package/src/memory/lib/core-memory-store.mjs +330 -0
- package/src/memory/lib/embedding-provider.mjs +269 -0
- package/src/memory/lib/embedding-worker.mjs +323 -0
- package/src/memory/lib/llm-worker-host.mjs +17 -0
- package/src/memory/lib/memory-cycle.mjs +11 -0
- package/src/memory/lib/memory-cycle1.mjs +641 -0
- package/src/memory/lib/memory-cycle2.mjs +1284 -0
- package/src/memory/lib/memory-cycle3.mjs +540 -0
- package/src/memory/lib/memory-embed.mjs +299 -0
- package/src/memory/lib/memory-extraction.mjs +5 -0
- package/src/memory/lib/memory-maintenance-store.mjs +32 -0
- package/src/memory/lib/memory-ops-policy.mjs +190 -0
- package/src/memory/lib/memory-recall-id-patch.mjs +15 -0
- package/src/memory/lib/memory-recall-read-query.mjs +7 -0
- package/src/memory/lib/memory-recall-scope-filter.mjs +63 -0
- package/src/memory/lib/memory-recall-store.mjs +621 -0
- package/src/memory/lib/memory-retrievers.mjs +112 -0
- package/src/memory/lib/memory-score.mjs +71 -0
- package/src/memory/lib/memory-text-utils.mjs +58 -0
- package/src/memory/lib/memory.mjs +412 -0
- package/src/memory/lib/model-profile.mjs +85 -0
- package/src/memory/lib/pg/adapter.mjs +308 -0
- package/src/memory/lib/pg/process.mjs +360 -0
- package/src/memory/lib/pg/supervisor.mjs +396 -0
- package/src/memory/lib/project-id-resolver.mjs +86 -0
- package/src/memory/lib/runtime-fetcher.mjs +442 -0
- package/src/memory/lib/trace-store.mjs +728 -0
- package/src/memory/tool-defs.mjs +79 -0
- package/src/search/index.mjs +1173 -0
- package/src/search/lib/backends/anthropic-oauth.mjs +98 -0
- package/src/search/lib/backends/exa.mjs +50 -0
- package/src/search/lib/backends/firecrawl.mjs +61 -0
- package/src/search/lib/backends/gemini-api.mjs +83 -0
- package/src/search/lib/backends/grok-oauth.mjs +86 -0
- package/src/search/lib/backends/index.mjs +150 -0
- package/src/search/lib/backends/openai-api.mjs +144 -0
- package/src/search/lib/backends/openai-oauth.mjs +98 -0
- package/src/search/lib/backends/openai-web-search.mjs +76 -0
- package/src/search/lib/backends/tavily.mjs +55 -0
- package/src/search/lib/backends/xai-api.mjs +113 -0
- package/src/search/lib/cache.mjs +131 -0
- package/src/search/lib/config.mjs +192 -0
- package/src/search/lib/formatter.mjs +115 -0
- package/src/search/lib/provider-usage.mjs +67 -0
- package/src/search/lib/providers.mjs +47 -0
- package/src/search/lib/search-intent.mjs +109 -0
- package/src/search/lib/setup-handler.mjs +261 -0
- package/src/search/lib/state.mjs +201 -0
- package/src/search/lib/web-tools.mjs +1207 -0
- package/src/search/tool-defs.mjs +83 -0
- package/src/setup/defender-exclusion.mjs +183 -0
- package/src/shared/abort-controller.mjs +15 -0
- package/src/shared/atomic-file.mjs +420 -0
- package/src/shared/config.mjs +350 -0
- package/src/shared/daemon-recycle.mjs +108 -0
- package/src/shared/disable-claude-builtins.mjs +88 -0
- package/src/shared/err-text.mjs +12 -0
- package/src/shared/llm/cost.mjs +66 -0
- package/src/shared/llm/http-agent.mjs +123 -0
- package/src/shared/llm/index.mjs +41 -0
- package/src/shared/llm/pid-cleanup.mjs +27 -0
- package/src/shared/llm/usage-log.mjs +47 -0
- package/src/shared/plugin-paths.mjs +58 -0
- package/src/shared/schedules-store.mjs +70 -0
- package/src/shared/seed.mjs +119 -0
- package/src/shared/user-cwd.mjs +213 -0
- package/src/shared/user-data-guard.mjs +238 -0
- package/src/status/aggregator.mjs +584 -0
- package/src/status/server.mjs +413 -0
- package/tools.json +1653 -0
|
@@ -0,0 +1,3305 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
process.removeAllListeners('warning')
|
|
3
|
+
process.on('warning', () => {})
|
|
4
|
+
|
|
5
|
+
import http from 'node:http'
|
|
6
|
+
import crypto from 'node:crypto'
|
|
7
|
+
import os from 'node:os'
|
|
8
|
+
import fs from 'node:fs'
|
|
9
|
+
import path from 'node:path'
|
|
10
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
11
|
+
|
|
12
|
+
const PLUGIN_ROOT = process.env.CLAUDE_PLUGIN_ROOT ?? path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
13
|
+
|
|
14
|
+
function readPluginVersion() {
|
|
15
|
+
try {
|
|
16
|
+
const manifestPath = path.join(PLUGIN_ROOT, '.claude-plugin', 'plugin.json')
|
|
17
|
+
return JSON.parse(fs.readFileSync(manifestPath, 'utf8')).version || '0.0.1'
|
|
18
|
+
} catch { return '0.0.1' }
|
|
19
|
+
}
|
|
20
|
+
const PLUGIN_VERSION = readPluginVersion()
|
|
21
|
+
const PROMOTION_FINGERPRINT_ROOTS = ['src/memory']
|
|
22
|
+
function collectPromotionFingerprintFiles() {
|
|
23
|
+
const out = []
|
|
24
|
+
const walk = (relDir) => {
|
|
25
|
+
let entries = []
|
|
26
|
+
try { entries = fs.readdirSync(path.join(PLUGIN_ROOT, relDir), { withFileTypes: true }) }
|
|
27
|
+
catch { return }
|
|
28
|
+
for (const ent of entries) {
|
|
29
|
+
const rel = `${relDir}/${ent.name}`.replace(/\\/g, '/')
|
|
30
|
+
if (ent.isDirectory()) {
|
|
31
|
+
walk(rel)
|
|
32
|
+
} else if (ent.isFile() && rel.endsWith('.mjs')) {
|
|
33
|
+
out.push(rel)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
for (const root of PROMOTION_FINGERPRINT_ROOTS) walk(root)
|
|
38
|
+
return out.sort()
|
|
39
|
+
}
|
|
40
|
+
function readPromotionCodeFingerprint() {
|
|
41
|
+
const hash = crypto.createHash('sha256')
|
|
42
|
+
const files = collectPromotionFingerprintFiles()
|
|
43
|
+
for (const rel of files) {
|
|
44
|
+
hash.update(rel)
|
|
45
|
+
hash.update('\0')
|
|
46
|
+
try {
|
|
47
|
+
hash.update(fs.readFileSync(path.join(PLUGIN_ROOT, rel)))
|
|
48
|
+
} catch {
|
|
49
|
+
hash.update('missing')
|
|
50
|
+
}
|
|
51
|
+
hash.update('\0')
|
|
52
|
+
}
|
|
53
|
+
return `src/memory:${files.length}:${hash.digest('hex').slice(0, 16)}`
|
|
54
|
+
}
|
|
55
|
+
const BOOT_PROMOTION_CODE_FINGERPRINT = readPromotionCodeFingerprint()
|
|
56
|
+
function promotionCodeChangedOnDisk() {
|
|
57
|
+
return readPromotionCodeFingerprint() !== BOOT_PROMOTION_CODE_FINGERPRINT
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try { os.setPriority(os.constants.priority.PRIORITY_BELOW_NORMAL) } catch {}
|
|
61
|
+
try {
|
|
62
|
+
const { env } = await import('@huggingface/transformers')
|
|
63
|
+
env.backends.onnx.wasm.numThreads = 1
|
|
64
|
+
} catch {}
|
|
65
|
+
|
|
66
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
|
67
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
68
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
|
|
69
|
+
import {
|
|
70
|
+
ListToolsRequestSchema,
|
|
71
|
+
CallToolRequestSchema,
|
|
72
|
+
} from '@modelcontextprotocol/sdk/types.js'
|
|
73
|
+
|
|
74
|
+
import { TOOL_DEFS } from './tool-defs.mjs'
|
|
75
|
+
|
|
76
|
+
import {
|
|
77
|
+
openDatabase,
|
|
78
|
+
closeDatabase,
|
|
79
|
+
isBootstrapComplete,
|
|
80
|
+
getMetaValue,
|
|
81
|
+
setMetaValue,
|
|
82
|
+
cleanMemoryText,
|
|
83
|
+
} from './lib/memory.mjs'
|
|
84
|
+
import { configureEmbedding, embedText, embedTexts, getEmbeddingDims, getEmbeddingModelId, getKnownDimsForCurrentModel, primeEmbeddingDims, warmupEmbeddingProvider } from './lib/embedding-provider.mjs'
|
|
85
|
+
import { startLlmWorker, stopLlmWorker } from './lib/llm-worker-host.mjs'
|
|
86
|
+
import { runCycle1, runCycle2, runCycle3, runUnifiedGate, parseInterval, syncRootEmbedding, applySimpleStatus, applyUpdate, applyMerge, CYCLE2_ACTIVE_TARGET_CAP } from './lib/memory-cycle.mjs'
|
|
87
|
+
import { getInFlightCycle1 } from './lib/memory-cycle1.mjs'
|
|
88
|
+
import { searchRelevantHybrid } from './lib/memory-recall-store.mjs'
|
|
89
|
+
import { fetchEntriesByIdsScoped } from './lib/memory-recall-id-patch.mjs'
|
|
90
|
+
import { retrieveEntries } from './lib/memory-retrievers.mjs'
|
|
91
|
+
import { pruneOldEntries } from './lib/memory-maintenance-store.mjs'
|
|
92
|
+
import { computeEntryScore } from './lib/memory-score.mjs'
|
|
93
|
+
import { runFullBackfill } from './lib/memory-ops-policy.mjs'
|
|
94
|
+
import { listCore, addCore, editCore, deleteCore, compactCoreIds, CORE_SUMMARY_MAX } from './lib/core-memory-store.mjs'
|
|
95
|
+
import { resolveProjectId, resolveProjectScope } from './lib/project-id-resolver.mjs'
|
|
96
|
+
import { openTraceDatabase, insertTraceEvents, enqueueTraceEvents, insertBridgeCalls, registerTraceExitDrain } from './lib/trace-store.mjs'
|
|
97
|
+
import { withFileLockSync, writeJsonAtomicSync } from '../shared/atomic-file.mjs'
|
|
98
|
+
const DATA_DIR = process.env.CLAUDE_PLUGIN_DATA || process.argv[2] || null
|
|
99
|
+
if (!DATA_DIR) {
|
|
100
|
+
process.stderr.write('[memory-service] CLAUDE_PLUGIN_DATA not set and no explicit data dir provided\n')
|
|
101
|
+
process.exit(1)
|
|
102
|
+
}
|
|
103
|
+
process.stderr.write(`[memory-service] DATA_DIR=${DATA_DIR}\n`)
|
|
104
|
+
|
|
105
|
+
import { execFileSync } from 'child_process'
|
|
106
|
+
|
|
107
|
+
const RUNTIME_ROOT = process.env.MIXDOG_RUNTIME_ROOT
|
|
108
|
+
? path.resolve(process.env.MIXDOG_RUNTIME_ROOT)
|
|
109
|
+
: path.join(os.tmpdir(), 'mixdog')
|
|
110
|
+
|
|
111
|
+
let _periodicAdvertiseInstalled = false
|
|
112
|
+
// Track the most recently advertised port so the periodic tick re-reads it
|
|
113
|
+
// every interval. Without this the setInterval closure binds the FIRST port
|
|
114
|
+
// (the upstream we proxied to) and keeps re-advertising the dead upstream
|
|
115
|
+
// port after fork-proxy promotion swaps in our own locally-bound port.
|
|
116
|
+
let _currentAdvertisedPort = null
|
|
117
|
+
|
|
118
|
+
function parsePositivePid(value) {
|
|
119
|
+
const pid = Number(value)
|
|
120
|
+
return Number.isFinite(pid) && pid > 0 ? pid : null
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const MEMORY_SERVER_PID = parsePositivePid(process.env.MIXDOG_SERVER_PID)
|
|
124
|
+
|
|
125
|
+
function advertiseMemoryPort(boundPort, attempt = 0) {
|
|
126
|
+
if (!Number.isFinite(boundPort) || boundPort <= 0) return
|
|
127
|
+
_currentAdvertisedPort = boundPort
|
|
128
|
+
const dir = RUNTIME_ROOT
|
|
129
|
+
const file = path.join(dir, 'active-instance.json')
|
|
130
|
+
try {
|
|
131
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
132
|
+
withFileLockSync(`${file}.lock`, () => {
|
|
133
|
+
let cur = {}
|
|
134
|
+
try { cur = JSON.parse(fs.readFileSync(file, 'utf8')) } catch {}
|
|
135
|
+
const curMemPort = Number(cur?.memory_port)
|
|
136
|
+
const curMemPid = parsePositivePid(cur?.memory_server_pid)
|
|
137
|
+
const portConflict = Number.isFinite(curMemPort) && curMemPort > 0 && curMemPort !== boundPort
|
|
138
|
+
const otherOwnerAlive =
|
|
139
|
+
curMemPid != null &&
|
|
140
|
+
curMemPid !== MEMORY_SERVER_PID &&
|
|
141
|
+
_isPidAliveLocal(curMemPid)
|
|
142
|
+
if (portConflict && otherOwnerAlive) {
|
|
143
|
+
process.stderr.write(`[memory-service] skip memory_port advertise port=${boundPort} curMemPort=${curMemPort} curMemPid=${curMemPid} memoryServerPid=${MEMORY_SERVER_PID}\n`)
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
const next = {
|
|
147
|
+
...cur,
|
|
148
|
+
memory_port: boundPort,
|
|
149
|
+
...(MEMORY_SERVER_PID ? { memory_server_pid: MEMORY_SERVER_PID } : {}),
|
|
150
|
+
updatedAt: Date.now(),
|
|
151
|
+
}
|
|
152
|
+
writeJsonAtomicSync(file, next, { compact: true, fsyncDir: true })
|
|
153
|
+
})
|
|
154
|
+
if (!_periodicAdvertiseInstalled) {
|
|
155
|
+
_periodicAdvertiseInstalled = true
|
|
156
|
+
setInterval(() => {
|
|
157
|
+
try {
|
|
158
|
+
if (_currentAdvertisedPort != null) {
|
|
159
|
+
advertiseMemoryPort(_currentAdvertisedPort)
|
|
160
|
+
}
|
|
161
|
+
} catch {}
|
|
162
|
+
}, 30_000).unref()
|
|
163
|
+
}
|
|
164
|
+
} catch (e) {
|
|
165
|
+
const transient = e?.code === 'EPERM' || e?.code === 'EBUSY' || e?.code === 'EACCES'
|
|
166
|
+
if (transient && attempt < 3) {
|
|
167
|
+
setTimeout(() => advertiseMemoryPort(boundPort, attempt + 1), 50 * (attempt + 1))
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
process.stderr.write(`[memory-service] active-instance memory_port advertise failed: ${e?.message || e}\n`)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const LOCK_FILE = path.join(DATA_DIR, '.memory-service.lock')
|
|
175
|
+
// Owner-election lock. Separate from LOCK_FILE so single-instance mode keeps
|
|
176
|
+
// its kill-the-previous protocol while multi-instance fork-proxy workers use
|
|
177
|
+
// atomic CAS for takeover. Created via fs.openSync(path,'wx') — node guarantees
|
|
178
|
+
// EEXIST when another process won the race.
|
|
179
|
+
const OWNER_LOCK_FILE = path.join(DATA_DIR, '.memory-owner.lock')
|
|
180
|
+
|
|
181
|
+
function _isPidAliveLocal(pid) {
|
|
182
|
+
if (!Number.isFinite(pid) || pid <= 0) return false
|
|
183
|
+
try { process.kill(pid, 0); return true }
|
|
184
|
+
catch (e) { return e.code !== 'ESRCH' }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function tryAcquireMemoryOwnerLock() {
|
|
188
|
+
// Returns true on success (this process now owns memory worker for the data
|
|
189
|
+
// dir), false when a live peer holds the lock. Stale locks (dead PID) are
|
|
190
|
+
// unlinked and retried atomically. Throws on unexpected fs errors so callers
|
|
191
|
+
// surface lock-system corruption rather than silently downgrading.
|
|
192
|
+
//
|
|
193
|
+
// EPERM/EBUSY/EACCES at openSync are transient — AV scanners (SignKorea /
|
|
194
|
+
// SKCert / ezPDFWS etc) briefly lock newly-created files during inspection.
|
|
195
|
+
// The 0.1.x baseline threw immediately and the worker promoted to
|
|
196
|
+
// permanentlyDegraded, killing memory tools for the rest of the session.
|
|
197
|
+
// Treat the AV error codes as retryable with bounded backoff (~750ms total)
|
|
198
|
+
// before giving up and rethrowing.
|
|
199
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
200
|
+
try {
|
|
201
|
+
const fd = fs.openSync(OWNER_LOCK_FILE, 'wx')
|
|
202
|
+
fs.writeSync(fd, String(process.pid))
|
|
203
|
+
fs.closeSync(fd)
|
|
204
|
+
return true
|
|
205
|
+
} catch (e) {
|
|
206
|
+
if (e.code === 'EEXIST') {
|
|
207
|
+
let ownerPid = NaN
|
|
208
|
+
try { ownerPid = Number(fs.readFileSync(OWNER_LOCK_FILE, 'utf8').trim()) } catch {}
|
|
209
|
+
if (_isPidAliveLocal(ownerPid)) return false
|
|
210
|
+
// Stale lock: dead owner — unlink and retry exclusive create.
|
|
211
|
+
try { fs.unlinkSync(OWNER_LOCK_FILE) } catch {}
|
|
212
|
+
continue
|
|
213
|
+
}
|
|
214
|
+
const transient = e.code === 'EPERM' || e.code === 'EBUSY' || e.code === 'EACCES'
|
|
215
|
+
if (transient && attempt < 4) {
|
|
216
|
+
// Sync busy-wait acceptable here: this runs on memory worker boot
|
|
217
|
+
// path, once per process; the parent handler is not blocked.
|
|
218
|
+
const end = Date.now() + 50 * (attempt + 1)
|
|
219
|
+
while (Date.now() < end) {}
|
|
220
|
+
continue
|
|
221
|
+
}
|
|
222
|
+
throw e
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return false
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function releaseMemoryOwnerLock() {
|
|
229
|
+
try {
|
|
230
|
+
const ownerPid = Number(fs.readFileSync(OWNER_LOCK_FILE, 'utf8').trim())
|
|
231
|
+
if (ownerPid === process.pid) fs.unlinkSync(OWNER_LOCK_FILE)
|
|
232
|
+
} catch {}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const ACTIVE_INSTANCE_FILE = path.join(RUNTIME_ROOT, 'active-instance.json')
|
|
236
|
+
const BASE_PORT = 3350
|
|
237
|
+
const MAX_PORT = 3357
|
|
238
|
+
|
|
239
|
+
let _traceDb = null
|
|
240
|
+
|
|
241
|
+
const MEMORY_INSTRUCTIONS_TEXT = ''
|
|
242
|
+
|
|
243
|
+
function killPreviousServer(pid) {
|
|
244
|
+
if (pid <= 0 || pid === process.pid) return false
|
|
245
|
+
if (process.platform === 'win32') {
|
|
246
|
+
try {
|
|
247
|
+
execFileSync('taskkill', ['/F', '/T', '/PID', String(pid)], { encoding: 'utf8', timeout: 5000, windowsHide: true })
|
|
248
|
+
process.stderr.write(`[memory-service] Killed previous server PID ${pid}\n`)
|
|
249
|
+
return true
|
|
250
|
+
} catch (e) {
|
|
251
|
+
// Exit code 128 = process not found; treat stale lock as already-dead = success.
|
|
252
|
+
// Status 128 reliably means "process not found" regardless of locale; no text match needed.
|
|
253
|
+
// Status 1 with English text match handles edge cases on some Windows versions.
|
|
254
|
+
const notFoundText = /not found|no running instance/i.test(e.stdout || '')
|
|
255
|
+
|| /not found|no running instance/i.test(e.stderr || '')
|
|
256
|
+
|| /not found|no running instance/i.test(e.message || '')
|
|
257
|
+
const alreadyDead = e.status === 128 || (e.status === 1 && notFoundText)
|
|
258
|
+
if (alreadyDead) {
|
|
259
|
+
process.stderr.write(`[memory-service] PID ${pid} already dead (stale lock), proceeding\n`)
|
|
260
|
+
return true
|
|
261
|
+
}
|
|
262
|
+
process.stderr.write(`[memory-service] taskkill failed for PID ${pid}: ${e.message}\n`)
|
|
263
|
+
return false
|
|
264
|
+
}
|
|
265
|
+
} else {
|
|
266
|
+
// Pre-flight: if the process is already gone, treat stale lock as success.
|
|
267
|
+
try {
|
|
268
|
+
process.kill(pid, 0)
|
|
269
|
+
} catch (e) {
|
|
270
|
+
if (e.code === 'ESRCH') {
|
|
271
|
+
process.stderr.write(`[memory-service] PID ${pid} already dead (stale lock), proceeding\n`)
|
|
272
|
+
return true
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
try { process.kill(pid, 'SIGTERM') } catch {}
|
|
276
|
+
try { process.kill(pid, 'SIGKILL') } catch {}
|
|
277
|
+
// Poll for death up to 2s
|
|
278
|
+
const deadline = Date.now() + 2000
|
|
279
|
+
while (Date.now() < deadline) {
|
|
280
|
+
try {
|
|
281
|
+
process.kill(pid, 0)
|
|
282
|
+
} catch (e) {
|
|
283
|
+
if (e.code === 'ESRCH') {
|
|
284
|
+
process.stderr.write(`[memory-service] Killed previous server PID ${pid}\n`)
|
|
285
|
+
return true
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
// Synchronous 50ms sleep via shared buffer spin
|
|
289
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50)
|
|
290
|
+
}
|
|
291
|
+
process.stderr.write(`[memory-service] PID ${pid} still alive after SIGKILL\n`)
|
|
292
|
+
return false
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function acquireLock() {
|
|
297
|
+
// Multi-instance guard. In multi-terminal mode the lock owner is a *peer*
|
|
298
|
+
// memory worker serving recall for another CC session. killPreviousServer
|
|
299
|
+
// would taskkill /F that healthy peer mid-flight, then this fork-proxy
|
|
300
|
+
// mode wouldn't even need a lock anyway. Skip the entire kill-the-previous
|
|
301
|
+
// protocol; fork-proxy detection in init() takes priority. If neither
|
|
302
|
+
// proxy nor lock-owner path applies (race window during simultaneous
|
|
303
|
+
// boot), the worker simply continues without the lock — server-main /
|
|
304
|
+
// PG / port-listen handle the actual conflict cases.
|
|
305
|
+
if (process.env.MIXDOG_MULTI_INSTANCE === '1') return
|
|
306
|
+
try {
|
|
307
|
+
if (fs.existsSync(LOCK_FILE)) {
|
|
308
|
+
const lockedPid = Number(fs.readFileSync(LOCK_FILE, 'utf8').trim())
|
|
309
|
+
if (lockedPid > 0 && lockedPid !== process.pid) {
|
|
310
|
+
const killed = killPreviousServer(lockedPid)
|
|
311
|
+
if (!killed) {
|
|
312
|
+
process.stderr.write(`[memory-service] Could not kill previous server PID ${lockedPid}, aborting\n`)
|
|
313
|
+
process.exit(1)
|
|
314
|
+
}
|
|
315
|
+
try { fs.unlinkSync(LOCK_FILE) } catch {}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
const fd = fs.openSync(LOCK_FILE, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o600)
|
|
319
|
+
try {
|
|
320
|
+
fs.writeSync(fd, String(process.pid))
|
|
321
|
+
} finally {
|
|
322
|
+
fs.closeSync(fd)
|
|
323
|
+
}
|
|
324
|
+
} catch (e) {
|
|
325
|
+
if (e.code === 'EEXIST') {
|
|
326
|
+
process.stderr.write(`[memory-service] Lock file exists (EEXIST) — another instance is already running, exiting\n`)
|
|
327
|
+
process.exit(0)
|
|
328
|
+
}
|
|
329
|
+
process.stderr.write(`[memory-service] Lock acquisition failed: ${e.message}\n`)
|
|
330
|
+
process.exit(1)
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function releaseLock() {
|
|
335
|
+
try {
|
|
336
|
+
const content = fs.readFileSync(LOCK_FILE, 'utf8').trim()
|
|
337
|
+
if (Number(content) === process.pid) fs.unlinkSync(LOCK_FILE)
|
|
338
|
+
} catch {}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
import { readSection } from '../shared/config.mjs'
|
|
342
|
+
|
|
343
|
+
function readMainConfig() {
|
|
344
|
+
return readSection('memory')
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
let db = null
|
|
348
|
+
let mainConfig = null
|
|
349
|
+
let _cycleInterval = null
|
|
350
|
+
let _startupTimeout = null
|
|
351
|
+
// Outer-layer cycle1 in-flight tracker (MCP-server scope).
|
|
352
|
+
//
|
|
353
|
+
// The AUTHORITATIVE guard lives in memory-cycle.mjs:runCycle1 itself — that
|
|
354
|
+
// one catches every caller, including direct imports (setup-server backfill,
|
|
355
|
+
// policy-layer backfill). This outer tracker is kept as a defense-in-depth
|
|
356
|
+
// layer local to the MCP server process: it coalesces simultaneous
|
|
357
|
+
// _awaitCycle1Run callers (MCP action, scheduler, flush) onto a shared
|
|
358
|
+
// promise so they all observe the SAME result object rather than some
|
|
359
|
+
// getting the real stats and others getting `skippedInFlight: true` from
|
|
360
|
+
// the inner guard.
|
|
361
|
+
let _cycle1InFlight = null // shared cycle1 promise (outer coalesce layer)
|
|
362
|
+
let _initialized = false
|
|
363
|
+
let _initPromise = null
|
|
364
|
+
let _bootTimestamp = null
|
|
365
|
+
let _transcriptOffsets = new Map()
|
|
366
|
+
// Boot-edge background warmup. ONNX session creation on the embedding worker
|
|
367
|
+
// thread is CPU-heavy, so it must not overlap the worker's own init (DB open,
|
|
368
|
+
// schema, cycle wiring). Previously this was gated behind a fixed setTimeout —
|
|
369
|
+
// a wall-clock guess at "boot settled". Now the warmup is queued during
|
|
370
|
+
// _initStore and fired at the _initRuntime completion edge (see _initRuntime),
|
|
371
|
+
// so it starts the instant boot's CPU-heavy work is done — no magic-number
|
|
372
|
+
// delay. MIXDOG_EMBED_WARMUP=0 disables it (model loads lazily on first use).
|
|
373
|
+
let _pendingEmbeddingWarmup = null
|
|
374
|
+
|
|
375
|
+
const TRANSCRIPT_OFFSETS_KEY = 'state.transcript_offsets'
|
|
376
|
+
const CYCLE_LAST_RUN_KEY = 'state.cycle_last_run'
|
|
377
|
+
|
|
378
|
+
function embeddingWarmupEnabled() {
|
|
379
|
+
const raw = String(process.env.MIXDOG_EMBED_WARMUP ?? '1').trim().toLowerCase()
|
|
380
|
+
return !(raw === '0' || raw === 'false' || raw === 'off' || raw === 'no')
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function scheduleBackgroundEmbeddingWarmup(metaPath, metaKey) {
|
|
384
|
+
if (!embeddingWarmupEnabled()) return
|
|
385
|
+
// Queue the warmup; _initRuntime fires it once boot completes.
|
|
386
|
+
_pendingEmbeddingWarmup = () => {
|
|
387
|
+
warmupEmbeddingProvider()
|
|
388
|
+
.then(() => {
|
|
389
|
+
const measured = Number(getEmbeddingDims())
|
|
390
|
+
try {
|
|
391
|
+
writeJsonAtomicSync(metaPath, { ...metaKey, dims: measured }, { lock: true })
|
|
392
|
+
} catch (e) {
|
|
393
|
+
process.stderr.write(`[memory-service] could not persist embedding-meta: ${e?.message || e}\n`)
|
|
394
|
+
}
|
|
395
|
+
})
|
|
396
|
+
.catch(err => {
|
|
397
|
+
process.stderr.write(`[memory-service] background warmup failed: ${err?.message || err}\n`)
|
|
398
|
+
process.exit(1)
|
|
399
|
+
})
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function fireDeferredEmbeddingWarmup() {
|
|
404
|
+
const fire = _pendingEmbeddingWarmup
|
|
405
|
+
if (!fire) return
|
|
406
|
+
_pendingEmbeddingWarmup = null
|
|
407
|
+
fire()
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async function _initStore() {
|
|
411
|
+
mainConfig = readMainConfig()
|
|
412
|
+
const embeddingConfig = mainConfig?.embedding
|
|
413
|
+
if (embeddingConfig?.provider || embeddingConfig?.ollamaModel || embeddingConfig?.dtype) {
|
|
414
|
+
configureEmbedding({
|
|
415
|
+
provider: embeddingConfig.provider,
|
|
416
|
+
ollamaModel: embeddingConfig.ollamaModel,
|
|
417
|
+
dtype: embeddingConfig.dtype,
|
|
418
|
+
})
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Persist embedding dims so warmup is off the boot critical path.
|
|
422
|
+
// On a cache hit (provider+model+dtype match) open the DB immediately,
|
|
423
|
+
// prime the known dimensions, then run the model warmup later in the
|
|
424
|
+
// background. If cycle1/recall needs embeddings first, that on-demand
|
|
425
|
+
// call owns the same worker queue and the delayed warmup becomes a no-op.
|
|
426
|
+
const EMBEDDING_META_PATH = path.join(DATA_DIR, 'embedding-meta.json')
|
|
427
|
+
const metaKey = {
|
|
428
|
+
provider: embeddingConfig?.provider ?? null,
|
|
429
|
+
model: getEmbeddingModelId(),
|
|
430
|
+
dtype: embeddingConfig?.dtype ?? null,
|
|
431
|
+
}
|
|
432
|
+
let dimsResolved = null
|
|
433
|
+
try {
|
|
434
|
+
const saved = JSON.parse(fs.readFileSync(EMBEDDING_META_PATH, 'utf8'))
|
|
435
|
+
if (saved.provider === metaKey.provider && saved.model === metaKey.model && saved.dtype === metaKey.dtype) {
|
|
436
|
+
dimsResolved = Number(saved.dims)
|
|
437
|
+
}
|
|
438
|
+
} catch { /* miss or missing — fall through */ }
|
|
439
|
+
|
|
440
|
+
// Registry fallback: model with statically known dims bypasses measurement.
|
|
441
|
+
// Delayed background warmup invariant-checks measured vs registry value;
|
|
442
|
+
// mismatch throws and crashes the worker for fail-fast parity with the cold
|
|
443
|
+
// path's boot-time degraded signal.
|
|
444
|
+
if (dimsResolved == null) {
|
|
445
|
+
const known = getKnownDimsForCurrentModel()
|
|
446
|
+
if (known != null) dimsResolved = known
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (dimsResolved) {
|
|
450
|
+
primeEmbeddingDims(dimsResolved)
|
|
451
|
+
db = await openDatabase(DATA_DIR, dimsResolved)
|
|
452
|
+
scheduleBackgroundEmbeddingWarmup(EMBEDDING_META_PATH, metaKey)
|
|
453
|
+
} else {
|
|
454
|
+
// Cold path: meta missed AND model not registered. Sequential.
|
|
455
|
+
await warmupEmbeddingProvider()
|
|
456
|
+
dimsResolved = Number(getEmbeddingDims())
|
|
457
|
+
db = await openDatabase(DATA_DIR, dimsResolved)
|
|
458
|
+
try {
|
|
459
|
+
writeJsonAtomicSync(EMBEDDING_META_PATH, { ...metaKey, dims: dimsResolved }, { lock: true })
|
|
460
|
+
} catch (e) {
|
|
461
|
+
process.stderr.write(`[memory-service] could not persist embedding-meta: ${e?.message || e}\n`)
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (!await isBootstrapComplete(db)) {
|
|
466
|
+
throw new Error('memory-service: bootstrap not complete after openDatabase')
|
|
467
|
+
}
|
|
468
|
+
startLlmWorker()
|
|
469
|
+
_bootTimestamp = Date.now()
|
|
470
|
+
await loadTranscriptOffsets()
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
async function loadTranscriptOffsets() {
|
|
474
|
+
try {
|
|
475
|
+
const raw = await getMetaValue(db, TRANSCRIPT_OFFSETS_KEY, '{}')
|
|
476
|
+
const obj = JSON.parse(raw)
|
|
477
|
+
_transcriptOffsets = new Map(Object.entries(obj))
|
|
478
|
+
} catch {
|
|
479
|
+
_transcriptOffsets = new Map()
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async function persistTranscriptOffsets() {
|
|
484
|
+
try {
|
|
485
|
+
const obj = Object.fromEntries(_transcriptOffsets)
|
|
486
|
+
await setMetaValue(db, TRANSCRIPT_OFFSETS_KEY, JSON.stringify(obj))
|
|
487
|
+
} catch (e) {
|
|
488
|
+
process.stderr.write(`[memory] persist transcript offsets failed: ${e.message}\n`)
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
async function getCycleLastRun() {
|
|
493
|
+
try {
|
|
494
|
+
const raw = await getMetaValue(db, CYCLE_LAST_RUN_KEY, '{}')
|
|
495
|
+
const obj = JSON.parse(raw)
|
|
496
|
+
return {
|
|
497
|
+
cycle1: Number(obj.cycle1) || 0,
|
|
498
|
+
cycle2: Number(obj.cycle2) || 0,
|
|
499
|
+
cycle3: Number(obj.cycle3) || 0,
|
|
500
|
+
// Phase B §2.4 auto-restart book-keeping — last time an overdue cycle1
|
|
501
|
+
// triggered an unscheduled run, rate-limited separately from the
|
|
502
|
+
// normal cycle timestamp so a long chain of failures cannot tight-loop.
|
|
503
|
+
cycle1_autoRestart: Number(obj.cycle1_autoRestart) || 0,
|
|
504
|
+
// #13/#14: heartbeat (every attempt, success or skip) and the auto-
|
|
505
|
+
// restart attempt timestamp (committed BEFORE the call) are tracked
|
|
506
|
+
// separately from the success timestamps above so a long string of
|
|
507
|
+
// failed/skipped runs cannot disguise itself as a healthy keeper.
|
|
508
|
+
cycle1_heartbeat: Number(obj.cycle1_heartbeat) || 0,
|
|
509
|
+
cycle1_autoRestart_attempt: Number(obj.cycle1_autoRestart_attempt) || 0,
|
|
510
|
+
// Last cycle2 failure message; cleared to '' on success.
|
|
511
|
+
cycle2_last_error: typeof obj.cycle2_last_error === 'string' ? obj.cycle2_last_error : '',
|
|
512
|
+
}
|
|
513
|
+
} catch {
|
|
514
|
+
return {
|
|
515
|
+
cycle1: 0, cycle2: 0, cycle3: 0, cycle1_autoRestart: 0,
|
|
516
|
+
cycle1_heartbeat: 0, cycle1_autoRestart_attempt: 0,
|
|
517
|
+
cycle2_last_error: '',
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
async function setCycleLastRun(kind, ts) {
|
|
523
|
+
const cur = await getCycleLastRun()
|
|
524
|
+
cur[kind] = ts
|
|
525
|
+
await setMetaValue(db, CYCLE_LAST_RUN_KEY, JSON.stringify(cur))
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Raw-row priority lookup for narrow-window queries. Raw rows (is_root=0,
|
|
529
|
+
// chunk_root IS NULL) are inserted immediately by ingestTranscriptFile before
|
|
530
|
+
// cycle1 runs, so they always carry the freshest turns in the DB.
|
|
531
|
+
async function readRawRowsInWindow(db, tsFromMs, tsToMs, hardLimit = 10, { projectScope } = {}) {
|
|
532
|
+
try {
|
|
533
|
+
let sql, params
|
|
534
|
+
if (projectScope === 'common') {
|
|
535
|
+
sql = `SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root,
|
|
536
|
+
element, category, summary, status, score, last_seen_at, project_id
|
|
537
|
+
FROM entries
|
|
538
|
+
WHERE chunk_root IS NULL AND is_root = 0
|
|
539
|
+
AND ts >= $1 AND ts <= $2
|
|
540
|
+
AND project_id IS NULL
|
|
541
|
+
ORDER BY ts DESC
|
|
542
|
+
LIMIT $3`
|
|
543
|
+
params = [tsFromMs ?? 0, tsToMs ?? Date.now(), hardLimit]
|
|
544
|
+
} else if (projectScope && projectScope !== 'all') {
|
|
545
|
+
sql = `SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root,
|
|
546
|
+
element, category, summary, status, score, last_seen_at, project_id
|
|
547
|
+
FROM entries
|
|
548
|
+
WHERE chunk_root IS NULL AND is_root = 0
|
|
549
|
+
AND ts >= $1 AND ts <= $2
|
|
550
|
+
AND (project_id IS NULL OR project_id = $3)
|
|
551
|
+
ORDER BY ts DESC
|
|
552
|
+
LIMIT $4`
|
|
553
|
+
params = [tsFromMs ?? 0, tsToMs ?? Date.now(), projectScope, hardLimit]
|
|
554
|
+
} else {
|
|
555
|
+
sql = `SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root,
|
|
556
|
+
element, category, summary, status, score, last_seen_at, project_id
|
|
557
|
+
FROM entries
|
|
558
|
+
WHERE chunk_root IS NULL AND is_root = 0
|
|
559
|
+
AND ts >= $1 AND ts <= $2
|
|
560
|
+
ORDER BY ts DESC
|
|
561
|
+
LIMIT $3`
|
|
562
|
+
params = [tsFromMs ?? 0, tsToMs ?? Date.now(), hardLimit]
|
|
563
|
+
}
|
|
564
|
+
const rows = (await db.query(sql, params)).rows
|
|
565
|
+
return rows.map(r => ({ ...r, retrievalScore: 0, rrf: 0 }))
|
|
566
|
+
} catch { return [] }
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
async function ingestTranscriptFile(transcriptPath, { cwd } = {}) {
|
|
570
|
+
let stat
|
|
571
|
+
try { stat = await fs.promises.stat(transcriptPath) } catch { return 0 }
|
|
572
|
+
const sessionUuid = path.basename(transcriptPath, '.jsonl')
|
|
573
|
+
const prev = _transcriptOffsets.get(transcriptPath) ?? { bytes: 0, lineIndex: 0 }
|
|
574
|
+
if (stat.size < prev.bytes) {
|
|
575
|
+
prev.bytes = 0
|
|
576
|
+
prev.lineIndex = 0
|
|
577
|
+
}
|
|
578
|
+
if (stat.size <= prev.bytes) return 0
|
|
579
|
+
|
|
580
|
+
const fh = await fs.promises.open(transcriptPath, 'r')
|
|
581
|
+
const buf = Buffer.alloc(stat.size - prev.bytes)
|
|
582
|
+
try {
|
|
583
|
+
await fh.read(buf, 0, buf.length, prev.bytes)
|
|
584
|
+
} finally {
|
|
585
|
+
await fh.close()
|
|
586
|
+
}
|
|
587
|
+
const text = buf.toString('utf8')
|
|
588
|
+
|
|
589
|
+
const resolvedCwd = typeof cwd === 'string' && cwd ? cwd : cwdFromTranscriptPath(transcriptPath)
|
|
590
|
+
// No cwd resolved -> classify as COMMON (project_id NULL). Falling back to
|
|
591
|
+
// process.cwd() would misclassify rows under the service/plugin cwd.
|
|
592
|
+
const projectId = resolvedCwd ? resolveProjectId(resolvedCwd) : null
|
|
593
|
+
|
|
594
|
+
let count = 0
|
|
595
|
+
let index = prev.lineIndex
|
|
596
|
+
// Track the byte boundary of the LAST line we fully consumed (parsed +
|
|
597
|
+
// either inserted or intentionally skipped). On parse failure or
|
|
598
|
+
// transient insert error we stop and leave the boundary untouched so the
|
|
599
|
+
// next sweep retries from the same position. This prevents malformed
|
|
600
|
+
// trailing JSONL (mid-write partial lines) and DB hiccups from being
|
|
601
|
+
// silently consumed forever.
|
|
602
|
+
let lastGoodBytes = prev.bytes
|
|
603
|
+
let lastGoodLineIndex = prev.lineIndex
|
|
604
|
+
let cursor = 0
|
|
605
|
+
while (cursor < text.length) {
|
|
606
|
+
const nl = text.indexOf('\n', cursor)
|
|
607
|
+
// No trailing newline -> partial line still being written; stop here
|
|
608
|
+
// without advancing so the rest is re-read once the writer flushes.
|
|
609
|
+
if (nl === -1) break
|
|
610
|
+
const rawLine = text.slice(cursor, nl)
|
|
611
|
+
const consumedBytes = Buffer.byteLength(rawLine, 'utf8') + 1
|
|
612
|
+
cursor = nl + 1
|
|
613
|
+
const line = rawLine.replace(/\r$/, '')
|
|
614
|
+
if (!line) {
|
|
615
|
+
lastGoodBytes += consumedBytes
|
|
616
|
+
continue
|
|
617
|
+
}
|
|
618
|
+
index += 1
|
|
619
|
+
let parsed
|
|
620
|
+
try { parsed = JSON.parse(line) } catch {
|
|
621
|
+
// Malformed line: do not advance past it; retry on next sweep.
|
|
622
|
+
index -= 1
|
|
623
|
+
break
|
|
624
|
+
}
|
|
625
|
+
const role = parsed.message?.role
|
|
626
|
+
if (role !== 'user' && role !== 'assistant') {
|
|
627
|
+
lastGoodBytes += consumedBytes
|
|
628
|
+
lastGoodLineIndex = index
|
|
629
|
+
continue
|
|
630
|
+
}
|
|
631
|
+
const content = firstTextContent(parsed.message?.content)
|
|
632
|
+
if (!content || !content.trim()) {
|
|
633
|
+
lastGoodBytes += consumedBytes
|
|
634
|
+
lastGoodLineIndex = index
|
|
635
|
+
continue
|
|
636
|
+
}
|
|
637
|
+
const cleaned = cleanMemoryText(content)
|
|
638
|
+
if (!cleaned) {
|
|
639
|
+
lastGoodBytes += consumedBytes
|
|
640
|
+
lastGoodLineIndex = index
|
|
641
|
+
continue
|
|
642
|
+
}
|
|
643
|
+
const tsMs = parseTsToMs(parsed.timestamp ?? parsed.ts ?? Date.now())
|
|
644
|
+
const sourceRef = `transcript:${sessionUuid}#${index}`
|
|
645
|
+
try {
|
|
646
|
+
const result = await db.query(
|
|
647
|
+
`INSERT INTO entries(ts, role, content, source_ref, session_id, source_turn, project_id)
|
|
648
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
649
|
+
ON CONFLICT DO NOTHING`,
|
|
650
|
+
[tsMs, role, cleaned, sourceRef, sessionUuid, index, projectId]
|
|
651
|
+
)
|
|
652
|
+
if (Number(result.rowCount ?? result.affectedRows ?? 0) > 0) count += 1
|
|
653
|
+
lastGoodBytes += consumedBytes
|
|
654
|
+
lastGoodLineIndex = index
|
|
655
|
+
} catch (e) {
|
|
656
|
+
process.stderr.write(`[transcript-watch] insert error (${sourceRef}): ${e.message}\n`)
|
|
657
|
+
// Transient insert failure: leave the boundary before this line so
|
|
658
|
+
// the next sweep retries it. Roll back the line counter too.
|
|
659
|
+
index -= 1
|
|
660
|
+
break
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
prev.bytes = lastGoodBytes
|
|
664
|
+
prev.lineIndex = lastGoodLineIndex
|
|
665
|
+
_transcriptOffsets.set(transcriptPath, prev)
|
|
666
|
+
await persistTranscriptOffsets()
|
|
667
|
+
return count
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function firstTextContent(content) {
|
|
671
|
+
if (typeof content === 'string') return content
|
|
672
|
+
if (!Array.isArray(content)) return ''
|
|
673
|
+
for (const item of content) {
|
|
674
|
+
if (typeof item === 'string') return item
|
|
675
|
+
if (item?.type === 'text' && typeof item.text === 'string') return item.text
|
|
676
|
+
}
|
|
677
|
+
return ''
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function parseTsToMs(value) {
|
|
681
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value < 1e12 ? value * 1000 : value
|
|
682
|
+
const parsed = Date.parse(String(value))
|
|
683
|
+
return Number.isFinite(parsed) ? parsed : Date.now()
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Extract cwd from the transcript file's JSONL rows. Claude Code embeds
|
|
687
|
+
// the session cwd as a top-level `cwd` field on every message row, so
|
|
688
|
+
// scanning the first few lines is reliable on all platforms (Windows/Linux)
|
|
689
|
+
// without slug-decoding ambiguity. Returns undefined when no cwd is found
|
|
690
|
+
// or the extracted path does not exist on disk (falls back to COMMON).
|
|
691
|
+
function cwdFromTranscriptPath(fp) {
|
|
692
|
+
let fd
|
|
693
|
+
try {
|
|
694
|
+
fd = fs.openSync(fp, 'r')
|
|
695
|
+
const buf = Buffer.alloc(Math.min(fs.fstatSync(fd).size, 100 * 1024))
|
|
696
|
+
fs.readSync(fd, buf, 0, buf.length, 0)
|
|
697
|
+
fs.closeSync(fd)
|
|
698
|
+
fd = undefined
|
|
699
|
+
const lines = buf.toString('utf8').split('\n')
|
|
700
|
+
for (let i = 0; i < Math.min(lines.length, 5); i++) {
|
|
701
|
+
const line = lines[i].trim()
|
|
702
|
+
if (!line) continue
|
|
703
|
+
try {
|
|
704
|
+
const obj = JSON.parse(line)
|
|
705
|
+
if (typeof obj.cwd === 'string' && obj.cwd) {
|
|
706
|
+
const candidate = obj.cwd
|
|
707
|
+
try { if (fs.statSync(candidate).isDirectory()) return candidate } catch {}
|
|
708
|
+
}
|
|
709
|
+
} catch {}
|
|
710
|
+
}
|
|
711
|
+
} catch {} finally {
|
|
712
|
+
if (fd != null) { try { fs.closeSync(fd) } catch {} }
|
|
713
|
+
}
|
|
714
|
+
return undefined
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function _initTranscriptWatcher() {
|
|
718
|
+
const projectsRoot = path.join(os.homedir(), '.claude', 'projects')
|
|
719
|
+
const SAFETY_POLL_MS = 5 * 60_000
|
|
720
|
+
const DEBOUNCE_MS = 500
|
|
721
|
+
const watchedFiles = new Map()
|
|
722
|
+
const pendingByFile = new Map()
|
|
723
|
+
const watchers = []
|
|
724
|
+
const intervals = []
|
|
725
|
+
const polledFiles = new Set()
|
|
726
|
+
let safetySweepTimeout = null
|
|
727
|
+
|
|
728
|
+
function isWatchable(relOrBase) {
|
|
729
|
+
const base = path.basename(relOrBase)
|
|
730
|
+
if (!base.endsWith('.jsonl') || base.startsWith('agent-')) return false
|
|
731
|
+
if (relOrBase.includes('tmp') || relOrBase.includes('cache') || relOrBase.includes('plugins')) return false
|
|
732
|
+
return true
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
async function ingestOne(fp) {
|
|
736
|
+
try {
|
|
737
|
+
if (!fs.existsSync(fp)) return
|
|
738
|
+
const stat = fs.statSync(fp)
|
|
739
|
+
const mtime = stat.mtimeMs
|
|
740
|
+
const prev = watchedFiles.get(fp)
|
|
741
|
+
if (prev && prev >= mtime) return
|
|
742
|
+
const n = await ingestTranscriptFile(fp, { cwd: cwdFromTranscriptPath(fp) })
|
|
743
|
+
// Only mark this mtime as 'consumed' once the persisted offset has
|
|
744
|
+
// fully advanced past the observed file size. On a transient insert
|
|
745
|
+
// error (or a malformed trailing line) ingestTranscriptFile leaves
|
|
746
|
+
// the persisted offset before the failed line for retry; caching
|
|
747
|
+
// the new mtime unconditionally would suppress the next sweep until
|
|
748
|
+
// the file mutated again, losing the retry. Leave the cache
|
|
749
|
+
// untouched on partial advance so the next sweep re-ingests.
|
|
750
|
+
const off = _transcriptOffsets.get(fp)
|
|
751
|
+
if (off && off.bytes >= stat.size) {
|
|
752
|
+
watchedFiles.set(fp, mtime)
|
|
753
|
+
}
|
|
754
|
+
if (n > 0) {
|
|
755
|
+
process.stderr.write(`[transcript-watch] ingested ${n} entries from ${path.basename(fp)}\n`)
|
|
756
|
+
}
|
|
757
|
+
} catch (e) {
|
|
758
|
+
process.stderr.write(`[transcript-watch] ingest error: ${e.message}\n`)
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function scheduleIngest(fp) {
|
|
763
|
+
const existing = pendingByFile.get(fp)
|
|
764
|
+
if (existing) clearTimeout(existing)
|
|
765
|
+
const timer = setTimeout(() => {
|
|
766
|
+
pendingByFile.delete(fp)
|
|
767
|
+
ingestOne(fp)
|
|
768
|
+
}, DEBOUNCE_MS)
|
|
769
|
+
pendingByFile.set(fp, timer)
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
async function discoverActiveTranscripts() {
|
|
773
|
+
let topLevel
|
|
774
|
+
try { topLevel = await fs.promises.readdir(projectsRoot) }
|
|
775
|
+
catch { return [] }
|
|
776
|
+
const files = []
|
|
777
|
+
for (const d of topLevel) {
|
|
778
|
+
if (d.includes('tmp') || d.includes('cache') || d.includes('plugins')) continue
|
|
779
|
+
const full = path.join(projectsRoot, d)
|
|
780
|
+
let inner
|
|
781
|
+
try { inner = await fs.promises.readdir(full) } catch { continue }
|
|
782
|
+
for (const f of inner) {
|
|
783
|
+
if (!f.endsWith('.jsonl') || f.startsWith('agent-')) continue
|
|
784
|
+
const fp = path.join(full, f)
|
|
785
|
+
try {
|
|
786
|
+
const stat = await fs.promises.stat(fp)
|
|
787
|
+
files.push({ path: fp, mtime: stat.mtimeMs })
|
|
788
|
+
} catch {}
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
const cutoff = Date.now() - 30 * 60_000
|
|
792
|
+
return files.filter(f => f.mtime > cutoff)
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
async function safetySweep() {
|
|
796
|
+
try {
|
|
797
|
+
const active = await discoverActiveTranscripts()
|
|
798
|
+
for (const { path: fp } of active) ingestOne(fp)
|
|
799
|
+
} catch (e) {
|
|
800
|
+
process.stderr.write(`[transcript-watch] safety sweep error: ${e.message}\n`)
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
safetySweepTimeout = setTimeout(safetySweep, 3_000)
|
|
805
|
+
|
|
806
|
+
// fs.watch({recursive}) is only reliable on win32.
|
|
807
|
+
// darwin: recursive option unreliable — use flat watch per-entry (glob dirs at start).
|
|
808
|
+
// linux/WSL: recursive not supported — use fs.watchFile polling per file found via
|
|
809
|
+
// the safety sweep, or fall back entirely to safety sweep.
|
|
810
|
+
if (process.platform === 'win32') {
|
|
811
|
+
try {
|
|
812
|
+
const watcher = fs.watch(projectsRoot, { recursive: true, persistent: true }, (_event, filename) => {
|
|
813
|
+
if (!filename) return
|
|
814
|
+
if (!isWatchable(filename)) return
|
|
815
|
+
const fp = path.join(projectsRoot, filename)
|
|
816
|
+
scheduleIngest(fp)
|
|
817
|
+
})
|
|
818
|
+
watcher.on('error', (err) => {
|
|
819
|
+
process.stderr.write(`[transcript-watch] fs.watch error: ${err.message}\n`)
|
|
820
|
+
})
|
|
821
|
+
watchers.push(watcher)
|
|
822
|
+
process.stderr.write(`[transcript-watch] fs.watch(recursive) active on ${projectsRoot}\n`)
|
|
823
|
+
} catch (e) {
|
|
824
|
+
process.stderr.write(`[transcript-watch] fs.watch setup failed: ${e.message} — relying on safety sweep only\n`)
|
|
825
|
+
}
|
|
826
|
+
intervals.push(setInterval(safetySweep, SAFETY_POLL_MS))
|
|
827
|
+
} else if (process.platform === 'darwin') {
|
|
828
|
+
// Flat watch: register a non-recursive watcher on each immediate subdirectory.
|
|
829
|
+
// New subdirs are picked up on the next safety sweep cycle.
|
|
830
|
+
try {
|
|
831
|
+
const registerFlat = (dir) => {
|
|
832
|
+
try {
|
|
833
|
+
const w = fs.watch(dir, { persistent: true }, (_event, filename) => {
|
|
834
|
+
if (!filename) return
|
|
835
|
+
const fp = path.join(dir, filename)
|
|
836
|
+
if (!isWatchable(fp)) return
|
|
837
|
+
scheduleIngest(fp)
|
|
838
|
+
})
|
|
839
|
+
w.on('error', () => { /* ignore individual dir errors */ })
|
|
840
|
+
watchers.push(w)
|
|
841
|
+
} catch { /* dir may not exist yet */ }
|
|
842
|
+
}
|
|
843
|
+
registerFlat(projectsRoot)
|
|
844
|
+
try {
|
|
845
|
+
for (const entry of fs.readdirSync(projectsRoot, { withFileTypes: true })) {
|
|
846
|
+
if (entry.isDirectory()) registerFlat(path.join(projectsRoot, entry.name))
|
|
847
|
+
}
|
|
848
|
+
} catch { /* best effort */ }
|
|
849
|
+
process.stderr.write(`[transcript-watch] flat fs.watch active on ${projectsRoot} (darwin)\n`)
|
|
850
|
+
} catch (e) {
|
|
851
|
+
process.stderr.write(`[transcript-watch] flat watch setup failed: ${e.message} — relying on safety sweep only\n`)
|
|
852
|
+
}
|
|
853
|
+
intervals.push(setInterval(safetySweep, SAFETY_POLL_MS))
|
|
854
|
+
} else {
|
|
855
|
+
// linux/WSL: fs.watch recursive is unsupported. Use fs.watchFile polling for
|
|
856
|
+
// individual files surfaced by the safety sweep, in addition to the sweep itself.
|
|
857
|
+
process.stderr.write(`[transcript-watch] linux/WSL — using safety sweep + fs.watchFile polling (no recursive watch)\n`)
|
|
858
|
+
// Wrap by reassigning the closure-captured reference is not possible here;
|
|
859
|
+
// instead, register watchFile inside the safety sweep callback by intercepting
|
|
860
|
+
// active file list after each sweep. The interval already calls safetySweep
|
|
861
|
+
// which calls ingestOne; watchFile additions happen as a side-effect of the sweep.
|
|
862
|
+
const _patchedSweep = async () => {
|
|
863
|
+
try {
|
|
864
|
+
const active = await discoverActiveTranscripts()
|
|
865
|
+
for (const { path: fp } of active) {
|
|
866
|
+
if (!polledFiles.has(fp)) {
|
|
867
|
+
polledFiles.add(fp)
|
|
868
|
+
fs.watchFile(fp, { persistent: false, interval: 2000 }, () => {
|
|
869
|
+
if (isWatchable(fp)) scheduleIngest(fp)
|
|
870
|
+
})
|
|
871
|
+
}
|
|
872
|
+
ingestOne(fp)
|
|
873
|
+
}
|
|
874
|
+
} catch (e) {
|
|
875
|
+
process.stderr.write(`[transcript-watch] linux sweep error: ${e.message}\n`)
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
// Replace the safety sweep interval with the patched version.
|
|
879
|
+
intervals.push(setInterval(_patchedSweep, SAFETY_POLL_MS))
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
return {
|
|
883
|
+
stop() {
|
|
884
|
+
if (safetySweepTimeout) { clearTimeout(safetySweepTimeout); safetySweepTimeout = null }
|
|
885
|
+
for (const t of pendingByFile.values()) { try { clearTimeout(t) } catch {} }
|
|
886
|
+
pendingByFile.clear()
|
|
887
|
+
for (const i of intervals) { try { clearInterval(i) } catch {} }
|
|
888
|
+
intervals.length = 0
|
|
889
|
+
for (const w of watchers) { try { w.close() } catch {} }
|
|
890
|
+
watchers.length = 0
|
|
891
|
+
for (const fp of polledFiles) { try { fs.unwatchFile(fp) } catch {} }
|
|
892
|
+
polledFiles.clear()
|
|
893
|
+
},
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// Phase B §2.4 — cache-keeper health thresholds.
|
|
898
|
+
// warning fires when cycle1 is overdue past HEALTH_OVERDUE_MS; an auto-
|
|
899
|
+
// restart attempt fires when the warning has been emitted AND the most
|
|
900
|
+
// recent unscheduled restart was more than AUTO_RESTART_COOLDOWN_MS ago.
|
|
901
|
+
// Both default to 5 min per spec; caller overrides are not exposed yet.
|
|
902
|
+
const CYCLE1_HEALTH_OVERDUE_MS = 5 * 60_000
|
|
903
|
+
const CYCLE1_AUTO_RESTART_COOLDOWN_MS = 5 * 60_000
|
|
904
|
+
|
|
905
|
+
function _startCycle1Run(config = {}, options = {}) {
|
|
906
|
+
_cycle1InFlight = (async () => {
|
|
907
|
+
try {
|
|
908
|
+
const result = await runCycle1(db, config, options, DATA_DIR)
|
|
909
|
+
// #13: heartbeat (attempt) is always recorded so the overdue check
|
|
910
|
+
// can tell the keeper is alive; success timestamp only advances when
|
|
911
|
+
// the run actually did work. Skipped/in-flight runs do NOT count as
|
|
912
|
+
// success because the next overdue check would otherwise see a fake
|
|
913
|
+
// green and stop forcing auto-restarts.
|
|
914
|
+
const now = Date.now()
|
|
915
|
+
await setCycleLastRun('cycle1_heartbeat', now)
|
|
916
|
+
const skipped = result?.skippedInFlight === true
|
|
917
|
+
const allFailed = !skipped
|
|
918
|
+
&& Number(result?.chunks ?? 0) === 0
|
|
919
|
+
&& Number(result?.processed ?? 0) === 0
|
|
920
|
+
&& Number(result?.skipped ?? 0) > 0
|
|
921
|
+
if (!skipped && !allFailed) {
|
|
922
|
+
await setCycleLastRun('cycle1', now)
|
|
923
|
+
}
|
|
924
|
+
return result
|
|
925
|
+
} finally {
|
|
926
|
+
if (_cycle1InFlight === promise) _cycle1InFlight = null
|
|
927
|
+
}
|
|
928
|
+
})()
|
|
929
|
+
const promise = _cycle1InFlight
|
|
930
|
+
return _cycle1InFlight
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
async function _awaitCycle1Run(config = {}, options = {}) {
|
|
934
|
+
const target = _cycle1InFlight || _startCycle1Run(config, options)
|
|
935
|
+
const callerDeadlineMs = Number(options.callerDeadlineMs) || 0
|
|
936
|
+
if (callerDeadlineMs <= 0) return await target
|
|
937
|
+
// Caller-deadline race. When the channels-side timeout fires, we
|
|
938
|
+
// (a) graceful-return a skippedInFlight envelope so the calling
|
|
939
|
+
// SessionStart slot stops blocking with a 200 OK + flags instead of a
|
|
940
|
+
// 503-class throw, and (b) release the outer in-flight handle. The
|
|
941
|
+
// underlying LLM run keeps progressing in the background — it still
|
|
942
|
+
// owns the inner dedup guard (memory-cycle.mjs _runCycle1InFlight).
|
|
943
|
+
// Releasing the outer handle is what breaks the cascade: any later
|
|
944
|
+
// _awaitCycle1Run call now re-enters _startCycle1Run, whose inner
|
|
945
|
+
// runCycle1 short-circuits with skippedInFlight:true the moment it
|
|
946
|
+
// sees the same db still busy. Returning a graceful object (vs the
|
|
947
|
+
// pre-0.1.198 throw) keeps the channel route response shape stable
|
|
948
|
+
// and lets pollers read inFlight=true rather than parse an error.
|
|
949
|
+
let timer
|
|
950
|
+
const deadlinePromise = new Promise((resolve) => {
|
|
951
|
+
timer = setTimeout(() => {
|
|
952
|
+
if (_cycle1InFlight === target) _cycle1InFlight = null
|
|
953
|
+
resolve({
|
|
954
|
+
processed: 0,
|
|
955
|
+
chunks: 0,
|
|
956
|
+
skipped: 0,
|
|
957
|
+
sessions: 0,
|
|
958
|
+
skippedInFlight: true,
|
|
959
|
+
timedOutWaiting: true,
|
|
960
|
+
callerDeadlineMs,
|
|
961
|
+
})
|
|
962
|
+
}, callerDeadlineMs)
|
|
963
|
+
})
|
|
964
|
+
try {
|
|
965
|
+
return await Promise.race([target, deadlinePromise])
|
|
966
|
+
} finally {
|
|
967
|
+
clearTimeout(timer)
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// Periodic cycle1 sizing: only enter when ≥ 20 pending rows have built up,
|
|
972
|
+
// then split into 2 windows of 50 rows each (≤100 rows per tick) and process
|
|
973
|
+
// both windows in parallel. The on-demand path used by SessionStart hooks runs
|
|
974
|
+
// with a 1-row threshold and 5×20 windows instead — see hooks/session-start.cjs
|
|
975
|
+
// ON_DEMAND_CYCLE1_ARGS.
|
|
976
|
+
// mainConfig.cycle1 values still win, so users can override any of these in
|
|
977
|
+
// config.json.
|
|
978
|
+
function periodicCycle1Config() {
|
|
979
|
+
return {
|
|
980
|
+
min_batch: 20,
|
|
981
|
+
session_cap: 2,
|
|
982
|
+
batch_size: 50,
|
|
983
|
+
concurrency: 2,
|
|
984
|
+
...(mainConfig?.cycle1 || {}),
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
async function _finalizeCycle2Run(result) {
|
|
989
|
+
if (result.ok) {
|
|
990
|
+
await setCycleLastRun('cycle2', Date.now())
|
|
991
|
+
await setCycleLastRun('cycle2_last_error', '')
|
|
992
|
+
process.stderr.write('[cycle2] completed\n')
|
|
993
|
+
} else {
|
|
994
|
+
await setCycleLastRun('cycle2_last_error', result.error || 'unknown error')
|
|
995
|
+
process.stderr.write(`[cycle2] failed: ${result.error}\n`)
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
async function checkCycles() {
|
|
1000
|
+
if (mainConfig?.enabled === false) return
|
|
1001
|
+
|
|
1002
|
+
const cycle1Ms = parseInterval(mainConfig?.cycle1?.interval || '10m')
|
|
1003
|
+
const cycle2Ms = parseInterval(mainConfig?.cycle2?.interval || '1h')
|
|
1004
|
+
const cycle3Ms = parseInterval(mainConfig?.cycle3?.interval || '24h')
|
|
1005
|
+
|
|
1006
|
+
const now = Date.now()
|
|
1007
|
+
const last = await getCycleLastRun()
|
|
1008
|
+
|
|
1009
|
+
// Phase B §2.4 — cache-keeper health check + auto-restart.
|
|
1010
|
+
//
|
|
1011
|
+
// `last.cycle1 + cycle1Ms` is the next scheduled run time; anything beyond
|
|
1012
|
+
// that by > HEALTH_OVERDUE_MS means the keeper missed its window and the
|
|
1013
|
+
// Anthropic shard is drifting cold. Emit a warning, and — if we haven't
|
|
1014
|
+
// retried in the last cooldown window — force an unscheduled run so the
|
|
1015
|
+
// shard gets re-touched before the next Worker / Sub call pays the 2×
|
|
1016
|
+
// write premium. Cooldown prevents a tight retry loop when the underlying
|
|
1017
|
+
// cause (network, provider outage) is still broken.
|
|
1018
|
+
//
|
|
1019
|
+
// Cold-start guard: a fresh DB has last.cycle1 = 0, which would make
|
|
1020
|
+
// (now - 0 - cycle1Ms) blow past HEALTH_OVERDUE_MS on every first boot
|
|
1021
|
+
// and force-trigger the auto-restart branch even though the shard never
|
|
1022
|
+
// existed in the first place. The "drifting cold" concept doesn't apply
|
|
1023
|
+
// until at least one successful run has anchored a baseline.
|
|
1024
|
+
const cycle1OverdueMs = last.cycle1 > 0
|
|
1025
|
+
? Math.max(0, now - last.cycle1 - cycle1Ms)
|
|
1026
|
+
: 0
|
|
1027
|
+
if (cycle1OverdueMs > CYCLE1_HEALTH_OVERDUE_MS) {
|
|
1028
|
+
const lastSeen = last.cycle1 ? new Date(last.cycle1).toISOString() : 'never'
|
|
1029
|
+
process.stderr.write(
|
|
1030
|
+
`[cycle1] overdue by ${Math.floor(cycle1OverdueMs / 60_000)}min `
|
|
1031
|
+
+ `(last=${lastSeen}). Pool B Anthropic shard may be cold.\n`
|
|
1032
|
+
)
|
|
1033
|
+
const lastAutoRestart = last.cycle1_autoRestart || 0
|
|
1034
|
+
if (now - lastAutoRestart >= CYCLE1_AUTO_RESTART_COOLDOWN_MS) {
|
|
1035
|
+
// #14: record the attempt timestamp BEFORE the call (so a hung run
|
|
1036
|
+
// cannot tight-loop) and the result timestamp only on success. On
|
|
1037
|
+
// failure we return immediately instead of falling through into the
|
|
1038
|
+
// due branch — falling through would silently re-enter the same
|
|
1039
|
+
// failing path within the same tick.
|
|
1040
|
+
await setCycleLastRun('cycle1_autoRestart_attempt', now)
|
|
1041
|
+
try {
|
|
1042
|
+
const result = await _awaitCycle1Run(periodicCycle1Config())
|
|
1043
|
+
await setCycleLastRun('cycle1_autoRestart', Date.now())
|
|
1044
|
+
process.stderr.write(
|
|
1045
|
+
`[cycle1] auto-restart completed chunks=${result?.chunks ?? 0} processed=${result?.processed ?? 0}\n`
|
|
1046
|
+
)
|
|
1047
|
+
return
|
|
1048
|
+
} catch (e) {
|
|
1049
|
+
process.stderr.write(`[cycle1] auto-restart error: ${e.message}\n`)
|
|
1050
|
+
// Cooldown attempt timestamp is committed; do NOT fall through
|
|
1051
|
+
// to the due branch — next tick will retry after cooldown.
|
|
1052
|
+
return
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
if (now - last.cycle1 >= cycle1Ms) {
|
|
1058
|
+
const result = await _awaitCycle1Run(periodicCycle1Config())
|
|
1059
|
+
process.stderr.write(`[cycle1] completed chunks=${result?.chunks ?? 0} processed=${result?.processed ?? 0}\n`)
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
if (now - last.cycle2 >= cycle2Ms) {
|
|
1063
|
+
if (!_cycle2InFlight) {
|
|
1064
|
+
_cycle2InFlight = true
|
|
1065
|
+
// Detached: cycle2 can take minutes; awaiting here would delay the
|
|
1066
|
+
// next periodic checkCycles() tick and block sibling IPC (search,
|
|
1067
|
+
// append) on the memory worker. The in-flight guard prevents
|
|
1068
|
+
// concurrent runs; rejection is logged but does not propagate.
|
|
1069
|
+
runCycle2(db, mainConfig?.cycle2 || {}, {}, DATA_DIR)
|
|
1070
|
+
.then(_finalizeCycle2Run)
|
|
1071
|
+
.catch(err => process.stderr.write(`[cycle2] detached run failed: ${err?.message || err}\n`))
|
|
1072
|
+
.finally(() => { _cycle2InFlight = false })
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
if (now - last.cycle3 >= cycle3Ms) {
|
|
1077
|
+
if (!_cycle3InFlight) {
|
|
1078
|
+
_cycle3InFlight = true
|
|
1079
|
+
// Detached like cycle2 — core review walks every core_entries row with a
|
|
1080
|
+
// recall + LLM call per row, so it can take a while. 24h cadence default.
|
|
1081
|
+
runCycle3(db, mainConfig || {}, DATA_DIR)
|
|
1082
|
+
.then(() => setCycleLastRun('cycle3', Date.now()))
|
|
1083
|
+
.catch(err => process.stderr.write(`[cycle3] detached run failed: ${err?.message || err}\n`))
|
|
1084
|
+
.finally(() => { _cycle3InFlight = false })
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
let _cycle2InFlight = false
|
|
1089
|
+
let _cycle3InFlight = false
|
|
1090
|
+
|
|
1091
|
+
// #12: self-rescheduling timer. setInterval would fire ticks regardless of
|
|
1092
|
+
// whether the previous checkCycles() call had finished; with cycle1/cycle2
|
|
1093
|
+
// each potentially taking minutes, that races. Use setTimeout that re-arms
|
|
1094
|
+
// itself only after the prior tick resolves, plus an in-flight guard so a
|
|
1095
|
+
// stray manual call cannot stack ticks.
|
|
1096
|
+
let _checkCyclesInFlight = false
|
|
1097
|
+
async function _runCheckCyclesGuarded() {
|
|
1098
|
+
if (_checkCyclesInFlight) return
|
|
1099
|
+
_checkCyclesInFlight = true
|
|
1100
|
+
try { await checkCycles() }
|
|
1101
|
+
catch (e) { process.stderr.write(`[cycle-tick] error: ${e.message}\n`) }
|
|
1102
|
+
finally { _checkCyclesInFlight = false }
|
|
1103
|
+
}
|
|
1104
|
+
function _scheduleNextCheck() {
|
|
1105
|
+
_cycleInterval = setTimeout(async () => {
|
|
1106
|
+
_cycleInterval = null
|
|
1107
|
+
try {
|
|
1108
|
+
await _runCheckCyclesGuarded()
|
|
1109
|
+
} catch (e) {
|
|
1110
|
+
process.stderr.write(`[cycle-tick] re-arm guard caught: ${e?.message || e}\n`)
|
|
1111
|
+
} finally {
|
|
1112
|
+
// Re-arm regardless of inner outcome — _runCheckCyclesGuarded already
|
|
1113
|
+
// swallows its own errors, but defensive try/finally guarantees the
|
|
1114
|
+
// periodic tick continues even if a synchronous throw escapes.
|
|
1115
|
+
if (_cyclesActive) _scheduleNextCheck()
|
|
1116
|
+
}
|
|
1117
|
+
}, 60_000)
|
|
1118
|
+
}
|
|
1119
|
+
let _cyclesActive = false
|
|
1120
|
+
let _transcriptWatcher = null
|
|
1121
|
+
function _startCycles() {
|
|
1122
|
+
if (_cyclesActive) return
|
|
1123
|
+
_cyclesActive = true
|
|
1124
|
+
_scheduleNextCheck()
|
|
1125
|
+
_startupTimeout = setTimeout(() => { void _runCheckCyclesGuarded() }, 30_000)
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
function _stopCycles() {
|
|
1129
|
+
_cyclesActive = false
|
|
1130
|
+
if (_cycleInterval) { clearTimeout(_cycleInterval); _cycleInterval = null }
|
|
1131
|
+
if (_startupTimeout) { clearTimeout(_startupTimeout); _startupTimeout = null }
|
|
1132
|
+
if (_transcriptWatcher) { try { _transcriptWatcher.stop() } catch {} _transcriptWatcher = null }
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
async function _initRuntime() {
|
|
1136
|
+
if (_initialized) return
|
|
1137
|
+
await _initStore()
|
|
1138
|
+
// Restore the core_entries.id == 1..N invariant once per boot: SERIAL only
|
|
1139
|
+
// increments, so deleted rows leave permanent gaps. Fast no-op when already
|
|
1140
|
+
// contiguous (or empty). Runs only here — never in cycle2/addCore/deleteCore.
|
|
1141
|
+
await compactCoreIds(DATA_DIR)
|
|
1142
|
+
_transcriptWatcher = _initTranscriptWatcher()
|
|
1143
|
+
_startCycles()
|
|
1144
|
+
_initialized = true
|
|
1145
|
+
// Boot complete — continue straight into the deferred embedding warmup.
|
|
1146
|
+
// Fire-and-forget on the embedding worker thread; never awaited so it does
|
|
1147
|
+
// not delay init() returning or the memory-ready signal.
|
|
1148
|
+
fireDeferredEmbeddingWarmup()
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
function _beginRuntimeInit() {
|
|
1152
|
+
if (_initialized) return Promise.resolve()
|
|
1153
|
+
if (!_initPromise) {
|
|
1154
|
+
_initPromise = _initRuntime().catch((e) => {
|
|
1155
|
+
process.stderr.write(`[memory-service] runtime init failed: ${e?.stack || e?.message || e}\n`)
|
|
1156
|
+
throw e
|
|
1157
|
+
})
|
|
1158
|
+
}
|
|
1159
|
+
return _initPromise
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
function fmtDateOnly(d) {
|
|
1163
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
function parsePeriod(period, hasQuery) {
|
|
1167
|
+
if (!period && hasQuery) period = '30d'
|
|
1168
|
+
if (!period) return null
|
|
1169
|
+
if (period === 'all') return null
|
|
1170
|
+
if (period === 'last') return { mode: 'last' }
|
|
1171
|
+
// Calendar-day windows: 'today' anchors at local midnight rather than
|
|
1172
|
+
// rolling 24h. Without this, a query asking 'today' at 01:30 would silently
|
|
1173
|
+
// include yesterday's last 22.5h of activity, mislabelling them as
|
|
1174
|
+
// 'today's work'. 'yesterday' is the previous calendar day.
|
|
1175
|
+
if (period === 'today') {
|
|
1176
|
+
const start = new Date()
|
|
1177
|
+
start.setHours(0, 0, 0, 0)
|
|
1178
|
+
return { startMs: start.getTime(), endMs: Date.now() }
|
|
1179
|
+
}
|
|
1180
|
+
if (period === 'yesterday') {
|
|
1181
|
+
const start = new Date()
|
|
1182
|
+
start.setDate(start.getDate() - 1)
|
|
1183
|
+
start.setHours(0, 0, 0, 0)
|
|
1184
|
+
const end = new Date(start)
|
|
1185
|
+
end.setHours(23, 59, 59, 999)
|
|
1186
|
+
return { startMs: start.getTime(), endMs: end.getTime() }
|
|
1187
|
+
}
|
|
1188
|
+
if (period === 'this_week' || period === 'last_week') {
|
|
1189
|
+
// R6 P9: calendar Mon-Sun previous/current week. Mon-start ISO
|
|
1190
|
+
// convention. Replaces R5 rolling 7-14d range which was empty for
|
|
1191
|
+
// sessions where "last week" decisions actually fell on Mon (4/27) of
|
|
1192
|
+
// this week. Precise calendar bounds match natural-language intuition.
|
|
1193
|
+
const d = new Date()
|
|
1194
|
+
d.setHours(0, 0, 0, 0)
|
|
1195
|
+
const dayOfWeek = d.getDay()
|
|
1196
|
+
const daysSinceMon = (dayOfWeek + 6) % 7
|
|
1197
|
+
const thisWeekMon = new Date(d)
|
|
1198
|
+
thisWeekMon.setDate(d.getDate() - daysSinceMon)
|
|
1199
|
+
if (period === 'this_week') {
|
|
1200
|
+
return { startMs: thisWeekMon.getTime(), endMs: Date.now() }
|
|
1201
|
+
}
|
|
1202
|
+
const lastWeekMon = new Date(thisWeekMon)
|
|
1203
|
+
lastWeekMon.setDate(thisWeekMon.getDate() - 7)
|
|
1204
|
+
const lastWeekSunEnd = new Date(thisWeekMon.getTime() - 1)
|
|
1205
|
+
return { startMs: lastWeekMon.getTime(), endMs: lastWeekSunEnd.getTime() }
|
|
1206
|
+
}
|
|
1207
|
+
const relMatch = period.match(/^(\d+)(m|h|d)$/)
|
|
1208
|
+
if (relMatch) {
|
|
1209
|
+
const n = parseInt(relMatch[1])
|
|
1210
|
+
const unit = relMatch[2]
|
|
1211
|
+
const now = new Date()
|
|
1212
|
+
if (unit === 'm') {
|
|
1213
|
+
// Minute granularity is for "resume from the previous turn / pick
|
|
1214
|
+
// up where we left off" style recall — sub-hour windows where 1h
|
|
1215
|
+
// is too coarse. n=0 is invalid (the regex requires \d+ which
|
|
1216
|
+
// matches "0" but a zero-width window returns no rows; leave that
|
|
1217
|
+
// as caller-supplied no-op).
|
|
1218
|
+
const start = new Date(now.getTime() - n * 60_000)
|
|
1219
|
+
return { startMs: start.getTime(), endMs: now.getTime() }
|
|
1220
|
+
}
|
|
1221
|
+
if (unit === 'h') {
|
|
1222
|
+
const start = new Date(now.getTime() - n * 3600_000)
|
|
1223
|
+
return { startMs: start.getTime(), endMs: now.getTime() }
|
|
1224
|
+
}
|
|
1225
|
+
const start = new Date(now)
|
|
1226
|
+
start.setDate(start.getDate() - n)
|
|
1227
|
+
return { startMs: start.getTime(), endMs: now.getTime() }
|
|
1228
|
+
}
|
|
1229
|
+
const rangeMatch = period.match(/^(\d{4}-\d{2}-\d{2})~(\d{4}-\d{2}-\d{2})$/)
|
|
1230
|
+
if (rangeMatch) {
|
|
1231
|
+
return {
|
|
1232
|
+
startMs: Date.parse(rangeMatch[1] + 'T00:00:00'),
|
|
1233
|
+
endMs: Date.parse(rangeMatch[2] + 'T23:59:59.999'),
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
const dateMatch = period.match(/^(\d{4}-\d{2}-\d{2})$/)
|
|
1237
|
+
if (dateMatch) {
|
|
1238
|
+
return {
|
|
1239
|
+
startMs: Date.parse(dateMatch[1] + 'T00:00:00'),
|
|
1240
|
+
endMs: Date.parse(dateMatch[1] + 'T23:59:59.999'),
|
|
1241
|
+
exact: true,
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
return null
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
function formatTs(tsMs) {
|
|
1248
|
+
const n = Number(tsMs)
|
|
1249
|
+
if (Number.isFinite(n) && n > 1e12) {
|
|
1250
|
+
return new Date(n).toLocaleString('sv-SE').slice(0, 16)
|
|
1251
|
+
}
|
|
1252
|
+
return String(tsMs ?? '').slice(0, 16)
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
async function handleSearch(args, signal) {
|
|
1256
|
+
// Cooperative abort check: throw early if the caller already aborted
|
|
1257
|
+
// (IPC cancel handler signals the AbortController before re-entry).
|
|
1258
|
+
if (signal?.aborted) throw signal.reason ?? new Error('aborted')
|
|
1259
|
+
// id mode (follow-up lookup): caller passed `#N` markers from a prior
|
|
1260
|
+
// recall result. Fetch those rows directly + their chunk members,
|
|
1261
|
+
// bypassing hybrid search entirely. Output reuses renderEntryLines so
|
|
1262
|
+
// the shape stays identical to the search path (chunk members first,
|
|
1263
|
+
// root summary fallback).
|
|
1264
|
+
if (Array.isArray(args.ids) && args.ids.length > 0) {
|
|
1265
|
+
const ids = args.ids
|
|
1266
|
+
.map(v => Number(v))
|
|
1267
|
+
.filter(v => Number.isFinite(v) && v > 0)
|
|
1268
|
+
if (ids.length === 0) return { text: '(no valid ids)' }
|
|
1269
|
+
const includeArchived = args.includeArchived !== false
|
|
1270
|
+
const category = args.category
|
|
1271
|
+
const period = String(args.period ?? '').trim() || undefined
|
|
1272
|
+
const temporal = parsePeriod(period, false)
|
|
1273
|
+
let projectScope
|
|
1274
|
+
if (typeof args.projectScope === 'string' && args.projectScope) {
|
|
1275
|
+
projectScope = args.projectScope
|
|
1276
|
+
} else {
|
|
1277
|
+
const projectId = resolveProjectScope(typeof args.cwd === 'string' && args.cwd ? args.cwd : null)
|
|
1278
|
+
projectScope = projectId !== null ? projectId : 'common'
|
|
1279
|
+
}
|
|
1280
|
+
const excludeStatuses = includeArchived ? [] : ['archived']
|
|
1281
|
+
const rows = await fetchEntriesByIdsScoped(db, ids, {
|
|
1282
|
+
ts_from: temporal?.startMs,
|
|
1283
|
+
ts_to: temporal?.endMs,
|
|
1284
|
+
excludeStatuses,
|
|
1285
|
+
category,
|
|
1286
|
+
projectScope,
|
|
1287
|
+
})
|
|
1288
|
+
if (rows.length === 0) return { text: '(no results)' }
|
|
1289
|
+
// Members for any root rows in the result set.
|
|
1290
|
+
const rootIds = rows.filter(r => r.is_root === 1).map(r => Number(r.id))
|
|
1291
|
+
const memberLeafIds = new Set()
|
|
1292
|
+
if (rootIds.length > 0) {
|
|
1293
|
+
const { rows: memberRows } = await db.query(
|
|
1294
|
+
`SELECT id, ts, role, content, chunk_root
|
|
1295
|
+
FROM entries WHERE chunk_root = ANY($1::bigint[]) AND is_root = 0
|
|
1296
|
+
ORDER BY ts ASC, id ASC`,
|
|
1297
|
+
[rootIds],
|
|
1298
|
+
)
|
|
1299
|
+
const membersByRoot = new Map()
|
|
1300
|
+
for (const m of memberRows) {
|
|
1301
|
+
const k = Number(m.chunk_root)
|
|
1302
|
+
if (!membersByRoot.has(k)) membersByRoot.set(k, [])
|
|
1303
|
+
membersByRoot.get(k).push(m)
|
|
1304
|
+
memberLeafIds.add(Number(m.id))
|
|
1305
|
+
}
|
|
1306
|
+
for (const r of rows) {
|
|
1307
|
+
if (r.is_root === 1) r.members = membersByRoot.get(Number(r.id)) ?? []
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
// Preserve caller-supplied id order; drop leaves already inlined as a
|
|
1311
|
+
// root's chunk member to prevent double emission when the caller names
|
|
1312
|
+
// a root and one of its leaves in the same batch.
|
|
1313
|
+
const byId = new Map(rows.map(r => [Number(r.id), r]))
|
|
1314
|
+
const ordered = ids
|
|
1315
|
+
.map(id => byId.get(id))
|
|
1316
|
+
.filter(Boolean)
|
|
1317
|
+
.filter(r => !(r.is_root === 0 && memberLeafIds.has(Number(r.id))))
|
|
1318
|
+
return { text: renderEntryLines(ordered) }
|
|
1319
|
+
}
|
|
1320
|
+
// Array query — fan out in parallel, each query runs its own hybrid search
|
|
1321
|
+
// path, and results are grouped in the response so the caller sees one
|
|
1322
|
+
// ranked list per angle. Collapses what would otherwise be N sequential
|
|
1323
|
+
// tool calls into a single invocation.
|
|
1324
|
+
if (Array.isArray(args.query)) {
|
|
1325
|
+
// Dedup + fan-out cap. The cap protects the result envelope from
|
|
1326
|
+
// over-eager callers (20+ near-duplicate queries N× the IO) without
|
|
1327
|
+
// silently swallowing the caller's intent: when the input exceeds
|
|
1328
|
+
// QUERIES_CAP, prepend a one-line note so the caller can see the
|
|
1329
|
+
// truncation and re-shape their query list.
|
|
1330
|
+
const QUERIES_CAP = 5
|
|
1331
|
+
const dedup = [...new Set(args.query.map(q => String(q || '').trim()).filter(Boolean))]
|
|
1332
|
+
if (dedup.length === 0) return { text: '' }
|
|
1333
|
+
const queries = dedup.slice(0, QUERIES_CAP)
|
|
1334
|
+
const dropped = dedup.length - queries.length
|
|
1335
|
+
const rest = { ...args }
|
|
1336
|
+
delete rest.query
|
|
1337
|
+
const deadlineSec = Math.max(1, Number(process.env.MEMORY_FANOUT_DEADLINE_S) || 180)
|
|
1338
|
+
const deadlineMs = deadlineSec * 1000
|
|
1339
|
+
const fanOutAbort = new AbortController()
|
|
1340
|
+
let deadlineTimer
|
|
1341
|
+
const deadlineRace = new Promise((_res, rej) => {
|
|
1342
|
+
deadlineTimer = setTimeout(() => {
|
|
1343
|
+
fanOutAbort.abort(new Error(`memory fan-out deadline exceeded (${deadlineSec}s)`))
|
|
1344
|
+
rej(Object.assign(new Error(`memory fan-out deadline exceeded (${deadlineSec}s)`), { _deadline: true }))
|
|
1345
|
+
}, deadlineMs)
|
|
1346
|
+
})
|
|
1347
|
+
let settled
|
|
1348
|
+
try {
|
|
1349
|
+
// Pre-warm the per-query embedding cache with one batched ONNX run so
|
|
1350
|
+
// each sub-search lands an embedText cache hit (~0ms) instead of
|
|
1351
|
+
// serially queueing through the worker's single-flight inference lock.
|
|
1352
|
+
// Replaces N sequential ~130ms inferences with one ~150-200ms batch.
|
|
1353
|
+
//
|
|
1354
|
+
// Race against the same deadline as the fan-out itself: a stuck
|
|
1355
|
+
// embedding worker would previously park here indefinitely because
|
|
1356
|
+
// the timer hadn't been started yet from the fan-out's perspective.
|
|
1357
|
+
await Promise.race([embedTexts(queries), deadlineRace])
|
|
1358
|
+
settled = await Promise.race([
|
|
1359
|
+
Promise.all(queries.map(async (q) => {
|
|
1360
|
+
if (fanOutAbort.signal.aborted) throw fanOutAbort.signal.reason
|
|
1361
|
+
if (signal?.aborted) throw signal.reason ?? new Error('aborted')
|
|
1362
|
+
const sub = await handleSearch({ ...rest, query: q }, signal)
|
|
1363
|
+
return `[${q}]\n${sub.text || '(no results)'}`
|
|
1364
|
+
})),
|
|
1365
|
+
deadlineRace,
|
|
1366
|
+
])
|
|
1367
|
+
} catch (err) {
|
|
1368
|
+
throw err
|
|
1369
|
+
} finally {
|
|
1370
|
+
clearTimeout(deadlineTimer)
|
|
1371
|
+
}
|
|
1372
|
+
const parts = settled
|
|
1373
|
+
const header = dropped > 0
|
|
1374
|
+
? `note: ${dedup.length} queries received, ${queries.length} processed, ${dropped} dropped (cap ${QUERIES_CAP})\n\n`
|
|
1375
|
+
: ''
|
|
1376
|
+
return { text: header + parts.join('\n\n') }
|
|
1377
|
+
}
|
|
1378
|
+
const query = String(args.query ?? '').trim()
|
|
1379
|
+
let period = String(args.period ?? '').trim() || undefined
|
|
1380
|
+
// Period and sort are caller-supplied only. Lead is responsible for
|
|
1381
|
+
// mapping vague time phrases / chronological intent into the period
|
|
1382
|
+
// argument before calling; the engine does not infer them from query
|
|
1383
|
+
// text.
|
|
1384
|
+
const RECALL_LIMIT_CAP = 100
|
|
1385
|
+
const RECALL_OFFSET_CAP = 500
|
|
1386
|
+
const requestedLimit = Number(args.limit)
|
|
1387
|
+
const requestedOffset = Number(args.offset)
|
|
1388
|
+
let limit = Math.max(1, Number.isFinite(requestedLimit) ? requestedLimit : 10)
|
|
1389
|
+
let offset = Math.max(0, Number.isFinite(requestedOffset) ? requestedOffset : 0)
|
|
1390
|
+
const recallCapNotes = []
|
|
1391
|
+
if (Number.isFinite(requestedLimit) && requestedLimit > RECALL_LIMIT_CAP) {
|
|
1392
|
+
limit = RECALL_LIMIT_CAP
|
|
1393
|
+
recallCapNotes.push(`limit capped to ${RECALL_LIMIT_CAP} (requested ${requestedLimit})`)
|
|
1394
|
+
} else {
|
|
1395
|
+
limit = Math.min(RECALL_LIMIT_CAP, limit)
|
|
1396
|
+
}
|
|
1397
|
+
if (Number.isFinite(requestedOffset) && requestedOffset > RECALL_OFFSET_CAP) {
|
|
1398
|
+
offset = RECALL_OFFSET_CAP
|
|
1399
|
+
recallCapNotes.push(`offset capped to ${RECALL_OFFSET_CAP} (requested ${requestedOffset})`)
|
|
1400
|
+
} else {
|
|
1401
|
+
offset = Math.min(RECALL_OFFSET_CAP, offset)
|
|
1402
|
+
}
|
|
1403
|
+
const recallCapPrefix = recallCapNotes.length ? `${recallCapNotes.join('; ')}\n` : ''
|
|
1404
|
+
const sort = args.sort != null ? String(args.sort) : 'importance'
|
|
1405
|
+
// Chunk content is the primary recall output. Members default to true so
|
|
1406
|
+
// callers receive the raw chunk leaves (the cycle1-produced semantic
|
|
1407
|
+
// chunks) rather than just the root's cycle2-compressed summary line.
|
|
1408
|
+
// Explicit `includeMembers:false` keeps the legacy summary-only mode.
|
|
1409
|
+
const includeMembers = args.includeMembers !== false
|
|
1410
|
+
const includeRaw = Boolean(args.includeRaw)
|
|
1411
|
+
const includeArchived = args.includeArchived !== false
|
|
1412
|
+
const category = args.category
|
|
1413
|
+
const temporal = parsePeriod(period, Boolean(query))
|
|
1414
|
+
|
|
1415
|
+
// Derive projectScope from caller cwd (falls back to process.cwd()).
|
|
1416
|
+
// Explicit args.projectScope (string) takes priority so callers can
|
|
1417
|
+
// override to 'all', 'common', or a specific slug.
|
|
1418
|
+
let projectScope
|
|
1419
|
+
if (typeof args.projectScope === 'string' && args.projectScope) {
|
|
1420
|
+
projectScope = args.projectScope
|
|
1421
|
+
} else {
|
|
1422
|
+
const projectId = resolveProjectScope(typeof args.cwd === 'string' && args.cwd ? args.cwd : null)
|
|
1423
|
+
projectScope = projectId !== null ? projectId : 'common'
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
// R11 reviewer M4: calendar-bounded periods disable freshness decay
|
|
1427
|
+
// so within-period ranking doesn't downgrade Mon entries vs Sun.
|
|
1428
|
+
const CALENDAR_PERIODS = new Set(['yesterday', 'today', 'this_week', 'last_week'])
|
|
1429
|
+
const isCalendarPeriod = period != null
|
|
1430
|
+
&& (CALENDAR_PERIODS.has(period) || /^\d{4}-\d{2}-\d{2}/.test(period))
|
|
1431
|
+
const applyFreshness = !isCalendarPeriod
|
|
1432
|
+
|
|
1433
|
+
if (query) {
|
|
1434
|
+
const _t0 = Date.now()
|
|
1435
|
+
if (signal?.aborted) throw signal.reason ?? new Error('aborted')
|
|
1436
|
+
const queryVector = await embedText(query)
|
|
1437
|
+
if (signal?.aborted) throw signal.reason ?? new Error('aborted')
|
|
1438
|
+
const _t1 = Date.now()
|
|
1439
|
+
if (process.env.MIXDOG_DEBUG_MEMORY) {
|
|
1440
|
+
process.stderr.write(`[search-time] embed=${_t1 - _t0}ms query="${query.slice(0, 60)}"\n`)
|
|
1441
|
+
}
|
|
1442
|
+
// Push ts and status filters into the hybrid candidate query so FTS / vec
|
|
1443
|
+
// rank inside the requested window, not the whole tree. The previous post-
|
|
1444
|
+
// filter approach silently emptied results when relevant matches sat
|
|
1445
|
+
// outside `period` (default 30d) and could not bubble through.
|
|
1446
|
+
// Recall is history-first: archived roots hold most prior work. Callers
|
|
1447
|
+
// that need only live invariants can pass includeArchived:false.
|
|
1448
|
+
const excludeStatuses = includeArchived ? [] : ['archived']
|
|
1449
|
+
const results = await searchRelevantHybrid(db, query, {
|
|
1450
|
+
limit: limit + offset,
|
|
1451
|
+
queryVector: Array.isArray(queryVector) ? queryVector : null,
|
|
1452
|
+
includeMembers,
|
|
1453
|
+
ts_from: temporal?.startMs,
|
|
1454
|
+
ts_to: temporal?.endMs,
|
|
1455
|
+
applyFreshness,
|
|
1456
|
+
projectScope,
|
|
1457
|
+
category,
|
|
1458
|
+
excludeStatuses,
|
|
1459
|
+
// useHotActive was set to true here so default (no-period) calls
|
|
1460
|
+
// routed through the mv_hot_active materialized view — a narrow
|
|
1461
|
+
// active-roots-only pool. Live usage is dominated by vague-time
|
|
1462
|
+
// queries ("recent / lately") where Lead callers omit the period
|
|
1463
|
+
// filter, leaving the MV as the sole source. That hid every
|
|
1464
|
+
// orphan leaf and every pending root — fresh work from the last 1-60
|
|
1465
|
+
// minutes never surfaced. Now that the entries-table CTE legs run
|
|
1466
|
+
// against broaden HNSW + GIN trgm partial indexes (the
|
|
1467
|
+
// is_root=1 predicate was dropped in the same revision), the
|
|
1468
|
+
// entries path is fast enough (1-2 ms ANN on ~10K rows, O(log N)
|
|
1469
|
+
// through 1M+) to be the single source of truth. The MV is left in
|
|
1470
|
+
// place for now but no longer routed to from search; cycle2 may stop
|
|
1471
|
+
// refreshing it in a follow-up commit once nothing else reads it.
|
|
1472
|
+
useHotActive: false,
|
|
1473
|
+
})
|
|
1474
|
+
let filtered = results
|
|
1475
|
+
if (sort === 'date') {
|
|
1476
|
+
// R11 reviewer L5: NaN guard — entries with null/undefined ts default
|
|
1477
|
+
// to 0 so the comparator stays numeric and stable.
|
|
1478
|
+
filtered.sort((a, b) => (Number(b.ts) || 0) - (Number(a.ts) || 0))
|
|
1479
|
+
} else {
|
|
1480
|
+
filtered.sort((a, b) => {
|
|
1481
|
+
const sa = (v) => { const n = Number(v); return Number.isFinite(n) ? n : 0 }
|
|
1482
|
+
return (sa(b.retrievalScore ?? b.rrf ?? 0) - sa(a.retrievalScore ?? a.rrf ?? 0))
|
|
1483
|
+
|| (sa(b.score ?? 0) - sa(a.score ?? 0))
|
|
1484
|
+
|| (sa(b.ts ?? 0) - sa(a.ts ?? 0))
|
|
1485
|
+
|| (Number(a.id ?? 0) - Number(b.id ?? 0))
|
|
1486
|
+
})
|
|
1487
|
+
}
|
|
1488
|
+
if (includeRaw) {
|
|
1489
|
+
// Reserve slots for raw rows under sort=importance: hybrid rows are
|
|
1490
|
+
// already score-sorted descending, so a full hybrid page (limit rows)
|
|
1491
|
+
// would shut out raw rows entirely after slice(offset, offset+limit).
|
|
1492
|
+
// Reserve up to RAW_RESERVE slots near the top of the post-slice
|
|
1493
|
+
// window by trimming the hybrid prefix before merging, then re-sort
|
|
1494
|
+
// for sort=date or otherwise append (already ranked) for importance.
|
|
1495
|
+
const RAW_FETCH = 20
|
|
1496
|
+
const rawRows = await readRawRowsInWindow(
|
|
1497
|
+
db,
|
|
1498
|
+
temporal?.startMs ?? null,
|
|
1499
|
+
temporal?.endMs ?? Date.now(),
|
|
1500
|
+
RAW_FETCH,
|
|
1501
|
+
{ projectScope },
|
|
1502
|
+
)
|
|
1503
|
+
const seenIds = new Set(filtered.map(r => r.id))
|
|
1504
|
+
const newRaw = rawRows.filter(r => !seenIds.has(r.id))
|
|
1505
|
+
if (sort === 'date') {
|
|
1506
|
+
for (const r of newRaw) filtered.push(r)
|
|
1507
|
+
filtered.sort((a, b) => (Number(b.ts) || 0) - (Number(a.ts) || 0))
|
|
1508
|
+
} else {
|
|
1509
|
+
// sort=importance: append raw rows after the hybrid page (mostly
|
|
1510
|
+
// ineffective — slice(offset, offset+limit) typically shuts them
|
|
1511
|
+
// out). Proper includeRaw paging fix deferred (needs fetching extra rows / paging redesign).
|
|
1512
|
+
for (const r of newRaw) filtered.push(r)
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
const sliced = filtered.slice(offset, offset + limit)
|
|
1516
|
+
const _t2 = Date.now()
|
|
1517
|
+
if (process.env.MIXDOG_DEBUG_MEMORY) {
|
|
1518
|
+
process.stderr.write(`[search-time] hybrid+sort+raw=${_t2 - _t1}ms rows=${filtered.length} sliced=${sliced.length}\n`)
|
|
1519
|
+
}
|
|
1520
|
+
// Emit a recall trace event so getTraceWithEntries() can correlate
|
|
1521
|
+
// this search with the top-ranked memory entry. One event per
|
|
1522
|
+
// handleSearch call (not per returned row) — cheapest meaningful link.
|
|
1523
|
+
// parent_span_id left null: the bridge-side span id is only known after
|
|
1524
|
+
// the DB insert of the loop/tool events, which happens async on the
|
|
1525
|
+
// client side and is not available here.
|
|
1526
|
+
if (_traceDb && filtered.length > 0) {
|
|
1527
|
+
const topHit = filtered[0]
|
|
1528
|
+
const topId = topHit?.id != null ? Number(topHit.id) : null
|
|
1529
|
+
if (topId !== null && Number.isFinite(topId)) {
|
|
1530
|
+
insertTraceEvents(_traceDb, [{
|
|
1531
|
+
ts: Date.now(),
|
|
1532
|
+
kind: 'recall',
|
|
1533
|
+
entry_id: topId,
|
|
1534
|
+
payload: { query: query.slice(0, 200), hit_count: filtered.length },
|
|
1535
|
+
}]).catch(e => process.stderr.write(`[trace] insertTraceEvents error: ${e?.message}\n`))
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
const out = { text: recallCapPrefix + renderEntryLines(sliced) }
|
|
1539
|
+
if (process.env.MIXDOG_DEBUG_MEMORY) {
|
|
1540
|
+
process.stderr.write(`[search-time] render+trace=${Date.now() - _t2}ms total=${Date.now() - _t0}ms textLen=${out.text.length}\n`)
|
|
1541
|
+
}
|
|
1542
|
+
return out
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
const filters = { limit: limit + offset }
|
|
1546
|
+
if (temporal?.startMs != null) { filters.ts_from = temporal.startMs; filters.ts_to = temporal.endMs }
|
|
1547
|
+
if (temporal?.mode === 'last' && _bootTimestamp) {
|
|
1548
|
+
filters.ts_to = _bootTimestamp - 1
|
|
1549
|
+
}
|
|
1550
|
+
filters.projectScope = projectScope
|
|
1551
|
+
if (category != null) filters.category = category
|
|
1552
|
+
filters.sort = sort
|
|
1553
|
+
if (!includeArchived) filters.excludeStatuses = ['archived']
|
|
1554
|
+
if (includeMembers) filters.includeMembers = true
|
|
1555
|
+
const rows = await retrieveEntries(db, filters)
|
|
1556
|
+
const sliced = rows.slice(offset, offset + limit)
|
|
1557
|
+
return { text: recallCapPrefix + renderEntryLines(sliced) }
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
function renderEntryLines(rows) {
|
|
1561
|
+
if (!rows || rows.length === 0) return '(no results)'
|
|
1562
|
+
const lines = []
|
|
1563
|
+
// Bound total emitted lines (roots x members) so a many-member recall can't
|
|
1564
|
+
// inject unbounded output. Per-line content is already capped at 1000 chars;
|
|
1565
|
+
// this caps the line COUNT. Narrow the query (limit/period/projectScope) for more.
|
|
1566
|
+
const RECALL_LINE_CAP = 200
|
|
1567
|
+
let _capped = false
|
|
1568
|
+
outer:
|
|
1569
|
+
for (const r of rows) {
|
|
1570
|
+
const hasMembers = Array.isArray(r.members) && r.members.length > 0
|
|
1571
|
+
if (hasMembers) {
|
|
1572
|
+
// Chunks present: emit each member as its own line. Root row is a
|
|
1573
|
+
// grouping artifact for retrieval — the caller wants the chunk
|
|
1574
|
+
// content (cycle1 raw), not the cycle2-compressed summary.
|
|
1575
|
+
for (const m of r.members) {
|
|
1576
|
+
if (lines.length >= RECALL_LINE_CAP) { _capped = true; break outer }
|
|
1577
|
+
const mTs = formatTs(m.ts)
|
|
1578
|
+
const role = m.role === 'user' ? 'u' : m.role === 'assistant' ? 'a' : (m.role || '?')
|
|
1579
|
+
const content = cleanMemoryText(String(m.content ?? '')).slice(0, 1000)
|
|
1580
|
+
lines.push(`[${mTs}] ${role}: ${content} #${m.id}`)
|
|
1581
|
+
}
|
|
1582
|
+
} else {
|
|
1583
|
+
if (lines.length >= RECALL_LINE_CAP) { _capped = true; break }
|
|
1584
|
+
// No chunks (root not yet chunked by cycle1, or orphan leaf): emit
|
|
1585
|
+
// the row itself in the same shape. element/summary fall back to
|
|
1586
|
+
// raw content when both are absent.
|
|
1587
|
+
const ts = formatTs(r.ts)
|
|
1588
|
+
const element = r.element ?? ''
|
|
1589
|
+
const summary = r.summary ?? ''
|
|
1590
|
+
// Standalone leaf rows (is_root=0, no parent chunks_root resolved
|
|
1591
|
+
// into a `members` list) carry their u/a role just like inline
|
|
1592
|
+
// chunk members — surface it so the format stays consistent across
|
|
1593
|
+
// the two emission paths.
|
|
1594
|
+
const rolePrefix = r.is_root === 0 && r.role
|
|
1595
|
+
? (r.role === 'user' ? 'u: ' : r.role === 'assistant' ? 'a: ' : `${r.role}: `)
|
|
1596
|
+
: ''
|
|
1597
|
+
const body = element || summary
|
|
1598
|
+
? `${element}${summary ? ' — ' + summary : ''}`
|
|
1599
|
+
: cleanMemoryText(String(r.content ?? '')).slice(0, 1000)
|
|
1600
|
+
lines.push(`[${ts}] ${rolePrefix}${body.slice(0, 1000)} #${r.id}`)
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
if (_capped) lines.push(`[recall truncated — showing first ${RECALL_LINE_CAP} lines; narrow the query (limit/period/projectScope) for the rest]`)
|
|
1604
|
+
return lines.join('\n')
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
async function entryStats() {
|
|
1608
|
+
return await db.transaction(async (tx) => {
|
|
1609
|
+
const total = (await tx.query(`SELECT COUNT(*) c FROM entries`)).rows[0].c
|
|
1610
|
+
const roots = (await tx.query(`SELECT COUNT(*) c FROM entries WHERE is_root = 1`)).rows[0].c
|
|
1611
|
+
const active_roots = (await tx.query(`SELECT COUNT(*) c FROM entries WHERE is_root = 1 AND status = 'active'`)).rows[0].c
|
|
1612
|
+
const archived_roots = (await tx.query(`SELECT COUNT(*) c FROM entries WHERE is_root = 1 AND status = 'archived'`)).rows[0].c
|
|
1613
|
+
const unchunked_leaves = (await tx.query(`SELECT COUNT(*) c FROM entries WHERE chunk_root IS NULL`)).rows[0].c
|
|
1614
|
+
const cycle2_pending_roots = (await tx.query(`SELECT COUNT(*) c FROM entries WHERE is_root = 1 AND status = 'pending'`)).rows[0].c
|
|
1615
|
+
const core_entries = (await tx.query(`SELECT COUNT(*) c FROM core_entries`)).rows[0].c
|
|
1616
|
+
const core_embed_null = (await tx.query(`SELECT COUNT(*) c FROM core_entries WHERE embedding IS NULL`)).rows[0].c
|
|
1617
|
+
const active_core_summaries = (await tx.query(`SELECT COUNT(*) c FROM entries WHERE is_root = 1 AND status = 'active' AND core_summary IS NOT NULL`)).rows[0].c
|
|
1618
|
+
const active_core_summary_missing = (await tx.query(`
|
|
1619
|
+
SELECT COUNT(*) c
|
|
1620
|
+
FROM entries
|
|
1621
|
+
WHERE is_root = 1
|
|
1622
|
+
AND status = 'active'
|
|
1623
|
+
AND (core_summary IS NULL OR btrim(core_summary) = '')
|
|
1624
|
+
`)).rows[0].c
|
|
1625
|
+
const byStatus = (await tx.query(`SELECT status, COUNT(*) c FROM entries WHERE is_root = 1 GROUP BY status`)).rows
|
|
1626
|
+
const byCategory = (await tx.query(`SELECT category, COUNT(*) c FROM entries WHERE is_root = 1 AND status = 'active' GROUP BY category ORDER BY c DESC`)).rows
|
|
1627
|
+
const mvRows = (await tx.query(`SELECT relispopulated FROM pg_class WHERE relname = 'mv_hot_active' LIMIT 1`)).rows
|
|
1628
|
+
const mv_hot_active_populated = mvRows.length ? Boolean(mvRows[0].relispopulated) : null
|
|
1629
|
+
return {
|
|
1630
|
+
total, roots, active_roots, archived_roots, unchunked_leaves, cycle2_pending_roots,
|
|
1631
|
+
core_entries, core_embed_null, active_core_summaries, active_core_summary_missing,
|
|
1632
|
+
mv_hot_active_populated,
|
|
1633
|
+
byStatus, byCategory,
|
|
1634
|
+
}
|
|
1635
|
+
})
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
async function _handleMemCycle1(args, config, signal) {
|
|
1639
|
+
const minBatchOverride = Number(args?.min_batch)
|
|
1640
|
+
const sessionCapOverride = Number(args?.session_cap)
|
|
1641
|
+
const batchSizeOverride = Number(args?.batch_size)
|
|
1642
|
+
const concurrencyOverride = Number(args?.concurrency)
|
|
1643
|
+
const baseCycle1 = config?.cycle1 || {}
|
|
1644
|
+
let cycle1Config = baseCycle1
|
|
1645
|
+
// _runCycle1Impl reads `config?.min_batch ?? config?.cycle1?.min_batch ??
|
|
1646
|
+
// default` — top-level wins, so pin the override at top-level only.
|
|
1647
|
+
if (Number.isFinite(minBatchOverride) && minBatchOverride > 0) {
|
|
1648
|
+
cycle1Config = { ...cycle1Config, min_batch: minBatchOverride }
|
|
1649
|
+
}
|
|
1650
|
+
if (Number.isFinite(sessionCapOverride) && sessionCapOverride > 0) {
|
|
1651
|
+
cycle1Config = { ...cycle1Config, session_cap: sessionCapOverride }
|
|
1652
|
+
}
|
|
1653
|
+
if (Number.isFinite(batchSizeOverride) && batchSizeOverride > 0) {
|
|
1654
|
+
cycle1Config = { ...cycle1Config, batch_size: batchSizeOverride }
|
|
1655
|
+
}
|
|
1656
|
+
if (Number.isFinite(concurrencyOverride) && concurrencyOverride > 0) {
|
|
1657
|
+
cycle1Config = { ...cycle1Config, concurrency: Math.min(8, Math.floor(concurrencyOverride)) }
|
|
1658
|
+
}
|
|
1659
|
+
const callerDeadlineMs = Number(args?._callerDeadlineMs) || 0
|
|
1660
|
+
if (signal?.aborted) throw signal.reason ?? new Error('aborted')
|
|
1661
|
+
const cycle1Options = callerDeadlineMs > 0 ? { callerDeadlineMs, signal } : { signal }
|
|
1662
|
+
const result = await _awaitCycle1Run(
|
|
1663
|
+
cycle1Config,
|
|
1664
|
+
cycle1Options,
|
|
1665
|
+
)
|
|
1666
|
+
if (signal?.aborted) throw signal.reason ?? new Error('aborted')
|
|
1667
|
+
const pendingStr = result?.pendingRows != null ? result.pendingRows : 0
|
|
1668
|
+
const inFlightStr = result?.skippedInFlight === true ? 'true' : 'false'
|
|
1669
|
+
const timedOutPart = result?.timedOutWaiting === true ? ' timedOut=true' : ''
|
|
1670
|
+
const omitted = Array.isArray(result?.omitted_row_ids) ? result.omitted_row_ids.length : Number(result?.quality?.omitted_rows || 0)
|
|
1671
|
+
const prefiltered = Array.isArray(result?.prefiltered_row_ids) ? result.prefiltered_row_ids.length : Number(result?.quality?.prefiltered_rows || 0)
|
|
1672
|
+
const failedRows = Array.isArray(result?.failed_row_ids) ? result.failed_row_ids.length : Number(result?.quality?.failed_rows || 0)
|
|
1673
|
+
const invalidChunks = Array.isArray(result?.invalid_chunks) ? result.invalid_chunks.length : Number(result?.quality?.invalid_chunks || 0)
|
|
1674
|
+
return {
|
|
1675
|
+
text: `cycle1: chunks=${result.chunks} processed=${result.processed} skipped_chunks=${result.skipped}` +
|
|
1676
|
+
` omitted=${omitted} prefiltered=${prefiltered} failed_rows=${failedRows} invalid_chunks=${invalidChunks}` +
|
|
1677
|
+
` pending=${pendingStr} inFlight=${inFlightStr}${timedOutPart}`,
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
async function _handleMemCycle2(args, config, signal) {
|
|
1682
|
+
if (signal?.aborted) throw signal.reason ?? new Error('aborted')
|
|
1683
|
+
const result = await runCycle2(db, config?.cycle2 || {}, { signal }, DATA_DIR)
|
|
1684
|
+
if (signal?.aborted) throw signal.reason ?? new Error('aborted')
|
|
1685
|
+
await _finalizeCycle2Run(result)
|
|
1686
|
+
const counts = {
|
|
1687
|
+
promoted: result?.promoted || 0,
|
|
1688
|
+
archived: result?.archived || 0,
|
|
1689
|
+
merged: result?.merged || 0,
|
|
1690
|
+
updated: result?.updated || 0,
|
|
1691
|
+
kept: result?.kept || 0,
|
|
1692
|
+
rejected_verb: result?.rejected_verb || 0,
|
|
1693
|
+
merge_rejected: result?.merge_rejected || 0,
|
|
1694
|
+
missing_core: result?.missing_core_summary || 0,
|
|
1695
|
+
core_backfill: result?.core_embedding_backfill || 0,
|
|
1696
|
+
cascade_drop: result?.cascade?.dropped || 0,
|
|
1697
|
+
phase_merge: result?.phase_merge?.merged || 0,
|
|
1698
|
+
core_overlap: result?.phase_merge?.core_overlap || 0,
|
|
1699
|
+
}
|
|
1700
|
+
const parts = Object.entries(counts).filter(([, v]) => v > 0).map(([k, v]) => `${k}=${v}`)
|
|
1701
|
+
if (parts.length) return { text: `cycle2 ${parts.join(' ')}` }
|
|
1702
|
+
// No applied counts — disambiguate the "noop" so a broken gate is visible
|
|
1703
|
+
// instead of looking like a clean, nothing-to-do run.
|
|
1704
|
+
let cause = ''
|
|
1705
|
+
if (result?.skippedInFlight) cause = ' (skipped: in-flight)'
|
|
1706
|
+
else if (result?.ok === false) cause = ` (error: ${result.error || 'unknown'})`
|
|
1707
|
+
else if (result?.gate_failed) cause = ' (gate_failed)'
|
|
1708
|
+
return { text: `cycle2 noop${cause}` }
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
async function _handleMemCycle3(args, config, signal) {
|
|
1712
|
+
if (signal?.aborted) throw signal.reason ?? new Error('aborted')
|
|
1713
|
+
const confirmed = args?.confirm === 'APPLY CYCLE3'
|
|
1714
|
+
const requestedMode = typeof args?.cycle3Mode === 'string' ? args.cycle3Mode : null
|
|
1715
|
+
const applyMode = confirmed
|
|
1716
|
+
? 'confirmed'
|
|
1717
|
+
: (requestedMode === 'proposal' || requestedMode === 'dry-run' || requestedMode === 'dryrun')
|
|
1718
|
+
? 'proposal'
|
|
1719
|
+
: 'conservative'
|
|
1720
|
+
const result = await runCycle3(db, config || {}, DATA_DIR, { signal, apply: confirmed ? true : undefined, applyMode })
|
|
1721
|
+
if (signal?.aborted) throw signal.reason ?? new Error('aborted')
|
|
1722
|
+
const parts = ['reviewed', 'kept', 'updated', 'merged', 'deleted']
|
|
1723
|
+
.map(k => `${k}=${result?.[k] || 0}`)
|
|
1724
|
+
if (result?.proposed) {
|
|
1725
|
+
parts.push(`proposal_update=${result.proposed.updated || 0}`)
|
|
1726
|
+
parts.push(`proposal_merge=${result.proposed.merged || 0}`)
|
|
1727
|
+
parts.push(`proposal_delete=${result.proposed.deleted || 0}`)
|
|
1728
|
+
}
|
|
1729
|
+
if (result?.held) {
|
|
1730
|
+
parts.push(`held_update=${result.held.updated || 0}`)
|
|
1731
|
+
parts.push(`held_merge=${result.held.merged || 0}`)
|
|
1732
|
+
parts.push(`held_delete=${result.held.deleted || 0}`)
|
|
1733
|
+
}
|
|
1734
|
+
parts.push(`mode=${result?.applyMode || applyMode}`)
|
|
1735
|
+
parts.push(`applied=${result?.applied === true ? 'true' : 'false'}`)
|
|
1736
|
+
if (result?.skippedInFlight) parts.push('inFlight=true')
|
|
1737
|
+
const errPart = result?.error ? ` error=${result.error}` : ''
|
|
1738
|
+
return { text: `cycle3 ${parts.join(' ')}${errPart}` }
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
async function _handleMemFlush(args, config, signal) {
|
|
1742
|
+
if (signal?.aborted) throw signal.reason ?? new Error('aborted')
|
|
1743
|
+
const r1 = await _awaitCycle1Run(config?.cycle1 || {}, { signal })
|
|
1744
|
+
if (signal?.aborted) throw signal.reason ?? new Error('aborted')
|
|
1745
|
+
const r2 = await runCycle2(db, config?.cycle2 || {}, { signal }, DATA_DIR)
|
|
1746
|
+
if (signal?.aborted) throw signal.reason ?? new Error('aborted')
|
|
1747
|
+
await _finalizeCycle2Run(r2)
|
|
1748
|
+
return { text: `flush: cycle1 chunks=${r1.chunks} processed=${r1.processed}, cycle2 ${JSON.stringify(r2)}` }
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
async function _handleMemStatus(args, config) {
|
|
1752
|
+
const stats = await entryStats()
|
|
1753
|
+
const last = await getCycleLastRun()
|
|
1754
|
+
let dims = 0
|
|
1755
|
+
let dimsErr = null
|
|
1756
|
+
try {
|
|
1757
|
+
const raw = await getMetaValue(db, 'embedding.current_dims', null)
|
|
1758
|
+
if (raw != null) dims = Number(JSON.parse(raw))
|
|
1759
|
+
if (!Number.isFinite(dims)) dims = 0
|
|
1760
|
+
} catch (e) {
|
|
1761
|
+
// Surface the error in the status line instead of masquerading a meta
|
|
1762
|
+
// read failure as dims=0 (which is indistinguishable from a fresh,
|
|
1763
|
+
// pre-bootstrap DB). Keep status callable so other lines still render.
|
|
1764
|
+
dims = 0
|
|
1765
|
+
dimsErr = e?.message || String(e)
|
|
1766
|
+
}
|
|
1767
|
+
const bootstrapComplete = await isBootstrapComplete(db)
|
|
1768
|
+
const lastCycle1Ago = last.cycle1 ? `${Math.round((Date.now() - last.cycle1) / 60000)}m ago` : 'never'
|
|
1769
|
+
const lastCycle2Ago = last.cycle2 ? `${Math.round((Date.now() - last.cycle2) / 60000)}m ago` : 'never'
|
|
1770
|
+
const activeTargetCap = Number.isFinite(Number(config?.cycle2?.active_target_cap))
|
|
1771
|
+
? Number(config?.cycle2?.active_target_cap)
|
|
1772
|
+
: CYCLE2_ACTIVE_TARGET_CAP
|
|
1773
|
+
const mvState = stats.mv_hot_active_populated === null
|
|
1774
|
+
? 'missing'
|
|
1775
|
+
: stats.mv_hot_active_populated ? 'populated' : 'unpopulated'
|
|
1776
|
+
const lines = [
|
|
1777
|
+
`entries: total=${stats.total} roots=${stats.roots} cycle1_raw=${stats.unchunked_leaves} (unchunked leaves) cycle2_pending=${stats.cycle2_pending_roots} (awaiting cycle2 review)`,
|
|
1778
|
+
`status: ${stats.byStatus.map(r => `${r.status ?? '?'}:${r.c}`).join(', ') || 'empty'}`,
|
|
1779
|
+
`categories(active): ${stats.byCategory.map(r => `${r.category ?? 'NULL'}:${r.c}`).join(', ') || 'empty'} active_target_cap=${activeTargetCap}`,
|
|
1780
|
+
`core_memory: user=${stats.core_entries} embed_null=${stats.core_embed_null} active_core=${stats.active_core_summaries} active_missing_core=${stats.active_core_summary_missing}`,
|
|
1781
|
+
`embedding_index: ready dims=${dims}${dimsErr ? ` (meta_read_error: ${dimsErr})` : ''}`,
|
|
1782
|
+
`recall_index: mv_hot_active=${mvState}`,
|
|
1783
|
+
`bootstrap: ${bootstrapComplete ? 'complete' : 'incomplete'}`,
|
|
1784
|
+
`last_cycle1: ${lastCycle1Ago}`,
|
|
1785
|
+
`last_cycle2: ${lastCycle2Ago}`,
|
|
1786
|
+
...(last.cycle2_last_error ? [`last_cycle2_error: ${last.cycle2_last_error}`] : []),
|
|
1787
|
+
]
|
|
1788
|
+
return { text: lines.join('\n') }
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
async function _handleMemRebuild(args, config, signal) {
|
|
1792
|
+
if (args.confirm !== 'REBUILD MEMORY') {
|
|
1793
|
+
return { text: 'rebuild requires confirm: "REBUILD MEMORY" (truncates classification columns and re-runs cycles)', isError: true }
|
|
1794
|
+
}
|
|
1795
|
+
// Drain any pre-reset cycle1 BEFORE the destructive truncation so the
|
|
1796
|
+
// post-reset run is not started concurrently against the same DB.
|
|
1797
|
+
// _awaitCycle1Run() may release the outer handle on a caller deadline while
|
|
1798
|
+
// the inner runCycle1 promise still owns the DB writes. Drain both layers,
|
|
1799
|
+
// then loop once more if one layer exposed another promise while awaiting.
|
|
1800
|
+
const drainedCycle1Promises = new Set()
|
|
1801
|
+
for (;;) {
|
|
1802
|
+
const pendingCycle1Promises = [_cycle1InFlight, getInFlightCycle1(db)]
|
|
1803
|
+
.filter(p => p && !drainedCycle1Promises.has(p))
|
|
1804
|
+
if (pendingCycle1Promises.length === 0) break
|
|
1805
|
+
for (const pendingCycle1 of pendingCycle1Promises) {
|
|
1806
|
+
drainedCycle1Promises.add(pendingCycle1)
|
|
1807
|
+
try { await pendingCycle1 } catch {}
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
if (signal?.aborted) throw signal.reason ?? new Error('aborted')
|
|
1811
|
+
// Cleanup must run BEFORE demotion: the original order demoted normal
|
|
1812
|
+
// roots (chunk_root = id) to is_root = 0 first, then ran the cleanup
|
|
1813
|
+
// WHERE is_root = 1 — which missed exactly those demoted rows, leaving
|
|
1814
|
+
// stale element/category/summary/score/embedding/summary_hash on rows that
|
|
1815
|
+
// had just become raw leaves. Reorder so all roots get their classification
|
|
1816
|
+
// columns cleared while is_root = 1 still selects them, then demote.
|
|
1817
|
+
// Wrap the whole destructive sequence in one transaction so a mid-step
|
|
1818
|
+
// failure rolls back rather than leaving a mixed state.
|
|
1819
|
+
await db.transaction(async (tx) => {
|
|
1820
|
+
await tx.query(`
|
|
1821
|
+
UPDATE entries
|
|
1822
|
+
SET element = NULL, category = NULL, summary = NULL,
|
|
1823
|
+
status = 'pending', score = NULL, last_seen_at = NULL,
|
|
1824
|
+
embedding = NULL, summary_hash = NULL,
|
|
1825
|
+
core_summary = NULL, reviewed_at = NULL, promoted_at = NULL,
|
|
1826
|
+
error_count = 0
|
|
1827
|
+
WHERE is_root = 1
|
|
1828
|
+
`)
|
|
1829
|
+
await tx.query(`UPDATE entries SET chunk_root = NULL, is_root = 0 WHERE chunk_root = id`)
|
|
1830
|
+
await tx.query(`UPDATE entries SET chunk_root = NULL WHERE is_root = 0`)
|
|
1831
|
+
await tx.query(`
|
|
1832
|
+
UPDATE entries
|
|
1833
|
+
SET status = NULL,
|
|
1834
|
+
element = NULL, category = NULL, summary = NULL,
|
|
1835
|
+
score = NULL, last_seen_at = NULL,
|
|
1836
|
+
embedding = NULL, summary_hash = NULL,
|
|
1837
|
+
core_summary = NULL, reviewed_at = NULL, promoted_at = NULL,
|
|
1838
|
+
error_count = 0
|
|
1839
|
+
WHERE is_root = 0
|
|
1840
|
+
`)
|
|
1841
|
+
})
|
|
1842
|
+
if (signal?.aborted) throw signal.reason ?? new Error('aborted')
|
|
1843
|
+
// Force a fresh post-reset cycle1: _cycle1InFlight is guaranteed null
|
|
1844
|
+
// here (we drained above and have not awaited any cycle1-starting call
|
|
1845
|
+
// since), so calling _startCycle1Run directly skips the coalesce branch
|
|
1846
|
+
// inside _awaitCycle1Run and guarantees the newly demoted rows are read.
|
|
1847
|
+
const r1 = await _startCycle1Run(config?.cycle1 || {}, { signal })
|
|
1848
|
+
if (signal?.aborted) throw signal.reason ?? new Error('aborted')
|
|
1849
|
+
const r2 = await runCycle2(db, config?.cycle2 || {}, { signal }, DATA_DIR)
|
|
1850
|
+
await _finalizeCycle2Run(r2)
|
|
1851
|
+
return { text: `rebuild: cycle1 chunks=${r1.chunks} processed=${r1.processed}, cycle2 ${JSON.stringify(r2)}` }
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
async function _handleMemPrune(args, config) {
|
|
1855
|
+
if (args.confirm !== 'PRUNE OLD ENTRIES') {
|
|
1856
|
+
return { text: 'prune requires confirm: "PRUNE OLD ENTRIES" (permanently deletes unclassified entries older than maxDays)', isError: true }
|
|
1857
|
+
}
|
|
1858
|
+
const days = Math.max(1, Number(args.maxDays ?? 30))
|
|
1859
|
+
const result = await pruneOldEntries(db, days)
|
|
1860
|
+
return { text: `prune: deleted ${result.deleted} unclassified entries older than ${days} days` }
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
async function _handleMemBackfill(args, config, signal) {
|
|
1864
|
+
// Whole-action mutex (transport-agnostic). _cycle1InFlight only protects
|
|
1865
|
+
// cycle1; ingest workers + cycle2 can still overlap if a second backfill
|
|
1866
|
+
// kicks in (timeout-retry, parallel callers, /api/tool vs /mcp vs
|
|
1867
|
+
// /admin/backfill). Sentinel is set synchronously before any await so a
|
|
1868
|
+
// burst of concurrent calls cannot all pass the check.
|
|
1869
|
+
if (_backfillInFlight) {
|
|
1870
|
+
return { text: 'backfill already in progress', isError: true }
|
|
1871
|
+
}
|
|
1872
|
+
if (signal?.aborted) throw signal.reason ?? new Error('aborted')
|
|
1873
|
+
const window = args.window != null ? String(args.window) : '7d'
|
|
1874
|
+
const scope = args.scope != null ? String(args.scope) : 'all'
|
|
1875
|
+
const limit = args.limit != null ? Math.max(1, Number(args.limit)) : null
|
|
1876
|
+
// Capture the cycle2 envelope so we can route through _finalizeCycle2Run
|
|
1877
|
+
// (which records cycle2_last_error and clears scheduler delay only on
|
|
1878
|
+
// ok:true) rather than stamping cycle2 unconditionally afterward.
|
|
1879
|
+
let _capturedCycle2
|
|
1880
|
+
const promise = runFullBackfill(db, {
|
|
1881
|
+
window,
|
|
1882
|
+
scope,
|
|
1883
|
+
limit,
|
|
1884
|
+
config,
|
|
1885
|
+
dataDir: DATA_DIR,
|
|
1886
|
+
ingestTranscriptFile,
|
|
1887
|
+
cwdFromTranscriptPath,
|
|
1888
|
+
// Re-check the IPC cancel signal at every cycle1/cycle2 iteration the
|
|
1889
|
+
// backfill driver dispatches. handleMemoryAction only checks once
|
|
1890
|
+
// before dispatch; without per-iteration checkpoints a long-running
|
|
1891
|
+
// backfill keeps spinning through ingest + cycle1 + cycle2 batches
|
|
1892
|
+
// after the proxy has already responded "cancelled" to the caller.
|
|
1893
|
+
runCycle1: (dbArg, cycle1Config = {}, options = {}, dir) => {
|
|
1894
|
+
if (signal?.aborted) throw signal.reason ?? new Error('aborted')
|
|
1895
|
+
return _awaitCycle1Run(cycle1Config, { ...options, signal })
|
|
1896
|
+
},
|
|
1897
|
+
runCycle2: async (dbArg, c2Config, c2Options, c2DataDir) => {
|
|
1898
|
+
if (signal?.aborted) throw signal.reason ?? new Error('aborted')
|
|
1899
|
+
const r2 = await runCycle2(dbArg, c2Config, { ...c2Options, signal }, c2DataDir)
|
|
1900
|
+
_capturedCycle2 = r2
|
|
1901
|
+
return r2
|
|
1902
|
+
},
|
|
1903
|
+
})
|
|
1904
|
+
_backfillInFlight = promise
|
|
1905
|
+
let result
|
|
1906
|
+
try {
|
|
1907
|
+
result = await promise
|
|
1908
|
+
} finally {
|
|
1909
|
+
if (_backfillInFlight === promise) _backfillInFlight = null
|
|
1910
|
+
}
|
|
1911
|
+
if (signal?.aborted) throw signal.reason ?? new Error('aborted')
|
|
1912
|
+
if (_capturedCycle2) {
|
|
1913
|
+
await _finalizeCycle2Run(_capturedCycle2)
|
|
1914
|
+
}
|
|
1915
|
+
return {
|
|
1916
|
+
text: `backfill: window=${result.window} scope=${result.scope} files=${result.files} ingested=${result.ingested} cycle1_iters=${result.cycle1_iters} promoted=${result.promoted} unclassified=${result.unclassified}`,
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
async function handleMemoryAction(args, signal) {
|
|
1921
|
+
// Cooperative abort check: surfaces caller-cancel (IPC cancel handler)
|
|
1922
|
+
// before any long DB work begins on the worker side.
|
|
1923
|
+
if (signal?.aborted) throw signal.reason ?? new Error('aborted')
|
|
1924
|
+
const action = String(args.action ?? '')
|
|
1925
|
+
const config = readMainConfig()
|
|
1926
|
+
|
|
1927
|
+
if (action === 'status') {
|
|
1928
|
+
return _handleMemStatus(args, config)
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
if (action === 'cycle1') {
|
|
1932
|
+
return _handleMemCycle1(args, config, signal)
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
if (action === 'cycle2' || action === 'sleep') {
|
|
1936
|
+
return _handleMemCycle2(args, config, signal)
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
if (action === 'cycle3') {
|
|
1940
|
+
return _handleMemCycle3(args, config, signal)
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
// Direct semantic-search surface for callers that want raw ranked rows
|
|
1944
|
+
// without going through the Lead-side recall synthesizer. The
|
|
1945
|
+
// handleSearch executor is exposed through the public `memory` tool action
|
|
1946
|
+
// `search` so callers can hit the hybrid CTE directly.
|
|
1947
|
+
if (action === 'search') {
|
|
1948
|
+
return handleSearch(args, signal)
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
if (action === 'flush') {
|
|
1952
|
+
return _handleMemFlush(args, config, signal)
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
if (action === 'rebuild') {
|
|
1956
|
+
return _handleMemRebuild(args, config, signal)
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
if (action === 'prune') {
|
|
1960
|
+
return _handleMemPrune(args, config)
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
if (action === 'backfill') {
|
|
1964
|
+
return _handleMemBackfill(args, config, signal)
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
if (action === 'manage') {
|
|
1968
|
+
const op = String(args.op ?? '').trim().toLowerCase()
|
|
1969
|
+
if (!['add', 'edit', 'delete'].includes(op)) {
|
|
1970
|
+
return { text: 'manage requires op: "add" | "edit" | "delete"', isError: true }
|
|
1971
|
+
}
|
|
1972
|
+
const VALID_CAT = new Set(['rule', 'constraint', 'decision', 'fact', 'goal', 'preference', 'task', 'issue'])
|
|
1973
|
+
const VALID_STATUS = new Set(['pending', 'active', 'archived'])
|
|
1974
|
+
|
|
1975
|
+
if (op === 'add') {
|
|
1976
|
+
const element = String(args.element ?? '').trim()
|
|
1977
|
+
const summary = String(args.summary ?? args.element ?? '').trim()
|
|
1978
|
+
const category = String(args.category ?? 'fact').trim().toLowerCase()
|
|
1979
|
+
if (!element || !summary) {
|
|
1980
|
+
return { text: 'manage add requires element and summary', isError: true }
|
|
1981
|
+
}
|
|
1982
|
+
if (!VALID_CAT.has(category)) {
|
|
1983
|
+
return { text: `manage add: invalid category "${category}". Valid: ${[...VALID_CAT].join(', ')}`, isError: true }
|
|
1984
|
+
}
|
|
1985
|
+
const nowMs = Date.now()
|
|
1986
|
+
const sourceRef = `manual:${nowMs}-${process.pid}`
|
|
1987
|
+
const manageProjectId = resolveProjectScope(typeof args.cwd === 'string' && args.cwd ? args.cwd : null)
|
|
1988
|
+
try {
|
|
1989
|
+
let newId
|
|
1990
|
+
await db.transaction(async (tx) => {
|
|
1991
|
+
const result = await tx.query(`
|
|
1992
|
+
INSERT INTO entries(ts, role, content, source_ref, session_id, project_id)
|
|
1993
|
+
VALUES ($1, 'system', $2, $3, NULL, $4)
|
|
1994
|
+
RETURNING id
|
|
1995
|
+
`, [nowMs, element + ' — ' + summary, sourceRef, manageProjectId])
|
|
1996
|
+
newId = Number(result.rows[0].id)
|
|
1997
|
+
const score = computeEntryScore(category, nowMs, nowMs)
|
|
1998
|
+
await tx.query(`
|
|
1999
|
+
UPDATE entries
|
|
2000
|
+
SET chunk_root = $1, is_root = 1, element = $2, category = $3, summary = $4,
|
|
2001
|
+
status = 'active', score = $5, last_seen_at = $6
|
|
2002
|
+
WHERE id = $7
|
|
2003
|
+
`, [newId, element, category, summary, score, nowMs, newId])
|
|
2004
|
+
})
|
|
2005
|
+
await syncRootEmbedding(db, newId)
|
|
2006
|
+
return { text: `added (id=${newId}): [${category}] ${element} — ${summary.slice(0, 200)}` }
|
|
2007
|
+
} catch (e) {
|
|
2008
|
+
return { text: `manage add failed: ${e.message}`, isError: true }
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
if (op === 'edit') {
|
|
2013
|
+
const id = Number(args.id)
|
|
2014
|
+
if (!Number.isFinite(id) || id <= 0) {
|
|
2015
|
+
return { text: 'manage edit requires numeric id', isError: true }
|
|
2016
|
+
}
|
|
2017
|
+
const existing = (await db.query(
|
|
2018
|
+
`SELECT id, element, summary, category, status, ts, is_root FROM entries WHERE id = $1`,
|
|
2019
|
+
[id]
|
|
2020
|
+
)).rows[0]
|
|
2021
|
+
if (!existing) return { text: `manage edit: no entry with id=${id}`, isError: true }
|
|
2022
|
+
if (existing.is_root !== 1) return { text: `manage edit: id=${id} is not a root`, isError: true }
|
|
2023
|
+
|
|
2024
|
+
const trimOrNull = v => {
|
|
2025
|
+
if (v == null) return null
|
|
2026
|
+
const s = String(v).trim()
|
|
2027
|
+
return s === '' ? null : s
|
|
2028
|
+
}
|
|
2029
|
+
const newElement = trimOrNull(args.element)
|
|
2030
|
+
const newSummary = trimOrNull(args.summary)
|
|
2031
|
+
const newCategory = trimOrNull(args.category)?.toLowerCase() ?? null
|
|
2032
|
+
const newStatus = trimOrNull(args.status)?.toLowerCase() ?? null
|
|
2033
|
+
|
|
2034
|
+
if (!newElement && !newSummary && !newCategory && !newStatus) {
|
|
2035
|
+
return { text: 'manage edit requires at least one field: element, summary, category, status', isError: true }
|
|
2036
|
+
}
|
|
2037
|
+
if (newCategory && !VALID_CAT.has(newCategory)) {
|
|
2038
|
+
return { text: `manage edit: invalid category "${newCategory}". Valid: ${[...VALID_CAT].join(', ')}`, isError: true }
|
|
2039
|
+
}
|
|
2040
|
+
if (newStatus && !VALID_STATUS.has(newStatus)) {
|
|
2041
|
+
return { text: `manage edit: invalid status "${newStatus}". Valid: ${[...VALID_STATUS].join(', ')}`, isError: true }
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
const finalElement = newElement ?? existing.element
|
|
2045
|
+
const finalSummary = newSummary ?? existing.summary
|
|
2046
|
+
const finalCategory = newCategory ?? existing.category
|
|
2047
|
+
const finalStatus = newStatus ?? existing.status
|
|
2048
|
+
const nowMs = Date.now()
|
|
2049
|
+
const score = computeEntryScore(finalCategory, nowMs, nowMs)
|
|
2050
|
+
const textChanged = newElement != null || newSummary != null
|
|
2051
|
+
// Guard null element/summary: a category/status-only edit on a root
|
|
2052
|
+
// whose element or summary is NULL would otherwise persist literal
|
|
2053
|
+
// 'null — null' content and explode on finalSummary.slice() below.
|
|
2054
|
+
// Use empty-string sentinels for the content composition + render so
|
|
2055
|
+
// the row stays consistent with what's actually stored.
|
|
2056
|
+
const elementStr = finalElement == null ? '' : String(finalElement)
|
|
2057
|
+
const summaryStr = finalSummary == null ? '' : String(finalSummary)
|
|
2058
|
+
const composedContent = elementStr || summaryStr
|
|
2059
|
+
? `${elementStr}${summaryStr ? ' — ' + summaryStr : ''}`
|
|
2060
|
+
: ''
|
|
2061
|
+
|
|
2062
|
+
try {
|
|
2063
|
+
await db.query(`
|
|
2064
|
+
UPDATE entries
|
|
2065
|
+
SET element = $1, summary = $2, category = $3, status = $4, score = $5,
|
|
2066
|
+
last_seen_at = $6, content = $7
|
|
2067
|
+
WHERE id = $8
|
|
2068
|
+
`, [finalElement, finalSummary, finalCategory, finalStatus, score,
|
|
2069
|
+
nowMs, composedContent, id])
|
|
2070
|
+
} catch (e) {
|
|
2071
|
+
return { text: `manage edit failed: ${e.message}`, isError: true }
|
|
2072
|
+
}
|
|
2073
|
+
if (textChanged) {
|
|
2074
|
+
try { await syncRootEmbedding(db, id) } catch (e) {
|
|
2075
|
+
process.stderr.write(`[memory.manage] embedding resync failed (id=${id}): ${e.message}\n`)
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
return { text: `edited (id=${id}): [${finalCategory}/${finalStatus}] ${elementStr}${summaryStr ? ' — ' + summaryStr.slice(0, 200) : ''}` }
|
|
2079
|
+
}
|
|
2080
|
+
|
|
2081
|
+
if (op === 'delete') {
|
|
2082
|
+
const id = Number(args.id)
|
|
2083
|
+
if (!Number.isFinite(id) || id <= 0) {
|
|
2084
|
+
return { text: 'manage delete requires numeric id', isError: true }
|
|
2085
|
+
}
|
|
2086
|
+
const info = (await db.query(
|
|
2087
|
+
`SELECT id, category, element, is_root FROM entries WHERE id = $1`,
|
|
2088
|
+
[id]
|
|
2089
|
+
)).rows[0]
|
|
2090
|
+
if (!info) return { text: `manage delete: no entry with id=${id}`, isError: true }
|
|
2091
|
+
try {
|
|
2092
|
+
const result = info.is_root === 1
|
|
2093
|
+
? await db.query(`DELETE FROM entries WHERE id = $1 OR chunk_root = $2`, [id, id])
|
|
2094
|
+
: await db.query(`DELETE FROM entries WHERE id = $1`, [id])
|
|
2095
|
+
return { text: `deleted (id=${id}, rows=${Number(result.rowCount ?? result.affectedRows ?? 0)}): [${info.category ?? '-'}] ${info.element ?? ''}` }
|
|
2096
|
+
} catch (e) {
|
|
2097
|
+
return { text: `manage delete failed: ${e.message}`, isError: true }
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
return { text: `manage: unhandled op "${op}"`, isError: true }
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
if (action === 'core') {
|
|
2105
|
+
const op = String(args.op ?? '').trim().toLowerCase()
|
|
2106
|
+
if (!['add', 'edit', 'delete', 'list'].includes(op)) {
|
|
2107
|
+
return { text: 'core requires op: "add" | "edit" | "delete" | "list"', isError: true }
|
|
2108
|
+
}
|
|
2109
|
+
const dataDir = process.env.CLAUDE_PLUGIN_DATA || (typeof DATA_DIR === 'string' ? DATA_DIR : null)
|
|
2110
|
+
if (!dataDir) return { text: 'core: CLAUDE_PLUGIN_DATA unset', isError: true }
|
|
2111
|
+
// Local trim helper — the manage-block trimOrNull at :1807 is scoped to
|
|
2112
|
+
// that branch and unreachable from here.
|
|
2113
|
+
// Normalize project_id: 'common' (case-insensitive) or null → null (COMMON pool); non-empty string → slug.
|
|
2114
|
+
const hasProjectIdKey = Object.prototype.hasOwnProperty.call(args, 'project_id')
|
|
2115
|
+
const projectId = (() => {
|
|
2116
|
+
if (!hasProjectIdKey || args.project_id == null) return null
|
|
2117
|
+
const s = String(args.project_id).trim()
|
|
2118
|
+
if (s === '' || s.toLowerCase() === 'common') return null
|
|
2119
|
+
if (s === '*') return '*'
|
|
2120
|
+
return s
|
|
2121
|
+
})()
|
|
2122
|
+
try {
|
|
2123
|
+
if (projectId === '*' && op !== 'list') {
|
|
2124
|
+
return { text: `core ${op}: project_id "*" only valid for op="list"`, isError: true }
|
|
2125
|
+
}
|
|
2126
|
+
if (op === 'list') {
|
|
2127
|
+
if (projectId !== '*') {
|
|
2128
|
+
const entries = await listCore(dataDir, projectId)
|
|
2129
|
+
if (entries.length === 0) return { text: 'core: empty' }
|
|
2130
|
+
return { text: entries.map(e => `id=${e.id} [${e.category}] ${e.element} — ${String(e.summary || '').slice(0, 200)}`).join('\n') }
|
|
2131
|
+
}
|
|
2132
|
+
// Cross-pool listing — group by project_id, COMMON first
|
|
2133
|
+
const entries = await listCore(dataDir, '*')
|
|
2134
|
+
if (entries.length === 0) return { text: 'core: empty' }
|
|
2135
|
+
const groups = new Map()
|
|
2136
|
+
for (const e of entries) {
|
|
2137
|
+
const key = e.project_id ?? null
|
|
2138
|
+
if (!groups.has(key)) groups.set(key, [])
|
|
2139
|
+
groups.get(key).push(e)
|
|
2140
|
+
}
|
|
2141
|
+
const lines = []
|
|
2142
|
+
for (const [key, rows] of groups) {
|
|
2143
|
+
lines.push(`${key === null ? 'COMMON' : key}:`)
|
|
2144
|
+
for (const e of rows) {
|
|
2145
|
+
lines.push(` id=${e.id} [${e.category}] ${e.element} — ${String(e.summary || '').slice(0, 200)}`)
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
return { text: lines.join('\n') }
|
|
2149
|
+
}
|
|
2150
|
+
if (op === 'add') {
|
|
2151
|
+
if (!hasProjectIdKey) {
|
|
2152
|
+
return { text: 'core add: project_id required — pass "common" for COMMON pool, or project slug like "owner/repo" for scoped pool', isError: true }
|
|
2153
|
+
}
|
|
2154
|
+
const entry = await addCore(dataDir, args, projectId)
|
|
2155
|
+
return { text: `core added (id=${entry.id}): [${entry.category}] ${entry.element} — ${entry.summary.slice(0, 200)}` }
|
|
2156
|
+
}
|
|
2157
|
+
if (op === 'edit') {
|
|
2158
|
+
const entry = await editCore(dataDir, args.id, args)
|
|
2159
|
+
return { text: `core edited (id=${entry.id}): [${entry.category}] ${entry.element} — ${entry.summary.slice(0, 200)}` }
|
|
2160
|
+
}
|
|
2161
|
+
if (op === 'delete') {
|
|
2162
|
+
const removed = await deleteCore(dataDir, args.id)
|
|
2163
|
+
return { text: `core deleted (id=${removed.id}): [${removed.category}] ${removed.element}` }
|
|
2164
|
+
}
|
|
2165
|
+
} catch (e) {
|
|
2166
|
+
return { text: `core ${op} failed: ${e.message}`, isError: true }
|
|
2167
|
+
}
|
|
2168
|
+
return { text: `core: unhandled op "${op}"`, isError: true }
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
if (action === 'purge') {
|
|
2172
|
+
if (args.confirm !== 'DELETE ALL MEMORY') {
|
|
2173
|
+
return { text: 'purge requires confirm: "DELETE ALL MEMORY"', isError: true }
|
|
2174
|
+
}
|
|
2175
|
+
const preCount = (await db.query(`SELECT COUNT(*) c FROM entries`)).rows[0].c
|
|
2176
|
+
const coreCount = (await db.query(`SELECT COUNT(*) c FROM core_entries`)).rows[0].c
|
|
2177
|
+
try {
|
|
2178
|
+
await db.query(`DELETE FROM entries`)
|
|
2179
|
+
} catch (e) {
|
|
2180
|
+
return { text: `purge failed: ${e.message}`, isError: true }
|
|
2181
|
+
}
|
|
2182
|
+
return { text: `purged generated memory entries (count=${preCount}); user core preserved (core_entries=${coreCount})` }
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
if (action === 'retro_eval_active') {
|
|
2186
|
+
if (args.confirm !== 'REEVAL ACTIVE') {
|
|
2187
|
+
return { text: 'retro_eval_active requires confirm: "REEVAL ACTIVE" (heavy LLM batch op — reviews all active roots through the unified gate)', isError: true }
|
|
2188
|
+
}
|
|
2189
|
+
const RETRO_BATCH = 50
|
|
2190
|
+
const cycle2Config = config?.cycle2 || {}
|
|
2191
|
+
const allActive = (await db.query(
|
|
2192
|
+
`SELECT id, element, category, summary, score, last_seen_at, project_id, status
|
|
2193
|
+
FROM entries WHERE is_root = 1 AND status = 'active'
|
|
2194
|
+
ORDER BY reviewed_at ASC, id ASC`
|
|
2195
|
+
)).rows
|
|
2196
|
+
const total = allActive.length
|
|
2197
|
+
let archived = 0, kept = 0, updated = 0, merged = 0, errors = 0
|
|
2198
|
+
const nowMs = Date.now()
|
|
2199
|
+
for (let offset = 0; offset < total; offset += RETRO_BATCH) {
|
|
2200
|
+
const batch = allActive.slice(offset, offset + RETRO_BATCH)
|
|
2201
|
+
const batchIds = batch.map(r => Number(r.id))
|
|
2202
|
+
const activeContext = (await db.query(
|
|
2203
|
+
`SELECT id, element, category, summary, score, last_seen_at, project_id, status
|
|
2204
|
+
FROM entries WHERE is_root = 1 AND status = 'active'
|
|
2205
|
+
ORDER BY score DESC, last_seen_at DESC, id ASC LIMIT 200`
|
|
2206
|
+
)).rows
|
|
2207
|
+
let gateResult
|
|
2208
|
+
try {
|
|
2209
|
+
gateResult = await runUnifiedGate(db, batch, activeContext, cycle2Config, { activeCap: 200 })
|
|
2210
|
+
} catch (err) {
|
|
2211
|
+
process.stderr.write(`[retro_eval_active] runUnifiedGate failed (offset=${offset}): ${err.message}\n`)
|
|
2212
|
+
errors += batch.length
|
|
2213
|
+
continue
|
|
2214
|
+
}
|
|
2215
|
+
if (gateResult?.parseOk === false || gateResult?.actions === null) {
|
|
2216
|
+
errors += batch.length
|
|
2217
|
+
continue
|
|
2218
|
+
}
|
|
2219
|
+
const actions = gateResult?.actions ?? []
|
|
2220
|
+
// Separate explicit `core` summary lines from primary verbs so an
|
|
2221
|
+
// update/merge/active also refreshes the injected core_summary — mirrors
|
|
2222
|
+
// the cycle2 apply path (memory-cycle2.mjs). Without this, retro could
|
|
2223
|
+
// rewrite a root's summary while leaving its core_summary stale.
|
|
2224
|
+
const coreSummaryById = new Map()
|
|
2225
|
+
const primaryActions = []
|
|
2226
|
+
for (const a of actions) {
|
|
2227
|
+
if (a?.action === 'core') {
|
|
2228
|
+
const cid = Number(a.entry_id)
|
|
2229
|
+
const core = String(a.core_summary ?? '').replace(/\s+/g, ' ').trim().slice(0, CORE_SUMMARY_MAX)
|
|
2230
|
+
if (Number.isFinite(cid) && core) coreSummaryById.set(cid, core)
|
|
2231
|
+
} else {
|
|
2232
|
+
primaryActions.push(a)
|
|
2233
|
+
}
|
|
2234
|
+
}
|
|
2235
|
+
const allowed = new Set(batchIds)
|
|
2236
|
+
const rejected = gateResult?.rejected ?? new Set()
|
|
2237
|
+
// Partial-apply contract: rows the gate never returned a verdict for
|
|
2238
|
+
// (missingIds) must NOT be marked reviewed — they are left for a later
|
|
2239
|
+
// run. Exclude both rejected and missing ids from the reviewed set.
|
|
2240
|
+
const missing = new Set((gateResult?.missingIds ?? []).map(Number))
|
|
2241
|
+
const successIds = new Set(batchIds.filter(id => !rejected.has(id) && !missing.has(id)))
|
|
2242
|
+
for (const id of successIds) {
|
|
2243
|
+
try { await db.query(`UPDATE entries SET reviewed_at = $1 WHERE id = $2`, [nowMs, id]) } catch {}
|
|
2244
|
+
}
|
|
2245
|
+
const setCoreSummary = async (entryId, core) => {
|
|
2246
|
+
if (!core) return
|
|
2247
|
+
try { await db.query(`UPDATE entries SET core_summary = $1 WHERE id = $2 AND is_root = 1`, [core, Number(entryId)]) }
|
|
2248
|
+
catch (err) { process.stderr.write(`[retro_eval_active] core_summary update failed (id=${entryId}): ${err.message}\n`) }
|
|
2249
|
+
}
|
|
2250
|
+
if (!primaryActions.length) { kept += batch.filter(r => successIds.has(Number(r.id))).length; continue }
|
|
2251
|
+
const acted = new Set()
|
|
2252
|
+
for (const act of primaryActions) {
|
|
2253
|
+
try {
|
|
2254
|
+
const eid = Number(act?.entry_id)
|
|
2255
|
+
if (!Number.isFinite(eid) || !allowed.has(eid)) continue
|
|
2256
|
+
acted.add(eid)
|
|
2257
|
+
if (act.action === 'archived') {
|
|
2258
|
+
if (await applySimpleStatus(db, eid, 'archived')) archived += 1
|
|
2259
|
+
} else if (act.action === 'active') {
|
|
2260
|
+
// active → active is a keep verdict from the gate.
|
|
2261
|
+
kept += 1
|
|
2262
|
+
await setCoreSummary(eid, coreSummaryById.get(eid))
|
|
2263
|
+
} else if (act.action === 'update') {
|
|
2264
|
+
if (await applyUpdate(db, eid, act.element, act.summary)) updated += 1
|
|
2265
|
+
await setCoreSummary(eid, coreSummaryById.get(eid))
|
|
2266
|
+
} else if (act.action === 'merge') {
|
|
2267
|
+
const targetId = Number(act?.target_id)
|
|
2268
|
+
const sourceIds = Array.isArray(act?.source_ids) ? act.source_ids : []
|
|
2269
|
+
if (!Number.isFinite(targetId) || !allowed.has(targetId)) {
|
|
2270
|
+
process.stderr.write(`[retro_eval_active] merge target outside batch (id=${targetId})\n`)
|
|
2271
|
+
acted.delete(eid)
|
|
2272
|
+
continue
|
|
2273
|
+
}
|
|
2274
|
+
const filteredSources = sourceIds.filter(s => allowed.has(Number(s)))
|
|
2275
|
+
if (filteredSources.length !== sourceIds.length) {
|
|
2276
|
+
process.stderr.write(
|
|
2277
|
+
`[retro_eval_active] merge sources filtered: ${JSON.stringify(sourceIds)} -> ${JSON.stringify(filteredSources)}\n`,
|
|
2278
|
+
)
|
|
2279
|
+
}
|
|
2280
|
+
acted.add(targetId)
|
|
2281
|
+
filteredSources.forEach(s => acted.add(Number(s)))
|
|
2282
|
+
const moved = await applyMerge(db, targetId, filteredSources)
|
|
2283
|
+
if (moved > 0) {
|
|
2284
|
+
merged += moved
|
|
2285
|
+
if (typeof act.element === 'string' || typeof act.summary === 'string') {
|
|
2286
|
+
try {
|
|
2287
|
+
if (await applyUpdate(db, targetId, act.element, act.summary)) updated += 1
|
|
2288
|
+
} catch (err) {
|
|
2289
|
+
process.stderr.write(`[retro_eval_active] merge target update failed (target=${targetId}): ${err.message}\n`)
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
2292
|
+
await setCoreSummary(targetId, coreSummaryById.get(targetId) || coreSummaryById.get(eid))
|
|
2293
|
+
}
|
|
2294
|
+
}
|
|
2295
|
+
} catch (err) {
|
|
2296
|
+
process.stderr.write(`[retro_eval_active] action error (id=${act?.entry_id}): ${err.message}\n`)
|
|
2297
|
+
errors += 1
|
|
2298
|
+
}
|
|
2299
|
+
}
|
|
2300
|
+
// Entries in successIds but not acted-upon (omit / no-op) are kept.
|
|
2301
|
+
kept += batch.filter(r => successIds.has(Number(r.id)) && !acted.has(Number(r.id))).length
|
|
2302
|
+
}
|
|
2303
|
+
return { text: `retro_eval_active: total=${total} archived=${archived} kept=${kept} updated=${updated} merged=${merged} errors=${errors}` }
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
return { text: `unknown memory action: ${action}`, isError: true }
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2309
|
+
async function handleToolCall(name, args, signal) {
|
|
2310
|
+
try {
|
|
2311
|
+
if (name === 'search_memories') {
|
|
2312
|
+
const result = await handleSearch(args || {}, signal)
|
|
2313
|
+
return { content: [{ type: 'text', text: result.text }], isError: result.isError || false }
|
|
2314
|
+
}
|
|
2315
|
+
if (name === 'recall') {
|
|
2316
|
+
// recall is aiWrapped in the unified build; in standalone mode map it to
|
|
2317
|
+
// search_memories so the advertised tool name actually works. Forward
|
|
2318
|
+
// every advertised arg so id/limit/offset/sort/includeArchived/
|
|
2319
|
+
// includeMembers/includeRaw reach handleSearch instead of being dropped.
|
|
2320
|
+
const a = args || {}
|
|
2321
|
+
const searchArgs = {
|
|
2322
|
+
...(a.query !== undefined ? { query: a.query } : {}),
|
|
2323
|
+
...(a.id !== undefined ? { ids: Array.isArray(a.id) ? a.id : [a.id] } : {}),
|
|
2324
|
+
...(a.period ? { period: a.period } : {}),
|
|
2325
|
+
...(a.limit !== undefined ? { limit: a.limit } : {}),
|
|
2326
|
+
...(a.offset !== undefined ? { offset: a.offset } : {}),
|
|
2327
|
+
...(a.sort !== undefined ? { sort: a.sort } : {}),
|
|
2328
|
+
...(a.category !== undefined ? { category: a.category } : {}),
|
|
2329
|
+
...(a.includeArchived !== undefined ? { includeArchived: a.includeArchived } : {}),
|
|
2330
|
+
...(a.includeMembers !== undefined ? { includeMembers: a.includeMembers } : {}),
|
|
2331
|
+
...(a.includeRaw !== undefined ? { includeRaw: a.includeRaw } : {}),
|
|
2332
|
+
...(a.cwd ? { cwd: a.cwd } : {}),
|
|
2333
|
+
...(a.projectScope ? { projectScope: a.projectScope } : {}),
|
|
2334
|
+
}
|
|
2335
|
+
const result = await handleSearch(searchArgs, signal)
|
|
2336
|
+
return { content: [{ type: 'text', text: result.text }], isError: result.isError || false }
|
|
2337
|
+
}
|
|
2338
|
+
if (name === 'memory') {
|
|
2339
|
+
const result = await handleMemoryAction(args || {}, signal)
|
|
2340
|
+
return { content: [{ type: 'text', text: result.text }], isError: result.isError || false }
|
|
2341
|
+
}
|
|
2342
|
+
return { content: [{ type: 'text', text: `unknown tool: ${name}` }], isError: true }
|
|
2343
|
+
} catch (err) {
|
|
2344
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
2345
|
+
return { content: [{ type: 'text', text: `${name} failed: ${msg}` }], isError: true }
|
|
2346
|
+
}
|
|
2347
|
+
}
|
|
2348
|
+
|
|
2349
|
+
const mcp = new Server(
|
|
2350
|
+
{ name: 'mixdog-memory', version: PLUGIN_VERSION },
|
|
2351
|
+
{ capabilities: { tools: {} }, instructions: MEMORY_INSTRUCTIONS_TEXT },
|
|
2352
|
+
)
|
|
2353
|
+
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOL_DEFS }))
|
|
2354
|
+
mcp.setRequestHandler(CallToolRequestSchema, (req) => handleToolCall(req.params.name, req.params.arguments ?? {}))
|
|
2355
|
+
|
|
2356
|
+
function createHttpMcpServer() {
|
|
2357
|
+
const s = new Server(
|
|
2358
|
+
{ name: 'mixdog-memory', version: PLUGIN_VERSION },
|
|
2359
|
+
{ capabilities: { tools: {} }, instructions: MEMORY_INSTRUCTIONS_TEXT },
|
|
2360
|
+
)
|
|
2361
|
+
s.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOL_DEFS }))
|
|
2362
|
+
s.setRequestHandler(CallToolRequestSchema, (req) => handleToolCall(req.params.name, req.params.arguments ?? {}))
|
|
2363
|
+
return s
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
function readBody(req) {
|
|
2367
|
+
return new Promise((resolve, reject) => {
|
|
2368
|
+
const chunks = []
|
|
2369
|
+
req.on('data', c => chunks.push(c))
|
|
2370
|
+
req.on('end', () => {
|
|
2371
|
+
const raw = Buffer.concat(chunks).toString('utf8').trim()
|
|
2372
|
+
if (!raw) { resolve({}); return }
|
|
2373
|
+
try { resolve(JSON.parse(raw)) }
|
|
2374
|
+
catch (error) {
|
|
2375
|
+
const e = new Error(`invalid JSON body: ${error.message}`)
|
|
2376
|
+
e.statusCode = 400
|
|
2377
|
+
reject(e)
|
|
2378
|
+
}
|
|
2379
|
+
})
|
|
2380
|
+
req.on('error', reject)
|
|
2381
|
+
})
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
function sendJson(res, data, status = 200) {
|
|
2385
|
+
const body = JSON.stringify(data)
|
|
2386
|
+
res.writeHead(status, {
|
|
2387
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
2388
|
+
'Content-Length': Buffer.byteLength(body),
|
|
2389
|
+
})
|
|
2390
|
+
res.end(body)
|
|
2391
|
+
}
|
|
2392
|
+
|
|
2393
|
+
function sendError(res, msg, status = 500) {
|
|
2394
|
+
sendJson(res, { error: msg }, status)
|
|
2395
|
+
}
|
|
2396
|
+
|
|
2397
|
+
async function awaitRuntimeReadyForHttp(res) {
|
|
2398
|
+
if (_initialized) return true
|
|
2399
|
+
if (!_initPromise) {
|
|
2400
|
+
sendJson(res, { error: 'memory runtime is starting' }, 503)
|
|
2401
|
+
return false
|
|
2402
|
+
}
|
|
2403
|
+
try {
|
|
2404
|
+
await _initPromise
|
|
2405
|
+
return true
|
|
2406
|
+
} catch (e) {
|
|
2407
|
+
sendJson(res, { error: `memory runtime failed: ${e?.message || e}` }, 503)
|
|
2408
|
+
return false
|
|
2409
|
+
}
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
// Origin/Referer guard for /admin/* mutation routes. Memory-service binds
|
|
2413
|
+
// 127.0.0.1, but browser DNS-rebinding or a stray cross-origin fetch could
|
|
2414
|
+
// still reach destructive endpoints (purge, backfill, entry mutations).
|
|
2415
|
+
// Server-to-server callers (setup-server, hooks) issue raw http.request
|
|
2416
|
+
// without a browser Origin/Referer, so absent headers pass; any non-loopback
|
|
2417
|
+
// Origin/Referer is rejected. Mirrors setup-server.mjs isAllowedOrigin.
|
|
2418
|
+
function isLocalOrigin(req) {
|
|
2419
|
+
const LOOP = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?(\/|$)/i
|
|
2420
|
+
const origin = req.headers.origin || ''
|
|
2421
|
+
const referer = req.headers.referer || ''
|
|
2422
|
+
if (origin && !LOOP.test(origin)) return false
|
|
2423
|
+
if (referer && !LOOP.test(referer)) return false
|
|
2424
|
+
return true
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
function normalizeCoreProjectId(value, { allowStar = false } = {}) {
|
|
2428
|
+
if (value == null) return null
|
|
2429
|
+
const s = String(value).trim()
|
|
2430
|
+
if (!s || s.toLowerCase() === 'common') return null
|
|
2431
|
+
if (allowStar && s === '*') return '*'
|
|
2432
|
+
return s
|
|
2433
|
+
}
|
|
2434
|
+
|
|
2435
|
+
async function buildSessionCoreMemoryPayload(cwd) {
|
|
2436
|
+
const projectId = resolveProjectScope(typeof cwd === 'string' && cwd ? cwd : null)
|
|
2437
|
+
const generatedScopeClause = projectId !== null
|
|
2438
|
+
? `project_id IS NULL OR project_id = $1`
|
|
2439
|
+
: `project_id IS NULL`
|
|
2440
|
+
const dbRows = (await db.query(`
|
|
2441
|
+
SELECT core_summary
|
|
2442
|
+
FROM entries
|
|
2443
|
+
WHERE is_root = 1
|
|
2444
|
+
AND status = 'active'
|
|
2445
|
+
AND core_summary IS NOT NULL
|
|
2446
|
+
AND (${generatedScopeClause})
|
|
2447
|
+
ORDER BY score DESC, last_seen_at DESC
|
|
2448
|
+
`, projectId !== null ? [projectId] : [])).rows
|
|
2449
|
+
const commonRows = (await db.query(
|
|
2450
|
+
`SELECT summary FROM core_entries WHERE project_id IS NULL ORDER BY id ASC`
|
|
2451
|
+
)).rows
|
|
2452
|
+
const scopedRows = projectId !== null
|
|
2453
|
+
? (await db.query(
|
|
2454
|
+
`SELECT summary FROM core_entries WHERE project_id = $1 ORDER BY id ASC`,
|
|
2455
|
+
[projectId]
|
|
2456
|
+
)).rows
|
|
2457
|
+
: []
|
|
2458
|
+
return {
|
|
2459
|
+
projectId,
|
|
2460
|
+
dbLines: dbRows.map(r => String(r.core_summary || '').trim()).filter(Boolean),
|
|
2461
|
+
userLines: [
|
|
2462
|
+
...commonRows.map(r => String(r.summary || '').trim()).filter(Boolean),
|
|
2463
|
+
...scopedRows.map(r => String(r.summary || '').trim()).filter(Boolean),
|
|
2464
|
+
],
|
|
2465
|
+
}
|
|
2466
|
+
}
|
|
2467
|
+
|
|
2468
|
+
// Whole-action backfill mutex. memory-cycle1's _cycle1InFlight only protects
|
|
2469
|
+
// cycle1; ingest workers (memory-ops-policy.mjs) and cycle2 can still overlap
|
|
2470
|
+
// if a second backfill kicks in (e.g. setup-server timeout + retry). Track the
|
|
2471
|
+
// in-flight promise here and reject overlaps with 409.
|
|
2472
|
+
let _backfillInFlight = null
|
|
2473
|
+
|
|
2474
|
+
// Owner-side /api/tool in-flight controllers keyed by caller-supplied
|
|
2475
|
+
// X-Mixdog-Call-Id. /api/cancel aborts the matching AbortSignal so the
|
|
2476
|
+
// upstream handleToolCall actually stops when the fork-proxy parent cancels.
|
|
2477
|
+
const _ownerInFlightHttpCalls = new Map()
|
|
2478
|
+
|
|
2479
|
+
const httpServer = http.createServer(async (req, res) => {
|
|
2480
|
+
if (req.method === 'POST' && req.url === '/session-reset') {
|
|
2481
|
+
_bootTimestamp = Date.now()
|
|
2482
|
+
sendJson(res, { ok: true, bootTimestamp: _bootTimestamp })
|
|
2483
|
+
return
|
|
2484
|
+
}
|
|
2485
|
+
if (req.method === 'POST' && req.url === '/rebind') {
|
|
2486
|
+
_bootTimestamp = Date.now()
|
|
2487
|
+
sendJson(res, { ok: true })
|
|
2488
|
+
return
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
if (req.method === 'GET' && req.url === '/health') {
|
|
2492
|
+
if (!_initialized) {
|
|
2493
|
+
sendJson(res, { status: 'starting' }, 503)
|
|
2494
|
+
return
|
|
2495
|
+
}
|
|
2496
|
+
try {
|
|
2497
|
+
const stats = await entryStats()
|
|
2498
|
+
sendJson(res, {
|
|
2499
|
+
status: 'ok',
|
|
2500
|
+
worker_pid: process.pid,
|
|
2501
|
+
server_pid: Number(process.env.MIXDOG_SERVER_PID) || null,
|
|
2502
|
+
owner_lead_pid: Number(process.env.MIXDOG_OWNER_LEAD_PID) || null,
|
|
2503
|
+
code_fingerprint: BOOT_PROMOTION_CODE_FINGERPRINT,
|
|
2504
|
+
bootstrap: await isBootstrapComplete(db),
|
|
2505
|
+
entries: stats.total,
|
|
2506
|
+
roots: stats.roots,
|
|
2507
|
+
active_roots: stats.active_roots,
|
|
2508
|
+
archived_roots: stats.archived_roots,
|
|
2509
|
+
unchunked_leaves: stats.unchunked_leaves,
|
|
2510
|
+
cycle2_pending_roots: stats.cycle2_pending_roots,
|
|
2511
|
+
core_entries: stats.core_entries,
|
|
2512
|
+
core_embed_null: stats.core_embed_null,
|
|
2513
|
+
active_core_summaries: stats.active_core_summaries,
|
|
2514
|
+
active_core_summary_missing: stats.active_core_summary_missing,
|
|
2515
|
+
mv_hot_active_populated: stats.mv_hot_active_populated,
|
|
2516
|
+
})
|
|
2517
|
+
} catch (e) { sendError(res, e.message) }
|
|
2518
|
+
return
|
|
2519
|
+
}
|
|
2520
|
+
|
|
2521
|
+
if (!await awaitRuntimeReadyForHttp(res)) return
|
|
2522
|
+
|
|
2523
|
+
if (req.method === 'GET' && req.url === '/admin/entries/active') {
|
|
2524
|
+
try {
|
|
2525
|
+
const { rows } = await db.query(`
|
|
2526
|
+
SELECT id, element, category, summary, score, last_seen_at
|
|
2527
|
+
FROM entries
|
|
2528
|
+
WHERE is_root = 1 AND status = 'active'
|
|
2529
|
+
ORDER BY score DESC
|
|
2530
|
+
`)
|
|
2531
|
+
sendJson(res, { ok: true, items: rows })
|
|
2532
|
+
} catch (e) { sendJson(res, { ok: false, error: e.message }, 500) }
|
|
2533
|
+
return
|
|
2534
|
+
}
|
|
2535
|
+
|
|
2536
|
+
if (req.method === 'GET' && req.url === '/admin/core/entries') {
|
|
2537
|
+
try {
|
|
2538
|
+
const rows = await listCore(DATA_DIR, '*')
|
|
2539
|
+
sendJson(res, { ok: true, items: rows })
|
|
2540
|
+
} catch (e) { sendJson(res, { ok: false, error: e.message }, 500) }
|
|
2541
|
+
return
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
if (req.method === 'POST' && req.url === '/admin/core/entries') {
|
|
2545
|
+
if (!isLocalOrigin(req)) {
|
|
2546
|
+
sendJson(res, { ok: false, error: 'forbidden: cross-origin' }, 403)
|
|
2547
|
+
return
|
|
2548
|
+
}
|
|
2549
|
+
try {
|
|
2550
|
+
const body = await readBody(req)
|
|
2551
|
+
const projectId = normalizeCoreProjectId(body.project_id)
|
|
2552
|
+
const entry = await addCore(DATA_DIR, body, projectId)
|
|
2553
|
+
sendJson(res, { ok: true, item: entry })
|
|
2554
|
+
} catch (e) { sendJson(res, { ok: false, error: e.message }, 500) }
|
|
2555
|
+
return
|
|
2556
|
+
}
|
|
2557
|
+
|
|
2558
|
+
if (req.method === 'POST' && req.url === '/admin/core/entries/delete') {
|
|
2559
|
+
if (!isLocalOrigin(req)) {
|
|
2560
|
+
sendJson(res, { ok: false, error: 'forbidden: cross-origin' }, 403)
|
|
2561
|
+
return
|
|
2562
|
+
}
|
|
2563
|
+
try {
|
|
2564
|
+
const body = await readBody(req)
|
|
2565
|
+
const removed = await deleteCore(DATA_DIR, body.id)
|
|
2566
|
+
sendJson(res, { ok: true, item: removed })
|
|
2567
|
+
} catch (e) { sendJson(res, { ok: false, error: e.message }, 500) }
|
|
2568
|
+
return
|
|
2569
|
+
}
|
|
2570
|
+
|
|
2571
|
+
if (req.method === 'POST' && req.url === '/admin/entries/status') {
|
|
2572
|
+
if (!isLocalOrigin(req)) {
|
|
2573
|
+
sendJson(res, { ok: false, error: 'forbidden: cross-origin' }, 403)
|
|
2574
|
+
return
|
|
2575
|
+
}
|
|
2576
|
+
try {
|
|
2577
|
+
const body = await readBody(req)
|
|
2578
|
+
const id = Number(body.id)
|
|
2579
|
+
const status = String(body.status ?? '').trim().toLowerCase()
|
|
2580
|
+
const VALID = ['pending', 'active', 'archived']
|
|
2581
|
+
if (!Number.isInteger(id) || id <= 0 || !VALID.includes(status)) {
|
|
2582
|
+
sendJson(res, { ok: false, error: 'valid id and status required' }, 400)
|
|
2583
|
+
return
|
|
2584
|
+
}
|
|
2585
|
+
const result = await db.query(
|
|
2586
|
+
`UPDATE entries SET status = $1 WHERE id = $2 AND is_root = 1`,
|
|
2587
|
+
[status, id]
|
|
2588
|
+
)
|
|
2589
|
+
sendJson(res, { ok: true, changes: Number(result.rowCount ?? result.affectedRows ?? 0) })
|
|
2590
|
+
} catch (e) { sendJson(res, { ok: false, error: e.message }, 500) }
|
|
2591
|
+
return
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2594
|
+
if (req.method === 'POST' && req.url === '/admin/entries/add') {
|
|
2595
|
+
if (!isLocalOrigin(req)) {
|
|
2596
|
+
sendJson(res, { ok: false, error: 'forbidden: cross-origin' }, 403)
|
|
2597
|
+
return
|
|
2598
|
+
}
|
|
2599
|
+
try {
|
|
2600
|
+
const body = await readBody(req)
|
|
2601
|
+
const result = await handleMemoryAction({
|
|
2602
|
+
action: 'manage',
|
|
2603
|
+
op: 'add',
|
|
2604
|
+
element: body.element,
|
|
2605
|
+
summary: body.summary,
|
|
2606
|
+
category: body.category,
|
|
2607
|
+
cwd: body.cwd,
|
|
2608
|
+
})
|
|
2609
|
+
if (result.isError) {
|
|
2610
|
+
sendJson(res, { ok: false, error: result.text }, 400)
|
|
2611
|
+
return
|
|
2612
|
+
}
|
|
2613
|
+
const idMatch = String(result.text || '').match(/id=(\d+)/)
|
|
2614
|
+
const newId = idMatch ? Number(idMatch[1]) : null
|
|
2615
|
+
sendJson(res, { ok: true, id: newId, text: result.text })
|
|
2616
|
+
} catch (e) { sendJson(res, { ok: false, error: e.message }, 500) }
|
|
2617
|
+
return
|
|
2618
|
+
}
|
|
2619
|
+
|
|
2620
|
+
if (req.method === 'POST' && req.url === '/admin/backfill') {
|
|
2621
|
+
if (!isLocalOrigin(req)) {
|
|
2622
|
+
sendJson(res, { ok: false, error: 'forbidden: cross-origin' }, 403)
|
|
2623
|
+
return
|
|
2624
|
+
}
|
|
2625
|
+
let body
|
|
2626
|
+
try { body = await readBody(req) }
|
|
2627
|
+
catch (e) { sendJson(res, { ok: false, error: e.message }, Number(e?.statusCode) || 500); return }
|
|
2628
|
+
try {
|
|
2629
|
+
const result = await handleMemoryAction({
|
|
2630
|
+
action: 'backfill',
|
|
2631
|
+
window: body.window,
|
|
2632
|
+
scope: body.scope,
|
|
2633
|
+
limit: body.limit,
|
|
2634
|
+
})
|
|
2635
|
+
if (result.isError) {
|
|
2636
|
+
// 'backfill already in progress' → 409, other failures → 500
|
|
2637
|
+
const status = result.text === 'backfill already in progress' ? 409 : 500
|
|
2638
|
+
sendJson(res, { ok: false, error: result.text }, status)
|
|
2639
|
+
return
|
|
2640
|
+
}
|
|
2641
|
+
sendJson(res, { ok: true, text: result.text })
|
|
2642
|
+
} catch (e) {
|
|
2643
|
+
sendJson(res, { ok: false, error: e.message }, 500)
|
|
2644
|
+
}
|
|
2645
|
+
return
|
|
2646
|
+
}
|
|
2647
|
+
|
|
2648
|
+
if (req.method === 'POST' && req.url === '/admin/purge') {
|
|
2649
|
+
if (!isLocalOrigin(req)) {
|
|
2650
|
+
sendJson(res, { ok: false, error: 'forbidden: cross-origin' }, 403)
|
|
2651
|
+
return
|
|
2652
|
+
}
|
|
2653
|
+
try {
|
|
2654
|
+
const body = await readBody(req)
|
|
2655
|
+
if (body?.confirm !== 'DELETE ALL MEMORY') {
|
|
2656
|
+
sendJson(res, { ok: false, error: 'confirm must be exactly "DELETE ALL MEMORY"' }, 400)
|
|
2657
|
+
return
|
|
2658
|
+
}
|
|
2659
|
+
const { rows: countRows } = await db.query(`SELECT COUNT(*) AS c FROM entries`)
|
|
2660
|
+
const preCount = Number(countRows[0].c)
|
|
2661
|
+
const { rows: coreCountRows } = await db.query(`SELECT COUNT(*) AS c FROM core_entries`)
|
|
2662
|
+
const coreCount = Number(coreCountRows[0].c)
|
|
2663
|
+
await db.transaction(async (tx) => {
|
|
2664
|
+
await tx.query(`DELETE FROM entries`)
|
|
2665
|
+
})
|
|
2666
|
+
sendJson(res, { ok: true, deleted: preCount, core_preserved: coreCount })
|
|
2667
|
+
} catch (e) { sendJson(res, { ok: false, error: e.message }, 500) }
|
|
2668
|
+
return
|
|
2669
|
+
}
|
|
2670
|
+
|
|
2671
|
+
if (req.method === 'POST' && req.url === '/admin/trace-record') {
|
|
2672
|
+
if (!isLocalOrigin(req)) {
|
|
2673
|
+
sendJson(res, { ok: false, error: 'forbidden: cross-origin' }, 403)
|
|
2674
|
+
return
|
|
2675
|
+
}
|
|
2676
|
+
let body
|
|
2677
|
+
try { body = await readBody(req) }
|
|
2678
|
+
catch (e) { sendJson(res, { ok: false, error: e.message }, 400); return }
|
|
2679
|
+
if (!Array.isArray(body?.events)) {
|
|
2680
|
+
sendJson(res, { ok: false, error: 'body.events must be an array' }, 400)
|
|
2681
|
+
return
|
|
2682
|
+
}
|
|
2683
|
+
if (body.events.length > 500) {
|
|
2684
|
+
sendJson(res, { ok: false, error: 'too many events (max 500)' }, 413)
|
|
2685
|
+
return
|
|
2686
|
+
}
|
|
2687
|
+
if (!_traceDb) {
|
|
2688
|
+
try {
|
|
2689
|
+
_traceDb = await openTraceDatabase(DATA_DIR)
|
|
2690
|
+
registerTraceExitDrain(_traceDb)
|
|
2691
|
+
} catch (e) {
|
|
2692
|
+
sendJson(res, { ok: false, error: `trace DB unavailable: ${e.message}` }, 503)
|
|
2693
|
+
return
|
|
2694
|
+
}
|
|
2695
|
+
}
|
|
2696
|
+
try {
|
|
2697
|
+
// Enqueue for async batched flush (100ms / 500-row window).
|
|
2698
|
+
enqueueTraceEvents(_traceDb, body.events)
|
|
2699
|
+
// Use `queued` — events are async; `inserted` would imply durability.
|
|
2700
|
+
sendJson(res, { ok: true, queued: body.events.length })
|
|
2701
|
+
// Fire-and-forget into focused bridge analytic tables.
|
|
2702
|
+
insertBridgeCalls(_traceDb, body.events).catch(e =>
|
|
2703
|
+
process.stderr.write(`[trace] insertBridgeCalls error: ${e?.message}\n`)
|
|
2704
|
+
)
|
|
2705
|
+
} catch (e) {
|
|
2706
|
+
sendJson(res, { ok: false, error: e.message }, 500)
|
|
2707
|
+
}
|
|
2708
|
+
return
|
|
2709
|
+
}
|
|
2710
|
+
|
|
2711
|
+
if (req.method === 'POST' && req.url === '/session-start/core-memory') {
|
|
2712
|
+
try {
|
|
2713
|
+
const body = await readBody(req)
|
|
2714
|
+
const { projectId, dbLines, userLines } = await buildSessionCoreMemoryPayload(body.cwd)
|
|
2715
|
+
sendJson(res, { ok: true, projectId, dbLines, userLines })
|
|
2716
|
+
} catch (e) { sendError(res, e.message) }
|
|
2717
|
+
return
|
|
2718
|
+
}
|
|
2719
|
+
|
|
2720
|
+
if (req.method === 'POST' && req.url === '/admin/shutdown') {
|
|
2721
|
+
if (!isLocalOrigin(req)) {
|
|
2722
|
+
sendJson(res, { ok: false, error: 'forbidden: cross-origin' }, 403)
|
|
2723
|
+
return
|
|
2724
|
+
}
|
|
2725
|
+
sendJson(res, { shutting_down: true }, 202)
|
|
2726
|
+
setImmediate(() => {
|
|
2727
|
+
const watchdog = setTimeout(() => {
|
|
2728
|
+
process.stderr.write('[shutdown] watchdog fired — forcing exit after 8s\n')
|
|
2729
|
+
process.exit(1)
|
|
2730
|
+
}, 8000)
|
|
2731
|
+
watchdog.unref?.()
|
|
2732
|
+
stop()
|
|
2733
|
+
.then(() => { clearTimeout(watchdog); process.exit(0) })
|
|
2734
|
+
.catch(e => {
|
|
2735
|
+
process.stderr.write(`[shutdown] error ${e.message}\n`)
|
|
2736
|
+
clearTimeout(watchdog)
|
|
2737
|
+
process.exit(1)
|
|
2738
|
+
})
|
|
2739
|
+
})
|
|
2740
|
+
return
|
|
2741
|
+
}
|
|
2742
|
+
|
|
2743
|
+
// DEV-ONLY cycle1 chunking bench. Gated by env MIXDOG_DEV_BENCH=1 so
|
|
2744
|
+
// production is untouched (route returns 404 when unset). Mirrors cycle1's
|
|
2745
|
+
// exact fetch query + per-session windowing, then runs each window through
|
|
2746
|
+
// buildCycle1ChunkPrompt + callBridgeLlm + parseCycle1LineFormat. STRICT
|
|
2747
|
+
// read-only — no UPDATE, no transaction, no commit.
|
|
2748
|
+
if (req.method === 'POST' && req.url === '/dev/cycle1-bench') {
|
|
2749
|
+
// Gate: env MIXDOG_DEV_BENCH=1 OR a runtime flag file, so it can be
|
|
2750
|
+
// toggled without restarting Claude Code (env only reaches the worker
|
|
2751
|
+
// on a full CC restart, not via dev-sync full-restart).
|
|
2752
|
+
const _devBenchOn = process.env.MIXDOG_DEV_BENCH === '1'
|
|
2753
|
+
|| (DATA_DIR && fs.existsSync(path.join(DATA_DIR, '.dev-bench-enabled')))
|
|
2754
|
+
if (!_devBenchOn) {
|
|
2755
|
+
sendJson(res, { error: 'not found' }, 404)
|
|
2756
|
+
return
|
|
2757
|
+
}
|
|
2758
|
+
if (!isLocalOrigin(req)) {
|
|
2759
|
+
sendJson(res, { ok: false, error: 'forbidden: cross-origin' }, 403)
|
|
2760
|
+
return
|
|
2761
|
+
}
|
|
2762
|
+
try {
|
|
2763
|
+
const body = await readBody(req)
|
|
2764
|
+
const sets = Math.max(1, Number(body?.sets ?? 5))
|
|
2765
|
+
const repeat = Math.max(1, Number(body?.repeat ?? 1))
|
|
2766
|
+
// Optional variant matrix. Each variant: {name, rules}. rules=null → default prompt.
|
|
2767
|
+
const rawVariants = Array.isArray(body?.variants) ? body.variants : null
|
|
2768
|
+
const variants = rawVariants && rawVariants.length > 0
|
|
2769
|
+
? rawVariants.map((v, i) => ({
|
|
2770
|
+
name: typeof v?.name === 'string' && v.name ? v.name : `variant-${i + 1}`,
|
|
2771
|
+
rules: Array.isArray(v?.rules) ? v.rules : null,
|
|
2772
|
+
}))
|
|
2773
|
+
: null
|
|
2774
|
+
|
|
2775
|
+
// Lazy-load LLM + chunking helpers so production boot pays nothing.
|
|
2776
|
+
const [{ buildCycle1ChunkPrompt, parseCycle1LineFormat }, { callBridgeLlm }, { resolveMaintenancePreset }] = await Promise.all([
|
|
2777
|
+
import('./lib/memory-cycle1.mjs'),
|
|
2778
|
+
import('./lib/agent-ipc.mjs'),
|
|
2779
|
+
import('../shared/llm/index.mjs'),
|
|
2780
|
+
])
|
|
2781
|
+
|
|
2782
|
+
const CYCLE1_MIN_BATCH = 3
|
|
2783
|
+
const CYCLE1_SESSION_CAP = 10
|
|
2784
|
+
const BATCH_SIZE = 100
|
|
2785
|
+
const TIMEOUT_MS = 180_000
|
|
2786
|
+
const fetchLimit = CYCLE1_SESSION_CAP * BATCH_SIZE
|
|
2787
|
+
|
|
2788
|
+
const fetchResult = await db.query(
|
|
2789
|
+
`SELECT id, ts, role, content, session_id, source_ref, project_id
|
|
2790
|
+
FROM entries
|
|
2791
|
+
WHERE chunk_root IS NULL AND session_id IS NOT NULL
|
|
2792
|
+
ORDER BY ts DESC, id DESC
|
|
2793
|
+
LIMIT $1`,
|
|
2794
|
+
[fetchLimit],
|
|
2795
|
+
)
|
|
2796
|
+
const rowsDesc = fetchResult.rows
|
|
2797
|
+
|
|
2798
|
+
if (rowsDesc.length < CYCLE1_MIN_BATCH) {
|
|
2799
|
+
sendJson(res, {
|
|
2800
|
+
ok: true,
|
|
2801
|
+
sets, repeat,
|
|
2802
|
+
windowsAvailable: 0,
|
|
2803
|
+
note: `not enough pending rows (need >= ${CYCLE1_MIN_BATCH}, got ${rowsDesc.length})`,
|
|
2804
|
+
results: [],
|
|
2805
|
+
})
|
|
2806
|
+
return
|
|
2807
|
+
}
|
|
2808
|
+
|
|
2809
|
+
// Partition by session_id — same as memory-cycle1.mjs _runCycle1Impl L207-233.
|
|
2810
|
+
const sessionMap = new Map()
|
|
2811
|
+
for (const row of rowsDesc.slice().reverse()) {
|
|
2812
|
+
const sid = row.session_id
|
|
2813
|
+
if (!sessionMap.has(sid)) sessionMap.set(sid, [])
|
|
2814
|
+
sessionMap.get(sid).push(row)
|
|
2815
|
+
}
|
|
2816
|
+
const windows = []
|
|
2817
|
+
for (const [sid, sessionRows] of sessionMap) {
|
|
2818
|
+
if (sessionRows.length < CYCLE1_MIN_BATCH) continue
|
|
2819
|
+
const windowCount = Math.max(1, Math.ceil(sessionRows.length / BATCH_SIZE))
|
|
2820
|
+
const baseSize = Math.floor(sessionRows.length / windowCount)
|
|
2821
|
+
const remainder = sessionRows.length % windowCount
|
|
2822
|
+
let _offset = 0
|
|
2823
|
+
for (let i = 0; i < windowCount; i++) {
|
|
2824
|
+
const size = baseSize + (i < remainder ? 1 : 0)
|
|
2825
|
+
windows.push({ sid, rows: sessionRows.slice(_offset, _offset + size) })
|
|
2826
|
+
_offset += size
|
|
2827
|
+
}
|
|
2828
|
+
}
|
|
2829
|
+
const chosen = windows.slice(0, sets)
|
|
2830
|
+
|
|
2831
|
+
const preset = resolveMaintenancePreset('cycle1')
|
|
2832
|
+
|
|
2833
|
+
function summariseChunks(chunks, totalEntries) {
|
|
2834
|
+
const usedIdx = new Set()
|
|
2835
|
+
for (const c of chunks) for (const i of (c._idxList || [])) usedIdx.add(i)
|
|
2836
|
+
const omitted = []
|
|
2837
|
+
for (let i = 1; i <= totalEntries; i++) if (!usedIdx.has(i)) omitted.push(i)
|
|
2838
|
+
return { covered: usedIdx.size, omitted }
|
|
2839
|
+
}
|
|
2840
|
+
|
|
2841
|
+
// When variants are absent, fall back to a single implicit baseline so the
|
|
2842
|
+
// pre-variant call shape (single rows × repeat) keeps producing the same
|
|
2843
|
+
// {runs:[…]} payload the trigger already knows how to print.
|
|
2844
|
+
const variantList = variants ?? [{ name: 'baseline', rules: null }]
|
|
2845
|
+
|
|
2846
|
+
async function runOnce(rows, customRules) {
|
|
2847
|
+
const userMessage = buildCycle1ChunkPrompt(rows, customRules)
|
|
2848
|
+
const t0 = Date.now()
|
|
2849
|
+
let raw, error
|
|
2850
|
+
try {
|
|
2851
|
+
raw = await callBridgeLlm({
|
|
2852
|
+
role: 'cycle1-agent',
|
|
2853
|
+
taskType: 'maintenance',
|
|
2854
|
+
mode: 'cycle1',
|
|
2855
|
+
preset,
|
|
2856
|
+
timeout: TIMEOUT_MS,
|
|
2857
|
+
cwd: null,
|
|
2858
|
+
}, userMessage)
|
|
2859
|
+
} catch (e) {
|
|
2860
|
+
error = e?.message ?? String(e)
|
|
2861
|
+
}
|
|
2862
|
+
const llmMs = Date.now() - t0
|
|
2863
|
+
if (error) return { ok: false, llmMs, error }
|
|
2864
|
+
const parsed = parseCycle1LineFormat(raw)
|
|
2865
|
+
const chunks = Array.isArray(parsed?.chunks) ? parsed.chunks : []
|
|
2866
|
+
const { covered, omitted } = summariseChunks(chunks, rows.length)
|
|
2867
|
+
const ratio = chunks.length > 0
|
|
2868
|
+
? parseFloat((rows.length / chunks.length).toFixed(2))
|
|
2869
|
+
: null
|
|
2870
|
+
return {
|
|
2871
|
+
ok: true,
|
|
2872
|
+
llmMs,
|
|
2873
|
+
entries: rows.length,
|
|
2874
|
+
chunks: chunks.length,
|
|
2875
|
+
ratio,
|
|
2876
|
+
covered,
|
|
2877
|
+
omitted,
|
|
2878
|
+
chunkList: chunks.map(c => ({
|
|
2879
|
+
idx: c._idxList,
|
|
2880
|
+
element: c.element,
|
|
2881
|
+
category: c.category,
|
|
2882
|
+
summary: c.summary,
|
|
2883
|
+
})),
|
|
2884
|
+
}
|
|
2885
|
+
}
|
|
2886
|
+
|
|
2887
|
+
const results = []
|
|
2888
|
+
for (let s = 0; s < chosen.length; s++) {
|
|
2889
|
+
const { sid, rows } = chosen[s]
|
|
2890
|
+
const sidShort = String(sid).slice(0, 8)
|
|
2891
|
+
if (variants) {
|
|
2892
|
+
// Variant mode: same rows, one run per variant per repeat.
|
|
2893
|
+
const variantResults = []
|
|
2894
|
+
for (const v of variantList) {
|
|
2895
|
+
const runs = []
|
|
2896
|
+
for (let r = 0; r < repeat; r++) {
|
|
2897
|
+
const run = await runOnce(rows, v.rules)
|
|
2898
|
+
runs.push({ repIdx: r + 1, ...run })
|
|
2899
|
+
}
|
|
2900
|
+
variantResults.push({ name: v.name, runs })
|
|
2901
|
+
}
|
|
2902
|
+
results.push({
|
|
2903
|
+
setIdx: s + 1,
|
|
2904
|
+
sessionIdShort: sidShort,
|
|
2905
|
+
entries: rows.length,
|
|
2906
|
+
variants: variantResults,
|
|
2907
|
+
})
|
|
2908
|
+
} else {
|
|
2909
|
+
// Legacy single-baseline payload shape.
|
|
2910
|
+
const runs = []
|
|
2911
|
+
for (let r = 0; r < repeat; r++) {
|
|
2912
|
+
const run = await runOnce(rows, null)
|
|
2913
|
+
runs.push({ repIdx: r + 1, ...run })
|
|
2914
|
+
}
|
|
2915
|
+
results.push({
|
|
2916
|
+
setIdx: s + 1,
|
|
2917
|
+
sessionIdShort: sidShort,
|
|
2918
|
+
entries: rows.length,
|
|
2919
|
+
runs,
|
|
2920
|
+
})
|
|
2921
|
+
}
|
|
2922
|
+
}
|
|
2923
|
+
sendJson(res, {
|
|
2924
|
+
ok: true,
|
|
2925
|
+
sets, repeat,
|
|
2926
|
+
windowsAvailable: windows.length,
|
|
2927
|
+
variants: variants ? variantList.map(v => v.name) : null,
|
|
2928
|
+
results,
|
|
2929
|
+
})
|
|
2930
|
+
} catch (e) {
|
|
2931
|
+
sendError(res, e?.message || String(e))
|
|
2932
|
+
}
|
|
2933
|
+
return
|
|
2934
|
+
}
|
|
2935
|
+
|
|
2936
|
+
if (req.method === 'POST' && req.url === '/session-start/recap') {
|
|
2937
|
+
try {
|
|
2938
|
+
const body = await readBody(req)
|
|
2939
|
+
const projectId = resolveProjectScope(typeof body.cwd === 'string' && body.cwd ? body.cwd : null)
|
|
2940
|
+
const rows = projectId !== null
|
|
2941
|
+
? (await db.query(`
|
|
2942
|
+
SELECT id, ts, summary FROM entries
|
|
2943
|
+
WHERE is_root = 1 AND (project_id IS NULL OR project_id = $1)
|
|
2944
|
+
ORDER BY ts DESC, id DESC LIMIT 20
|
|
2945
|
+
`, [projectId])).rows
|
|
2946
|
+
: (await db.query(`
|
|
2947
|
+
SELECT id, ts, summary FROM entries
|
|
2948
|
+
WHERE is_root = 1
|
|
2949
|
+
ORDER BY ts DESC, id DESC LIMIT 20
|
|
2950
|
+
`)).rows
|
|
2951
|
+
sendJson(res, { ok: true, projectId, rows })
|
|
2952
|
+
} catch (e) { sendError(res, e.message) }
|
|
2953
|
+
return
|
|
2954
|
+
}
|
|
2955
|
+
|
|
2956
|
+
if (req.method === 'POST' && req.url === '/api/tool') {
|
|
2957
|
+
if (!isLocalOrigin(req)) {
|
|
2958
|
+
sendJson(res, { content: [{ type: 'text', text: 'forbidden: cross-origin' }], isError: true }, 403)
|
|
2959
|
+
return
|
|
2960
|
+
}
|
|
2961
|
+
// Owner-side cancel plumbing: the fork-proxy worker forwards parent
|
|
2962
|
+
// 'cancel' IPC by issuing POST /api/cancel with the same callId. Track
|
|
2963
|
+
// each in-flight /api/tool by its caller-supplied X-Mixdog-Call-Id so
|
|
2964
|
+
// the cancel endpoint can abort the AbortSignal threaded into
|
|
2965
|
+
// handleToolCall. Without this the proxy-side fetch aborts but the
|
|
2966
|
+
// owner keeps running the upstream tool to completion.
|
|
2967
|
+
const callId = String(req.headers['x-mixdog-call-id'] || '').trim() || null
|
|
2968
|
+
const ac = new AbortController()
|
|
2969
|
+
// Abort only on a genuine mid-flight client disconnect. The req 'close'
|
|
2970
|
+
// event fires on every normal request once the request body is consumed
|
|
2971
|
+
// (before handleToolCall resolves), so gating on it would mark normal
|
|
2972
|
+
// completions as aborted. Use the response side instead: when the
|
|
2973
|
+
// socket closes, res.writableFinished is true iff the response was
|
|
2974
|
+
// fully written — a real client disconnect closes the socket before
|
|
2975
|
+
// the response finishes, leaving writableFinished===false.
|
|
2976
|
+
res.on('close', () => {
|
|
2977
|
+
if (res.writableFinished) return
|
|
2978
|
+
try { ac.abort() } catch {}
|
|
2979
|
+
})
|
|
2980
|
+
if (callId) _ownerInFlightHttpCalls.set(callId, ac)
|
|
2981
|
+
try {
|
|
2982
|
+
const body = await readBody(req)
|
|
2983
|
+
const result = await handleToolCall(body.name, body.arguments ?? {}, ac.signal)
|
|
2984
|
+
sendJson(res, result)
|
|
2985
|
+
} catch (e) {
|
|
2986
|
+
sendJson(res, { content: [{ type: 'text', text: `api/tool error: ${e.message}` }], isError: true }, Number(e?.statusCode) || 500)
|
|
2987
|
+
} finally {
|
|
2988
|
+
if (callId) _ownerInFlightHttpCalls.delete(callId)
|
|
2989
|
+
}
|
|
2990
|
+
return
|
|
2991
|
+
}
|
|
2992
|
+
|
|
2993
|
+
if (req.method === 'POST' && req.url === '/api/cancel') {
|
|
2994
|
+
if (!isLocalOrigin(req)) {
|
|
2995
|
+
sendJson(res, { ok: false, error: 'forbidden: cross-origin' }, 403)
|
|
2996
|
+
return
|
|
2997
|
+
}
|
|
2998
|
+
try {
|
|
2999
|
+
const body = await readBody(req)
|
|
3000
|
+
const id = String(body.callId || '').trim()
|
|
3001
|
+
if (!id) { sendJson(res, { ok: false, error: 'callId required' }, 400); return }
|
|
3002
|
+
const ac = _ownerInFlightHttpCalls.get(id)
|
|
3003
|
+
if (ac) {
|
|
3004
|
+
try { ac.abort() } catch {}
|
|
3005
|
+
_ownerInFlightHttpCalls.delete(id)
|
|
3006
|
+
sendJson(res, { ok: true, cancelled: true })
|
|
3007
|
+
} else {
|
|
3008
|
+
sendJson(res, { ok: true, cancelled: false })
|
|
3009
|
+
}
|
|
3010
|
+
} catch (e) {
|
|
3011
|
+
sendJson(res, { ok: false, error: e.message }, Number(e?.statusCode) || 500)
|
|
3012
|
+
}
|
|
3013
|
+
return
|
|
3014
|
+
}
|
|
3015
|
+
|
|
3016
|
+
if (req.url === '/mcp') {
|
|
3017
|
+
if (!isLocalOrigin(req)) {
|
|
3018
|
+
sendJson(res, { error: 'forbidden: cross-origin' }, 403)
|
|
3019
|
+
return
|
|
3020
|
+
}
|
|
3021
|
+
try {
|
|
3022
|
+
if (req.method === 'POST') {
|
|
3023
|
+
const httpMcp = createHttpMcpServer()
|
|
3024
|
+
const httpTransport = new StreamableHTTPServerTransport({
|
|
3025
|
+
sessionIdGenerator: undefined,
|
|
3026
|
+
enableJsonResponse: true,
|
|
3027
|
+
})
|
|
3028
|
+
res.on('close', () => {
|
|
3029
|
+
httpTransport.close()
|
|
3030
|
+
void httpMcp.close()
|
|
3031
|
+
})
|
|
3032
|
+
await httpMcp.connect(httpTransport)
|
|
3033
|
+
const body = await readBody(req)
|
|
3034
|
+
await httpTransport.handleRequest(req, res, body)
|
|
3035
|
+
} else {
|
|
3036
|
+
sendJson(res, { error: 'Method not allowed' }, 405)
|
|
3037
|
+
}
|
|
3038
|
+
} catch (e) {
|
|
3039
|
+
process.stderr.write(`[memory-service] /mcp error: ${e.stack || e.message}\n`)
|
|
3040
|
+
if (!res.headersSent) sendError(res, e.message, Number(e?.statusCode) || 500)
|
|
3041
|
+
}
|
|
3042
|
+
return
|
|
3043
|
+
}
|
|
3044
|
+
|
|
3045
|
+
if (req.method !== 'POST') {
|
|
3046
|
+
sendJson(res, { error: 'Method not allowed' }, 405)
|
|
3047
|
+
return
|
|
3048
|
+
}
|
|
3049
|
+
|
|
3050
|
+
// Tail block handles /entry and /ingest-transcript — both mutate the DB,
|
|
3051
|
+
// so apply the same cross-origin guard as /admin/* routes.
|
|
3052
|
+
if (!isLocalOrigin(req)) {
|
|
3053
|
+
sendError(res, 'forbidden: cross-origin', 403)
|
|
3054
|
+
return
|
|
3055
|
+
}
|
|
3056
|
+
|
|
3057
|
+
let body
|
|
3058
|
+
try { body = await readBody(req) }
|
|
3059
|
+
catch (e) { sendError(res, e.message, Number(e?.statusCode) || 500); return }
|
|
3060
|
+
|
|
3061
|
+
try {
|
|
3062
|
+
if (req.url === '/entry') {
|
|
3063
|
+
const role = String(body.role ?? 'user')
|
|
3064
|
+
const content = String(body.content ?? '')
|
|
3065
|
+
const sourceRef = String(body.sourceRef ?? `manual:${Date.now()}-${process.pid}`)
|
|
3066
|
+
const sessionId = body.sessionId ?? null
|
|
3067
|
+
const tsMs = parseTsToMs(body.ts ?? Date.now())
|
|
3068
|
+
if (!content) { sendJson(res, { error: 'content required' }, 400); return }
|
|
3069
|
+
// Run the same scrubber used by ingestTranscriptFile so noise markers
|
|
3070
|
+
// like "[Request interrupted by user]" and whitespace-only payloads
|
|
3071
|
+
// are rejected before they reach the entries table. Match the
|
|
3072
|
+
// existing 400 / { error } convention for invalid payloads.
|
|
3073
|
+
const cleaned = cleanMemoryText(content)
|
|
3074
|
+
if (!cleaned || !cleaned.trim()) {
|
|
3075
|
+
sendJson(res, { error: 'empty after clean' }, 400)
|
|
3076
|
+
return
|
|
3077
|
+
}
|
|
3078
|
+
const entryProjectId = resolveProjectScope(typeof body.cwd === 'string' && body.cwd ? body.cwd : null)
|
|
3079
|
+
try {
|
|
3080
|
+
const result = await db.query(`
|
|
3081
|
+
INSERT INTO entries(ts, role, content, source_ref, session_id, project_id)
|
|
3082
|
+
VALUES ($1, $2, $3, $4, $5, $6)
|
|
3083
|
+
ON CONFLICT DO NOTHING
|
|
3084
|
+
RETURNING id
|
|
3085
|
+
`, [tsMs, role, cleaned, sourceRef, sessionId, entryProjectId])
|
|
3086
|
+
const insertedId = result.rows[0]?.id ?? null
|
|
3087
|
+
sendJson(res, { ok: true, id: insertedId !== null ? Number(insertedId) : null, changes: Number(result.rowCount ?? result.affectedRows ?? 0) })
|
|
3088
|
+
} catch (e) {
|
|
3089
|
+
sendJson(res, { error: e.message }, 500)
|
|
3090
|
+
}
|
|
3091
|
+
return
|
|
3092
|
+
}
|
|
3093
|
+
|
|
3094
|
+
if (req.url === '/ingest-transcript') {
|
|
3095
|
+
const filePath = body.filePath
|
|
3096
|
+
if (!filePath) { sendJson(res, { error: 'filePath required' }, 400); return }
|
|
3097
|
+
try {
|
|
3098
|
+
const n = await ingestTranscriptFile(filePath, { cwd: body.cwd })
|
|
3099
|
+
sendJson(res, { ok: true, ingested: n })
|
|
3100
|
+
} catch (e) {
|
|
3101
|
+
sendJson(res, { error: e.message }, 500)
|
|
3102
|
+
}
|
|
3103
|
+
return
|
|
3104
|
+
}
|
|
3105
|
+
|
|
3106
|
+
sendJson(res, { error: 'Not found' }, 404)
|
|
3107
|
+
} catch (e) {
|
|
3108
|
+
process.stderr.write(`[memory-service] ${req.url} error: ${e.stack || e.message}\n`)
|
|
3109
|
+
sendError(res, e.message)
|
|
3110
|
+
}
|
|
3111
|
+
})
|
|
3112
|
+
|
|
3113
|
+
export { TOOL_DEFS, handleToolCall }
|
|
3114
|
+
export { MEMORY_INSTRUCTIONS_TEXT as instructions }
|
|
3115
|
+
export { acquireLock, releaseLock }
|
|
3116
|
+
export { cwdFromTranscriptPath }
|
|
3117
|
+
export async function init() {
|
|
3118
|
+
if (_initialized) return
|
|
3119
|
+
process.stderr.write(`[boot-time] tag=memory-init-start tMs=${Date.now()}\n`)
|
|
3120
|
+
if (process.env.MIXDOG_WORKER_MODE === '1' && process.send) {
|
|
3121
|
+
// Single-worker daemon: acquire the owner lock (which reclaims a crashed
|
|
3122
|
+
// predecessor's stale, dead-PID lock). If a LIVE peer still holds it — an
|
|
3123
|
+
// anomaly, since server-main forks exactly one memory worker — exit so
|
|
3124
|
+
// server-main respawns us instead of running a second owner.
|
|
3125
|
+
if (!tryAcquireMemoryOwnerLock()) {
|
|
3126
|
+
process.stderr.write('[memory-service] live peer holds owner lock — exiting for respawn\n')
|
|
3127
|
+
process.exit(0)
|
|
3128
|
+
}
|
|
3129
|
+
process.on('exit', releaseMemoryOwnerLock)
|
|
3130
|
+
}
|
|
3131
|
+
const runtimeReady = _beginRuntimeInit()
|
|
3132
|
+
const boundPort = await _startHttpServer()
|
|
3133
|
+
await runtimeReady
|
|
3134
|
+
advertiseMemoryPort(boundPort)
|
|
3135
|
+
if (process.env.MIXDOG_WORKER_MODE === '1' && process.send) {
|
|
3136
|
+
process.stderr.write(`[boot-time] tag=memory-ready tMs=${Date.now()}\n`)
|
|
3137
|
+
process.send({ type: 'ready', port: boundPort })
|
|
3138
|
+
}
|
|
3139
|
+
process.stderr.write(`[memory-service] init() complete (entries unified mode, version=${PLUGIN_VERSION})\n`)
|
|
3140
|
+
}
|
|
3141
|
+
|
|
3142
|
+
export async function stop() {
|
|
3143
|
+
_stopCycles()
|
|
3144
|
+
await stopLlmWorker()
|
|
3145
|
+
if (httpServer) await new Promise(resolve => httpServer.close(resolve))
|
|
3146
|
+
await closeDatabase(DATA_DIR)
|
|
3147
|
+
// Stop the PG postmaster after the connection pool has been drained.
|
|
3148
|
+
// closeDatabase() only ends the client pool; without this the child
|
|
3149
|
+
// postmaster keeps running after the memory service exits.
|
|
3150
|
+
try {
|
|
3151
|
+
const { stopPgForShutdown } = await import('./lib/pg/supervisor.mjs')
|
|
3152
|
+
await stopPgForShutdown()
|
|
3153
|
+
} catch {}
|
|
3154
|
+
releaseLock()
|
|
3155
|
+
}
|
|
3156
|
+
|
|
3157
|
+
let activePort = BASE_PORT
|
|
3158
|
+
let _httpReadyPromise = null
|
|
3159
|
+
let _httpBoundPort = null
|
|
3160
|
+
|
|
3161
|
+
function _startHttpServer() {
|
|
3162
|
+
if (_httpBoundPort != null) return Promise.resolve(_httpBoundPort)
|
|
3163
|
+
if (_httpReadyPromise) return _httpReadyPromise
|
|
3164
|
+
_httpReadyPromise = new Promise((resolve, reject) => {
|
|
3165
|
+
function tryListen() {
|
|
3166
|
+
httpServer.listen(activePort, '127.0.0.1', () => {
|
|
3167
|
+
// Use actual bound port (important when activePort=0, OS assigns a free port).
|
|
3168
|
+
const boundPort = httpServer.address().port
|
|
3169
|
+
_httpBoundPort = boundPort
|
|
3170
|
+
process.stderr.write(`[memory-service] HTTP listening on 127.0.0.1:${boundPort}\n`)
|
|
3171
|
+
resolve(boundPort)
|
|
3172
|
+
})
|
|
3173
|
+
}
|
|
3174
|
+
httpServer.on('error', (err) => {
|
|
3175
|
+
if (err.code === 'EADDRINUSE' && activePort < MAX_PORT) {
|
|
3176
|
+
activePort++
|
|
3177
|
+
tryListen()
|
|
3178
|
+
} else if (err.code === 'EADDRINUSE') {
|
|
3179
|
+
// All fixed ports exhausted; let OS pick a free port.
|
|
3180
|
+
activePort = 0
|
|
3181
|
+
tryListen()
|
|
3182
|
+
} else {
|
|
3183
|
+
process.stderr.write(`[memory-service] HTTP fatal: ${err.message}\n`)
|
|
3184
|
+
reject(err)
|
|
3185
|
+
}
|
|
3186
|
+
})
|
|
3187
|
+
tryListen()
|
|
3188
|
+
})
|
|
3189
|
+
return _httpReadyPromise
|
|
3190
|
+
}
|
|
3191
|
+
|
|
3192
|
+
if (process.env.MIXDOG_WORKER_MODE === '1' && process.send) {
|
|
3193
|
+
// SIGTERM/SIGINT handler for worker mode: call stop() (fsyncs,
|
|
3194
|
+
// removes port file) then exit(0). Prevents taskkill /F from bypassing
|
|
3195
|
+
// graceful shutdown and leaving pgdata in an inconsistent checkpoint state.
|
|
3196
|
+
let _stopInFlight = false
|
|
3197
|
+
const _workerSignalHandler = (sig) => {
|
|
3198
|
+
if (_stopInFlight) {
|
|
3199
|
+
process.stderr.write(`[memory-worker] ${sig} — stop already in flight, ignoring\n`)
|
|
3200
|
+
return
|
|
3201
|
+
}
|
|
3202
|
+
_stopInFlight = true
|
|
3203
|
+
process.stderr.write(`[memory-worker] received ${sig} — calling stop() for clean shutdown\n`)
|
|
3204
|
+
const _exitTimer = setTimeout(() => {
|
|
3205
|
+
process.stderr.write(`[memory-worker] stop() timed out after 6000ms — forcing exit(2)\n`)
|
|
3206
|
+
process.exit(2)
|
|
3207
|
+
}, 6000)
|
|
3208
|
+
stop().then(() => {
|
|
3209
|
+
clearTimeout(_exitTimer)
|
|
3210
|
+
process.stderr.write(`[memory-worker] stop() complete — exiting cleanly\n`)
|
|
3211
|
+
process.exit(0)
|
|
3212
|
+
}).catch((e) => {
|
|
3213
|
+
clearTimeout(_exitTimer)
|
|
3214
|
+
process.stderr.write(`[memory-worker] stop() error on ${sig}: ${e && (e.message || e)}\n`)
|
|
3215
|
+
process.exit(1)
|
|
3216
|
+
})
|
|
3217
|
+
}
|
|
3218
|
+
process.on('SIGTERM', () => _workerSignalHandler('SIGTERM'))
|
|
3219
|
+
process.on('SIGINT', () => _workerSignalHandler('SIGINT'))
|
|
3220
|
+
|
|
3221
|
+
// callId → AbortController for in-flight IPC calls (cancel handler uses this).
|
|
3222
|
+
const _inFlightCalls = new Map()
|
|
3223
|
+
|
|
3224
|
+
process.on('message', async (msg) => {
|
|
3225
|
+
// Handle parent-initiated graceful shutdown IPC message.
|
|
3226
|
+
if (msg.type === 'shutdown') {
|
|
3227
|
+
process.stderr.write('[memory-worker] received IPC shutdown — calling stop()\n')
|
|
3228
|
+
_workerSignalHandler('IPC:shutdown')
|
|
3229
|
+
return
|
|
3230
|
+
}
|
|
3231
|
+
if (msg.type === 'cancel' && msg.callId) {
|
|
3232
|
+
const entry = _inFlightCalls.get(msg.callId)
|
|
3233
|
+
if (entry) {
|
|
3234
|
+
// Mark cancelled so the in-flight call's result/error branch below
|
|
3235
|
+
// does not double-respond after the AbortController fires.
|
|
3236
|
+
entry.cancelled = true
|
|
3237
|
+
entry.ac.abort()
|
|
3238
|
+
_inFlightCalls.delete(msg.callId)
|
|
3239
|
+
process.send({ type: 'result', callId: msg.callId, error: 'cancelled' })
|
|
3240
|
+
}
|
|
3241
|
+
return
|
|
3242
|
+
}
|
|
3243
|
+
if (msg.type !== 'call' || !msg.callId) return
|
|
3244
|
+
const entry = { ac: new AbortController(), cancelled: false }
|
|
3245
|
+
_inFlightCalls.set(msg.callId, entry)
|
|
3246
|
+
try {
|
|
3247
|
+
let result
|
|
3248
|
+
try {
|
|
3249
|
+
result = await handleToolCall(msg.name, msg.args || {}, entry.ac.signal)
|
|
3250
|
+
} finally {
|
|
3251
|
+
_inFlightCalls.delete(msg.callId)
|
|
3252
|
+
}
|
|
3253
|
+
if (!entry.cancelled) process.send({ type: 'result', callId: msg.callId, result })
|
|
3254
|
+
} catch (e) {
|
|
3255
|
+
if (!entry.cancelled) process.send({ type: 'result', callId: msg.callId, error: e.message })
|
|
3256
|
+
}
|
|
3257
|
+
})
|
|
3258
|
+
init().catch(e => {
|
|
3259
|
+
let detail
|
|
3260
|
+
try {
|
|
3261
|
+
const parts = []
|
|
3262
|
+
if (e?.name) parts.push(`name=${e.name}`)
|
|
3263
|
+
if (e?.code) parts.push(`code=${e.code}`)
|
|
3264
|
+
if (e?.errno) parts.push(`errno=${e.errno}`)
|
|
3265
|
+
if (e?.syscall) parts.push(`syscall=${e.syscall}`)
|
|
3266
|
+
if (e?.path) parts.push(`path=${e.path}`)
|
|
3267
|
+
if (e?.message) parts.push(`message=${e.message}`)
|
|
3268
|
+
let stringified = null
|
|
3269
|
+
try { stringified = JSON.stringify(e, Object.getOwnPropertyNames(e || {})) } catch {}
|
|
3270
|
+
if (stringified && stringified !== '{}' && stringified !== '"{}"') parts.push(`json=${stringified}`)
|
|
3271
|
+
if (e?.stack) parts.push(`\nstack=\n${e.stack}`)
|
|
3272
|
+
if (parts.length === 0) parts.push(`raw=${typeof e}:${String(e)}`)
|
|
3273
|
+
detail = parts.join(' | ')
|
|
3274
|
+
} catch (logErr) {
|
|
3275
|
+
detail = `(error formatting failed: ${logErr?.message}) raw=${String(e)}`
|
|
3276
|
+
}
|
|
3277
|
+
process.stderr.write(`[memory-worker] init failed: ${detail}\n`)
|
|
3278
|
+
// Signal degraded state to parent before exiting so it records the failure
|
|
3279
|
+
// rather than treating this as a normal pre-ready crash.
|
|
3280
|
+
try { process.send({ type: 'ready', degraded: true, error: detail.slice(0, 800) }) } catch {}
|
|
3281
|
+
process.exit(1)
|
|
3282
|
+
})
|
|
3283
|
+
}
|
|
3284
|
+
|
|
3285
|
+
// Standalone MCP launcher path. When this module is the entry script AND no
|
|
3286
|
+
// MIXDOG_WORKER_MODE flag is set, we own stdio and bring up the full MCP
|
|
3287
|
+
// server with acquireLock + StdioServerTransport. Server-main spawnWorker
|
|
3288
|
+
// also forks this file with MIXDOG_WORKER_MODE='1'; that path uses the IPC
|
|
3289
|
+
// handler block above and acquireLock/init() as the single memory owner.
|
|
3290
|
+
if (import.meta.url === pathToFileURL(process.argv[1] || '').href && process.env.MIXDOG_WORKER_MODE !== '1') {
|
|
3291
|
+
;(async () => {
|
|
3292
|
+
acquireLock()
|
|
3293
|
+
process.on('exit', releaseLock)
|
|
3294
|
+
process.on('SIGINT', () => { stop().finally(() => process.exit(0)) })
|
|
3295
|
+
process.on('SIGTERM', () => { stop().finally(() => process.exit(0)) })
|
|
3296
|
+
await init()
|
|
3297
|
+
const transport = new StdioServerTransport()
|
|
3298
|
+
await mcp.connect(transport)
|
|
3299
|
+
await new Promise((resolve) => { mcp.onclose = resolve })
|
|
3300
|
+
await stop()
|
|
3301
|
+
})().catch((err) => {
|
|
3302
|
+
process.stderr.write(`[memory-service] startup failed: ${err.stack || err.message}\n`)
|
|
3303
|
+
process.exit(1)
|
|
3304
|
+
})
|
|
3305
|
+
}
|