mixdog 0.9.2 → 0.9.4
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/package.json +8 -3
- package/scripts/anthropic-maxtokens-test.mjs +119 -0
- package/scripts/bench/lead-review-tasks-r3.json +20 -0
- package/scripts/bench/lead-review-tasks.json +20 -0
- package/scripts/bench/r4-mixed-tasks.json +20 -0
- package/scripts/bench/review-tasks.json +20 -0
- package/scripts/bench/round-codex.json +114 -0
- package/scripts/bench/round-mixdog-lead-r3.json +269 -0
- package/scripts/bench/round-mixdog-lead.json +269 -0
- package/scripts/bench/round-mixdog.json +126 -0
- package/scripts/bench/round-r10-bigsample.json +679 -0
- package/scripts/bench/round-r11-codexalign.json +257 -0
- package/scripts/bench/round-r4-codex.json +114 -0
- package/scripts/bench/round-r4-mixed.json +225 -0
- package/scripts/bench/round-r5-gpt-lead.json +259 -0
- package/scripts/bench/round-r6-codex.json +114 -0
- package/scripts/bench/round-r6-solo.json +257 -0
- package/scripts/bench/round-r7-full.json +254 -0
- package/scripts/bench/round-r8-fulldefault.json +255 -0
- package/scripts/bench-run.mjs +215 -29
- package/scripts/build-tui.mjs +13 -1
- package/scripts/explore-bench.mjs +124 -0
- package/scripts/freevar-smoke.mjs +95 -0
- package/scripts/hook-bus-test.mjs +191 -0
- package/scripts/internal-comms-bench.mjs +1 -0
- package/scripts/internal-comms-smoke.mjs +10 -9
- package/scripts/mouse-probe.mjs +45 -0
- package/scripts/output-style-bench.mjs +13 -6
- package/scripts/output-style-smoke.mjs +4 -4
- package/scripts/path-suffix-test.mjs +57 -0
- package/scripts/provider-toolcall-test.mjs +7 -3
- package/scripts/recall-bench.mjs +207 -0
- package/scripts/recall-usecase-cases.json +18 -0
- package/scripts/recall-usecase-probe.json +6 -0
- package/scripts/session-bench.mjs +152 -6
- package/scripts/tool-smoke.mjs +30 -67
- package/scripts/tui-render-smoke.mjs +90 -0
- package/scripts/webhook-smoke.mjs +208 -0
- package/src/agents/debugger/AGENT.md +5 -2
- package/src/agents/heavy-worker/AGENT.md +21 -11
- package/src/agents/maintainer/AGENT.md +4 -0
- package/src/agents/reviewer/AGENT.md +3 -2
- package/src/agents/worker/AGENT.md +21 -11
- package/src/lib/rules-builder.cjs +4 -0
- package/src/mixdog-session-runtime.mjs +933 -3731
- package/src/output-styles/default.md +34 -9
- package/src/output-styles/{oneline.md → extreme-minimal.md} +5 -4
- package/src/output-styles/minimal.md +4 -1
- package/src/output-styles/simple.md +22 -7
- package/src/repl.mjs +5 -5
- package/src/rules/agent/00-common.md +2 -0
- package/src/rules/agent/30-explorer.md +8 -11
- package/src/rules/lead/lead-brief.md +12 -0
- package/src/rules/lead/lead-tool.md +2 -9
- package/src/rules/shared/01-tool.md +11 -5
- package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +25 -0
- package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +100 -23
- package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +6 -15
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +362 -0
- package/src/runtime/agent/orchestrator/agent-trace-io.mjs +410 -0
- package/src/runtime/agent/orchestrator/agent-trace.mjs +16 -735
- package/src/runtime/agent/orchestrator/config.mjs +69 -2
- package/src/runtime/agent/orchestrator/context/collect.mjs +51 -0
- package/src/runtime/agent/orchestrator/mcp/client.mjs +6 -2
- package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +63 -21
- package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +93 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-model-resolve.mjs +209 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +489 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +97 -1343
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +607 -0
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +78 -10
- package/src/runtime/agent/orchestrator/providers/api-usage.mjs +1 -13
- package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +81 -0
- package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +248 -0
- package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +303 -0
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +505 -0
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +44 -1014
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +18 -4
- package/src/runtime/agent/orchestrator/providers/lib/usage-primitives.mjs +32 -0
- package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +86 -11
- package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +348 -0
- package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +54 -20
- package/src/runtime/agent/orchestrator/providers/openai-codex-model.mjs +108 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +19 -12
- package/src/runtime/agent/orchestrator/providers/openai-compat-trace.mjs +58 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +368 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +760 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +41 -1142
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +732 -0
- package/src/runtime/agent/orchestrator/providers/openai-oauth-login.mjs +193 -0
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +303 -2119
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +140 -995
- package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +227 -0
- package/src/runtime/agent/orchestrator/providers/openai-ws-events.mjs +67 -0
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +436 -0
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +1105 -0
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +2 -1
- package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +38 -12
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +7 -8
- package/src/runtime/agent/orchestrator/session/compact/budget.mjs +288 -0
- package/src/runtime/agent/orchestrator/session/compact/constants.mjs +85 -0
- package/src/runtime/agent/orchestrator/session/compact/engine.mjs +749 -0
- package/src/runtime/agent/orchestrator/session/compact/messages.mjs +82 -0
- package/src/runtime/agent/orchestrator/session/compact/summary-schema.mjs +315 -0
- package/src/runtime/agent/orchestrator/session/compact/summary.mjs +643 -0
- package/src/runtime/agent/orchestrator/session/compact/text-utils.mjs +326 -0
- package/src/runtime/agent/orchestrator/session/compact.mjs +40 -2282
- package/src/runtime/agent/orchestrator/session/loop/compact-debug.mjs +28 -0
- package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +274 -0
- package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +61 -0
- package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +38 -0
- package/src/runtime/agent/orchestrator/session/loop/env.mjs +14 -0
- package/src/runtime/agent/orchestrator/session/loop/hidden-agents.mjs +21 -0
- package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +47 -0
- package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +182 -0
- package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +173 -0
- package/src/runtime/agent/orchestrator/session/loop/steering.mjs +63 -0
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +100 -0
- package/src/runtime/agent/orchestrator/session/loop/termination.mjs +58 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +52 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +239 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +218 -0
- package/src/runtime/agent/orchestrator/session/loop/transcript-repair.mjs +101 -0
- package/src/runtime/agent/orchestrator/session/loop/usage.mjs +35 -0
- package/src/runtime/agent/orchestrator/session/loop.mjs +409 -1304
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +471 -0
- package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +230 -0
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +235 -0
- package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +149 -0
- package/src/runtime/agent/orchestrator/session/manager/rules-cache.mjs +155 -0
- package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +406 -0
- package/src/runtime/agent/orchestrator/session/manager/status-telemetry.mjs +80 -0
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +303 -0
- package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +210 -0
- package/src/runtime/agent/orchestrator/session/manager.mjs +226 -2114
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +189 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +74 -179
- package/src/runtime/agent/orchestrator/stall-policy.mjs +3 -3
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +70 -20
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +22 -2
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +33 -42
- package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +241 -0
- package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +40 -0
- package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +42 -2
- package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +11 -4
- package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +29 -0
- package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +8 -0
- package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +126 -0
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +81 -87
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +161 -0
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +108 -0
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +78 -304
- package/src/runtime/agent/orchestrator/tools/builtin.mjs +11 -6
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +303 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +43 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +382 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +551 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +295 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +158 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/keyword-match.mjs +82 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/lang-predicates.mjs +128 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.mjs +66 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +44 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +1080 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/source-access.mjs +81 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/span.mjs +19 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/symbol-index.mjs +280 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/text-columns.mjs +45 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/text-mask.mjs +347 -0
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +6 -6
- package/src/runtime/agent/orchestrator/tools/code-graph.mjs +36 -4277
- package/src/runtime/agent/orchestrator/tools/graph-binary-fetcher.mjs +6 -3
- package/src/runtime/agent/orchestrator/tools/patch/constants.mjs +9 -0
- package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +171 -0
- package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +471 -0
- package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +436 -0
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +342 -0
- package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +359 -0
- package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +340 -0
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +643 -0
- package/src/runtime/agent/orchestrator/tools/patch.mjs +36 -2959
- package/src/runtime/agent/orchestrator/tools/progress-message.mjs +1 -23
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +10 -74
- package/src/runtime/agent/orchestrator/tools/shell-powershell.mjs +77 -0
- package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +2 -4
- package/src/runtime/agent/orchestrator/tools/shell-state.mjs +154 -0
- package/src/runtime/channels/backends/discord-access.mjs +32 -0
- package/src/runtime/channels/backends/discord-attachments.mjs +65 -0
- package/src/runtime/channels/backends/discord-gateway.mjs +233 -0
- package/src/runtime/channels/backends/discord.mjs +12 -292
- package/src/runtime/channels/index.mjs +241 -894
- package/src/runtime/channels/lib/backend-dispatch.mjs +44 -0
- package/src/runtime/channels/lib/boot-profile.mjs +23 -0
- package/src/runtime/channels/lib/crash-log.mjs +106 -0
- package/src/runtime/channels/lib/event-pipeline.mjs +18 -1
- package/src/runtime/channels/lib/event-queue.mjs +63 -4
- package/src/runtime/channels/lib/inbound-routing.mjs +111 -0
- package/src/runtime/channels/lib/index-drop-trace.mjs +72 -0
- package/src/runtime/channels/lib/output-forwarder.mjs +9 -1
- package/src/runtime/channels/lib/owner-heartbeat.mjs +75 -0
- package/src/runtime/channels/lib/parent-bridge.mjs +88 -0
- package/src/runtime/channels/lib/runtime-paths.mjs +14 -4
- package/src/runtime/channels/lib/session-discovery.mjs +56 -4
- package/src/runtime/channels/lib/telegram-format.mjs +19 -22
- package/src/runtime/channels/lib/tool-dispatch.mjs +158 -0
- package/src/runtime/channels/lib/tool-format.mjs +1 -1
- package/src/runtime/channels/lib/transcript-discovery.mjs +4 -4
- package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +6 -3
- package/src/runtime/channels/lib/voice-transcription.mjs +179 -0
- package/src/runtime/channels/lib/webhook/deliveries.mjs +312 -0
- package/src/runtime/channels/lib/webhook/log.mjs +42 -0
- package/src/runtime/channels/lib/webhook/ngrok.mjs +181 -0
- package/src/runtime/channels/lib/webhook/signature.mjs +60 -0
- package/src/runtime/channels/lib/webhook.mjs +36 -570
- package/src/runtime/channels/lib/whisper-language.mjs +42 -0
- package/src/runtime/channels/tool-defs.mjs +11 -130
- package/src/runtime/memory/index.mjs +258 -2050
- package/src/runtime/memory/lib/core-memory-store.mjs +351 -1
- package/src/runtime/memory/lib/cycle-llm-adapters.mjs +58 -0
- package/src/runtime/memory/lib/cycle-scheduler.mjs +497 -0
- package/src/runtime/memory/lib/cycle-signatures.mjs +34 -0
- package/src/runtime/memory/lib/embedding-warmup.mjs +58 -0
- package/src/runtime/memory/lib/http-wire.mjs +57 -0
- package/src/runtime/memory/lib/memory-config-flags.mjs +91 -0
- package/src/runtime/memory/lib/memory-cycle.mjs +1 -1
- package/src/runtime/memory/lib/memory-cycle2-gate.mjs +515 -0
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +324 -0
- package/src/runtime/memory/lib/memory-cycle2-shared.mjs +18 -0
- package/src/runtime/memory/lib/memory-cycle2.mjs +72 -837
- package/src/runtime/memory/lib/memory-embed.mjs +149 -0
- package/src/runtime/memory/lib/memory-process-lock.mjs +162 -0
- package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +24 -0
- package/src/runtime/memory/lib/memory-recall-store.mjs +22 -2
- package/src/runtime/memory/lib/memory-retrievers.mjs +8 -0
- package/src/runtime/memory/lib/memory.mjs +20 -0
- package/src/runtime/memory/lib/pg/supervisor.mjs +1 -1
- package/src/runtime/memory/lib/promotion-fingerprint.mjs +50 -0
- package/src/runtime/memory/lib/query-handlers.mjs +780 -0
- package/src/runtime/memory/lib/recall-format.mjs +238 -0
- package/src/runtime/memory/lib/runtime-fetcher.mjs +8 -3
- package/src/runtime/memory/lib/transcript-ingest.mjs +425 -0
- package/src/runtime/memory/tool-defs.mjs +6 -14
- package/src/runtime/search/lib/http-fetch.mjs +274 -0
- package/src/runtime/search/lib/ssrf-guard.mjs +333 -0
- package/src/runtime/search/lib/web-tools.mjs +24 -602
- package/src/runtime/shared/abort-controller.mjs +1 -1
- package/src/runtime/shared/atomic-file.mjs +26 -1
- package/src/runtime/shared/background-tasks.mjs +2 -3
- package/src/runtime/shared/buffered-appender.mjs +149 -0
- package/src/runtime/shared/launcher-control.mjs +2 -2
- package/src/runtime/shared/task-notification-envelope.mjs +98 -0
- package/src/runtime/shared/tool-execution-contract.mjs +2 -2
- package/src/runtime/shared/tool-primitives.mjs +308 -0
- package/src/runtime/shared/tool-result-summary.mjs +515 -0
- package/src/runtime/shared/tool-surface.mjs +80 -898
- package/src/runtime/shared/transcript-writer.mjs +52 -2
- package/src/runtime/shared/update-checker.mjs +7 -4
- package/src/session-runtime/config-helpers.mjs +291 -0
- package/src/session-runtime/config-lifecycle.mjs +232 -0
- package/src/session-runtime/cwd-plugins.mjs +226 -0
- package/src/session-runtime/effort.mjs +128 -0
- package/src/session-runtime/fs-utils.mjs +10 -0
- package/src/session-runtime/mcp-glue.mjs +177 -0
- package/src/session-runtime/model-capabilities.mjs +130 -0
- package/src/session-runtime/model-recency.mjs +111 -0
- package/src/session-runtime/native-search.mjs +247 -0
- package/src/session-runtime/output-styles.mjs +126 -0
- package/src/session-runtime/plugin-mcp.mjs +114 -0
- package/src/session-runtime/prewarm.mjs +142 -0
- package/src/session-runtime/provider-models.mjs +278 -0
- package/src/session-runtime/provider-usage.mjs +120 -0
- package/src/session-runtime/quick-model-rows.mjs +170 -0
- package/src/session-runtime/quick-search-models.mjs +46 -0
- package/src/session-runtime/session-hooks.mjs +93 -0
- package/src/session-runtime/session-text.mjs +100 -0
- package/src/session-runtime/settings-api.mjs +319 -0
- package/src/session-runtime/statusline-route.mjs +35 -0
- package/src/session-runtime/tool-catalog.mjs +720 -0
- package/src/session-runtime/tool-defs.mjs +84 -0
- package/src/session-runtime/warmup-schedulers.mjs +201 -0
- package/src/session-runtime/workflow.mjs +358 -0
- package/src/standalone/agent-tool/helpers.mjs +237 -0
- package/src/standalone/agent-tool/notify.mjs +107 -0
- package/src/standalone/agent-tool/provider-init.mjs +143 -0
- package/src/standalone/agent-tool/render.mjs +152 -0
- package/src/standalone/agent-tool/tool-def.mjs +55 -0
- package/src/standalone/agent-tool.mjs +155 -677
- package/src/standalone/channel-worker.mjs +7 -9
- package/src/standalone/explore-tool.mjs +40 -12
- package/src/standalone/hook-bus/config.mjs +207 -0
- package/src/standalone/hook-bus/constants.mjs +90 -0
- package/src/standalone/hook-bus/handlers.mjs +481 -0
- package/src/standalone/hook-bus/payload.mjs +31 -0
- package/src/standalone/hook-bus/rules.mjs +77 -0
- package/src/standalone/hook-bus.mjs +110 -746
- package/src/standalone/memory-runtime-proxy.mjs +7 -0
- package/src/standalone/opencode-go-login.mjs +125 -0
- package/src/standalone/provider-admin.mjs +15 -19
- package/src/standalone/usage-dashboard.mjs +3 -1
- package/src/tui/App.jsx +1163 -7571
- package/src/tui/app/app-format.mjs +206 -0
- package/src/tui/app/channel-pickers.mjs +510 -0
- package/src/tui/app/clipboard.mjs +67 -0
- package/src/tui/app/core-memory-picker.mjs +210 -0
- package/src/tui/app/extension-pickers.mjs +506 -0
- package/src/tui/app/input-parsers.mjs +193 -0
- package/src/tui/app/maintenance-pickers.mjs +324 -0
- package/src/tui/app/model-options.mjs +330 -0
- package/src/tui/app/model-picker.mjs +365 -0
- package/src/tui/app/onboarding-steps.mjs +400 -0
- package/src/tui/app/project-picker.mjs +247 -0
- package/src/tui/app/provider-setup-picker.mjs +580 -0
- package/src/tui/app/resume-picker.mjs +55 -0
- package/src/tui/app/route-pickers.mjs +419 -0
- package/src/tui/app/settings-picker.mjs +490 -0
- package/src/tui/app/slash-commands.mjs +101 -0
- package/src/tui/app/slash-dispatch.mjs +427 -0
- package/src/tui/app/text-layout.mjs +46 -0
- package/src/tui/app/theme-effort-pickers.mjs +154 -0
- package/src/tui/app/transcript-window.mjs +671 -0
- package/src/tui/app/use-mouse-input.mjs +460 -0
- package/src/tui/app/use-prompt-handlers.mjs +310 -0
- package/src/tui/app/use-transcript-scroll.mjs +510 -0
- package/src/tui/app/use-transcript-window.mjs +589 -0
- package/src/tui/components/ConfirmBar.jsx +1 -1
- package/src/tui/components/Picker.jsx +32 -4
- package/src/tui/components/PromptInput.jsx +259 -80
- package/src/tui/components/SlashCommandPalette.jsx +8 -1
- package/src/tui/components/StatusLine.jsx +63 -12
- package/src/tui/components/TextEntryPanel.jsx +11 -0
- package/src/tui/components/ToolExecution.jsx +56 -588
- package/src/tui/components/TranscriptItem.jsx +105 -0
- package/src/tui/components/UsagePanel.jsx +18 -4
- package/src/tui/components/prompt-input/edit-helpers.mjs +72 -0
- package/src/tui/components/prompt-input/voice-indicator.mjs +39 -0
- package/src/tui/components/tool-execution/ResultBody.jsx +56 -0
- package/src/tui/components/tool-execution/surface-detail.mjs +405 -0
- package/src/tui/components/tool-execution/text-format.mjs +161 -0
- package/src/tui/components/tool-output-format.mjs +2 -2
- package/src/tui/display-width.mjs +20 -3
- package/src/tui/dist/index.mjs +18034 -17188
- package/src/tui/engine/agent-envelope.mjs +296 -0
- package/src/tui/engine/agent-job-feed.mjs +133 -0
- package/src/tui/engine/boot-profile.mjs +21 -0
- package/src/tui/engine/labels.mjs +67 -0
- package/src/tui/engine/notice-text.mjs +112 -0
- package/src/tui/engine/notification-plan.mjs +76 -0
- package/src/tui/engine/queue-helpers.mjs +161 -0
- package/src/tui/engine/render-timing.mjs +17 -0
- package/src/tui/engine/session-stats.mjs +46 -0
- package/src/tui/engine/tool-approval.mjs +94 -0
- package/src/tui/engine/tool-call-fields.mjs +23 -0
- package/src/tui/engine/tool-card-results.mjs +234 -0
- package/src/tui/engine/tool-result-status.mjs +135 -0
- package/src/tui/engine/tool-result-text.mjs +126 -0
- package/src/tui/engine.mjs +405 -1385
- package/src/tui/figures.mjs +5 -0
- package/src/tui/index.jsx +105 -0
- package/src/tui/input-editing.mjs +60 -10
- package/src/tui/keyboard-protocol.mjs +2 -2
- package/src/tui/lib/voice-recorder.mjs +35 -19
- package/src/tui/markdown/format-token.mjs +11 -9
- package/src/tui/paste-attachments.mjs +38 -0
- package/src/tui/statusline-ansi-bridge.mjs +11 -3
- package/src/tui/theme.mjs +6 -0
- package/src/tui/themes/base.mjs +2 -2
- package/src/tui/themes/kanagawa.mjs +4 -4
- package/src/tui/themes/teal.mjs +4 -5
- package/src/tui/themes/utils.mjs +1 -1
- package/src/ui/statusline-agents.mjs +213 -0
- package/src/ui/statusline-format.mjs +146 -0
- package/src/ui/statusline-segments.mjs +148 -0
- package/src/ui/statusline.mjs +77 -462
- package/src/ui/tool-card.mjs +0 -1
- package/src/vendor/statusline/bin/statusline-route.mjs +15 -2
- package/src/workflows/default/WORKFLOW.md +16 -9
- package/src/workflows/sequential/WORKFLOW.md +16 -11
- package/src/workflows/solo/WORKFLOW.md +5 -1
- package/vendor/ink/build/display-width.js +19 -3
- package/vendor/ink/build/ink.js +103 -6
- package/vendor/ink/build/log-update.js +17 -3
- package/vendor/ink/build/wrap-text.js +125 -0
- package/scripts/_test-folder-dialog.mjs +0 -30
- package/scripts/fix-brief-fn.mjs +0 -35
- package/scripts/fix-format-tool-surface.mjs +0 -24
- package/scripts/fix-tool-exec-visible.mjs +0 -42
- package/scripts/patch-agent-brief.mjs +0 -48
- package/scripts/patch-app.mjs +0 -21
- package/scripts/patch-app2.mjs +0 -18
- package/scripts/patch-dist-brief.mjs +0 -96
- package/scripts/patch-tool-exec.mjs +0 -70
- package/src/examples/schedules/SCHEDULE.example.md +0 -32
- package/src/examples/webhooks/WEBHOOK.example.md +0 -40
- package/src/runtime/agent/orchestrator/session/manager.reactive-persist.test.mjs +0 -107
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +0 -143
- package/src/runtime/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +0 -285
- package/src/runtime/agent/orchestrator/tools/builtin/open-config-tool.mjs +0 -26
- package/src/runtime/shared/channel-notification-routing.test.mjs +0 -45
- package/src/runtime/shared/tool-execution-contract.test.mjs +0 -183
- package/src/standalone/agent-task-status.test.mjs +0 -76
- package/src/tui/components/tool-output-format.test.mjs +0 -399
- package/src/tui/display-width.test.mjs +0 -35
- package/src/tui/engine-runtime-notification.test.mjs +0 -115
- package/src/tui/engine-tool-result-text.test.mjs +0 -75
- package/src/tui/markdown/format-token.test.mjs +0 -354
- package/src/tui/markdown/render-ansi.test.mjs +0 -108
- package/src/tui/markdown/stream-fence.test.mjs +0 -26
- package/src/tui/markdown/streaming-markdown.test.mjs +0 -70
- package/src/tui/prompt-history-store.test.mjs +0 -52
- package/src/tui/statusline-ansi-bridge.test.mjs +0 -159
- package/src/tui/transcript-tool-failures.test.mjs +0 -111
- package/src/ui/markdown.test.mjs +0 -70
- package/src/ui/statusline-context-label.test.mjs +0 -15
- package/src/vendor/statusline/bin/statusline-lib.mjs +0 -186
- package/src/vendor/statusline/bin/statusline-route.test.mjs +0 -80
|
@@ -9,7 +9,6 @@ process.removeAllListeners('warning')
|
|
|
9
9
|
process.on('warning', () => {})
|
|
10
10
|
|
|
11
11
|
import http from 'node:http'
|
|
12
|
-
import crypto from 'node:crypto'
|
|
13
12
|
import os from 'node:os'
|
|
14
13
|
import fs from 'node:fs'
|
|
15
14
|
import path from 'node:path'
|
|
@@ -17,47 +16,9 @@ import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
|
17
16
|
|
|
18
17
|
const PLUGIN_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
|
|
19
18
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
} catch { return '0.0.1' }
|
|
24
|
-
}
|
|
25
|
-
const PLUGIN_VERSION = readPluginVersion()
|
|
26
|
-
const PROMOTION_FINGERPRINT_ROOTS = ['src/memory']
|
|
27
|
-
function collectPromotionFingerprintFiles() {
|
|
28
|
-
const out = []
|
|
29
|
-
const walk = (relDir) => {
|
|
30
|
-
let entries = []
|
|
31
|
-
try { entries = fs.readdirSync(path.join(PLUGIN_ROOT, relDir), { withFileTypes: true }) }
|
|
32
|
-
catch { return }
|
|
33
|
-
for (const ent of entries) {
|
|
34
|
-
const rel = `${relDir}/${ent.name}`.replace(/\\/g, '/')
|
|
35
|
-
if (ent.isDirectory()) {
|
|
36
|
-
walk(rel)
|
|
37
|
-
} else if (ent.isFile() && rel.endsWith('.mjs')) {
|
|
38
|
-
out.push(rel)
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
for (const root of PROMOTION_FINGERPRINT_ROOTS) walk(root)
|
|
43
|
-
return out.sort()
|
|
44
|
-
}
|
|
45
|
-
function readPromotionCodeFingerprint() {
|
|
46
|
-
const hash = crypto.createHash('sha256')
|
|
47
|
-
const files = collectPromotionFingerprintFiles()
|
|
48
|
-
for (const rel of files) {
|
|
49
|
-
hash.update(rel)
|
|
50
|
-
hash.update('\0')
|
|
51
|
-
try {
|
|
52
|
-
hash.update(fs.readFileSync(path.join(PLUGIN_ROOT, rel)))
|
|
53
|
-
} catch {
|
|
54
|
-
hash.update('missing')
|
|
55
|
-
}
|
|
56
|
-
hash.update('\0')
|
|
57
|
-
}
|
|
58
|
-
return `src/memory:${files.length}:${hash.digest('hex').slice(0, 16)}`
|
|
59
|
-
}
|
|
60
|
-
const BOOT_PROMOTION_CODE_FINGERPRINT = readPromotionCodeFingerprint()
|
|
19
|
+
import { readPluginVersion, readPromotionCodeFingerprint } from './lib/promotion-fingerprint.mjs'
|
|
20
|
+
const PLUGIN_VERSION = readPluginVersion(PLUGIN_ROOT)
|
|
21
|
+
const BOOT_PROMOTION_CODE_FINGERPRINT = readPromotionCodeFingerprint(PLUGIN_ROOT)
|
|
61
22
|
|
|
62
23
|
try { os.setPriority(os.constants.priority.PRIORITY_BELOW_NORMAL) } catch {}
|
|
63
24
|
try {
|
|
@@ -95,24 +56,43 @@ import {
|
|
|
95
56
|
} from './lib/session-ingest.mjs'
|
|
96
57
|
import { configureEmbedding, embedText, embedTexts, getEmbeddingDims, getEmbeddingDtype, getEmbeddingModelId, getKnownDimsForCurrentModel, isEmbeddingModelReady, primeEmbeddingDims, warmupEmbeddingProvider } from './lib/embedding-provider.mjs'
|
|
97
58
|
import { startLlmWorker, stopLlmWorker } from './lib/llm-worker-host.mjs'
|
|
98
|
-
import { runCycle1, runCycle2, runCycle3, runUnifiedGate, parseInterval, syncRootEmbedding, applySimpleStatus, applyUpdate, applyMerge, CYCLE2_ACTIVE_TARGET_CAP } from './lib/memory-cycle.mjs'
|
|
59
|
+
import { runCycle1, runCycle2, runCycle3, runUnifiedGate, parseInterval, syncRootEmbedding, flushRawEmbeddings, applySimpleStatus, applyUpdate, applyMerge, CYCLE2_ACTIVE_TARGET_CAP } from './lib/memory-cycle.mjs'
|
|
99
60
|
import { loadConfig as loadAgentConfig } from '../agent/orchestrator/config.mjs'
|
|
100
61
|
import { initProviders } from '../agent/orchestrator/providers/registry.mjs'
|
|
101
62
|
import { makeAgentDispatch } from '../agent/orchestrator/agent-runtime/agent-dispatch.mjs'
|
|
102
63
|
import { getInFlightCycle1 } from './lib/memory-cycle1.mjs'
|
|
103
|
-
import {
|
|
104
|
-
import { claimAndMarkScheduledCycle, makeCycleRequestSignature, resolveCoalesceMaxRetries, scheduleCoalescedCycleRetry } from './lib/memory-cycle-requests.mjs'
|
|
64
|
+
import { claimAndMarkScheduledCycle, resolveCoalesceMaxRetries, scheduleCoalescedCycleRetry } from './lib/memory-cycle-requests.mjs'
|
|
105
65
|
import { searchRelevantHybrid } from './lib/memory-recall-store.mjs'
|
|
106
66
|
import { fetchEntriesByIdsScoped } from './lib/memory-recall-id-patch.mjs'
|
|
107
67
|
import { retrieveEntries } from './lib/memory-retrievers.mjs'
|
|
108
68
|
import { pruneOldEntries } from './lib/memory-maintenance-store.mjs'
|
|
109
69
|
import { computeEntryScore } from './lib/memory-score.mjs'
|
|
110
70
|
import { runFullBackfill } from './lib/memory-ops-policy.mjs'
|
|
111
|
-
import { listCore, addCore, editCore, deleteCore, compactCoreIds, CORE_SUMMARY_MAX } from './lib/core-memory-store.mjs'
|
|
71
|
+
import { listCore, addCore, editCore, deleteCore, compactCoreIds, listCoreCandidates, promoteCoreCandidate, dismissCoreCandidate, CORE_SUMMARY_MAX } from './lib/core-memory-store.mjs'
|
|
112
72
|
import { resolveProjectId, resolveProjectScope } from './lib/project-id-resolver.mjs'
|
|
113
73
|
import { openTraceDatabase, closeTraceDatabase, insertTraceEvents, enqueueTraceEvents, insertAgentCalls, registerTraceExitDrain } from './lib/trace-store.mjs'
|
|
114
74
|
import { updateJsonAtomicSync, writeJsonAtomicSync } from '../shared/atomic-file.mjs'
|
|
115
75
|
import { resolvePluginData, mixdogHome } from '../shared/plugin-paths.mjs'
|
|
76
|
+
import { parsePeriod, formatTs, coreRecallTerms, normalizeRecallProjectScope, sessionRecallTerms, interleaveRawRows, renderEntryLines, renderSessionGroupedLines } from './lib/recall-format.mjs'
|
|
77
|
+
import { readBody, sendJson, sendError, isLocalOrigin, normalizeCoreProjectId } from './lib/http-wire.mjs'
|
|
78
|
+
import { scheduledCycle1Signature, scheduledCycle2Signature, scheduledCycle3Signature } from './lib/cycle-signatures.mjs'
|
|
79
|
+
import { createTranscriptIngest } from './lib/transcript-ingest.mjs'
|
|
80
|
+
import { createEmbeddingWarmup } from './lib/embedding-warmup.mjs'
|
|
81
|
+
import { createCycleLlmAdapters } from './lib/cycle-llm-adapters.mjs'
|
|
82
|
+
import { createCycleScheduler } from './lib/cycle-scheduler.mjs'
|
|
83
|
+
import { createQueryHandlers } from './lib/query-handlers.mjs'
|
|
84
|
+
import {
|
|
85
|
+
readMainConfig,
|
|
86
|
+
readRecapEnabled,
|
|
87
|
+
embeddingWarmupEnabled,
|
|
88
|
+
envFlagEnabled,
|
|
89
|
+
memorySecondaryMode,
|
|
90
|
+
embeddingWarmupCanStart,
|
|
91
|
+
memoryLlmWorkerEnabled,
|
|
92
|
+
memoryCyclesEnabled,
|
|
93
|
+
secondaryPgAdvertised as _secondaryPgAdvertised,
|
|
94
|
+
assertSecondaryPgAttachable as _assertSecondaryPgAttachable,
|
|
95
|
+
} from './lib/memory-config-flags.mjs'
|
|
116
96
|
const IS_MEMORY_ENTRY = !!process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href
|
|
117
97
|
const USE_ARG_DATA_DIR = IS_MEMORY_ENTRY || process.env.MIXDOG_WORKER_MODE === '1'
|
|
118
98
|
const DATA_DIR = process.env.MIXDOG_DATA_DIR || (USE_ARG_DATA_DIR ? process.argv[2] : '') || resolvePluginData()
|
|
@@ -122,7 +102,15 @@ if (!DATA_DIR) {
|
|
|
122
102
|
}
|
|
123
103
|
__mixdogMemoryLog(`[memory-service] DATA_DIR=${DATA_DIR}\n`)
|
|
124
104
|
|
|
125
|
-
import {
|
|
105
|
+
import {
|
|
106
|
+
parsePositivePid,
|
|
107
|
+
isPidAliveLocal,
|
|
108
|
+
tryAcquireMemoryOwnerLock as _tryAcquireMemoryOwnerLock,
|
|
109
|
+
releaseMemoryOwnerLock as _releaseMemoryOwnerLock,
|
|
110
|
+
killPreviousServer as _killPreviousServer,
|
|
111
|
+
acquireLock as _acquireLock,
|
|
112
|
+
releaseLock as _releaseLock,
|
|
113
|
+
} from './lib/memory-process-lock.mjs'
|
|
126
114
|
|
|
127
115
|
const RUNTIME_ROOT = process.env.MIXDOG_RUNTIME_ROOT
|
|
128
116
|
? path.resolve(process.env.MIXDOG_RUNTIME_ROOT)
|
|
@@ -136,12 +124,8 @@ let _periodicAdvertiseTimer = null
|
|
|
136
124
|
// port after fork-proxy promotion swaps in our own locally-bound port.
|
|
137
125
|
let _currentAdvertisedPort = null
|
|
138
126
|
|
|
139
|
-
function parsePositivePid(value) {
|
|
140
|
-
const pid = Number(value)
|
|
141
|
-
return Number.isFinite(pid) && pid > 0 ? pid : null
|
|
142
|
-
}
|
|
143
|
-
|
|
144
127
|
const MEMORY_SERVER_PID = parsePositivePid(process.env.MIXDOG_SERVER_PID) ?? process.pid
|
|
128
|
+
const _isPidAliveLocal = isPidAliveLocal
|
|
145
129
|
const MEMORY_DAEMON_MODE = process.env.MIXDOG_MEMORY_DAEMON === '1'
|
|
146
130
|
const MEMORY_IDLE_TTL_MS = Math.max(0, Number(process.env.MIXDOG_MEMORY_IDLE_TTL_MS) || 10 * 60_000)
|
|
147
131
|
let _idleShutdownTimer = null
|
|
@@ -191,7 +175,7 @@ function advertiseMemoryPort(boundPort, attempt = 0) {
|
|
|
191
175
|
updatedAt: Date.now(),
|
|
192
176
|
}
|
|
193
177
|
return next
|
|
194
|
-
}, { compact: true, fsyncDir: true })
|
|
178
|
+
}, { compact: true, fsyncDir: true, renameFallback: 'truncate' })
|
|
195
179
|
if (!_periodicAdvertiseInstalled) {
|
|
196
180
|
_periodicAdvertiseInstalled = true
|
|
197
181
|
_periodicAdvertiseTimer = setInterval(() => {
|
|
@@ -221,58 +205,12 @@ const LOCK_FILE = path.join(DATA_DIR, '.memory-service.lock')
|
|
|
221
205
|
// EEXIST when another process won the race.
|
|
222
206
|
const OWNER_LOCK_FILE = path.join(DATA_DIR, '.memory-owner.lock')
|
|
223
207
|
|
|
224
|
-
function _isPidAliveLocal(pid) {
|
|
225
|
-
if (!Number.isFinite(pid) || pid <= 0) return false
|
|
226
|
-
try { process.kill(pid, 0); return true }
|
|
227
|
-
catch (e) { return e.code !== 'ESRCH' }
|
|
228
|
-
}
|
|
229
|
-
|
|
230
208
|
function tryAcquireMemoryOwnerLock() {
|
|
231
|
-
|
|
232
|
-
// dir), false when a live peer holds the lock. Stale locks (dead PID) are
|
|
233
|
-
// unlinked and retried atomically. Throws on unexpected fs errors so callers
|
|
234
|
-
// surface lock-system corruption rather than silently downgrading.
|
|
235
|
-
//
|
|
236
|
-
// EPERM/EBUSY/EACCES at openSync are transient — AV scanners (SignKorea /
|
|
237
|
-
// SKCert / ezPDFWS etc) briefly lock newly-created files during inspection.
|
|
238
|
-
// The 0.1.x baseline threw immediately and the worker promoted to
|
|
239
|
-
// permanentlyDegraded, killing memory tools for the rest of the session.
|
|
240
|
-
// Treat the AV error codes as retryable with bounded backoff (~750ms total)
|
|
241
|
-
// before giving up and rethrowing.
|
|
242
|
-
for (let attempt = 0; attempt < 5; attempt++) {
|
|
243
|
-
try {
|
|
244
|
-
const fd = fs.openSync(OWNER_LOCK_FILE, 'wx')
|
|
245
|
-
fs.writeSync(fd, String(process.pid))
|
|
246
|
-
fs.closeSync(fd)
|
|
247
|
-
return true
|
|
248
|
-
} catch (e) {
|
|
249
|
-
if (e.code === 'EEXIST') {
|
|
250
|
-
let ownerPid = NaN
|
|
251
|
-
try { ownerPid = Number(fs.readFileSync(OWNER_LOCK_FILE, 'utf8').trim()) } catch {}
|
|
252
|
-
if (_isPidAliveLocal(ownerPid)) return false
|
|
253
|
-
// Stale lock: dead owner — unlink and retry exclusive create.
|
|
254
|
-
try { fs.unlinkSync(OWNER_LOCK_FILE) } catch {}
|
|
255
|
-
continue
|
|
256
|
-
}
|
|
257
|
-
const transient = e.code === 'EPERM' || e.code === 'EBUSY' || e.code === 'EACCES'
|
|
258
|
-
if (transient && attempt < 4) {
|
|
259
|
-
// Sync busy-wait acceptable here: this runs on memory worker boot
|
|
260
|
-
// path, once per process; the parent handler is not blocked.
|
|
261
|
-
const end = Date.now() + 50 * (attempt + 1)
|
|
262
|
-
while (Date.now() < end) {}
|
|
263
|
-
continue
|
|
264
|
-
}
|
|
265
|
-
throw e
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
return false
|
|
209
|
+
return _tryAcquireMemoryOwnerLock(OWNER_LOCK_FILE, __mixdogMemoryLog)
|
|
269
210
|
}
|
|
270
211
|
|
|
271
212
|
function releaseMemoryOwnerLock() {
|
|
272
|
-
|
|
273
|
-
const ownerPid = Number(fs.readFileSync(OWNER_LOCK_FILE, 'utf8').trim())
|
|
274
|
-
if (ownerPid === process.pid) fs.unlinkSync(OWNER_LOCK_FILE)
|
|
275
|
-
} catch {}
|
|
213
|
+
return _releaseMemoryOwnerLock(OWNER_LOCK_FILE)
|
|
276
214
|
}
|
|
277
215
|
|
|
278
216
|
const BASE_PORT = 3350
|
|
@@ -283,139 +221,39 @@ let _traceDb = null
|
|
|
283
221
|
const MEMORY_INSTRUCTIONS_TEXT = ''
|
|
284
222
|
|
|
285
223
|
function killPreviousServer(pid) {
|
|
286
|
-
|
|
287
|
-
if (process.platform === 'win32') {
|
|
288
|
-
try {
|
|
289
|
-
execFileSync('taskkill', ['/F', '/T', '/PID', String(pid)], { encoding: 'utf8', timeout: 5000, windowsHide: true })
|
|
290
|
-
__mixdogMemoryLog(`[memory-service] Killed previous server PID ${pid}\n`)
|
|
291
|
-
return true
|
|
292
|
-
} catch (e) {
|
|
293
|
-
// Exit code 128 = process not found; treat stale lock as already-dead = success.
|
|
294
|
-
// Status 128 reliably means "process not found" regardless of locale; no text match needed.
|
|
295
|
-
// Status 1 with English text match handles edge cases on some Windows versions.
|
|
296
|
-
const notFoundText = /not found|no running instance/i.test(e.stdout || '')
|
|
297
|
-
|| /not found|no running instance/i.test(e.stderr || '')
|
|
298
|
-
|| /not found|no running instance/i.test(e.message || '')
|
|
299
|
-
const alreadyDead = e.status === 128 || (e.status === 1 && notFoundText)
|
|
300
|
-
if (alreadyDead) {
|
|
301
|
-
__mixdogMemoryLog(`[memory-service] PID ${pid} already dead (stale lock), proceeding\n`)
|
|
302
|
-
return true
|
|
303
|
-
}
|
|
304
|
-
__mixdogMemoryLog(`[memory-service] taskkill failed for PID ${pid}: ${e.message}\n`)
|
|
305
|
-
return false
|
|
306
|
-
}
|
|
307
|
-
} else {
|
|
308
|
-
// Pre-flight: if the process is already gone, treat stale lock as success.
|
|
309
|
-
try {
|
|
310
|
-
process.kill(pid, 0)
|
|
311
|
-
} catch (e) {
|
|
312
|
-
if (e.code === 'ESRCH') {
|
|
313
|
-
__mixdogMemoryLog(`[memory-service] PID ${pid} already dead (stale lock), proceeding\n`)
|
|
314
|
-
return true
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
try { process.kill(pid, 'SIGTERM') } catch {}
|
|
318
|
-
try { process.kill(pid, 'SIGKILL') } catch {}
|
|
319
|
-
// Poll for death up to 2s
|
|
320
|
-
const deadline = Date.now() + 2000
|
|
321
|
-
while (Date.now() < deadline) {
|
|
322
|
-
try {
|
|
323
|
-
process.kill(pid, 0)
|
|
324
|
-
} catch (e) {
|
|
325
|
-
if (e.code === 'ESRCH') {
|
|
326
|
-
__mixdogMemoryLog(`[memory-service] Killed previous server PID ${pid}\n`)
|
|
327
|
-
return true
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
// Synchronous 50ms sleep via shared buffer spin
|
|
331
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50)
|
|
332
|
-
}
|
|
333
|
-
__mixdogMemoryLog(`[memory-service] PID ${pid} still alive after SIGKILL\n`)
|
|
334
|
-
return false
|
|
335
|
-
}
|
|
224
|
+
return _killPreviousServer(pid, __mixdogMemoryLog)
|
|
336
225
|
}
|
|
337
226
|
|
|
338
227
|
function acquireLock() {
|
|
339
|
-
|
|
340
|
-
// memory worker serving recall for another CC session. killPreviousServer
|
|
341
|
-
// would taskkill /F that healthy peer mid-flight, then this fork-proxy
|
|
342
|
-
// mode wouldn't even need a lock anyway. Skip the entire kill-the-previous
|
|
343
|
-
// protocol; fork-proxy detection in init() takes priority. If neither
|
|
344
|
-
// proxy nor lock-owner path applies (race window during simultaneous
|
|
345
|
-
// boot), the worker simply continues without the lock — server-main /
|
|
346
|
-
// PG / port-listen handle the actual conflict cases.
|
|
347
|
-
if (process.env.MIXDOG_MULTI_INSTANCE === '1') return
|
|
348
|
-
try {
|
|
349
|
-
if (fs.existsSync(LOCK_FILE)) {
|
|
350
|
-
const lockedPid = Number(fs.readFileSync(LOCK_FILE, 'utf8').trim())
|
|
351
|
-
if (lockedPid > 0 && lockedPid !== process.pid) {
|
|
352
|
-
const killed = killPreviousServer(lockedPid)
|
|
353
|
-
if (!killed) {
|
|
354
|
-
__mixdogMemoryLog(`[memory-service] Could not kill previous server PID ${lockedPid}, aborting\n`)
|
|
355
|
-
process.exit(1)
|
|
356
|
-
}
|
|
357
|
-
try { fs.unlinkSync(LOCK_FILE) } catch {}
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
const fd = fs.openSync(LOCK_FILE, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o600)
|
|
361
|
-
try {
|
|
362
|
-
fs.writeSync(fd, String(process.pid))
|
|
363
|
-
} finally {
|
|
364
|
-
fs.closeSync(fd)
|
|
365
|
-
}
|
|
366
|
-
} catch (e) {
|
|
367
|
-
if (e.code === 'EEXIST') {
|
|
368
|
-
__mixdogMemoryLog(`[memory-service] Lock file exists (EEXIST) — another instance is already running, exiting\n`)
|
|
369
|
-
process.exit(0)
|
|
370
|
-
}
|
|
371
|
-
__mixdogMemoryLog(`[memory-service] Lock acquisition failed: ${e.message}\n`)
|
|
372
|
-
process.exit(1)
|
|
373
|
-
}
|
|
228
|
+
return _acquireLock(LOCK_FILE, __mixdogMemoryLog)
|
|
374
229
|
}
|
|
375
230
|
|
|
376
231
|
function releaseLock() {
|
|
377
|
-
|
|
378
|
-
const content = fs.readFileSync(LOCK_FILE, 'utf8').trim()
|
|
379
|
-
if (Number(content) === process.pid) fs.unlinkSync(LOCK_FILE)
|
|
380
|
-
} catch {}
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
import { readSection } from '../shared/config.mjs'
|
|
384
|
-
|
|
385
|
-
function readMainConfig() {
|
|
386
|
-
return readSection('memory')
|
|
232
|
+
return _releaseLock(LOCK_FILE)
|
|
387
233
|
}
|
|
388
234
|
|
|
389
235
|
let db = null
|
|
390
236
|
let mainConfig = null
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
// The AUTHORITATIVE guard lives in memory-cycle.mjs:runCycle1 itself — that
|
|
396
|
-
// one catches every caller, including direct imports (setup-server backfill,
|
|
397
|
-
// policy-layer backfill). This outer tracker is kept as a defense-in-depth
|
|
398
|
-
// layer local to the MCP server process: it coalesces simultaneous
|
|
399
|
-
// _awaitCycle1Run callers (MCP action, scheduler, flush) onto a shared
|
|
400
|
-
// promise so they all observe the SAME result object rather than some
|
|
401
|
-
// getting the real stats and others getting `skippedInFlight: true` from
|
|
402
|
-
// the inner guard.
|
|
403
|
-
let _cycle1InFlight = null // shared cycle1 promise (outer coalesce layer)
|
|
404
|
-
// Per-session recall-drain in-flight map. Multiple terminals share one
|
|
405
|
-
// memory daemon (active-instance.json memory_port); concurrent recall calls
|
|
406
|
-
// for the SAME session must share one drain promise (dedup), while DIFFERENT
|
|
407
|
-
// sessions drain in parallel (cycle1's own advisory-lock/coalesce guard in
|
|
408
|
-
// memory-cycle1.mjs still serializes the actual DB work). A drain never
|
|
409
|
-
// blocks recall for sessions other than its own.
|
|
410
|
-
const _recallDrainInFlight = new Map() // sessionId -> Promise
|
|
237
|
+
// NOTE: cycle tick timers + the cycle1 outer-coalesce in-flight tracker now
|
|
238
|
+
// live inside the cycle scheduler factory (lib/cycle-scheduler.mjs). The
|
|
239
|
+
// AUTHORITATIVE cycle1 guard is still memory-cycle.mjs:runCycle1; the
|
|
240
|
+
// scheduler's outer layer coalesces simultaneous awaitCycle1Run callers.
|
|
411
241
|
let _initialized = false
|
|
412
242
|
let _initPromise = null
|
|
413
243
|
let _stopPromise = null
|
|
414
244
|
let _bootTimestamp = null
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
245
|
+
// Post-ingest raw-embedding flush chain (facade-local). The checkCycles tick
|
|
246
|
+
// keeps its OWN independent guard inside the scheduler; this chain serializes
|
|
247
|
+
// flushes kicked from ingestSessionMessages so ingest bursts never stack
|
|
248
|
+
// embedding work, while still guaranteeing every ingest's rows get their own
|
|
249
|
+
// flush pass (the old boolean guard silently SKIPPED bursts, leaving rows
|
|
250
|
+
// embedding-less until the ~60s tick — a recall (no results) window).
|
|
251
|
+
let _rawEmbedFlushChain = Promise.resolve()
|
|
252
|
+
// Ingest now WAITS for its embedding flush (bounded) so freshly ingested rows
|
|
253
|
+
// are dense-searchable the moment ingest_session returns. On a cold ONNX
|
|
254
|
+
// model the flush can stall on warmup; the race below caps the wait and lets
|
|
255
|
+
// the flush finish in the background (tick fallback still sweeps stragglers).
|
|
256
|
+
const INGEST_EMBED_WAIT_MS = 15_000
|
|
419
257
|
// Boot-edge background warmup. ONNX session creation on the embedding worker
|
|
420
258
|
// thread is CPU-heavy, so it must not overlap the worker's own init (DB open,
|
|
421
259
|
// schema, cycle wiring). Previously this was gated behind a fixed setTimeout —
|
|
@@ -423,83 +261,47 @@ let _transcriptOffsetsPersistTail = Promise.resolve()
|
|
|
423
261
|
// _initStore and fired at the _initRuntime completion edge (see _initRuntime),
|
|
424
262
|
// so it starts the instant boot's CPU-heavy work is done — no magic-number
|
|
425
263
|
// delay. MIXDOG_EMBED_WARMUP=0 disables it (model loads lazily on first use).
|
|
426
|
-
let _pendingEmbeddingWarmup = null
|
|
427
|
-
let _embeddingColdRecallLogAt = 0
|
|
428
264
|
|
|
429
265
|
const TRANSCRIPT_OFFSETS_KEY = 'state.transcript_offsets'
|
|
430
266
|
const CYCLE_LAST_RUN_KEY = 'state.cycle_last_run'
|
|
431
267
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
}
|
|
268
|
+
// Transcript ingest cluster (extracted to lib/transcript-ingest.mjs). Live
|
|
269
|
+
// db/config coupling is injected so index.mjs keeps lifecycle ownership.
|
|
270
|
+
const _transcriptIngest = createTranscriptIngest({
|
|
271
|
+
getDb: () => db,
|
|
272
|
+
loadMeta: () => getMetaValue(db, TRANSCRIPT_OFFSETS_KEY, '{}'),
|
|
273
|
+
persistMeta: (json) => setMetaValue(db, TRANSCRIPT_OFFSETS_KEY, json),
|
|
274
|
+
projectsRoot: () => path.join(mixdogHome(), 'projects'),
|
|
275
|
+
resolveProjectId,
|
|
276
|
+
firstTextContent,
|
|
277
|
+
cleanMemoryText,
|
|
278
|
+
log: __mixdogMemoryLog,
|
|
279
|
+
})
|
|
280
|
+
const {
|
|
281
|
+
loadTranscriptOffsets,
|
|
282
|
+
ingestTranscriptFile,
|
|
283
|
+
cwdFromTranscriptPath,
|
|
284
|
+
parseTsToMs,
|
|
285
|
+
} = _transcriptIngest
|
|
286
|
+
|
|
287
|
+
// Boot-edge embedding warmup queue (extracted to lib/embedding-warmup.mjs).
|
|
288
|
+
const _embeddingWarmup = createEmbeddingWarmup({
|
|
289
|
+
canStart: embeddingWarmupCanStart,
|
|
290
|
+
warmup: warmupEmbeddingProvider,
|
|
291
|
+
getDims: getEmbeddingDims,
|
|
292
|
+
persistMeta: (metaPath, value) => writeJsonAtomicSync(metaPath, value, { lock: true }),
|
|
293
|
+
log: __mixdogMemoryLog,
|
|
294
|
+
})
|
|
457
295
|
|
|
296
|
+
// DATA_DIR-bound wrappers over the extracted pure flag helpers (see
|
|
297
|
+
// ./lib/memory-config-flags.mjs). The pg-attach check needs DATA_DIR, which is
|
|
298
|
+
// module-local here.
|
|
458
299
|
function secondaryPgAdvertised() {
|
|
459
|
-
|
|
460
|
-
const runtimeRoot = process.env.MIXDOG_RUNTIME_ROOT
|
|
461
|
-
? path.resolve(process.env.MIXDOG_RUNTIME_ROOT)
|
|
462
|
-
: path.join(os.tmpdir(), 'mixdog')
|
|
463
|
-
try {
|
|
464
|
-
const cur = JSON.parse(fs.readFileSync(path.join(runtimeRoot, 'active-instance.json'), 'utf8'))
|
|
465
|
-
const port = Number(cur?.pg_port)
|
|
466
|
-
const pgdata = cur?.pg_pgdata ? path.resolve(String(cur.pg_pgdata)) : ''
|
|
467
|
-
return Number.isInteger(port) && port > 0 && pgdata === path.resolve(path.join(DATA_DIR, 'pgdata'))
|
|
468
|
-
} catch {
|
|
469
|
-
return false
|
|
470
|
-
}
|
|
300
|
+
return _secondaryPgAdvertised(DATA_DIR)
|
|
471
301
|
}
|
|
472
302
|
|
|
473
303
|
function assertSecondaryPgAttachable() {
|
|
474
|
-
|
|
475
|
-
throw new Error('memory-service: secondary mode requires an existing primary PG instance')
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
function scheduleBackgroundEmbeddingWarmup(metaPath, metaKey) {
|
|
480
|
-
if (!embeddingWarmupCanStart()) return
|
|
481
|
-
// Queue the warmup; _initRuntime fires it once boot completes.
|
|
482
|
-
_pendingEmbeddingWarmup = () => {
|
|
483
|
-
warmupEmbeddingProvider()
|
|
484
|
-
.then(() => {
|
|
485
|
-
const measured = Number(getEmbeddingDims())
|
|
486
|
-
try {
|
|
487
|
-
writeJsonAtomicSync(metaPath, { ...metaKey, dims: measured }, { lock: true })
|
|
488
|
-
} catch (e) {
|
|
489
|
-
__mixdogMemoryLog(`[memory-service] could not persist embedding-meta: ${e?.message || e}\n`)
|
|
490
|
-
}
|
|
491
|
-
})
|
|
492
|
-
.catch(err => {
|
|
493
|
-
__mixdogMemoryLog(`[memory-service] background warmup failed: ${err?.message || err}\n`)
|
|
494
|
-
})
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
function fireDeferredEmbeddingWarmup() {
|
|
499
|
-
const fire = _pendingEmbeddingWarmup
|
|
500
|
-
if (!fire) return
|
|
501
|
-
_pendingEmbeddingWarmup = null
|
|
502
|
-
fire()
|
|
304
|
+
return _assertSecondaryPgAttachable(DATA_DIR)
|
|
503
305
|
}
|
|
504
306
|
|
|
505
307
|
async function _initStore() {
|
|
@@ -545,7 +347,7 @@ async function _initStore() {
|
|
|
545
347
|
primeEmbeddingDims(dimsResolved)
|
|
546
348
|
assertSecondaryPgAttachable()
|
|
547
349
|
db = await openDatabase(DATA_DIR, dimsResolved)
|
|
548
|
-
|
|
350
|
+
_embeddingWarmup.schedule(EMBEDDING_META_PATH, metaKey)
|
|
549
351
|
} else {
|
|
550
352
|
if (!embeddingWarmupCanStart()) {
|
|
551
353
|
throw new Error('memory-service: embedding dims unavailable while warmup is disabled')
|
|
@@ -583,7 +385,14 @@ async function _initStore() {
|
|
|
583
385
|
// MIXDOG_MEMORY_DISABLE_LLM_WORKER=1 + cycles enabled hits an empty registry
|
|
584
386
|
// and fails with "Provider not found". Non-fatal: a failure here is logged
|
|
585
387
|
// and cycle1's own callLlm surfaces the unresolved-provider error per call.
|
|
586
|
-
|
|
388
|
+
// Registry must be available whenever cycles COULD run in this process, not
|
|
389
|
+
// just when they are currently enabled: recap can be toggled on at runtime
|
|
390
|
+
// (memoryCyclesEnabled() now includes the recap flag), so gate registry init
|
|
391
|
+
// on the cycle-capable conditions (not secondary, env not hard-disabled) OR
|
|
392
|
+
// the llm worker gate — never on the runtime recap flag itself, or enabling
|
|
393
|
+
// recap later would hit an empty registry and fail with "Provider not found".
|
|
394
|
+
const cyclesCapable = !memorySecondaryMode() && !envFlagEnabled('MIXDOG_MEMORY_DISABLE_CYCLES')
|
|
395
|
+
if (cyclesCapable || memoryLlmWorkerEnabled()) {
|
|
587
396
|
try {
|
|
588
397
|
const agentCfg = loadAgentConfig()
|
|
589
398
|
await initProviders(agentCfg.providers || {})
|
|
@@ -595,29 +404,6 @@ async function _initStore() {
|
|
|
595
404
|
await loadTranscriptOffsets()
|
|
596
405
|
}
|
|
597
406
|
|
|
598
|
-
async function loadTranscriptOffsets() {
|
|
599
|
-
try {
|
|
600
|
-
const raw = await getMetaValue(db, TRANSCRIPT_OFFSETS_KEY, '{}')
|
|
601
|
-
const obj = JSON.parse(raw)
|
|
602
|
-
_transcriptOffsets = new Map(Object.entries(obj))
|
|
603
|
-
} catch {
|
|
604
|
-
_transcriptOffsets = new Map()
|
|
605
|
-
}
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
async function persistTranscriptOffsets() {
|
|
609
|
-
const run = _transcriptOffsetsPersistTail.catch(() => {}).then(async () => {
|
|
610
|
-
try {
|
|
611
|
-
const obj = Object.fromEntries(_transcriptOffsets)
|
|
612
|
-
await setMetaValue(db, TRANSCRIPT_OFFSETS_KEY, JSON.stringify(obj))
|
|
613
|
-
} catch (e) {
|
|
614
|
-
__mixdogMemoryLog(`[memory] persist transcript offsets failed: ${e.message}\n`)
|
|
615
|
-
}
|
|
616
|
-
})
|
|
617
|
-
_transcriptOffsetsPersistTail = run.catch(() => {})
|
|
618
|
-
return run
|
|
619
|
-
}
|
|
620
|
-
|
|
621
407
|
async function getCycleLastRun() {
|
|
622
408
|
try {
|
|
623
409
|
const raw = await getMetaValue(db, CYCLE_LAST_RUN_KEY, '{}')
|
|
@@ -652,141 +438,6 @@ async function setCycleLastRun(kind, ts) {
|
|
|
652
438
|
await mergeMetaValue(db, CYCLE_LAST_RUN_KEY, { [kind]: ts })
|
|
653
439
|
}
|
|
654
440
|
|
|
655
|
-
// Raw-row priority lookup for narrow-window queries. Raw rows (is_root=0,
|
|
656
|
-
// chunk_root IS NULL) are inserted immediately by ingestTranscriptFile before
|
|
657
|
-
// cycle1 runs, so they always carry the freshest turns in the DB.
|
|
658
|
-
async function readRawRowsInWindow(db, tsFromMs, tsToMs, hardLimit = 10, { projectScope } = {}) {
|
|
659
|
-
try {
|
|
660
|
-
let sql, params
|
|
661
|
-
if (projectScope === 'common') {
|
|
662
|
-
sql = `SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root,
|
|
663
|
-
element, category, summary, status, score, last_seen_at, project_id
|
|
664
|
-
FROM entries
|
|
665
|
-
WHERE chunk_root IS NULL AND is_root = 0
|
|
666
|
-
AND ts >= $1 AND ts <= $2
|
|
667
|
-
AND project_id IS NULL
|
|
668
|
-
ORDER BY ts DESC
|
|
669
|
-
LIMIT $3`
|
|
670
|
-
params = [tsFromMs ?? 0, tsToMs ?? Date.now(), hardLimit]
|
|
671
|
-
} else if (projectScope && projectScope !== 'all') {
|
|
672
|
-
sql = `SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root,
|
|
673
|
-
element, category, summary, status, score, last_seen_at, project_id
|
|
674
|
-
FROM entries
|
|
675
|
-
WHERE chunk_root IS NULL AND is_root = 0
|
|
676
|
-
AND ts >= $1 AND ts <= $2
|
|
677
|
-
AND (project_id IS NULL OR project_id = $3)
|
|
678
|
-
ORDER BY ts DESC
|
|
679
|
-
LIMIT $4`
|
|
680
|
-
params = [tsFromMs ?? 0, tsToMs ?? Date.now(), projectScope, hardLimit]
|
|
681
|
-
} else {
|
|
682
|
-
sql = `SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root,
|
|
683
|
-
element, category, summary, status, score, last_seen_at, project_id
|
|
684
|
-
FROM entries
|
|
685
|
-
WHERE chunk_root IS NULL AND is_root = 0
|
|
686
|
-
AND ts >= $1 AND ts <= $2
|
|
687
|
-
ORDER BY ts DESC
|
|
688
|
-
LIMIT $3`
|
|
689
|
-
params = [tsFromMs ?? 0, tsToMs ?? Date.now(), hardLimit]
|
|
690
|
-
}
|
|
691
|
-
const rows = (await db.query(sql, params)).rows
|
|
692
|
-
return rows.map(r => ({ ...r, retrievalScore: 0, rrf: 0 }))
|
|
693
|
-
} catch { return [] }
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
// Spread raw (unchunked) rows evenly across a hybrid-ranked list at a fixed
|
|
697
|
-
// stride so every offset page draws its proportional share of raw rows,
|
|
698
|
-
// instead of them all landing after the hybrid page-0 window (the old
|
|
699
|
-
// append-only behaviour). Stride is derived from the ratio of hybrid:raw
|
|
700
|
-
// counts so a small raw set doesn't get pushed arbitrarily deep, and a large
|
|
701
|
-
// raw set doesn't crowd out every hybrid hit near the top.
|
|
702
|
-
function interleaveRawRows(hybridRows, rawRows) {
|
|
703
|
-
if (!Array.isArray(rawRows) || rawRows.length === 0) return hybridRows
|
|
704
|
-
const out = []
|
|
705
|
-
const stride = Math.max(1, Math.round(hybridRows.length / (rawRows.length + 1)))
|
|
706
|
-
let rawIdx = 0
|
|
707
|
-
for (let i = 0; i < hybridRows.length; i += 1) {
|
|
708
|
-
out.push(hybridRows[i])
|
|
709
|
-
if ((i + 1) % stride === 0 && rawIdx < rawRows.length) {
|
|
710
|
-
out.push(rawRows[rawIdx])
|
|
711
|
-
rawIdx += 1
|
|
712
|
-
}
|
|
713
|
-
}
|
|
714
|
-
while (rawIdx < rawRows.length) out.push(rawRows[rawIdx++])
|
|
715
|
-
return out
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
function sessionRecallTerms(query) {
|
|
719
|
-
return [...new Set(String(query || '').toLowerCase().match(/[\p{L}\p{N}_./:-]{2,}/gu) || [])]
|
|
720
|
-
.filter((term) => !CORE_RECALL_STOPWORDS.has(term))
|
|
721
|
-
.slice(0, 12)
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
async function recallSessionRows(args = {}) {
|
|
725
|
-
const sessionId = String(args.sessionId || args.session_id || '').trim()
|
|
726
|
-
if (!sessionId) return { text: '(no current session)' }
|
|
727
|
-
const limit = Math.max(1, Math.min(100, Number(args.limit) || 20))
|
|
728
|
-
const terms = sessionRecallTerms(args.query)
|
|
729
|
-
const params = [sessionId]
|
|
730
|
-
const where = ['session_id = $1']
|
|
731
|
-
if (terms.length > 0) {
|
|
732
|
-
const textExpr = `lower(coalesce(content, '') || ' ' || coalesce(element, '') || ' ' || coalesce(summary, ''))`
|
|
733
|
-
const clauses = terms.map((term) => {
|
|
734
|
-
params.push(`%${term}%`)
|
|
735
|
-
return `${textExpr} LIKE $${params.length}`
|
|
736
|
-
})
|
|
737
|
-
where.push(`(${clauses.join(' OR ')})`)
|
|
738
|
-
}
|
|
739
|
-
params.push(limit)
|
|
740
|
-
let rows = (await db.query(`
|
|
741
|
-
SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root,
|
|
742
|
-
element, category, summary, status, score, last_seen_at, project_id
|
|
743
|
-
FROM entries
|
|
744
|
-
WHERE ${where.join(' AND ')}
|
|
745
|
-
ORDER BY ts DESC, id DESC
|
|
746
|
-
LIMIT $${params.length}
|
|
747
|
-
`, params)).rows
|
|
748
|
-
if (rows.length < limit) {
|
|
749
|
-
const seen = new Set(rows.map((row) => Number(row.id)).filter((id) => Number.isFinite(id)))
|
|
750
|
-
const fillLimit = Math.max(0, limit - rows.length)
|
|
751
|
-
const fillRows = fillLimit > 0
|
|
752
|
-
? (await db.query(`
|
|
753
|
-
SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root,
|
|
754
|
-
element, category, summary, status, score, last_seen_at, project_id
|
|
755
|
-
FROM entries
|
|
756
|
-
WHERE session_id = $1
|
|
757
|
-
AND id <> ALL($2::bigint[])
|
|
758
|
-
ORDER BY ts DESC, id DESC
|
|
759
|
-
LIMIT $3
|
|
760
|
-
`, [sessionId, [...seen], fillLimit])).rows
|
|
761
|
-
: []
|
|
762
|
-
if (fillRows.length > 0) rows = [...rows, ...fillRows]
|
|
763
|
-
}
|
|
764
|
-
if (args.includeMembers === true) {
|
|
765
|
-
const rootIds = rows
|
|
766
|
-
.filter((row) => Number(row.is_root) === 1)
|
|
767
|
-
.map((row) => Number(row.id))
|
|
768
|
-
.filter((id) => Number.isFinite(id))
|
|
769
|
-
if (rootIds.length > 0) {
|
|
770
|
-
const members = (await db.query(`
|
|
771
|
-
SELECT id, ts, role, content, session_id, source_turn, project_id, chunk_root
|
|
772
|
-
FROM entries
|
|
773
|
-
WHERE chunk_root = ANY($1::bigint[]) AND is_root = 0
|
|
774
|
-
ORDER BY chunk_root ASC, COALESCE(source_turn, 2147483647) ASC, ts ASC, id ASC
|
|
775
|
-
`, [rootIds])).rows
|
|
776
|
-
const byRoot = new Map(rootIds.map((id) => [id, []]))
|
|
777
|
-
for (const member of members) {
|
|
778
|
-
const root = Number(member.chunk_root)
|
|
779
|
-
if (byRoot.has(root)) byRoot.get(root).push(member)
|
|
780
|
-
}
|
|
781
|
-
for (const row of rows) {
|
|
782
|
-
const id = Number(row.id)
|
|
783
|
-
if (byRoot.has(id)) row.members = byRoot.get(id)
|
|
784
|
-
}
|
|
785
|
-
}
|
|
786
|
-
}
|
|
787
|
-
return { text: renderEntryLines(rows) }
|
|
788
|
-
}
|
|
789
|
-
|
|
790
441
|
async function ingestSessionMessages(args = {}) {
|
|
791
442
|
const sessionId = String(args.sessionId || args.session_id || `session-${Date.now()}`).trim()
|
|
792
443
|
const messages = Array.isArray(args.messages) ? args.messages : []
|
|
@@ -859,808 +510,85 @@ async function ingestSessionMessages(args = {}) {
|
|
|
859
510
|
turnAllocator.next()
|
|
860
511
|
}
|
|
861
512
|
}
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
async function ingestTranscriptFileImpl(transcriptPath, { cwd } = {}) {
|
|
880
|
-
let stat
|
|
881
|
-
try { stat = await fs.promises.stat(transcriptPath) } catch { return 0 }
|
|
882
|
-
const sessionUuid = path.basename(transcriptPath, '.jsonl')
|
|
883
|
-
const prev = snapshotTranscriptOffset(transcriptPath)
|
|
884
|
-
if (stat.size < prev.bytes) {
|
|
885
|
-
prev.bytes = 0
|
|
886
|
-
prev.lineIndex = 0
|
|
887
|
-
}
|
|
888
|
-
if (stat.size <= prev.bytes) return 0
|
|
889
|
-
|
|
890
|
-
const fh = await fs.promises.open(transcriptPath, 'r')
|
|
891
|
-
const buf = Buffer.alloc(stat.size - prev.bytes)
|
|
892
|
-
try {
|
|
893
|
-
await fh.read(buf, 0, buf.length, prev.bytes)
|
|
894
|
-
} finally {
|
|
895
|
-
await fh.close()
|
|
896
|
-
}
|
|
897
|
-
const text = buf.toString('utf8')
|
|
898
|
-
|
|
899
|
-
const resolvedCwd = typeof cwd === 'string' && cwd ? cwd : cwdFromTranscriptPath(transcriptPath)
|
|
900
|
-
// No cwd resolved -> classify as COMMON (project_id NULL). Falling back to
|
|
901
|
-
// process.cwd() would misclassify rows under the service/plugin cwd.
|
|
902
|
-
const projectId = resolvedCwd ? resolveProjectId(resolvedCwd) : null
|
|
903
|
-
|
|
904
|
-
let count = 0
|
|
905
|
-
let index = prev.lineIndex
|
|
906
|
-
// Track the byte boundary of the LAST line we fully consumed (parsed +
|
|
907
|
-
// either inserted or intentionally skipped). On parse failure or
|
|
908
|
-
// transient insert error we stop and leave the boundary untouched so the
|
|
909
|
-
// next sweep retries from the same position. This prevents malformed
|
|
910
|
-
// trailing JSONL (mid-write partial lines) and DB hiccups from being
|
|
911
|
-
// silently consumed forever.
|
|
912
|
-
let lastGoodBytes = prev.bytes
|
|
913
|
-
let lastGoodLineIndex = prev.lineIndex
|
|
914
|
-
let cursor = 0
|
|
915
|
-
while (cursor < text.length) {
|
|
916
|
-
const nl = text.indexOf('\n', cursor)
|
|
917
|
-
// No trailing newline -> partial line still being written; stop here
|
|
918
|
-
// without advancing so the rest is re-read once the writer flushes.
|
|
919
|
-
if (nl === -1) break
|
|
920
|
-
const rawLine = text.slice(cursor, nl)
|
|
921
|
-
const consumedBytes = Buffer.byteLength(rawLine, 'utf8') + 1
|
|
922
|
-
cursor = nl + 1
|
|
923
|
-
const line = rawLine.replace(/\r$/, '')
|
|
924
|
-
if (!line) {
|
|
925
|
-
lastGoodBytes += consumedBytes
|
|
926
|
-
continue
|
|
927
|
-
}
|
|
928
|
-
index += 1
|
|
929
|
-
let parsed
|
|
930
|
-
try { parsed = JSON.parse(line) } catch {
|
|
931
|
-
// Malformed line: do not advance past it; retry on next sweep.
|
|
932
|
-
index -= 1
|
|
933
|
-
break
|
|
934
|
-
}
|
|
935
|
-
const role = parsed.message?.role
|
|
936
|
-
if (role !== 'user' && role !== 'assistant') {
|
|
937
|
-
lastGoodBytes += consumedBytes
|
|
938
|
-
lastGoodLineIndex = index
|
|
939
|
-
continue
|
|
940
|
-
}
|
|
941
|
-
const content = firstTextContent(parsed.message?.content)
|
|
942
|
-
if (!content || !content.trim()) {
|
|
943
|
-
lastGoodBytes += consumedBytes
|
|
944
|
-
lastGoodLineIndex = index
|
|
945
|
-
continue
|
|
946
|
-
}
|
|
947
|
-
const cleaned = cleanMemoryText(content)
|
|
948
|
-
if (!cleaned) {
|
|
949
|
-
lastGoodBytes += consumedBytes
|
|
950
|
-
lastGoodLineIndex = index
|
|
951
|
-
continue
|
|
952
|
-
}
|
|
953
|
-
const tsMs = parseTsToMs(parsed.timestamp ?? parsed.ts ?? Date.now())
|
|
954
|
-
const sourceRef = `transcript:${sessionUuid}#${index}`
|
|
955
|
-
try {
|
|
956
|
-
const result = await db.query(
|
|
957
|
-
`INSERT INTO entries(ts, role, content, source_ref, session_id, source_turn, project_id)
|
|
958
|
-
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
959
|
-
ON CONFLICT DO NOTHING`,
|
|
960
|
-
[tsMs, role, cleaned, sourceRef, sessionUuid, index, projectId]
|
|
961
|
-
)
|
|
962
|
-
if (Number(result.rowCount ?? result.affectedRows ?? 0) > 0) count += 1
|
|
963
|
-
lastGoodBytes += consumedBytes
|
|
964
|
-
lastGoodLineIndex = index
|
|
965
|
-
} catch (e) {
|
|
966
|
-
__mixdogMemoryLog(`[transcript-watch] insert error (${sourceRef}): ${e.message}\n`)
|
|
967
|
-
// Transient insert failure: leave the boundary before this line so
|
|
968
|
-
// the next sweep retries it. Roll back the line counter too.
|
|
969
|
-
index -= 1
|
|
970
|
-
break
|
|
971
|
-
}
|
|
972
|
-
}
|
|
973
|
-
_transcriptOffsets.set(transcriptPath, {
|
|
974
|
-
bytes: lastGoodBytes,
|
|
975
|
-
lineIndex: lastGoodLineIndex,
|
|
976
|
-
})
|
|
977
|
-
await persistTranscriptOffsets()
|
|
978
|
-
return count
|
|
979
|
-
}
|
|
980
|
-
|
|
981
|
-
async function ingestTranscriptFile(transcriptPath, options = {}) {
|
|
982
|
-
return runTranscriptIngestSerialized(transcriptPath, () => ingestTranscriptFileImpl(transcriptPath, options))
|
|
983
|
-
}
|
|
984
|
-
|
|
985
|
-
function parseTsToMs(value) {
|
|
986
|
-
if (typeof value === 'number' && Number.isFinite(value)) return value < 1e12 ? value * 1000 : value
|
|
987
|
-
const parsed = Date.parse(String(value))
|
|
988
|
-
return Number.isFinite(parsed) ? parsed : Date.now()
|
|
989
|
-
}
|
|
990
|
-
|
|
991
|
-
// Extract cwd from the transcript file's JSONL rows. Mixdog embeds
|
|
992
|
-
// the session cwd as a top-level `cwd` field on every message row, so
|
|
993
|
-
// scanning the first few lines is reliable on all platforms (Windows/Linux)
|
|
994
|
-
// without slug-decoding ambiguity. Returns undefined when no cwd is found
|
|
995
|
-
// or the extracted path does not exist on disk (falls back to COMMON).
|
|
996
|
-
function cwdFromTranscriptPath(fp) {
|
|
997
|
-
let fd
|
|
998
|
-
try {
|
|
999
|
-
fd = fs.openSync(fp, 'r')
|
|
1000
|
-
const buf = Buffer.alloc(Math.min(fs.fstatSync(fd).size, 100 * 1024))
|
|
1001
|
-
fs.readSync(fd, buf, 0, buf.length, 0)
|
|
1002
|
-
fs.closeSync(fd)
|
|
1003
|
-
fd = undefined
|
|
1004
|
-
const lines = buf.toString('utf8').split('\n')
|
|
1005
|
-
for (let i = 0; i < Math.min(lines.length, 5); i++) {
|
|
1006
|
-
const line = lines[i].trim()
|
|
1007
|
-
if (!line) continue
|
|
1008
|
-
try {
|
|
1009
|
-
const obj = JSON.parse(line)
|
|
1010
|
-
if (typeof obj.cwd === 'string' && obj.cwd) {
|
|
1011
|
-
const candidate = obj.cwd
|
|
1012
|
-
try { if (fs.statSync(candidate).isDirectory()) return candidate } catch {}
|
|
1013
|
-
}
|
|
1014
|
-
} catch {}
|
|
1015
|
-
}
|
|
1016
|
-
} catch {} finally {
|
|
1017
|
-
if (fd != null) { try { fs.closeSync(fd) } catch {} }
|
|
1018
|
-
}
|
|
1019
|
-
return undefined
|
|
1020
|
-
}
|
|
1021
|
-
|
|
1022
|
-
function _initTranscriptWatcher() {
|
|
1023
|
-
const projectsRoot = path.join(mixdogHome(), 'projects')
|
|
1024
|
-
const SAFETY_POLL_MS = 5 * 60_000
|
|
1025
|
-
const DEBOUNCE_MS = 500
|
|
1026
|
-
const watchedFiles = new Map()
|
|
1027
|
-
const pendingByFile = new Map()
|
|
1028
|
-
const watchers = []
|
|
1029
|
-
const intervals = []
|
|
1030
|
-
const polledFiles = new Set()
|
|
1031
|
-
let safetySweepTimeout = null
|
|
1032
|
-
|
|
1033
|
-
function isWatchable(relOrBase) {
|
|
1034
|
-
const base = path.basename(relOrBase)
|
|
1035
|
-
if (!base.endsWith('.jsonl') || base.startsWith('agent-')) return false
|
|
1036
|
-
if (relOrBase.includes('tmp') || relOrBase.includes('cache') || relOrBase.includes('plugins')) return false
|
|
1037
|
-
return true
|
|
1038
|
-
}
|
|
1039
|
-
|
|
1040
|
-
async function ingestOne(fp) {
|
|
1041
|
-
try {
|
|
1042
|
-
if (!fs.existsSync(fp)) return
|
|
1043
|
-
const stat = fs.statSync(fp)
|
|
1044
|
-
const mtime = stat.mtimeMs
|
|
1045
|
-
const prev = watchedFiles.get(fp)
|
|
1046
|
-
if (prev && prev >= mtime) return
|
|
1047
|
-
const n = await ingestTranscriptFile(fp, { cwd: cwdFromTranscriptPath(fp) })
|
|
1048
|
-
// Only mark this mtime as 'consumed' once the persisted offset has
|
|
1049
|
-
// fully advanced past the observed file size. On a transient insert
|
|
1050
|
-
// error (or a malformed trailing line) ingestTranscriptFile leaves
|
|
1051
|
-
// the persisted offset before the failed line for retry; caching
|
|
1052
|
-
// the new mtime unconditionally would suppress the next sweep until
|
|
1053
|
-
// the file mutated again, losing the retry. Leave the cache
|
|
1054
|
-
// untouched on partial advance so the next sweep re-ingests.
|
|
1055
|
-
const off = _transcriptOffsets.get(fp)
|
|
1056
|
-
if (off && off.bytes >= stat.size) {
|
|
1057
|
-
watchedFiles.set(fp, mtime)
|
|
1058
|
-
}
|
|
1059
|
-
if (n > 0) {
|
|
1060
|
-
__mixdogMemoryLog(`[transcript-watch] ingested ${n} entries from ${path.basename(fp)}\n`)
|
|
1061
|
-
}
|
|
1062
|
-
} catch (e) {
|
|
1063
|
-
__mixdogMemoryLog(`[transcript-watch] ingest error: ${e.message}\n`)
|
|
1064
|
-
}
|
|
1065
|
-
}
|
|
1066
|
-
|
|
1067
|
-
function scheduleIngest(fp) {
|
|
1068
|
-
const existing = pendingByFile.get(fp)
|
|
1069
|
-
if (existing) clearTimeout(existing)
|
|
1070
|
-
const timer = setTimeout(() => {
|
|
1071
|
-
pendingByFile.delete(fp)
|
|
1072
|
-
ingestOne(fp)
|
|
1073
|
-
}, DEBOUNCE_MS)
|
|
1074
|
-
pendingByFile.set(fp, timer)
|
|
1075
|
-
}
|
|
1076
|
-
|
|
1077
|
-
async function discoverActiveTranscripts() {
|
|
1078
|
-
let topLevel
|
|
1079
|
-
try { topLevel = await fs.promises.readdir(projectsRoot) }
|
|
1080
|
-
catch { return [] }
|
|
1081
|
-
const files = []
|
|
1082
|
-
for (const d of topLevel) {
|
|
1083
|
-
if (d.includes('tmp') || d.includes('cache') || d.includes('plugins')) continue
|
|
1084
|
-
const full = path.join(projectsRoot, d)
|
|
1085
|
-
let inner
|
|
1086
|
-
try { inner = await fs.promises.readdir(full) } catch { continue }
|
|
1087
|
-
for (const f of inner) {
|
|
1088
|
-
if (!f.endsWith('.jsonl') || f.startsWith('agent-')) continue
|
|
1089
|
-
const fp = path.join(full, f)
|
|
1090
|
-
try {
|
|
1091
|
-
const stat = await fs.promises.stat(fp)
|
|
1092
|
-
files.push({ path: fp, mtime: stat.mtimeMs })
|
|
1093
|
-
} catch {}
|
|
1094
|
-
}
|
|
1095
|
-
}
|
|
1096
|
-
const cutoff = Date.now() - 30 * 60_000
|
|
1097
|
-
return files.filter(f => f.mtime > cutoff)
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
async function safetySweep() {
|
|
1101
|
-
try {
|
|
1102
|
-
const active = await discoverActiveTranscripts()
|
|
1103
|
-
for (const { path: fp } of active) ingestOne(fp)
|
|
1104
|
-
} catch (e) {
|
|
1105
|
-
__mixdogMemoryLog(`[transcript-watch] safety sweep error: ${e.message}\n`)
|
|
1106
|
-
}
|
|
1107
|
-
}
|
|
1108
|
-
|
|
1109
|
-
safetySweepTimeout = setTimeout(safetySweep, 3_000)
|
|
1110
|
-
|
|
1111
|
-
// fs.watch({recursive}) is only reliable on win32.
|
|
1112
|
-
// darwin: recursive option unreliable — use flat watch per-entry (glob dirs at start).
|
|
1113
|
-
// linux/WSL: recursive not supported — use fs.watchFile polling per file found via
|
|
1114
|
-
// the safety sweep, or fall back entirely to safety sweep.
|
|
1115
|
-
if (process.platform === 'win32') {
|
|
1116
|
-
try {
|
|
1117
|
-
const watcher = fs.watch(projectsRoot, { recursive: true, persistent: true }, (_event, filename) => {
|
|
1118
|
-
if (!filename) return
|
|
1119
|
-
if (!isWatchable(filename)) return
|
|
1120
|
-
const fp = path.join(projectsRoot, filename)
|
|
1121
|
-
scheduleIngest(fp)
|
|
513
|
+
// Always-on post-ingest raw embedding: freshly ingested rows become
|
|
514
|
+
// dense-searchable immediately (autoclear/recall-fasttrack hydration,
|
|
515
|
+
// recall empty-fallback), without waiting for cycle1 chunking or the
|
|
516
|
+
// ~60s background tick. Local ONNX only — no LLM cost, so it runs
|
|
517
|
+
// regardless of the recap toggle. SYNCHRONOUS (bounded): ingest_session
|
|
518
|
+
// resolves only after this session's rows are embedded, so an immediately
|
|
519
|
+
// following recall sees them in the dense leg. Bursts serialize on
|
|
520
|
+
// _rawEmbedFlushChain instead of being skipped; a 15s cap keeps a cold
|
|
521
|
+
// ONNX warmup from wedging ingest (tick fallback sweeps what's left).
|
|
522
|
+
if (inserted > 0) {
|
|
523
|
+
const run = _rawEmbedFlushChain
|
|
524
|
+
.catch(() => {}) // never let a previous flush failure poison the chain
|
|
525
|
+
.then(() => flushRawEmbeddings(db, { limit: 200 }))
|
|
526
|
+
.then((r) => {
|
|
527
|
+
if (r.attempted > 0) __mixdogMemoryLog(`[embed] post-ingest raw flush attempted=${r.attempted} embedded=${r.embedded}\n`)
|
|
528
|
+
return r
|
|
1122
529
|
})
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
}
|
|
1131
|
-
intervals.push(setInterval(safetySweep, SAFETY_POLL_MS))
|
|
1132
|
-
} else if (process.platform === 'darwin') {
|
|
1133
|
-
// Flat watch: register a non-recursive watcher on each immediate subdirectory.
|
|
1134
|
-
// New subdirs are picked up on the next safety sweep cycle.
|
|
1135
|
-
try {
|
|
1136
|
-
const registerFlat = (dir) => {
|
|
1137
|
-
try {
|
|
1138
|
-
const w = fs.watch(dir, { persistent: true }, (_event, filename) => {
|
|
1139
|
-
if (!filename) return
|
|
1140
|
-
const fp = path.join(dir, filename)
|
|
1141
|
-
if (!isWatchable(fp)) return
|
|
1142
|
-
scheduleIngest(fp)
|
|
1143
|
-
})
|
|
1144
|
-
w.on('error', () => { /* ignore individual dir errors */ })
|
|
1145
|
-
watchers.push(w)
|
|
1146
|
-
} catch { /* dir may not exist yet */ }
|
|
1147
|
-
}
|
|
1148
|
-
registerFlat(projectsRoot)
|
|
1149
|
-
try {
|
|
1150
|
-
for (const entry of fs.readdirSync(projectsRoot, { withFileTypes: true })) {
|
|
1151
|
-
if (entry.isDirectory()) registerFlat(path.join(projectsRoot, entry.name))
|
|
1152
|
-
}
|
|
1153
|
-
} catch { /* best effort */ }
|
|
1154
|
-
__mixdogMemoryLog(`[transcript-watch] flat fs.watch active on ${projectsRoot} (darwin)\n`)
|
|
1155
|
-
} catch (e) {
|
|
1156
|
-
__mixdogMemoryLog(`[transcript-watch] flat watch setup failed: ${e.message} — relying on safety sweep only\n`)
|
|
1157
|
-
}
|
|
1158
|
-
intervals.push(setInterval(safetySweep, SAFETY_POLL_MS))
|
|
1159
|
-
} else {
|
|
1160
|
-
// linux/WSL: fs.watch recursive is unsupported. Use fs.watchFile polling for
|
|
1161
|
-
// individual files surfaced by the safety sweep, in addition to the sweep itself.
|
|
1162
|
-
__mixdogMemoryLog(`[transcript-watch] linux/WSL — using safety sweep + fs.watchFile polling (no recursive watch)\n`)
|
|
1163
|
-
// Wrap by reassigning the closure-captured reference is not possible here;
|
|
1164
|
-
// instead, register watchFile inside the safety sweep callback by intercepting
|
|
1165
|
-
// active file list after each sweep. The interval already calls safetySweep
|
|
1166
|
-
// which calls ingestOne; watchFile additions happen as a side-effect of the sweep.
|
|
1167
|
-
const _patchedSweep = async () => {
|
|
1168
|
-
try {
|
|
1169
|
-
const active = await discoverActiveTranscripts()
|
|
1170
|
-
for (const { path: fp } of active) {
|
|
1171
|
-
if (!polledFiles.has(fp)) {
|
|
1172
|
-
polledFiles.add(fp)
|
|
1173
|
-
fs.watchFile(fp, { persistent: false, interval: 2000 }, () => {
|
|
1174
|
-
if (isWatchable(fp)) scheduleIngest(fp)
|
|
1175
|
-
})
|
|
1176
|
-
}
|
|
1177
|
-
ingestOne(fp)
|
|
1178
|
-
}
|
|
1179
|
-
} catch (e) {
|
|
1180
|
-
__mixdogMemoryLog(`[transcript-watch] linux sweep error: ${e.message}\n`)
|
|
1181
|
-
}
|
|
1182
|
-
}
|
|
1183
|
-
// Replace the safety sweep interval with the patched version.
|
|
1184
|
-
intervals.push(setInterval(_patchedSweep, SAFETY_POLL_MS))
|
|
1185
|
-
}
|
|
1186
|
-
|
|
1187
|
-
return {
|
|
1188
|
-
stop() {
|
|
1189
|
-
if (safetySweepTimeout) { clearTimeout(safetySweepTimeout); safetySweepTimeout = null }
|
|
1190
|
-
for (const t of pendingByFile.values()) { try { clearTimeout(t) } catch {} }
|
|
1191
|
-
pendingByFile.clear()
|
|
1192
|
-
for (const i of intervals) { try { clearInterval(i) } catch {} }
|
|
1193
|
-
intervals.length = 0
|
|
1194
|
-
for (const w of watchers) { try { w.close() } catch {} }
|
|
1195
|
-
watchers.length = 0
|
|
1196
|
-
for (const fp of polledFiles) { try { fs.unwatchFile(fp) } catch {} }
|
|
1197
|
-
polledFiles.clear()
|
|
1198
|
-
},
|
|
1199
|
-
}
|
|
1200
|
-
}
|
|
1201
|
-
|
|
1202
|
-
// Phase B §2.4 — cache-keeper health thresholds.
|
|
1203
|
-
// warning fires when cycle1 is overdue past HEALTH_OVERDUE_MS; an auto-
|
|
1204
|
-
// restart attempt fires when the warning has been emitted AND the most
|
|
1205
|
-
// recent unscheduled restart was more than AUTO_RESTART_COOLDOWN_MS ago.
|
|
1206
|
-
// Both default to 5 min per spec; caller overrides are not exposed yet.
|
|
1207
|
-
const CYCLE1_HEALTH_OVERDUE_MS = 5 * 60_000
|
|
1208
|
-
const CYCLE1_AUTO_RESTART_COOLDOWN_MS = 5 * 60_000
|
|
1209
|
-
|
|
1210
|
-
// In-process cycle1 LLM adapter. The memory daemon runs makeAgentDispatch()
|
|
1211
|
-
// locally (provider registry is initialized in _initStore), so cycle1 never
|
|
1212
|
-
// has to route over the dead IPC agent path (agent-ipc.mjs callAgentDispatch). The
|
|
1213
|
-
// factory is built once (role/taskType are fixed) and the returned function
|
|
1214
|
-
// is reshaped to cycle1's call signature: cycle1 invokes
|
|
1215
|
-
// `callLlm({ role, taskType, mode, preset, timeout, cwd }, userMessage)` and
|
|
1216
|
-
// expects a raw string, while makeAgentDispatch's function takes a single
|
|
1217
|
-
// `{ prompt }` object. The adapter maps the two — preset/cwd resolution is
|
|
1218
|
-
// handled inside makeAgentDispatch via role (cycle1-agent → maint.memory slot).
|
|
1219
|
-
let _cycle1AgentDispatch = null
|
|
1220
|
-
function getCycle1CallLlm() {
|
|
1221
|
-
if (!_cycle1AgentDispatch) {
|
|
1222
|
-
_cycle1AgentDispatch = makeAgentDispatch({
|
|
1223
|
-
agent: 'cycle1-agent',
|
|
1224
|
-
taskType: 'maintenance',
|
|
1225
|
-
sourceType: 'memory-cycle',
|
|
1226
|
-
// cycle1 parses the full raw line-format response; the agent brief cap
|
|
1227
|
-
// (12KB) would truncate a large valid response and append prose, causing
|
|
1228
|
-
// partial parsing / omitted / invalid chunks. Opt out so the cycle1
|
|
1229
|
-
// no-truncation contract is preserved through makeAgentDispatch.
|
|
1230
|
-
brief: false,
|
|
1231
|
-
})
|
|
1232
|
-
}
|
|
1233
|
-
return async (opts = {}, userMessage) => {
|
|
1234
|
-
// Preserve cycle1's timeout contract: cycle1 derives `opts.timeout` from
|
|
1235
|
-
// config / caller deadline and expects it to bound the call. makeAgentDispatch
|
|
1236
|
-
// takes it as a per-call `idleTimeoutMs` (stale watchdog). Map it through;
|
|
1237
|
-
// omit when absent/0 so agent defaults apply.
|
|
1238
|
-
const callTimeout = Number(opts?.timeout)
|
|
1239
|
-
return _cycle1AgentDispatch({
|
|
1240
|
-
prompt: String(userMessage ?? ''),
|
|
1241
|
-
preset: opts?.preset || undefined,
|
|
1242
|
-
...(Number.isFinite(callTimeout) && callTimeout > 0 ? { idleTimeoutMs: callTimeout } : {}),
|
|
1243
|
-
})
|
|
1244
|
-
}
|
|
1245
|
-
}
|
|
1246
|
-
|
|
1247
|
-
let _cycle2AgentDispatch = null
|
|
1248
|
-
function getCycle2CallLlm() {
|
|
1249
|
-
if (!_cycle2AgentDispatch) {
|
|
1250
|
-
_cycle2AgentDispatch = makeAgentDispatch({
|
|
1251
|
-
agent: 'cycle2-agent',
|
|
1252
|
-
taskType: 'maintenance',
|
|
1253
|
-
sourceType: 'memory-cycle',
|
|
1254
|
-
brief: false,
|
|
1255
|
-
})
|
|
1256
|
-
}
|
|
1257
|
-
return async (opts = {}, userMessage) => {
|
|
1258
|
-
const callTimeout = Number(opts?.timeout)
|
|
1259
|
-
return _cycle2AgentDispatch({
|
|
1260
|
-
prompt: String(userMessage ?? ''),
|
|
1261
|
-
preset: opts?.preset || undefined,
|
|
1262
|
-
...(Number.isFinite(callTimeout) && callTimeout > 0 ? { idleTimeoutMs: callTimeout } : {}),
|
|
1263
|
-
})
|
|
1264
|
-
}
|
|
1265
|
-
}
|
|
1266
|
-
|
|
1267
|
-
let _cycle3AgentDispatch = null
|
|
1268
|
-
function getCycle3CallLlm() {
|
|
1269
|
-
if (!_cycle3AgentDispatch) {
|
|
1270
|
-
_cycle3AgentDispatch = makeAgentDispatch({
|
|
1271
|
-
agent: 'cycle3-agent',
|
|
1272
|
-
taskType: 'maintenance',
|
|
1273
|
-
sourceType: 'memory-cycle',
|
|
1274
|
-
brief: false,
|
|
1275
|
-
})
|
|
1276
|
-
}
|
|
1277
|
-
return async (opts = {}, userMessage) => {
|
|
1278
|
-
const callTimeout = Number(opts?.timeout)
|
|
1279
|
-
return _cycle3AgentDispatch({
|
|
1280
|
-
prompt: String(userMessage ?? ''),
|
|
1281
|
-
preset: opts?.preset || undefined,
|
|
1282
|
-
...(Number.isFinite(callTimeout) && callTimeout > 0 ? { idleTimeoutMs: callTimeout } : {}),
|
|
1283
|
-
})
|
|
1284
|
-
}
|
|
1285
|
-
}
|
|
1286
|
-
|
|
1287
|
-
async function recordCycle1Result(result) {
|
|
1288
|
-
const now = Date.now()
|
|
1289
|
-
await setCycleLastRun('cycle1_heartbeat', now)
|
|
1290
|
-
const skipped = result?.skippedInFlight === true
|
|
1291
|
-
const coalescedNoop = result?.coalescedRetryNoop === true
|
|
1292
|
-
const allFailed = !skipped
|
|
1293
|
-
&& Number(result?.chunks ?? 0) === 0
|
|
1294
|
-
&& Number(result?.processed ?? 0) === 0
|
|
1295
|
-
&& Number(result?.skipped ?? 0) > 0
|
|
1296
|
-
if (!skipped && !coalescedNoop && !allFailed) {
|
|
1297
|
-
await setCycleLastRun('cycle1', now)
|
|
1298
|
-
}
|
|
1299
|
-
}
|
|
1300
|
-
|
|
1301
|
-
function _startCycle1Run(config = {}, options = {}) {
|
|
1302
|
-
// Default to the in-process agent dispatch so every cycle1 path — scheduled,
|
|
1303
|
-
// auto-restart, periodic, manual (action:cycle1), backfill drain, rebuild —
|
|
1304
|
-
// dispatches locally and the dead IPC fallback in memory-cycle1.mjs is never
|
|
1305
|
-
// reached. Explicit options.callLlm (if a caller ever passes one) wins.
|
|
1306
|
-
if (typeof options?.callLlm !== 'function') {
|
|
1307
|
-
options = { ...options, callLlm: getCycle1CallLlm() }
|
|
1308
|
-
}
|
|
1309
|
-
_cycle1InFlight = (async () => {
|
|
1310
|
-
try {
|
|
1311
|
-
const result = await runCycle1(db, config, options, DATA_DIR)
|
|
1312
|
-
// #13: heartbeat (attempt) is always recorded so the overdue check
|
|
1313
|
-
// can tell the keeper is alive; success timestamp only advances when
|
|
1314
|
-
// the run actually did work. Skipped/in-flight runs do NOT count as
|
|
1315
|
-
// success because the next overdue check would otherwise see a fake
|
|
1316
|
-
// green and stop forcing auto-restarts.
|
|
1317
|
-
if (typeof options?.onCoalescedSuccess !== 'function') {
|
|
1318
|
-
await recordCycle1Result(result)
|
|
1319
|
-
}
|
|
1320
|
-
return result
|
|
1321
|
-
} finally {
|
|
1322
|
-
if (_cycle1InFlight === promise) _cycle1InFlight = null
|
|
1323
|
-
}
|
|
1324
|
-
})()
|
|
1325
|
-
const promise = _cycle1InFlight
|
|
1326
|
-
return _cycle1InFlight
|
|
1327
|
-
}
|
|
1328
|
-
|
|
1329
|
-
async function _awaitCycle1Run(config = {}, options = {}) {
|
|
1330
|
-
const target = _cycle1InFlight || _startCycle1Run(config, options)
|
|
1331
|
-
const callerDeadlineMs = Number(options.callerDeadlineMs) || 0
|
|
1332
|
-
if (callerDeadlineMs <= 0) return await target
|
|
1333
|
-
// Caller-deadline race. When the channels-side timeout fires, we
|
|
1334
|
-
// (a) graceful-return a skippedInFlight envelope so the calling
|
|
1335
|
-
// SessionStart slot stops blocking with a 200 OK + flags instead of a
|
|
1336
|
-
// 503-class throw, and (b) release the outer in-flight handle. The
|
|
1337
|
-
// underlying LLM run keeps progressing in the background — it still
|
|
1338
|
-
// owns the inner dedup guard (memory-cycle.mjs _runCycle1InFlight).
|
|
1339
|
-
// Releasing the outer handle is what breaks the cascade: any later
|
|
1340
|
-
// _awaitCycle1Run call now re-enters _startCycle1Run, whose inner
|
|
1341
|
-
// runCycle1 short-circuits with skippedInFlight:true the moment it
|
|
1342
|
-
// sees the same db still busy. Returning a graceful object (vs the
|
|
1343
|
-
// pre-0.1.198 throw) keeps the channel route response shape stable
|
|
1344
|
-
// and lets pollers read inFlight=true rather than parse an error.
|
|
1345
|
-
let timer
|
|
1346
|
-
const deadlinePromise = new Promise((resolve) => {
|
|
1347
|
-
timer = setTimeout(() => {
|
|
1348
|
-
if (_cycle1InFlight === target) _cycle1InFlight = null
|
|
1349
|
-
resolve({
|
|
1350
|
-
processed: 0,
|
|
1351
|
-
chunks: 0,
|
|
1352
|
-
skipped: 0,
|
|
1353
|
-
sessions: 0,
|
|
1354
|
-
skippedInFlight: true,
|
|
1355
|
-
timedOutWaiting: true,
|
|
1356
|
-
callerDeadlineMs,
|
|
1357
|
-
})
|
|
1358
|
-
}, callerDeadlineMs)
|
|
1359
|
-
})
|
|
1360
|
-
try {
|
|
1361
|
-
return await Promise.race([target, deadlinePromise])
|
|
1362
|
-
} finally {
|
|
1363
|
-
clearTimeout(timer)
|
|
1364
|
-
}
|
|
1365
|
-
}
|
|
1366
|
-
|
|
1367
|
-
// Periodic cycle1 sizing: only enter when ≥ 20 pending rows have built up,
|
|
1368
|
-
// then process up to 2 sessions × 50 rows per tick. cycle1 itself keeps each
|
|
1369
|
-
// classifier window session-isolated. The on-demand path used by SessionStart
|
|
1370
|
-
// hooks runs with a 1-row threshold and 5×20 windows instead — see
|
|
1371
|
-
// hooks/session-start.cjs ON_DEMAND_CYCLE1_ARGS.
|
|
1372
|
-
// mainConfig.cycle1 values still win, so users can override any of these in
|
|
1373
|
-
// config.json.
|
|
1374
|
-
function periodicCycle1Config() {
|
|
1375
|
-
return {
|
|
1376
|
-
min_batch: 20,
|
|
1377
|
-
session_cap: 2,
|
|
1378
|
-
batch_size: 50,
|
|
1379
|
-
concurrency: 2,
|
|
1380
|
-
...(mainConfig?.cycle1 || {}),
|
|
1381
|
-
}
|
|
1382
|
-
}
|
|
1383
|
-
|
|
1384
|
-
function scheduledCycle1Signature(config) {
|
|
1385
|
-
return makeCycleRequestSignature('cycle1', config, {
|
|
1386
|
-
preset: undefined,
|
|
1387
|
-
concurrency: undefined,
|
|
1388
|
-
maxConcurrent: undefined,
|
|
1389
|
-
})
|
|
1390
|
-
}
|
|
1391
|
-
|
|
1392
|
-
function scheduledCycle2Signature(config) {
|
|
1393
|
-
return makeCycleRequestSignature('cycle2', config, {
|
|
1394
|
-
cascadePreset: undefined,
|
|
1395
|
-
concurrency: undefined,
|
|
1396
|
-
})
|
|
1397
|
-
}
|
|
1398
|
-
|
|
1399
|
-
function scheduledCycle3ApplyMode(config) {
|
|
1400
|
-
const raw = String(config?.cycle3?.applyMode || 'conservative').trim().toLowerCase()
|
|
1401
|
-
return (raw === 'proposal' || raw === 'dry-run' || raw === 'dryrun') ? 'proposal' : 'conservative'
|
|
1402
|
-
}
|
|
1403
|
-
|
|
1404
|
-
function scheduledCycle3Signature(config) {
|
|
1405
|
-
const retryConfig = config?.cycle3 || config
|
|
1406
|
-
return makeCycleRequestSignature('cycle3', retryConfig, {
|
|
1407
|
-
applyMode: scheduledCycle3ApplyMode(config),
|
|
1408
|
-
apply: undefined,
|
|
1409
|
-
})
|
|
1410
|
-
}
|
|
1411
|
-
|
|
1412
|
-
async function enqueueScheduledCycle(kind, intervalMs, signature) {
|
|
1413
|
-
const claim = await claimAndMarkScheduledCycle(db, kind, intervalMs, signature, { reason: 'scheduled' })
|
|
1414
|
-
return claim.claimed === true
|
|
1415
|
-
}
|
|
1416
|
-
|
|
1417
|
-
async function enqueueScheduledCycle1(intervalMs, _reason = 'scheduled') {
|
|
1418
|
-
const config = periodicCycle1Config()
|
|
1419
|
-
const signature = scheduledCycle1Signature(config)
|
|
1420
|
-
if (await enqueueScheduledCycle('cycle1', intervalMs, signature)) {
|
|
1421
|
-
scheduleScheduledCycle1(config, signature)
|
|
1422
|
-
}
|
|
1423
|
-
}
|
|
1424
|
-
|
|
1425
|
-
async function enqueueScheduledCycle2(intervalMs, _reason = 'scheduled') {
|
|
1426
|
-
const config = mainConfig?.cycle2 || {}
|
|
1427
|
-
const signature = scheduledCycle2Signature(config)
|
|
1428
|
-
if (await enqueueScheduledCycle('cycle2', intervalMs, signature)) {
|
|
1429
|
-
scheduleScheduledCycle2(config, signature)
|
|
1430
|
-
}
|
|
1431
|
-
}
|
|
1432
|
-
|
|
1433
|
-
async function enqueueScheduledCycle3(intervalMs, _reason = 'scheduled') {
|
|
1434
|
-
const config = mainConfig || {}
|
|
1435
|
-
const signature = scheduledCycle3Signature(config)
|
|
1436
|
-
if (await enqueueScheduledCycle('cycle3', intervalMs, signature)) {
|
|
1437
|
-
scheduleScheduledCycle3(config, signature)
|
|
1438
|
-
}
|
|
1439
|
-
}
|
|
1440
|
-
|
|
1441
|
-
function scheduleScheduledCycle1(config, signature, attempt = 0) {
|
|
1442
|
-
const maxRetries = resolveCoalesceMaxRetries(config, 3)
|
|
1443
|
-
if (attempt > maxRetries) {
|
|
1444
|
-
__mixdogMemoryLog('[cycle1] scheduled queue retry cap reached\n')
|
|
1445
|
-
return
|
|
1446
|
-
}
|
|
1447
|
-
scheduleCoalescedCycleRetry(db, 'cycle1', async () => {
|
|
1448
|
-
if (_cycle1InFlight) {
|
|
1449
|
-
scheduleScheduledCycle1(config, signature, attempt + 1)
|
|
1450
|
-
return
|
|
1451
|
-
}
|
|
1452
|
-
const result = await _awaitCycle1Run(config, {
|
|
1453
|
-
coalescedRetry: true,
|
|
1454
|
-
onCoalescedSuccess: recordCycle1Result,
|
|
1455
|
-
})
|
|
1456
|
-
if (result?.skippedInFlight) scheduleScheduledCycle1(config, signature, attempt + 1)
|
|
1457
|
-
}, config, signature)
|
|
1458
|
-
}
|
|
1459
|
-
|
|
1460
|
-
function scheduleScheduledCycle2(config, signature, attempt = 0) {
|
|
1461
|
-
const maxRetries = resolveCoalesceMaxRetries(config, 3)
|
|
1462
|
-
if (attempt > maxRetries) {
|
|
1463
|
-
__mixdogMemoryLog('[cycle2] scheduled queue retry cap reached\n')
|
|
1464
|
-
return
|
|
1465
|
-
}
|
|
1466
|
-
scheduleCoalescedCycleRetry(db, 'cycle2', async () => {
|
|
1467
|
-
if (_cycle2InFlight) {
|
|
1468
|
-
scheduleScheduledCycle2(config, signature, attempt + 1)
|
|
1469
|
-
return
|
|
1470
|
-
}
|
|
1471
|
-
_cycle2InFlight = true
|
|
1472
|
-
try {
|
|
1473
|
-
let c2Options = {
|
|
1474
|
-
coalescedRetry: true,
|
|
1475
|
-
onCoalescedSuccess: _finalizeCycle2Run,
|
|
1476
|
-
}
|
|
1477
|
-
if (typeof c2Options?.callLlm !== 'function') {
|
|
1478
|
-
c2Options = { ...c2Options, callLlm: getCycle2CallLlm() }
|
|
1479
|
-
}
|
|
1480
|
-
const result = await runCycle2(db, config, c2Options, DATA_DIR)
|
|
1481
|
-
if (result?.skippedInFlight) {
|
|
1482
|
-
scheduleScheduledCycle2(config, signature, attempt + 1)
|
|
1483
|
-
} else if (result?.coalescedRetryNoop) {
|
|
1484
|
-
__mixdogMemoryLog('[cycle2] scheduled queue noop\n')
|
|
1485
|
-
} else if (result?.ok === false) {
|
|
1486
|
-
await _finalizeCycle2Run(result)
|
|
1487
|
-
}
|
|
1488
|
-
} catch (err) {
|
|
1489
|
-
__mixdogMemoryLog(`[cycle2] scheduled queue failed: ${err?.message || err}\n`)
|
|
1490
|
-
} finally {
|
|
1491
|
-
_cycle2InFlight = false
|
|
1492
|
-
}
|
|
1493
|
-
}, config, signature)
|
|
1494
|
-
}
|
|
1495
|
-
|
|
1496
|
-
function scheduleScheduledCycle3(config, signature, attempt = 0) {
|
|
1497
|
-
const retryConfig = config?.cycle3 || config
|
|
1498
|
-
const maxRetries = resolveCoalesceMaxRetries(retryConfig, 3)
|
|
1499
|
-
if (attempt > maxRetries) {
|
|
1500
|
-
__mixdogMemoryLog('[cycle3] scheduled queue retry cap reached\n')
|
|
1501
|
-
return
|
|
1502
|
-
}
|
|
1503
|
-
scheduleCoalescedCycleRetry(db, 'cycle3', async () => {
|
|
1504
|
-
if (_cycle3InFlight) {
|
|
1505
|
-
scheduleScheduledCycle3(config, signature, attempt + 1)
|
|
1506
|
-
return
|
|
1507
|
-
}
|
|
1508
|
-
_cycle3InFlight = true
|
|
1509
|
-
try {
|
|
1510
|
-
let c3Options = {
|
|
1511
|
-
coalescedRetry: true,
|
|
1512
|
-
onCoalescedSuccess: () => setCycleLastRun('cycle3', Date.now()),
|
|
1513
|
-
}
|
|
1514
|
-
if (typeof c3Options?.callLlm !== 'function') {
|
|
1515
|
-
c3Options = { ...c3Options, callLlm: getCycle3CallLlm() }
|
|
1516
|
-
}
|
|
1517
|
-
const result = await runCycle3(db, config, DATA_DIR, c3Options)
|
|
1518
|
-
if (result?.skippedInFlight) {
|
|
1519
|
-
scheduleScheduledCycle3(config, signature, attempt + 1)
|
|
1520
|
-
} else if (result?.coalescedRetryNoop) {
|
|
1521
|
-
__mixdogMemoryLog('[cycle3] scheduled queue noop\n')
|
|
1522
|
-
}
|
|
1523
|
-
} catch (err) {
|
|
1524
|
-
__mixdogMemoryLog(`[cycle3] scheduled queue failed: ${err?.message || err}\n`)
|
|
1525
|
-
} finally {
|
|
1526
|
-
_cycle3InFlight = false
|
|
1527
|
-
}
|
|
1528
|
-
}, retryConfig, signature)
|
|
1529
|
-
}
|
|
1530
|
-
|
|
1531
|
-
async function _finalizeCycle2Run(result) {
|
|
1532
|
-
if (result?.skippedInFlight) {
|
|
1533
|
-
__mixdogMemoryLog('[cycle2] skipped: in flight\n')
|
|
1534
|
-
return
|
|
1535
|
-
}
|
|
1536
|
-
if (result.ok) {
|
|
1537
|
-
await setCycleLastRun('cycle2', Date.now())
|
|
1538
|
-
await setCycleLastRun('cycle2_last_error', '')
|
|
1539
|
-
__mixdogMemoryLog('[cycle2] completed\n')
|
|
1540
|
-
} else {
|
|
1541
|
-
await setCycleLastRun('cycle2_last_error', result.error || 'unknown error')
|
|
1542
|
-
__mixdogMemoryLog(`[cycle2] failed: ${result.error}\n`)
|
|
530
|
+
.catch((err) => __mixdogMemoryLog(`[embed] post-ingest raw flush failed: ${err?.message || err}\n`))
|
|
531
|
+
_rawEmbedFlushChain = run
|
|
532
|
+
let timer
|
|
533
|
+
await Promise.race([
|
|
534
|
+
run,
|
|
535
|
+
new Promise((resolve) => { timer = setTimeout(resolve, INGEST_EMBED_WAIT_MS) }),
|
|
536
|
+
]).finally(() => clearTimeout(timer))
|
|
1543
537
|
}
|
|
538
|
+
return { text: `ingest_session: considered=${considered} inserted=${inserted} session=${sessionId}` }
|
|
1544
539
|
}
|
|
1545
540
|
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
// cannot tight-loop) and the result timestamp only on success. On
|
|
1588
|
-
// failure we return immediately instead of falling through into the
|
|
1589
|
-
// due branch — falling through would silently re-enter the same
|
|
1590
|
-
// failing path within the same tick.
|
|
1591
|
-
await setCycleLastRun('cycle1_autoRestart_attempt', now)
|
|
1592
|
-
try {
|
|
1593
|
-
const result = await _awaitCycle1Run(periodicCycle1Config())
|
|
1594
|
-
await setCycleLastRun('cycle1_autoRestart', Date.now())
|
|
1595
|
-
__mixdogMemoryLog(
|
|
1596
|
-
`[cycle1] auto-restart completed chunks=${result?.chunks ?? 0} processed=${result?.processed ?? 0}\n`
|
|
1597
|
-
)
|
|
1598
|
-
return
|
|
1599
|
-
} catch (e) {
|
|
1600
|
-
__mixdogMemoryLog(`[cycle1] auto-restart error: ${e.message}\n`)
|
|
1601
|
-
// Cooldown attempt timestamp is committed; do NOT fall through
|
|
1602
|
-
// to the due branch — next tick will retry after cooldown.
|
|
1603
|
-
return
|
|
1604
|
-
}
|
|
1605
|
-
}
|
|
1606
|
-
}
|
|
1607
|
-
|
|
1608
|
-
if (now - last.cycle1 >= cycle1Ms) {
|
|
1609
|
-
await enqueueScheduledCycle1(cycle1Ms, 'scheduled')
|
|
1610
|
-
}
|
|
1611
|
-
|
|
1612
|
-
if (now - last.cycle2 >= cycle2Ms) {
|
|
1613
|
-
await enqueueScheduledCycle2(cycle2Ms, 'scheduled')
|
|
1614
|
-
}
|
|
541
|
+
// ── Cycle scheduling cluster (extracted to lib/cycle-scheduler.mjs) ────────
|
|
542
|
+
// The mutually-referential cycle machinery (health ledger, cycle1 outer
|
|
543
|
+
// coalesce layer, scheduled enqueue/retry paths, checkCycles, tick loop) lives
|
|
544
|
+
// in the factory below. index.mjs keeps lifecycle ownership by injecting live
|
|
545
|
+
// getters (getDb/getConfig/setConfig) plus runners and LLM adapters.
|
|
546
|
+
const CYCLE_STATE_FILE = path.join(DATA_DIR, 'memory-cycle-state.json')
|
|
547
|
+
|
|
548
|
+
const _cycleLlmAdapters = createCycleLlmAdapters({ makeAgentDispatch })
|
|
549
|
+
const { getCycle1CallLlm, getCycle2CallLlm, getCycle3CallLlm } = _cycleLlmAdapters
|
|
550
|
+
|
|
551
|
+
const _cycleScheduler = createCycleScheduler({
|
|
552
|
+
getDb: () => db,
|
|
553
|
+
getConfig: () => mainConfig,
|
|
554
|
+
setConfig: (cfg) => { mainConfig = cfg },
|
|
555
|
+
dataDir: DATA_DIR,
|
|
556
|
+
log: __mixdogMemoryLog,
|
|
557
|
+
getCycleLastRun,
|
|
558
|
+
setCycleLastRun,
|
|
559
|
+
readMainConfig,
|
|
560
|
+
memoryCyclesEnabled,
|
|
561
|
+
getCycle1CallLlm,
|
|
562
|
+
getCycle2CallLlm,
|
|
563
|
+
getCycle3CallLlm,
|
|
564
|
+
runCycle1,
|
|
565
|
+
runCycle2,
|
|
566
|
+
runCycle3,
|
|
567
|
+
parseInterval,
|
|
568
|
+
flushRawEmbeddings,
|
|
569
|
+
getInFlightCycle1,
|
|
570
|
+
claimAndMarkScheduledCycle,
|
|
571
|
+
resolveCoalesceMaxRetries,
|
|
572
|
+
scheduleCoalescedCycleRetry,
|
|
573
|
+
scheduledCycle1Signature,
|
|
574
|
+
scheduledCycle2Signature,
|
|
575
|
+
scheduledCycle3Signature,
|
|
576
|
+
cycleStateFile: CYCLE_STATE_FILE,
|
|
577
|
+
})
|
|
578
|
+
// Cycle1 run primitives + cycle2 finalize used by MCP action handlers below.
|
|
579
|
+
const _startCycle1Run = _cycleScheduler.startCycle1Run
|
|
580
|
+
const _awaitCycle1Run = _cycleScheduler.awaitCycle1Run
|
|
581
|
+
const _finalizeCycle2Run = _cycleScheduler.finalizeCycle2Run
|
|
1615
582
|
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
}
|
|
1619
|
-
}
|
|
1620
|
-
let _cycle2InFlight = false
|
|
1621
|
-
let _cycle3InFlight = false
|
|
1622
|
-
|
|
1623
|
-
// #12: self-rescheduling timer. setInterval would fire ticks regardless of
|
|
1624
|
-
// whether the previous checkCycles() call had finished; with cycle1/cycle2
|
|
1625
|
-
// each potentially taking minutes, that races. Use setTimeout that re-arms
|
|
1626
|
-
// itself only after the prior tick resolves, plus an in-flight guard so a
|
|
1627
|
-
// stray manual call cannot stack ticks.
|
|
1628
|
-
let _checkCyclesInFlight = false
|
|
1629
|
-
async function _runCheckCyclesGuarded() {
|
|
1630
|
-
if (_checkCyclesInFlight) return
|
|
1631
|
-
_checkCyclesInFlight = true
|
|
1632
|
-
try { await checkCycles() }
|
|
1633
|
-
catch (e) { __mixdogMemoryLog(`[cycle-tick] error: ${e.message}\n`) }
|
|
1634
|
-
finally { _checkCyclesInFlight = false }
|
|
1635
|
-
}
|
|
1636
|
-
function _scheduleNextCheck() {
|
|
1637
|
-
_cycleInterval = setTimeout(async () => {
|
|
1638
|
-
_cycleInterval = null
|
|
1639
|
-
try {
|
|
1640
|
-
await _runCheckCyclesGuarded()
|
|
1641
|
-
} catch (e) {
|
|
1642
|
-
__mixdogMemoryLog(`[cycle-tick] re-arm guard caught: ${e?.message || e}\n`)
|
|
1643
|
-
} finally {
|
|
1644
|
-
// Re-arm regardless of inner outcome — _runCheckCyclesGuarded already
|
|
1645
|
-
// swallows its own errors, but defensive try/finally guarantees the
|
|
1646
|
-
// periodic tick continues even if a synchronous throw escapes.
|
|
1647
|
-
if (_cyclesActive) _scheduleNextCheck()
|
|
1648
|
-
}
|
|
1649
|
-
}, 60_000)
|
|
1650
|
-
}
|
|
1651
|
-
let _cyclesActive = false
|
|
583
|
+
// Transcript watcher lifecycle stays in the facade (owns _transcriptIngest);
|
|
584
|
+
// the cycle tick loop start/stop is delegated to the scheduler.
|
|
1652
585
|
let _transcriptWatcher = null
|
|
1653
586
|
function _startCycles() {
|
|
1654
|
-
|
|
1655
|
-
_cyclesActive = true
|
|
1656
|
-
_scheduleNextCheck()
|
|
1657
|
-
_startupTimeout = setTimeout(() => { void _runCheckCyclesGuarded() }, 30_000)
|
|
587
|
+
_cycleScheduler.startCycles()
|
|
1658
588
|
}
|
|
1659
589
|
|
|
1660
590
|
function _stopCycles() {
|
|
1661
|
-
|
|
1662
|
-
if (_cycleInterval) { clearTimeout(_cycleInterval); _cycleInterval = null }
|
|
1663
|
-
if (_startupTimeout) { clearTimeout(_startupTimeout); _startupTimeout = null }
|
|
591
|
+
_cycleScheduler.stopCycles()
|
|
1664
592
|
if (_transcriptWatcher) { try { _transcriptWatcher.stop() } catch {} _transcriptWatcher = null }
|
|
1665
593
|
}
|
|
1666
594
|
|
|
@@ -1671,17 +599,28 @@ async function _initRuntime() {
|
|
|
1671
599
|
// increments, so deleted rows leave permanent gaps. Fast no-op when already
|
|
1672
600
|
// contiguous (or empty). Runs only here — never in cycle2/addCore/deleteCore.
|
|
1673
601
|
await compactCoreIds(DATA_DIR)
|
|
1674
|
-
|
|
1675
|
-
|
|
602
|
+
// Memory module is always-on: the transcript watcher/ingest runs
|
|
603
|
+
// unconditionally except in secondary mode (secondary attaches to a primary's
|
|
604
|
+
// PG and must not double-ingest). The recap toggle only gates whether the
|
|
605
|
+
// background cycles actually schedule work — but we still start the tick loop
|
|
606
|
+
// so recap can be toggled ON at runtime (checkCycles polls recap each tick and
|
|
607
|
+
// no-ops while recap is off). The env hard-override / secondary mode skip the
|
|
608
|
+
// tick loop entirely.
|
|
609
|
+
if (!memorySecondaryMode()) {
|
|
610
|
+
_transcriptWatcher = _transcriptIngest.initTranscriptWatcher()
|
|
611
|
+
} else {
|
|
612
|
+
__mixdogMemoryLog('[memory-service] secondary mode; skipping transcript watcher\n')
|
|
613
|
+
}
|
|
614
|
+
if (!memorySecondaryMode() && !envFlagEnabled('MIXDOG_MEMORY_DISABLE_CYCLES')) {
|
|
1676
615
|
_startCycles()
|
|
1677
616
|
} else {
|
|
1678
|
-
__mixdogMemoryLog('[memory-service]
|
|
617
|
+
__mixdogMemoryLog('[memory-service] background cycle tick loop not started (secondary/env-disabled)\n')
|
|
1679
618
|
}
|
|
1680
619
|
_initialized = true
|
|
1681
620
|
// Boot complete — continue straight into the deferred embedding warmup.
|
|
1682
621
|
// Fire-and-forget on the embedding worker thread; never awaited so it does
|
|
1683
622
|
// not delay init() returning or the memory-ready signal.
|
|
1684
|
-
|
|
623
|
+
_embeddingWarmup.fireDeferred()
|
|
1685
624
|
}
|
|
1686
625
|
|
|
1687
626
|
function _beginRuntimeInit() {
|
|
@@ -1696,748 +635,22 @@ function _beginRuntimeInit() {
|
|
|
1696
635
|
return _initPromise
|
|
1697
636
|
}
|
|
1698
637
|
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
const start = new Date()
|
|
1716
|
-
start.setDate(start.getDate() - 1)
|
|
1717
|
-
start.setHours(0, 0, 0, 0)
|
|
1718
|
-
const end = new Date(start)
|
|
1719
|
-
end.setHours(23, 59, 59, 999)
|
|
1720
|
-
return { startMs: start.getTime(), endMs: end.getTime() }
|
|
1721
|
-
}
|
|
1722
|
-
if (period === 'this_week' || period === 'last_week') {
|
|
1723
|
-
// R6 P9: calendar Mon-Sun previous/current week. Mon-start ISO
|
|
1724
|
-
// convention. Replaces R5 rolling 7-14d range which was empty for
|
|
1725
|
-
// sessions where "last week" decisions actually fell on Mon (4/27) of
|
|
1726
|
-
// this week. Precise calendar bounds match natural-language intuition.
|
|
1727
|
-
const d = new Date()
|
|
1728
|
-
d.setHours(0, 0, 0, 0)
|
|
1729
|
-
const dayOfWeek = d.getDay()
|
|
1730
|
-
const daysSinceMon = (dayOfWeek + 6) % 7
|
|
1731
|
-
const thisWeekMon = new Date(d)
|
|
1732
|
-
thisWeekMon.setDate(d.getDate() - daysSinceMon)
|
|
1733
|
-
if (period === 'this_week') {
|
|
1734
|
-
return { startMs: thisWeekMon.getTime(), endMs: Date.now() }
|
|
1735
|
-
}
|
|
1736
|
-
const lastWeekMon = new Date(thisWeekMon)
|
|
1737
|
-
lastWeekMon.setDate(thisWeekMon.getDate() - 7)
|
|
1738
|
-
const lastWeekSunEnd = new Date(thisWeekMon.getTime() - 1)
|
|
1739
|
-
return { startMs: lastWeekMon.getTime(), endMs: lastWeekSunEnd.getTime() }
|
|
1740
|
-
}
|
|
1741
|
-
const relMatch = period.match(/^(\d+)(m|h|d)$/)
|
|
1742
|
-
if (relMatch) {
|
|
1743
|
-
const n = parseInt(relMatch[1])
|
|
1744
|
-
const unit = relMatch[2]
|
|
1745
|
-
const now = new Date()
|
|
1746
|
-
if (unit === 'm') {
|
|
1747
|
-
// Minute granularity is for "resume from the previous turn / pick
|
|
1748
|
-
// up where we left off" style recall — sub-hour windows where 1h
|
|
1749
|
-
// is too coarse. n=0 is invalid (the regex requires \d+ which
|
|
1750
|
-
// matches "0" but a zero-width window returns no rows; leave that
|
|
1751
|
-
// as caller-supplied no-op).
|
|
1752
|
-
const start = new Date(now.getTime() - n * 60_000)
|
|
1753
|
-
return { startMs: start.getTime(), endMs: now.getTime() }
|
|
1754
|
-
}
|
|
1755
|
-
if (unit === 'h') {
|
|
1756
|
-
const start = new Date(now.getTime() - n * 3600_000)
|
|
1757
|
-
return { startMs: start.getTime(), endMs: now.getTime() }
|
|
1758
|
-
}
|
|
1759
|
-
const start = new Date(now)
|
|
1760
|
-
start.setDate(start.getDate() - n)
|
|
1761
|
-
return { startMs: start.getTime(), endMs: now.getTime() }
|
|
1762
|
-
}
|
|
1763
|
-
const rangeMatch = period.match(/^(\d{4}-\d{2}-\d{2})~(\d{4}-\d{2}-\d{2})$/)
|
|
1764
|
-
if (rangeMatch) {
|
|
1765
|
-
return {
|
|
1766
|
-
startMs: Date.parse(rangeMatch[1] + 'T00:00:00'),
|
|
1767
|
-
endMs: Date.parse(rangeMatch[2] + 'T23:59:59.999'),
|
|
1768
|
-
}
|
|
1769
|
-
}
|
|
1770
|
-
const dateMatch = period.match(/^(\d{4}-\d{2}-\d{2})$/)
|
|
1771
|
-
if (dateMatch) {
|
|
1772
|
-
return {
|
|
1773
|
-
startMs: Date.parse(dateMatch[1] + 'T00:00:00'),
|
|
1774
|
-
endMs: Date.parse(dateMatch[1] + 'T23:59:59.999'),
|
|
1775
|
-
exact: true,
|
|
1776
|
-
}
|
|
1777
|
-
}
|
|
1778
|
-
return null
|
|
1779
|
-
}
|
|
1780
|
-
|
|
1781
|
-
function formatTs(tsMs) {
|
|
1782
|
-
const n = Number(tsMs)
|
|
1783
|
-
if (Number.isFinite(n) && n > 1e12) {
|
|
1784
|
-
return new Date(n).toLocaleString('sv-SE').slice(0, 16)
|
|
1785
|
-
}
|
|
1786
|
-
return String(tsMs ?? '').slice(0, 16)
|
|
1787
|
-
}
|
|
1788
|
-
|
|
1789
|
-
const CORE_RECALL_STOPWORDS = new Set([
|
|
1790
|
-
'about', 'after', 'again', 'before', 'check', 'color', 'decision', 'decided',
|
|
1791
|
-
'earlier', 'memory', 'previous', 'routing', 'stored', 'tell', 'what',
|
|
1792
|
-
])
|
|
1793
|
-
|
|
1794
|
-
function coreRecallTerms(query) {
|
|
1795
|
-
return [...new Set(String(query || '').toLowerCase().match(/[\p{L}\p{N}_-]{4,}/gu) || [])]
|
|
1796
|
-
.filter((term) => !CORE_RECALL_STOPWORDS.has(term))
|
|
1797
|
-
.slice(0, 8)
|
|
1798
|
-
}
|
|
1799
|
-
|
|
1800
|
-
function normalizeRecallProjectScope(projectScope) {
|
|
1801
|
-
const raw = String(projectScope || 'common').trim()
|
|
1802
|
-
if (!raw || raw.toLowerCase() === 'common') return null
|
|
1803
|
-
if (raw.toLowerCase() === 'all') return '*'
|
|
1804
|
-
return raw
|
|
1805
|
-
}
|
|
1806
|
-
|
|
1807
|
-
async function recallCoreRows(query, { projectScope, category, limit } = {}) {
|
|
1808
|
-
const terms = coreRecallTerms(query)
|
|
1809
|
-
if (terms.length === 0) return []
|
|
1810
|
-
|
|
1811
|
-
const params = []
|
|
1812
|
-
const where = []
|
|
1813
|
-
const scope = normalizeRecallProjectScope(projectScope)
|
|
1814
|
-
if (scope === null) {
|
|
1815
|
-
where.push('project_id IS NULL')
|
|
1816
|
-
} else if (scope !== '*') {
|
|
1817
|
-
params.push(scope)
|
|
1818
|
-
where.push(`(project_id IS NULL OR project_id = $${params.length})`)
|
|
1819
|
-
}
|
|
1820
|
-
if (category != null) {
|
|
1821
|
-
const cats = (Array.isArray(category) ? category : [category])
|
|
1822
|
-
.map((value) => String(value || '').trim().toLowerCase())
|
|
1823
|
-
.filter(Boolean)
|
|
1824
|
-
if (cats.length > 0) {
|
|
1825
|
-
const placeholders = cats.map((cat) => {
|
|
1826
|
-
params.push(cat)
|
|
1827
|
-
return `$${params.length}`
|
|
1828
|
-
})
|
|
1829
|
-
where.push(`category IN (${placeholders.join(', ')})`)
|
|
1830
|
-
}
|
|
1831
|
-
}
|
|
1832
|
-
|
|
1833
|
-
const textExpr = `lower(coalesce(element, '') || ' ' || coalesce(summary, ''))`
|
|
1834
|
-
const termClauses = terms.map((term) => {
|
|
1835
|
-
params.push(`%${term}%`)
|
|
1836
|
-
return `${textExpr} LIKE $${params.length}`
|
|
1837
|
-
})
|
|
1838
|
-
where.push(`(${termClauses.join(' OR ')})`)
|
|
1839
|
-
const hitExpr = termClauses.map((clause) => `CASE WHEN ${clause} THEN 1 ELSE 0 END`).join(' + ')
|
|
1840
|
-
const rowLimit = Math.max(1, Math.min(10, Number(limit) || 5))
|
|
1841
|
-
params.push(rowLimit)
|
|
1842
|
-
|
|
1843
|
-
const rows = (await db.query(`
|
|
1844
|
-
SELECT id, element, summary, category, project_id, created_at, updated_at,
|
|
1845
|
-
(${hitExpr}) AS hit_count
|
|
1846
|
-
FROM core_entries
|
|
1847
|
-
WHERE ${where.join(' AND ')}
|
|
1848
|
-
ORDER BY hit_count DESC, updated_at DESC, id ASC
|
|
1849
|
-
LIMIT $${params.length}
|
|
1850
|
-
`, params)).rows
|
|
1851
|
-
|
|
1852
|
-
return rows.map((row) => ({
|
|
1853
|
-
...row,
|
|
1854
|
-
id: `core:${row.id}`,
|
|
1855
|
-
ts: row.updated_at || row.created_at || Date.now(),
|
|
1856
|
-
is_root: 1,
|
|
1857
|
-
}))
|
|
1858
|
-
}
|
|
1859
|
-
|
|
1860
|
-
// Session-scoped full drain before a recall(query) search. Chunking is
|
|
1861
|
-
// partitioned by session_id (memory-cycle1.mjs:409-431 selected_sessions
|
|
1862
|
-
// GROUP BY session_id + ROW_NUMBER PARTITION BY session_id), so this drains
|
|
1863
|
-
// ONLY the calling session's backlog via the same session_id override
|
|
1864
|
-
// (memory-cycle1.mjs:394) — other sessions' pending rows are left for the
|
|
1865
|
-
// periodic cycle1 sweep. Reuses recall-fasttrack's drainSessionCycle1
|
|
1866
|
-
// (session/compact.mjs:504) which loops cycle1 until raw rows hit 0 or a
|
|
1867
|
-
// no-progress pass. Safety limits: a 2,000-row hard cap (both in the pending
|
|
1868
|
-
// pre-check and as the per-call cycle1 batch ceiling) and cooperative
|
|
1869
|
-
// abort-signal propagation through handleMemoryAction — no time budget.
|
|
1870
|
-
//
|
|
1871
|
-
// Multi-terminal safety: the memory daemon is one shared instance across
|
|
1872
|
-
// terminals (active-instance.json memory_port). Concurrent recall(query)
|
|
1873
|
-
// calls for the SAME session share one in-flight drain promise (dedup);
|
|
1874
|
-
// different sessions drain independently in parallel (cycle1's own
|
|
1875
|
-
// advisory-lock/coalesce guard in memory-cycle1.mjs still serializes actual
|
|
1876
|
-
// DB writes). A drain only gates recall for its OWN session — every other
|
|
1877
|
-
// terminal's recall (different session, or no session) proceeds unblocked.
|
|
1878
|
-
const RECALL_DRAIN_HARD_CAP_ROWS = 2000
|
|
1879
|
-
async function _drainSessionForRecallQuery(sessionId, signal) {
|
|
1880
|
-
if (!sessionId || signal?.aborted) return
|
|
1881
|
-
const existing = _recallDrainInFlight.get(sessionId)
|
|
1882
|
-
if (existing) { try { await existing } catch {} return }
|
|
1883
|
-
let pendingCount = 0
|
|
1884
|
-
try {
|
|
1885
|
-
const r = await db.query(
|
|
1886
|
-
`SELECT COUNT(*) c FROM entries WHERE chunk_root IS NULL AND session_id = $1`,
|
|
1887
|
-
[sessionId],
|
|
1888
|
-
)
|
|
1889
|
-
pendingCount = Number(r.rows?.[0]?.c ?? 0)
|
|
1890
|
-
} catch { return }
|
|
1891
|
-
if (pendingCount <= 0) return
|
|
1892
|
-
const runTool = async (_name, toolArgs) => {
|
|
1893
|
-
const result = await handleMemoryAction(toolArgs, signal)
|
|
1894
|
-
return result?.text ?? ''
|
|
1895
|
-
}
|
|
1896
|
-
const dumpArgs = {
|
|
1897
|
-
action: 'dump_session_roots',
|
|
1898
|
-
sessionId,
|
|
1899
|
-
includeRaw: true,
|
|
1900
|
-
limit: Math.min(RECALL_DRAIN_HARD_CAP_ROWS, Math.max(1000, pendingCount)),
|
|
1901
|
-
}
|
|
1902
|
-
const drainPromise = (async () => {
|
|
1903
|
-
try {
|
|
1904
|
-
return await drainSessionCycle1(runTool, {
|
|
1905
|
-
sessionId,
|
|
1906
|
-
dumpArgs,
|
|
1907
|
-
deadlineMs: 0, // no time budget — drive the session's backlog to 0
|
|
1908
|
-
maxPasses: 20, // safety stopper; drainSessionCycle1 also self-stops on no-progress
|
|
1909
|
-
cycleArgs: {
|
|
1910
|
-
min_batch: 1,
|
|
1911
|
-
session_cap: 1,
|
|
1912
|
-
batch_size: RECALL_DRAIN_HARD_CAP_ROWS,
|
|
1913
|
-
rows_per_session: RECALL_DRAIN_HARD_CAP_ROWS,
|
|
1914
|
-
window_size: 20,
|
|
1915
|
-
concurrency: 4,
|
|
1916
|
-
},
|
|
1917
|
-
})
|
|
1918
|
-
} catch (err) {
|
|
1919
|
-
__mixdogMemoryLog(`[recall] session drain aborted/failed (sess=${sessionId}): ${err?.message || err}\n`)
|
|
1920
|
-
return null
|
|
1921
|
-
}
|
|
1922
|
-
})()
|
|
1923
|
-
_recallDrainInFlight.set(sessionId, drainPromise)
|
|
1924
|
-
try {
|
|
1925
|
-
await drainPromise
|
|
1926
|
-
} finally {
|
|
1927
|
-
if (_recallDrainInFlight.get(sessionId) === drainPromise) _recallDrainInFlight.delete(sessionId)
|
|
1928
|
-
}
|
|
1929
|
-
}
|
|
1930
|
-
|
|
1931
|
-
async function handleSearch(args, signal) {
|
|
1932
|
-
// Cooperative abort check: throw early if the caller already aborted
|
|
1933
|
-
// (IPC cancel handler signals the AbortController before re-entry).
|
|
1934
|
-
if (signal?.aborted) throw signal.reason ?? new Error('aborted')
|
|
1935
|
-
// Drain this session's unchunked backlog to zero BEFORE searching, so a
|
|
1936
|
-
// query recall never returns raw/unclassified transcript tail instead of
|
|
1937
|
-
// the chunked summary. Only runs when both a session and a query are
|
|
1938
|
-
// present; a bare recent-browsing call (no query) skips this entirely.
|
|
1939
|
-
{
|
|
1940
|
-
const _drainSessionId = String(args?.sessionId || args?.session_id || '').trim()
|
|
1941
|
-
const _drainHasQuery = Array.isArray(args?.query)
|
|
1942
|
-
? args.query.some((v) => String(v || '').trim())
|
|
1943
|
-
: String(args?.query ?? '').trim() !== ''
|
|
1944
|
-
if (_drainSessionId && _drainHasQuery) {
|
|
1945
|
-
await _drainSessionForRecallQuery(_drainSessionId, signal)
|
|
1946
|
-
}
|
|
1947
|
-
}
|
|
1948
|
-
if (args?.currentSession === true || args?.sessionId || args?.session_id) {
|
|
1949
|
-
return await recallSessionRows(args)
|
|
1950
|
-
}
|
|
1951
|
-
// id mode (follow-up lookup): caller passed `#N` markers from a prior
|
|
1952
|
-
// recall result. Fetch those rows directly + their chunk members,
|
|
1953
|
-
// bypassing hybrid search entirely. Output reuses renderEntryLines so
|
|
1954
|
-
// the shape stays identical to the search path (chunk members first,
|
|
1955
|
-
// root summary fallback).
|
|
1956
|
-
if (Array.isArray(args.ids) && args.ids.length > 0) {
|
|
1957
|
-
const ids = args.ids
|
|
1958
|
-
.map(v => Number(v))
|
|
1959
|
-
.filter(v => Number.isFinite(v) && v > 0)
|
|
1960
|
-
if (ids.length === 0) return { text: '(no valid ids)' }
|
|
1961
|
-
const includeArchived = args.includeArchived !== false
|
|
1962
|
-
const category = args.category
|
|
1963
|
-
const period = String(args.period ?? '').trim() || undefined
|
|
1964
|
-
const temporal = parsePeriod(period, false)
|
|
1965
|
-
let projectScope
|
|
1966
|
-
if (typeof args.projectScope === 'string' && args.projectScope) {
|
|
1967
|
-
projectScope = args.projectScope
|
|
1968
|
-
} else {
|
|
1969
|
-
const projectId = resolveProjectScope(typeof args.cwd === 'string' && args.cwd ? args.cwd : null)
|
|
1970
|
-
projectScope = projectId !== null ? projectId : 'common'
|
|
1971
|
-
}
|
|
1972
|
-
const excludeStatuses = includeArchived ? [] : ['archived']
|
|
1973
|
-
const rows = await fetchEntriesByIdsScoped(db, ids, {
|
|
1974
|
-
ts_from: temporal?.startMs,
|
|
1975
|
-
ts_to: temporal?.endMs,
|
|
1976
|
-
excludeStatuses,
|
|
1977
|
-
category,
|
|
1978
|
-
projectScope,
|
|
1979
|
-
})
|
|
1980
|
-
if (rows.length === 0) return { text: '(no results)' }
|
|
1981
|
-
// Members for any root rows in the result set.
|
|
1982
|
-
const rootIds = rows.filter(r => r.is_root === 1).map(r => Number(r.id))
|
|
1983
|
-
const memberLeafIds = new Set()
|
|
1984
|
-
if (rootIds.length > 0) {
|
|
1985
|
-
const { rows: memberRows } = await db.query(
|
|
1986
|
-
`SELECT id, ts, role, content, chunk_root
|
|
1987
|
-
FROM entries WHERE chunk_root = ANY($1::bigint[]) AND is_root = 0
|
|
1988
|
-
ORDER BY ts ASC, id ASC`,
|
|
1989
|
-
[rootIds],
|
|
1990
|
-
)
|
|
1991
|
-
const membersByRoot = new Map()
|
|
1992
|
-
for (const m of memberRows) {
|
|
1993
|
-
const k = Number(m.chunk_root)
|
|
1994
|
-
if (!membersByRoot.has(k)) membersByRoot.set(k, [])
|
|
1995
|
-
membersByRoot.get(k).push(m)
|
|
1996
|
-
memberLeafIds.add(Number(m.id))
|
|
1997
|
-
}
|
|
1998
|
-
for (const r of rows) {
|
|
1999
|
-
if (r.is_root === 1) r.members = membersByRoot.get(Number(r.id)) ?? []
|
|
2000
|
-
}
|
|
2001
|
-
}
|
|
2002
|
-
// Preserve caller-supplied id order; drop leaves already inlined as a
|
|
2003
|
-
// root's chunk member to prevent double emission when the caller names
|
|
2004
|
-
// a root and one of its leaves in the same batch.
|
|
2005
|
-
const byId = new Map(rows.map(r => [Number(r.id), r]))
|
|
2006
|
-
const ordered = ids
|
|
2007
|
-
.map(id => byId.get(id))
|
|
2008
|
-
.filter(Boolean)
|
|
2009
|
-
.filter(r => !(r.is_root === 0 && memberLeafIds.has(Number(r.id))))
|
|
2010
|
-
return { text: renderEntryLines(ordered) }
|
|
2011
|
-
}
|
|
2012
|
-
// Array query — fan out in parallel, each query runs its own hybrid search
|
|
2013
|
-
// path, and results are grouped in the response so the caller sees one
|
|
2014
|
-
// ranked list per angle. Collapses what would otherwise be N sequential
|
|
2015
|
-
// tool calls into a single invocation.
|
|
2016
|
-
if (Array.isArray(args.query)) {
|
|
2017
|
-
// Dedup + fan-out cap. The cap protects the result envelope from
|
|
2018
|
-
// over-eager callers (20+ near-duplicate queries N× the IO) without
|
|
2019
|
-
// silently swallowing the caller's intent: when the input exceeds
|
|
2020
|
-
// QUERIES_CAP, prepend a one-line note so the caller can see the
|
|
2021
|
-
// truncation and re-shape their query list.
|
|
2022
|
-
const QUERIES_CAP = 5
|
|
2023
|
-
const dedup = [...new Set(args.query.map(q => String(q || '').trim()).filter(Boolean))]
|
|
2024
|
-
if (dedup.length === 0) return { text: '' }
|
|
2025
|
-
const queries = dedup.slice(0, QUERIES_CAP)
|
|
2026
|
-
const dropped = dedup.length - queries.length
|
|
2027
|
-
const rest = { ...args }
|
|
2028
|
-
delete rest.query
|
|
2029
|
-
const deadlineSec = Math.max(1, Number(process.env.MEMORY_FANOUT_DEADLINE_S) || 180)
|
|
2030
|
-
const deadlineMs = deadlineSec * 1000
|
|
2031
|
-
const fanOutAbort = new AbortController()
|
|
2032
|
-
let deadlineTimer
|
|
2033
|
-
const deadlineRace = new Promise((_res, rej) => {
|
|
2034
|
-
deadlineTimer = setTimeout(() => {
|
|
2035
|
-
fanOutAbort.abort(new Error(`memory fan-out deadline exceeded (${deadlineSec}s)`))
|
|
2036
|
-
rej(Object.assign(new Error(`memory fan-out deadline exceeded (${deadlineSec}s)`), { _deadline: true }))
|
|
2037
|
-
}, deadlineMs)
|
|
2038
|
-
})
|
|
2039
|
-
let settled
|
|
2040
|
-
try {
|
|
2041
|
-
// Pre-warm only when the embedding model is already resident. If the
|
|
2042
|
-
// process is still cold, keep recall responsive and let the background
|
|
2043
|
-
// warmup finish independently instead of making the first query pay the
|
|
2044
|
-
// ONNX session-create cost.
|
|
2045
|
-
if (isEmbeddingModelReady()) {
|
|
2046
|
-
// Race against the same deadline as the fan-out itself: a stuck
|
|
2047
|
-
// embedding worker would previously park here indefinitely because
|
|
2048
|
-
// the timer hadn't been started yet from the fan-out's perspective.
|
|
2049
|
-
await Promise.race([embedTexts(queries), deadlineRace])
|
|
2050
|
-
} else if (embeddingWarmupCanStart()) {
|
|
2051
|
-
void warmupEmbeddingProvider().catch((err) => {
|
|
2052
|
-
__mixdogMemoryLog(`[memory-service] embedding warmup after cold fan-out skipped dense search: ${err?.message || err}\n`)
|
|
2053
|
-
})
|
|
2054
|
-
}
|
|
2055
|
-
settled = await Promise.race([
|
|
2056
|
-
Promise.all(queries.map(async (q) => {
|
|
2057
|
-
if (fanOutAbort.signal.aborted) throw fanOutAbort.signal.reason
|
|
2058
|
-
if (signal?.aborted) throw signal.reason ?? new Error('aborted')
|
|
2059
|
-
const sub = await handleSearch({ ...rest, query: q }, signal)
|
|
2060
|
-
return `[${q}]\n${sub.text || '(no results)'}`
|
|
2061
|
-
})),
|
|
2062
|
-
deadlineRace,
|
|
2063
|
-
])
|
|
2064
|
-
} finally {
|
|
2065
|
-
clearTimeout(deadlineTimer)
|
|
2066
|
-
}
|
|
2067
|
-
const parts = settled
|
|
2068
|
-
const header = dropped > 0
|
|
2069
|
-
? `note: ${dedup.length} queries received, ${queries.length} processed, ${dropped} dropped (cap ${QUERIES_CAP})\n\n`
|
|
2070
|
-
: ''
|
|
2071
|
-
return { text: header + parts.join('\n\n') }
|
|
2072
|
-
}
|
|
2073
|
-
const query = String(args.query ?? '').trim()
|
|
2074
|
-
let period = String(args.period ?? '').trim() || undefined
|
|
2075
|
-
// Period and sort are caller-supplied only. Lead is responsible for
|
|
2076
|
-
// mapping vague time phrases / chronological intent into the period
|
|
2077
|
-
// argument before calling; the engine does not infer them from query
|
|
2078
|
-
// text.
|
|
2079
|
-
const RECALL_LIMIT_CAP = 100
|
|
2080
|
-
const RECALL_OFFSET_CAP = 500
|
|
2081
|
-
const requestedLimit = Number(args.limit)
|
|
2082
|
-
const requestedOffset = Number(args.offset)
|
|
2083
|
-
let limit = Math.max(1, Number.isFinite(requestedLimit) ? requestedLimit : 10)
|
|
2084
|
-
let offset = Math.max(0, Number.isFinite(requestedOffset) ? requestedOffset : 0)
|
|
2085
|
-
const recallCapNotes = []
|
|
2086
|
-
if (Number.isFinite(requestedLimit) && requestedLimit > RECALL_LIMIT_CAP) {
|
|
2087
|
-
limit = RECALL_LIMIT_CAP
|
|
2088
|
-
recallCapNotes.push(`limit capped to ${RECALL_LIMIT_CAP} (requested ${requestedLimit})`)
|
|
2089
|
-
} else {
|
|
2090
|
-
limit = Math.min(RECALL_LIMIT_CAP, limit)
|
|
2091
|
-
}
|
|
2092
|
-
if (Number.isFinite(requestedOffset) && requestedOffset > RECALL_OFFSET_CAP) {
|
|
2093
|
-
offset = RECALL_OFFSET_CAP
|
|
2094
|
-
recallCapNotes.push(`offset capped to ${RECALL_OFFSET_CAP} (requested ${requestedOffset})`)
|
|
2095
|
-
} else {
|
|
2096
|
-
offset = Math.min(RECALL_OFFSET_CAP, offset)
|
|
2097
|
-
}
|
|
2098
|
-
const recallCapPrefix = recallCapNotes.length ? `${recallCapNotes.join('; ')}\n` : ''
|
|
2099
|
-
const sort = args.sort != null ? String(args.sort) : 'importance'
|
|
2100
|
-
// Chunk content is the primary recall output. Members default to true so
|
|
2101
|
-
// callers receive the raw chunk leaves (the cycle1-produced semantic
|
|
2102
|
-
// chunks) rather than just the root's cycle2-compressed summary line.
|
|
2103
|
-
// Explicit `includeMembers:false` keeps the legacy summary-only mode.
|
|
2104
|
-
const includeMembers = args.includeMembers !== false
|
|
2105
|
-
const includeRaw = Boolean(args.includeRaw)
|
|
2106
|
-
const includeArchived = args.includeArchived !== false
|
|
2107
|
-
const category = args.category
|
|
2108
|
-
const temporal = parsePeriod(period, Boolean(query))
|
|
2109
|
-
|
|
2110
|
-
// Derive projectScope from caller cwd (falls back to process.cwd()).
|
|
2111
|
-
// Explicit args.projectScope (string) takes priority so callers can
|
|
2112
|
-
// override to 'all', 'common', or a specific slug.
|
|
2113
|
-
let projectScope
|
|
2114
|
-
if (typeof args.projectScope === 'string' && args.projectScope) {
|
|
2115
|
-
projectScope = args.projectScope
|
|
2116
|
-
} else {
|
|
2117
|
-
const projectId = resolveProjectScope(typeof args.cwd === 'string' && args.cwd ? args.cwd : null)
|
|
2118
|
-
projectScope = projectId !== null ? projectId : 'common'
|
|
2119
|
-
}
|
|
2120
|
-
|
|
2121
|
-
// R11 reviewer M4: calendar-bounded periods disable freshness decay
|
|
2122
|
-
// so within-period ranking doesn't downgrade Mon entries vs Sun.
|
|
2123
|
-
const CALENDAR_PERIODS = new Set(['yesterday', 'today', 'this_week', 'last_week'])
|
|
2124
|
-
const isCalendarPeriod = period != null
|
|
2125
|
-
&& (CALENDAR_PERIODS.has(period) || /^\d{4}-\d{2}-\d{2}/.test(period))
|
|
2126
|
-
const applyFreshness = !isCalendarPeriod
|
|
2127
|
-
|
|
2128
|
-
if (query) {
|
|
2129
|
-
const _t0 = Date.now()
|
|
2130
|
-
if (signal?.aborted) throw signal.reason ?? new Error('aborted')
|
|
2131
|
-
let queryVector = null
|
|
2132
|
-
if (isEmbeddingModelReady()) {
|
|
2133
|
-
queryVector = await embedText(query)
|
|
2134
|
-
} else {
|
|
2135
|
-
const now = Date.now()
|
|
2136
|
-
if (now - _embeddingColdRecallLogAt > 10_000) {
|
|
2137
|
-
_embeddingColdRecallLogAt = now
|
|
2138
|
-
__mixdogMemoryLog('[recall] embedding model cold; returning lexical results while background warmup continues\n')
|
|
2139
|
-
}
|
|
2140
|
-
if (embeddingWarmupCanStart()) {
|
|
2141
|
-
void warmupEmbeddingProvider().catch((err) => {
|
|
2142
|
-
__mixdogMemoryLog(`[memory-service] embedding warmup after cold recall failed: ${err?.message || err}\n`)
|
|
2143
|
-
})
|
|
2144
|
-
}
|
|
2145
|
-
}
|
|
2146
|
-
if (signal?.aborted) throw signal.reason ?? new Error('aborted')
|
|
2147
|
-
const _t1 = Date.now()
|
|
2148
|
-
if (process.env.MIXDOG_DEBUG_MEMORY) {
|
|
2149
|
-
__mixdogMemoryLog(`[search-time] embed=${_t1 - _t0}ms query="${query.slice(0, 60)}"\n`)
|
|
2150
|
-
}
|
|
2151
|
-
// Push ts and status filters into the hybrid candidate query so FTS / vec
|
|
2152
|
-
// rank inside the requested window, not the whole tree. The previous post-
|
|
2153
|
-
// filter approach silently emptied results when relevant matches sat
|
|
2154
|
-
// outside `period` (default 30d) and could not bubble through.
|
|
2155
|
-
// Recall is history-first: archived roots hold most prior work. Callers
|
|
2156
|
-
// that need only live invariants can pass includeArchived:false.
|
|
2157
|
-
const excludeStatuses = includeArchived ? [] : ['archived']
|
|
2158
|
-
const results = await searchRelevantHybrid(db, query, {
|
|
2159
|
-
limit: limit + offset,
|
|
2160
|
-
queryVector: Array.isArray(queryVector) ? queryVector : null,
|
|
2161
|
-
includeMembers,
|
|
2162
|
-
ts_from: temporal?.startMs,
|
|
2163
|
-
ts_to: temporal?.endMs,
|
|
2164
|
-
applyFreshness,
|
|
2165
|
-
projectScope,
|
|
2166
|
-
category,
|
|
2167
|
-
excludeStatuses,
|
|
2168
|
-
// useHotActive was set to true here so default (no-period) calls
|
|
2169
|
-
// routed through the mv_hot_active materialized view — a narrow
|
|
2170
|
-
// active-roots-only pool. Live usage is dominated by vague-time
|
|
2171
|
-
// queries ("recent / lately") where Lead callers omit the period
|
|
2172
|
-
// filter, leaving the MV as the sole source. That hid every
|
|
2173
|
-
// orphan leaf and every pending root — fresh work from the last 1-60
|
|
2174
|
-
// minutes never surfaced. Now that the entries-table CTE legs run
|
|
2175
|
-
// against broaden HNSW + GIN trgm partial indexes (the
|
|
2176
|
-
// is_root=1 predicate was dropped in the same revision), the
|
|
2177
|
-
// entries path is fast enough (1-2 ms ANN on ~10K rows, O(log N)
|
|
2178
|
-
// through 1M+) to be the single source of truth. The MV is left in
|
|
2179
|
-
// place for now but no longer routed to from search; cycle2 may stop
|
|
2180
|
-
// refreshing it in a follow-up commit once nothing else reads it.
|
|
2181
|
-
useHotActive: false,
|
|
2182
|
-
})
|
|
2183
|
-
let filtered = results
|
|
2184
|
-
if (sort === 'date') {
|
|
2185
|
-
// R11 reviewer L5: NaN guard — entries with null/undefined ts default
|
|
2186
|
-
// to 0 so the comparator stays numeric and stable.
|
|
2187
|
-
filtered.sort((a, b) => (Number(b.ts) || 0) - (Number(a.ts) || 0))
|
|
2188
|
-
} else {
|
|
2189
|
-
filtered.sort((a, b) => {
|
|
2190
|
-
const sa = (v) => { const n = Number(v); return Number.isFinite(n) ? n : 0 }
|
|
2191
|
-
return (sa(b.retrievalScore ?? b.rrf ?? 0) - sa(a.retrievalScore ?? a.rrf ?? 0))
|
|
2192
|
-
|| (sa(b.score ?? 0) - sa(a.score ?? 0))
|
|
2193
|
-
|| (sa(b.ts ?? 0) - sa(a.ts ?? 0))
|
|
2194
|
-
|| (Number(a.id ?? 0) - Number(b.id ?? 0))
|
|
2195
|
-
})
|
|
2196
|
-
}
|
|
2197
|
-
if (includeRaw) {
|
|
2198
|
-
// Raw rows (chunk_root IS NULL) carry no retrievalScore, so a naive
|
|
2199
|
-
// append-after-hybrid under sort=importance always lands them past
|
|
2200
|
-
// slice(offset, offset+limit) once the hybrid pool exceeds one page —
|
|
2201
|
-
// every page beyond the first silently drops them. Fetch a wider raw
|
|
2202
|
-
// window (bounded like the hybrid candidate pool) and spread the
|
|
2203
|
-
// fetched raw rows evenly across the WHOLE hybrid list before slicing,
|
|
2204
|
-
// so every offset page gets its proportional share instead of only
|
|
2205
|
-
// page 0. Same projectScope/ts window as the hybrid leg — filter
|
|
2206
|
-
// parity (item 3) is deliberate, not accidental.
|
|
2207
|
-
const RAW_FETCH = Math.min(500, Math.max(20, limit + offset))
|
|
2208
|
-
const rawRows = await readRawRowsInWindow(
|
|
2209
|
-
db,
|
|
2210
|
-
temporal?.startMs ?? null,
|
|
2211
|
-
temporal?.endMs ?? Date.now(),
|
|
2212
|
-
RAW_FETCH,
|
|
2213
|
-
{ projectScope },
|
|
2214
|
-
)
|
|
2215
|
-
const seenIds = new Set(filtered.map(r => r.id))
|
|
2216
|
-
const newRaw = rawRows.filter(r => !seenIds.has(r.id))
|
|
2217
|
-
if (sort === 'date') {
|
|
2218
|
-
for (const r of newRaw) filtered.push(r)
|
|
2219
|
-
filtered.sort((a, b) => (Number(b.ts) || 0) - (Number(a.ts) || 0))
|
|
2220
|
-
} else {
|
|
2221
|
-
// sort=importance: interleave raw rows at a fixed stride through the
|
|
2222
|
-
// full (pre-slice) hybrid list instead of appending at the tail, so
|
|
2223
|
-
// offset > 0 pages also draw from the raw pool proportionally.
|
|
2224
|
-
filtered = interleaveRawRows(filtered, newRaw)
|
|
2225
|
-
}
|
|
2226
|
-
}
|
|
2227
|
-
const coreRows = await recallCoreRows(query, { projectScope, category, limit })
|
|
2228
|
-
if (coreRows.length > 0) {
|
|
2229
|
-
filtered = [...coreRows, ...filtered]
|
|
2230
|
-
}
|
|
2231
|
-
const sliced = filtered.slice(offset, offset + limit)
|
|
2232
|
-
const _t2 = Date.now()
|
|
2233
|
-
if (process.env.MIXDOG_DEBUG_MEMORY) {
|
|
2234
|
-
__mixdogMemoryLog(`[search-time] hybrid+sort+raw=${_t2 - _t1}ms rows=${filtered.length} sliced=${sliced.length}\n`)
|
|
2235
|
-
}
|
|
2236
|
-
// Emit a recall trace event so getTraceWithEntries() can correlate
|
|
2237
|
-
// this search with the top-ranked memory entry. One event per
|
|
2238
|
-
// handleSearch call (not per returned row) — cheapest meaningful link.
|
|
2239
|
-
// parent_span_id left null: the agent-side span id is only known after
|
|
2240
|
-
// the DB insert of the loop/tool events, which happens async on the
|
|
2241
|
-
// client side and is not available here.
|
|
2242
|
-
if (_traceDb && filtered.length > 0) {
|
|
2243
|
-
const topHit = filtered[0]
|
|
2244
|
-
const topId = topHit?.id != null ? Number(topHit.id) : null
|
|
2245
|
-
if (topId !== null && Number.isFinite(topId)) {
|
|
2246
|
-
insertTraceEvents(_traceDb, [{
|
|
2247
|
-
ts: Date.now(),
|
|
2248
|
-
kind: 'recall',
|
|
2249
|
-
entry_id: topId,
|
|
2250
|
-
payload: { query: query.slice(0, 200), hit_count: filtered.length },
|
|
2251
|
-
}]).catch(e => __mixdogMemoryLog(`[trace] insertTraceEvents error: ${e?.message}\n`))
|
|
2252
|
-
}
|
|
2253
|
-
}
|
|
2254
|
-
const out = { text: recallCapPrefix + renderEntryLines(sliced) }
|
|
2255
|
-
if (process.env.MIXDOG_DEBUG_MEMORY) {
|
|
2256
|
-
__mixdogMemoryLog(`[search-time] render+trace=${Date.now() - _t2}ms total=${Date.now() - _t0}ms textLen=${out.text.length}\n`)
|
|
2257
|
-
}
|
|
2258
|
-
return out
|
|
2259
|
-
}
|
|
2260
|
-
|
|
2261
|
-
const filters = { limit: limit + offset }
|
|
2262
|
-
if (temporal?.startMs != null) { filters.ts_from = temporal.startMs; filters.ts_to = temporal.endMs }
|
|
2263
|
-
if (temporal?.mode === 'last' && _bootTimestamp) {
|
|
2264
|
-
filters.ts_to = _bootTimestamp - 1
|
|
2265
|
-
}
|
|
2266
|
-
filters.projectScope = projectScope
|
|
2267
|
-
if (category != null) filters.category = category
|
|
2268
|
-
filters.sort = sort
|
|
2269
|
-
if (!includeArchived) filters.excludeStatuses = ['archived']
|
|
2270
|
-
if (includeMembers) filters.includeMembers = true
|
|
2271
|
-
const rows = await retrieveEntries(db, filters)
|
|
2272
|
-
const sliced = rows.slice(offset, offset + limit)
|
|
2273
|
-
return { text: recallCapPrefix + renderEntryLines(sliced) }
|
|
2274
|
-
}
|
|
2275
|
-
|
|
2276
|
-
function renderEntryLines(rows) {
|
|
2277
|
-
if (!rows || rows.length === 0) return '(no results)'
|
|
2278
|
-
const lines = []
|
|
2279
|
-
// Bound total emitted lines (roots x members) so a many-member recall can't
|
|
2280
|
-
// inject unbounded output. Per-line content is already capped at 1000 chars;
|
|
2281
|
-
// this caps the line COUNT. Narrow the query (limit/period/projectScope) for more.
|
|
2282
|
-
const RECALL_LINE_CAP = 200
|
|
2283
|
-
let _capped = false
|
|
2284
|
-
outer:
|
|
2285
|
-
for (const r of rows) {
|
|
2286
|
-
const hasMembers = Array.isArray(r.members) && r.members.length > 0
|
|
2287
|
-
if (hasMembers) {
|
|
2288
|
-
// Chunks present: emit each member as its own line. Root row is a
|
|
2289
|
-
// grouping artifact for retrieval — the caller wants the chunk
|
|
2290
|
-
// content (cycle1 raw), not the cycle2-compressed summary.
|
|
2291
|
-
for (const m of r.members) {
|
|
2292
|
-
if (lines.length >= RECALL_LINE_CAP) { _capped = true; break outer }
|
|
2293
|
-
const mTs = formatTs(m.ts)
|
|
2294
|
-
const role = m.role === 'user' ? 'u' : m.role === 'assistant' ? 'a' : (m.role || '?')
|
|
2295
|
-
const content = cleanMemoryText(String(m.content ?? '')).slice(0, 1000)
|
|
2296
|
-
lines.push(`[${mTs}] ${role}: ${content} #${m.id}`)
|
|
2297
|
-
}
|
|
2298
|
-
} else {
|
|
2299
|
-
if (lines.length >= RECALL_LINE_CAP) { _capped = true; break }
|
|
2300
|
-
// No chunks (root not yet chunked by cycle1, or orphan leaf): emit
|
|
2301
|
-
// the row itself in the same shape. element/summary fall back to
|
|
2302
|
-
// raw content when both are absent.
|
|
2303
|
-
const ts = formatTs(r.ts)
|
|
2304
|
-
const element = r.element ?? ''
|
|
2305
|
-
const summary = r.summary ?? ''
|
|
2306
|
-
// Standalone leaf rows (is_root=0, no parent chunks_root resolved
|
|
2307
|
-
// into a `members` list) carry their u/a role just like inline
|
|
2308
|
-
// chunk members — surface it so the format stays consistent across
|
|
2309
|
-
// the two emission paths.
|
|
2310
|
-
const rolePrefix = r.is_root === 0 && r.role
|
|
2311
|
-
? (r.role === 'user' ? 'u: ' : r.role === 'assistant' ? 'a: ' : `${r.role}: `)
|
|
2312
|
-
: ''
|
|
2313
|
-
const body = element || summary
|
|
2314
|
-
? `${element}${summary ? ' — ' + summary : ''}`
|
|
2315
|
-
: cleanMemoryText(String(r.content ?? '')).slice(0, 1000)
|
|
2316
|
-
lines.push(`[${ts}] ${rolePrefix}${body.slice(0, 1000)} #${r.id}`)
|
|
2317
|
-
}
|
|
2318
|
-
}
|
|
2319
|
-
if (_capped) lines.push(`[recall truncated — showing first ${RECALL_LINE_CAP} lines; narrow the query (limit/period/projectScope) for the rest]`)
|
|
2320
|
-
return lines.join('\n')
|
|
2321
|
-
}
|
|
2322
|
-
|
|
2323
|
-
async function dumpSessionRootChunks(args = {}) {
|
|
2324
|
-
const sessionId = String(args.sessionId || args.session_id || '').trim()
|
|
2325
|
-
if (!sessionId) return { text: '(no current session)', rows: [], chunks: [], isError: true }
|
|
2326
|
-
const includeRaw = args.includeRaw !== false
|
|
2327
|
-
const limit = Math.max(1, Math.min(1000, Number(args.limit) || 1000))
|
|
2328
|
-
const rootRows = (await db.query(`
|
|
2329
|
-
SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root,
|
|
2330
|
-
element, category, summary, status, score, last_seen_at, project_id
|
|
2331
|
-
FROM entries
|
|
2332
|
-
WHERE session_id = $1 AND is_root = 1
|
|
2333
|
-
ORDER BY COALESCE(source_turn, 2147483647) ASC, ts ASC, id ASC
|
|
2334
|
-
LIMIT $2
|
|
2335
|
-
`, [sessionId, limit])).rows
|
|
2336
|
-
const roots = rootRows.map((r) => ({ ...r, members: [] }))
|
|
2337
|
-
const rootIds = roots.map((r) => Number(r.id)).filter((id) => Number.isFinite(id))
|
|
2338
|
-
const memberRows = rootIds.length > 0
|
|
2339
|
-
? (await db.query(`
|
|
2340
|
-
SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root, project_id
|
|
2341
|
-
FROM entries
|
|
2342
|
-
WHERE chunk_root = ANY($1::bigint[]) AND is_root = 0
|
|
2343
|
-
ORDER BY chunk_root ASC, COALESCE(source_turn, 2147483647) ASC, ts ASC, id ASC
|
|
2344
|
-
`, [rootIds])).rows
|
|
2345
|
-
: []
|
|
2346
|
-
const byRoot = new Map(roots.map((r) => [Number(r.id), r]))
|
|
2347
|
-
for (const m of memberRows) {
|
|
2348
|
-
const root = byRoot.get(Number(m.chunk_root))
|
|
2349
|
-
if (root) root.members.push(m)
|
|
2350
|
-
}
|
|
2351
|
-
let rawRows = []
|
|
2352
|
-
if (includeRaw) {
|
|
2353
|
-
rawRows = (await db.query(`
|
|
2354
|
-
SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root, project_id
|
|
2355
|
-
FROM entries
|
|
2356
|
-
WHERE session_id = $1
|
|
2357
|
-
AND is_root = 0
|
|
2358
|
-
AND (chunk_root IS NULL OR chunk_root = id)
|
|
2359
|
-
ORDER BY COALESCE(source_turn, 2147483647) ASC, ts ASC, id ASC
|
|
2360
|
-
LIMIT $2
|
|
2361
|
-
`, [sessionId, limit])).rows
|
|
2362
|
-
}
|
|
2363
|
-
const chunks = []
|
|
2364
|
-
for (const root of roots) {
|
|
2365
|
-
const memberText = root.members
|
|
2366
|
-
.map((m) => `${m.role === 'assistant' ? 'assistant' : m.role === 'user' ? 'user' : m.role}: ${cleanMemoryText(String(m.content ?? ''))}`)
|
|
2367
|
-
.filter(Boolean)
|
|
2368
|
-
.join('\n')
|
|
2369
|
-
const summary = [root.element, root.summary].map((v) => String(v || '').trim()).filter(Boolean).join(' — ')
|
|
2370
|
-
chunks.push({
|
|
2371
|
-
id: Number(root.id),
|
|
2372
|
-
kind: 'root',
|
|
2373
|
-
ts: Number(root.ts) || 0,
|
|
2374
|
-
sourceTurn: root.source_turn ?? null,
|
|
2375
|
-
category: root.category || null,
|
|
2376
|
-
summary,
|
|
2377
|
-
text: memberText || cleanMemoryText(String(root.content ?? '')),
|
|
2378
|
-
members: root.members,
|
|
2379
|
-
})
|
|
2380
|
-
}
|
|
2381
|
-
for (const raw of rawRows) {
|
|
2382
|
-
chunks.push({
|
|
2383
|
-
id: Number(raw.id),
|
|
2384
|
-
kind: 'raw',
|
|
2385
|
-
chunkRoot: raw.chunk_root ?? null,
|
|
2386
|
-
ts: Number(raw.ts) || 0,
|
|
2387
|
-
sourceTurn: raw.source_turn ?? null,
|
|
2388
|
-
category: null,
|
|
2389
|
-
summary: '',
|
|
2390
|
-
text: `${raw.role === 'assistant' ? 'assistant' : raw.role === 'user' ? 'user' : raw.role}: ${cleanMemoryText(String(raw.content ?? ''))}`,
|
|
2391
|
-
members: [],
|
|
2392
|
-
})
|
|
2393
|
-
}
|
|
2394
|
-
chunks.sort((a, b) => {
|
|
2395
|
-
const at = Number.isFinite(Number(a.sourceTurn)) ? Number(a.sourceTurn) : 2147483647
|
|
2396
|
-
const bt = Number.isFinite(Number(b.sourceTurn)) ? Number(b.sourceTurn) : 2147483647
|
|
2397
|
-
return (at - bt) || ((a.ts || 0) - (b.ts || 0)) || ((a.id || 0) - (b.id || 0))
|
|
2398
|
-
})
|
|
2399
|
-
const text = chunks.length
|
|
2400
|
-
? chunks.map((chunk, idx) => {
|
|
2401
|
-
const label = chunk.kind === 'root'
|
|
2402
|
-
? `# chunk ${idx + 1} root=${chunk.id}${chunk.category ? ` category=${chunk.category}` : ''}`
|
|
2403
|
-
: `${chunk.chunkRoot == null ? '# raw_pending' : '# raw_terminal'} ${idx + 1} id=${chunk.id}`
|
|
2404
|
-
const summary = chunk.summary ? `summary: ${chunk.summary}\n` : ''
|
|
2405
|
-
return `${label}\n${summary}${chunk.text}`.trim()
|
|
2406
|
-
}).join('\n\n')
|
|
2407
|
-
: '(no results)'
|
|
2408
|
-
return { text, rows: [...roots, ...rawRows], chunks }
|
|
2409
|
-
}
|
|
2410
|
-
|
|
2411
|
-
async function entryStats() {
|
|
2412
|
-
return await db.transaction(async (tx) => {
|
|
2413
|
-
const total = (await tx.query(`SELECT COUNT(*) c FROM entries`)).rows[0].c
|
|
2414
|
-
const roots = (await tx.query(`SELECT COUNT(*) c FROM entries WHERE is_root = 1`)).rows[0].c
|
|
2415
|
-
const active_roots = (await tx.query(`SELECT COUNT(*) c FROM entries WHERE is_root = 1 AND status = 'active'`)).rows[0].c
|
|
2416
|
-
const archived_roots = (await tx.query(`SELECT COUNT(*) c FROM entries WHERE is_root = 1 AND status = 'archived'`)).rows[0].c
|
|
2417
|
-
const unchunked_leaves = (await tx.query(`SELECT COUNT(*) c FROM entries WHERE chunk_root IS NULL`)).rows[0].c
|
|
2418
|
-
const cycle2_pending_roots = (await tx.query(`SELECT COUNT(*) c FROM entries WHERE is_root = 1 AND status = 'pending'`)).rows[0].c
|
|
2419
|
-
const core_entries = (await tx.query(`SELECT COUNT(*) c FROM core_entries`)).rows[0].c
|
|
2420
|
-
const core_embed_null = (await tx.query(`SELECT COUNT(*) c FROM core_entries WHERE embedding IS NULL`)).rows[0].c
|
|
2421
|
-
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
|
|
2422
|
-
const active_core_summary_missing = (await tx.query(`
|
|
2423
|
-
SELECT COUNT(*) c
|
|
2424
|
-
FROM entries
|
|
2425
|
-
WHERE is_root = 1
|
|
2426
|
-
AND status = 'active'
|
|
2427
|
-
AND (core_summary IS NULL OR btrim(core_summary) = '')
|
|
2428
|
-
`)).rows[0].c
|
|
2429
|
-
const byStatus = (await tx.query(`SELECT status, COUNT(*) c FROM entries WHERE is_root = 1 GROUP BY status`)).rows
|
|
2430
|
-
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
|
|
2431
|
-
const mvRows = (await tx.query(`SELECT relispopulated FROM pg_class WHERE relname = 'mv_hot_active' LIMIT 1`)).rows
|
|
2432
|
-
const mv_hot_active_populated = mvRows.length ? Boolean(mvRows[0].relispopulated) : null
|
|
2433
|
-
return {
|
|
2434
|
-
total, roots, active_roots, archived_roots, unchunked_leaves, cycle2_pending_roots,
|
|
2435
|
-
core_entries, core_embed_null, active_core_summaries, active_core_summary_missing,
|
|
2436
|
-
mv_hot_active_populated,
|
|
2437
|
-
byStatus, byCategory,
|
|
2438
|
-
}
|
|
2439
|
-
})
|
|
2440
|
-
}
|
|
638
|
+
const __queryHandlers = createQueryHandlers({
|
|
639
|
+
getDb: () => db,
|
|
640
|
+
log: __mixdogMemoryLog,
|
|
641
|
+
resolveProjectScope,
|
|
642
|
+
embeddingWarmupCanStart,
|
|
643
|
+
getBootTimestamp: () => _bootTimestamp,
|
|
644
|
+
getTraceDb: () => _traceDb,
|
|
645
|
+
})
|
|
646
|
+
const {
|
|
647
|
+
readRawRowsInWindow,
|
|
648
|
+
recallSessionRows,
|
|
649
|
+
recallCoreRows,
|
|
650
|
+
handleSearch,
|
|
651
|
+
dumpSessionRootChunks,
|
|
652
|
+
entryStats,
|
|
653
|
+
} = __queryHandlers
|
|
2441
654
|
|
|
2442
655
|
async function _handleMemCycle1(args, config, signal) {
|
|
2443
656
|
const minBatchOverride = Number(args?.min_batch)
|
|
@@ -2631,7 +844,7 @@ async function _handleMemRebuild(args, config, signal) {
|
|
|
2631
844
|
// then loop once more if one layer exposed another promise while awaiting.
|
|
2632
845
|
const drainedCycle1Promises = new Set()
|
|
2633
846
|
for (;;) {
|
|
2634
|
-
const pendingCycle1Promises = [
|
|
847
|
+
const pendingCycle1Promises = [_cycleScheduler.getCycle1InFlight(), getInFlightCycle1(db)]
|
|
2635
848
|
.filter(p => p && !drainedCycle1Promises.has(p))
|
|
2636
849
|
if (pendingCycle1Promises.length === 0) break
|
|
2637
850
|
for (const pendingCycle1 of pendingCycle1Promises) {
|
|
@@ -2951,11 +1164,57 @@ async function handleMemoryAction(args, signal) {
|
|
|
2951
1164
|
|
|
2952
1165
|
if (action === 'core') {
|
|
2953
1166
|
const op = String(args.op ?? '').trim().toLowerCase()
|
|
2954
|
-
if (!['add', 'edit', 'delete', 'list'].includes(op)) {
|
|
2955
|
-
return { text: 'core requires op: "add" | "edit" | "delete" | "list"', isError: true }
|
|
1167
|
+
if (!['add', 'edit', 'delete', 'list', 'candidates', 'promote', 'dismiss'].includes(op)) {
|
|
1168
|
+
return { text: 'core requires op: "add" | "edit" | "delete" | "list" | "candidates" | "promote" | "dismiss"', isError: true }
|
|
2956
1169
|
}
|
|
2957
1170
|
const dataDir = (typeof DATA_DIR === 'string' ? DATA_DIR : resolvePluginData())
|
|
2958
1171
|
if (!dataDir) return { text: 'core: memory data dir is not initialized', isError: true }
|
|
1172
|
+
// Core-candidate promotion pipeline (proposal mode). The candidate flag
|
|
1173
|
+
// lives on generated `entries`, which carry a project_id, so these ops MUST
|
|
1174
|
+
// be project-scoped just like add/edit/delete — an unscoped listing/promote
|
|
1175
|
+
// would leak candidates across projects. project_id resolution mirrors the
|
|
1176
|
+
// block below: 'common'/null → COMMON (project_id NULL), '*' → all pools
|
|
1177
|
+
// (candidates op only, same escape hatch as op:'list'). UI calls exactly
|
|
1178
|
+
// these op names.
|
|
1179
|
+
if (op === 'candidates' || op === 'promote' || op === 'dismiss') {
|
|
1180
|
+
const hasPid = Object.prototype.hasOwnProperty.call(args, 'project_id')
|
|
1181
|
+
const scope = (() => {
|
|
1182
|
+
if (!hasPid || args.project_id == null) return null
|
|
1183
|
+
const s = String(args.project_id).trim()
|
|
1184
|
+
if (s === '' || s.toLowerCase() === 'common') return null
|
|
1185
|
+
if (s === '*') return '*'
|
|
1186
|
+
return s
|
|
1187
|
+
})()
|
|
1188
|
+
try {
|
|
1189
|
+
if (op === 'candidates') {
|
|
1190
|
+
const list = await listCoreCandidates(dataDir, scope)
|
|
1191
|
+
if (list.length === 0) return { text: 'core candidates: none' }
|
|
1192
|
+
return {
|
|
1193
|
+
text: list.map(c =>
|
|
1194
|
+
// project=<pool> lets the UI thread project_id into the follow-up
|
|
1195
|
+
// promote/dismiss call — matters under project_id:'*' listing where
|
|
1196
|
+
// rows span pools. Uses the same COMMON/slug convention as op:'list'.
|
|
1197
|
+
`id=${c.id} project=${c.project_id == null ? 'COMMON' : c.project_id} [${c.category}] score=${c.score == null ? '-' : c.score.toFixed(2)} ${c.element} — ${String(c.summary || '').slice(0, 200)} (${c.reason})`,
|
|
1198
|
+
).join('\n'),
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
// promote/dismiss operate on a single id but are scope-guarded: the
|
|
1202
|
+
// candidate must belong to the resolved scope (or COMMON), never '*'.
|
|
1203
|
+
if (scope === '*') {
|
|
1204
|
+
return { text: `core ${op}: project_id "*" only valid for op="candidates"`, isError: true }
|
|
1205
|
+
}
|
|
1206
|
+
if (op === 'promote') {
|
|
1207
|
+
const entry = await promoteCoreCandidate(dataDir, args.id, { ...args, scope })
|
|
1208
|
+
const mergeNote = entry.merged_with ? ` (merged into core id=${entry.merged_with}, sim=${entry.sim})` : ''
|
|
1209
|
+
return { text: `core promoted candidate id=${args.id} → core id=${entry.id}${mergeNote}: [${entry.category}] ${entry.element}` }
|
|
1210
|
+
}
|
|
1211
|
+
// dismiss
|
|
1212
|
+
const removed = await dismissCoreCandidate(dataDir, args.id, { scope })
|
|
1213
|
+
return { text: `core candidate dismissed (id=${removed.id}): [${removed.category}] ${removed.element}` }
|
|
1214
|
+
} catch (e) {
|
|
1215
|
+
return { text: `core ${op} failed: ${e.message}`, isError: true }
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
2959
1218
|
// Local trim helper — the manage-block trimOrNull at :1807 is scoped to
|
|
2960
1219
|
// that branch and unreachable from here.
|
|
2961
1220
|
// Normalize project_id: 'common' (case-insensitive) or null → null (COMMON pool); non-empty string → slug.
|
|
@@ -3190,6 +1449,9 @@ async function handleToolCall(name, args, signal) {
|
|
|
3190
1449
|
...(a.sessionId ? { sessionId: a.sessionId } : {}),
|
|
3191
1450
|
...(a.session_id ? { session_id: a.session_id } : {}),
|
|
3192
1451
|
...(a.currentSession !== undefined ? { currentSession: a.currentSession } : {}),
|
|
1452
|
+
// Hint only — never a filter. Marks the caller's own session as
|
|
1453
|
+
// "(current)" in the multi-session grouped browse output.
|
|
1454
|
+
...(a.currentSessionId ? { currentSessionId: a.currentSessionId } : {}),
|
|
3193
1455
|
}
|
|
3194
1456
|
const result = await handleSearch(searchArgs, signal)
|
|
3195
1457
|
return { ...result, content: [{ type: 'text', text: result.text }], isError: result.isError || false }
|
|
@@ -3222,37 +1484,6 @@ function createHttpMcpServer() {
|
|
|
3222
1484
|
return s
|
|
3223
1485
|
}
|
|
3224
1486
|
|
|
3225
|
-
function readBody(req) {
|
|
3226
|
-
return new Promise((resolve, reject) => {
|
|
3227
|
-
const chunks = []
|
|
3228
|
-
req.on('data', c => chunks.push(c))
|
|
3229
|
-
req.on('end', () => {
|
|
3230
|
-
const raw = Buffer.concat(chunks).toString('utf8').trim()
|
|
3231
|
-
if (!raw) { resolve({}); return }
|
|
3232
|
-
try { resolve(JSON.parse(raw)) }
|
|
3233
|
-
catch (error) {
|
|
3234
|
-
const e = new Error(`invalid JSON body: ${error.message}`)
|
|
3235
|
-
e.statusCode = 400
|
|
3236
|
-
reject(e)
|
|
3237
|
-
}
|
|
3238
|
-
})
|
|
3239
|
-
req.on('error', reject)
|
|
3240
|
-
})
|
|
3241
|
-
}
|
|
3242
|
-
|
|
3243
|
-
function sendJson(res, data, status = 200) {
|
|
3244
|
-
const body = JSON.stringify(data)
|
|
3245
|
-
res.writeHead(status, {
|
|
3246
|
-
'Content-Type': 'application/json; charset=utf-8',
|
|
3247
|
-
'Content-Length': Buffer.byteLength(body),
|
|
3248
|
-
})
|
|
3249
|
-
res.end(body)
|
|
3250
|
-
}
|
|
3251
|
-
|
|
3252
|
-
function sendError(res, msg, status = 500) {
|
|
3253
|
-
sendJson(res, { error: msg }, status)
|
|
3254
|
-
}
|
|
3255
|
-
|
|
3256
1487
|
async function awaitRuntimeReadyForHttp(res) {
|
|
3257
1488
|
if (_initialized) return true
|
|
3258
1489
|
if (!_initPromise) {
|
|
@@ -3268,29 +1499,6 @@ async function awaitRuntimeReadyForHttp(res) {
|
|
|
3268
1499
|
}
|
|
3269
1500
|
}
|
|
3270
1501
|
|
|
3271
|
-
// Origin/Referer guard for /admin/* mutation routes. Memory-service binds
|
|
3272
|
-
// 127.0.0.1, but browser DNS-rebinding or a stray cross-origin fetch could
|
|
3273
|
-
// still reach destructive endpoints (purge, backfill, entry mutations).
|
|
3274
|
-
// Server-to-server callers (setup-server, hooks) issue raw http.request
|
|
3275
|
-
// without a browser Origin/Referer, so absent headers pass; any non-loopback
|
|
3276
|
-
// Origin/Referer is rejected. Mirrors setup-server.mjs isAllowedOrigin.
|
|
3277
|
-
function isLocalOrigin(req) {
|
|
3278
|
-
const LOOP = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?(\/|$)/i
|
|
3279
|
-
const origin = req.headers.origin || ''
|
|
3280
|
-
const referer = req.headers.referer || ''
|
|
3281
|
-
if (origin && !LOOP.test(origin)) return false
|
|
3282
|
-
if (referer && !LOOP.test(referer)) return false
|
|
3283
|
-
return true
|
|
3284
|
-
}
|
|
3285
|
-
|
|
3286
|
-
function normalizeCoreProjectId(value, { allowStar = false } = {}) {
|
|
3287
|
-
if (value == null) return null
|
|
3288
|
-
const s = String(value).trim()
|
|
3289
|
-
if (!s || s.toLowerCase() === 'common') return null
|
|
3290
|
-
if (allowStar && s === '*') return '*'
|
|
3291
|
-
return s
|
|
3292
|
-
}
|
|
3293
|
-
|
|
3294
1502
|
async function buildSessionCoreMemoryPayload(cwd) {
|
|
3295
1503
|
const projectId = resolveProjectScope(typeof cwd === 'string' && cwd ? cwd : null)
|
|
3296
1504
|
const generatedScopeClause = projectId !== null
|
|
@@ -3373,6 +1581,9 @@ const httpServer = http.createServer(async (req, res) => {
|
|
|
3373
1581
|
active_core_summaries: stats.active_core_summaries,
|
|
3374
1582
|
active_core_summary_missing: stats.active_core_summary_missing,
|
|
3375
1583
|
mv_hot_active_populated: stats.mv_hot_active_populated,
|
|
1584
|
+
cycle_running: _cycleScheduler.getCycleRunning(),
|
|
1585
|
+
cycle_health: _cycleScheduler.getCycleHealth(),
|
|
1586
|
+
cycle_backlog: _cycleScheduler.getCycleBacklogSnapshot(),
|
|
3376
1587
|
})
|
|
3377
1588
|
} catch (e) { sendError(res, e.message) }
|
|
3378
1589
|
return
|
|
@@ -3956,7 +2167,7 @@ const httpServer = http.createServer(async (req, res) => {
|
|
|
3956
2167
|
}
|
|
3957
2168
|
const fileSize = stat.size
|
|
3958
2169
|
await ingestTranscriptFile(filePath, { cwd: body.cwd })
|
|
3959
|
-
const off =
|
|
2170
|
+
const off = _transcriptIngest.getOffset(filePath)
|
|
3960
2171
|
const offsetBytes = off && Number.isFinite(off.bytes) ? off.bytes : 0
|
|
3961
2172
|
const complete = offsetBytes >= fileSize
|
|
3962
2173
|
sendJson(res, { ok: true, offsetBytes, fileSize, complete })
|
|
@@ -4018,7 +2229,7 @@ export async function stop() {
|
|
|
4018
2229
|
}
|
|
4019
2230
|
_periodicAdvertiseInstalled = false
|
|
4020
2231
|
_currentAdvertisedPort = null
|
|
4021
|
-
|
|
2232
|
+
_embeddingWarmup.reset()
|
|
4022
2233
|
if (_idleShutdownTimer) {
|
|
4023
2234
|
try { clearTimeout(_idleShutdownTimer) } catch {}
|
|
4024
2235
|
_idleShutdownTimer = null
|
|
@@ -4096,11 +2307,8 @@ export async function stop() {
|
|
|
4096
2307
|
_initialized = false
|
|
4097
2308
|
_initPromise = null
|
|
4098
2309
|
_bootTimestamp = null
|
|
4099
|
-
|
|
4100
|
-
|
|
4101
|
-
_cycle2InFlight = false
|
|
4102
|
-
_cycle3InFlight = false
|
|
4103
|
-
_checkCyclesInFlight = false
|
|
2310
|
+
_transcriptIngest.resetOffsets()
|
|
2311
|
+
_cycleScheduler.resetInFlight()
|
|
4104
2312
|
releaseLock()
|
|
4105
2313
|
})().finally(() => {
|
|
4106
2314
|
_stopPromise = null
|