mixdog 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +31 -0
- package/.claude-plugin/plugin.json +20 -0
- package/.gitattributes +34 -0
- package/.mcp.json +14 -0
- package/ARCHITECTURE.md +77 -0
- package/CHANGELOG.md +7 -0
- package/CONTRIBUTING.md +45 -0
- package/DATA-FLOW.md +79 -0
- package/LICENSE +21 -0
- package/README.md +389 -0
- package/SECURITY.md +138 -0
- package/UNINSTALL.md +112 -0
- package/agents/maintenance.md +5 -0
- package/agents/memory-classification.md +30 -0
- package/agents/scheduler-task.md +18 -0
- package/agents/webhook-handler.md +27 -0
- package/agents/worker.md +24 -0
- package/bin/bridge +133 -0
- package/bin/statusline-launcher.mjs +78 -0
- package/bin/statusline-lib.mjs +550 -0
- package/bin/statusline.mjs +607 -0
- package/bun.lock +802 -0
- package/commands/config.md +16 -0
- package/commands/doctor.md +13 -0
- package/commands/setup.md +17 -0
- package/defaults/cycle3-review-prompt.md +90 -0
- package/defaults/hidden-roles.json +65 -0
- package/defaults/memory-chunk-prompt.md +63 -0
- package/defaults/memory-promote-prompt.md +135 -0
- package/defaults/mixdog-config.template.json +27 -0
- package/defaults/user-workflow.json +8 -0
- package/defaults/user-workflow.md +12 -0
- package/hooks/hooks.json +73 -0
- package/hooks/lib/active-instance.cjs +77 -0
- package/hooks/lib/permission-evaluator.cjs +411 -0
- package/hooks/lib/permission-route.cjs +63 -0
- package/hooks/lib/permission-rules.cjs +170 -0
- package/hooks/lib/settings-loader.cjs +116 -0
- package/hooks/post-tool-use.cjs +84 -0
- package/hooks/pre-mcp-sandbox.cjs +158 -0
- package/hooks/pre-tool-subagent.cjs +253 -0
- package/hooks/session-start.cjs +1372 -0
- package/hooks/turn-timer.cjs +82 -0
- package/lib/claude-md-writer.cjs +386 -0
- package/lib/config-cjs.cjs +61 -0
- package/lib/hook-pipe-path.cjs +10 -0
- package/lib/keychain-cjs.cjs +263 -0
- package/lib/plugin-paths.cjs +61 -0
- package/lib/rules-builder.cjs +241 -0
- package/lib/text-utils.cjs +61 -0
- package/native/README.md +117 -0
- package/native/prebuilt/linux-aarch64/mixdog-shim +0 -0
- package/native/prebuilt/linux-x86_64/mixdog-shim +0 -0
- package/native/prebuilt/macos-aarch64/mixdog-shim +0 -0
- package/native/prebuilt/macos-x86_64/mixdog-shim +0 -0
- package/native/prebuilt/windows-x86_64/mixdog-shim.exe +0 -0
- package/package.json +107 -0
- package/prompts/code-review.txt +16 -0
- package/prompts/security-audit.txt +17 -0
- package/rules/bridge/00-common.md +39 -0
- package/rules/bridge/20-skip-protocol.md +18 -0
- package/rules/bridge/30-explorer.md +33 -0
- package/rules/bridge/40-cycle1-agent.md +52 -0
- package/rules/bridge/41-cycle2-agent.md +62 -0
- package/rules/bridge/42-cycle3-agent.md +44 -0
- package/rules/lead/00-tool-lead.md +61 -0
- package/rules/lead/01-general.md +23 -0
- package/rules/lead/02-channels.md +49 -0
- package/rules/lead/03-team.md +27 -0
- package/rules/lead/04-workflow.md +20 -0
- package/rules/shared/00-language.md +14 -0
- package/rules/shared/01-tool.md +138 -0
- package/scripts/bootstrap.mjs +184 -0
- package/scripts/bridge-unify-smoke.mjs +308 -0
- package/scripts/build-runtime-linux.sh +348 -0
- package/scripts/build-runtime-macos.sh +217 -0
- package/scripts/build-runtime-windows.ps1 +242 -0
- package/scripts/builtin-utils-smoke.mjs +392 -0
- package/scripts/check-json.mjs +45 -0
- package/scripts/check-syntax-changed.mjs +102 -0
- package/scripts/check-syntax.mjs +58 -0
- package/scripts/code-graph-batch.test.mjs +33 -0
- package/scripts/config-preserve-smoke.mjs +180 -0
- package/scripts/doctor.mjs +484 -0
- package/scripts/edit-normalize-fuzz.mjs +130 -0
- package/scripts/edit-normalize-smoke.mjs +401 -0
- package/scripts/edit-operation-smoke.mjs +369 -0
- package/scripts/edit2-smoke.mjs +63 -0
- package/scripts/fuzzy-e2e.mjs +28 -0
- package/scripts/fuzzy-smoke.mjs +26 -0
- package/scripts/generate-runtime-manifest.mjs +166 -0
- package/scripts/guard-smoke.mjs +66 -0
- package/scripts/hidden-role-schema-smoke.mjs +162 -0
- package/scripts/hook-routing-smoke.mjs +29 -0
- package/scripts/inject-input.ps1 +204 -0
- package/scripts/io-complex-smoke.mjs +667 -0
- package/scripts/io-explore-bench.mjs +424 -0
- package/scripts/io-guardrails-smoke.mjs +205 -0
- package/scripts/io-mini-bench-baseline.json +11 -0
- package/scripts/io-mini-bench.mjs +216 -0
- package/scripts/io-route-harness.mjs +933 -0
- package/scripts/io-telemetry-report.mjs +691 -0
- package/scripts/mutation-bench.mjs +564 -0
- package/scripts/mutation-io-smoke.mjs +1081 -0
- package/scripts/native-patch-bridge-smoke.mjs +288 -0
- package/scripts/native-patch-smoke.mjs +304 -0
- package/scripts/patch-interior-context-smoke.mjs +49 -0
- package/scripts/patch-newline-utf8-smoke.mjs +157 -0
- package/scripts/perf-hook-smoke.mjs +71 -0
- package/scripts/permission-eval-smoke.mjs +426 -0
- package/scripts/prep-patch.mjs +53 -0
- package/scripts/prep-shim.mjs +96 -0
- package/scripts/provider-cache-smoke.mjs +687 -0
- package/scripts/report-runtime-health.mjs +132 -0
- package/scripts/run-mcp.mjs +1547 -0
- package/scripts/salvage-v4a-shatter.test.mjs +58 -0
- package/scripts/scoped-cache-io-smoke.mjs +103 -0
- package/scripts/shell-policy-round3-smoke.mjs +46 -0
- package/scripts/smoke-runtime-negative.ps1 +100 -0
- package/scripts/smoke-runtime-negative.sh +95 -0
- package/scripts/stall-policy-smoke.mjs +50 -0
- package/scripts/start-memory-worker.mjs +23 -0
- package/scripts/statusline-launcher-smoke.mjs +82 -0
- package/scripts/stress-atomic-write.mjs +1028 -0
- package/scripts/test-config-rmw-restore.mjs +122 -0
- package/scripts/test-fault-inject.mjs +164 -0
- package/scripts/test-large-file.mjs +174 -0
- package/scripts/tool-edge-smoke.mjs +209 -0
- package/scripts/uninstall.mjs +201 -0
- package/scripts/webhook-selfheal-smoke.mjs +29 -0
- package/scripts/write-overwrite-guard-smoke.mjs +56 -0
- package/server-main.mjs +3055 -0
- package/server.mjs +468 -0
- package/setup/config-merge.mjs +254 -0
- package/setup/install.mjs +120 -0
- package/setup/launch-core.mjs +507 -0
- package/setup/launch.mjs +101 -0
- package/setup/setup-server.mjs +3206 -0
- package/setup/setup.html +3693 -0
- package/skills/retro-skill-proposer/SKILL.md +92 -0
- package/skills/schedule-add/SKILL.md +77 -0
- package/skills/setup/SKILL.md +346 -0
- package/skills/webhook-add/SKILL.md +81 -0
- package/src/agent/bridge-stall-watchdog.mjs +337 -0
- package/src/agent/index.mjs +2138 -0
- package/src/agent/orchestrator/activity-bus.mjs +38 -0
- package/src/agent/orchestrator/ai-wrapped-dispatch.mjs +1010 -0
- package/src/agent/orchestrator/bridge-retry.mjs +220 -0
- package/src/agent/orchestrator/bridge-trace.mjs +583 -0
- package/src/agent/orchestrator/cache-mtime.mjs +58 -0
- package/src/agent/orchestrator/config.mjs +358 -0
- package/src/agent/orchestrator/context/collect.mjs +651 -0
- package/src/agent/orchestrator/dispatch-persist.mjs +549 -0
- package/src/agent/orchestrator/drain-registry.mjs +50 -0
- package/src/agent/orchestrator/explore-validator.mjs +8 -0
- package/src/agent/orchestrator/internal-roles.mjs +118 -0
- package/src/agent/orchestrator/internal-tools.mjs +88 -0
- package/src/agent/orchestrator/jobs.mjs +116 -0
- package/src/agent/orchestrator/mcp/client.mjs +364 -0
- package/src/agent/orchestrator/providers/anthropic-betas.mjs +21 -0
- package/src/agent/orchestrator/providers/anthropic-oauth.mjs +1745 -0
- package/src/agent/orchestrator/providers/anthropic.mjs +437 -0
- package/src/agent/orchestrator/providers/gemini.mjs +1175 -0
- package/src/agent/orchestrator/providers/grok-oauth.mjs +782 -0
- package/src/agent/orchestrator/providers/model-catalog.mjs +241 -0
- package/src/agent/orchestrator/providers/openai-compat.mjs +1467 -0
- package/src/agent/orchestrator/providers/openai-oauth-ws.mjs +1890 -0
- package/src/agent/orchestrator/providers/openai-oauth.mjs +1307 -0
- package/src/agent/orchestrator/providers/openai-ws.mjs +104 -0
- package/src/agent/orchestrator/providers/registry.mjs +192 -0
- package/src/agent/orchestrator/providers/retry-classifier.mjs +325 -0
- package/src/agent/orchestrator/session/abort-lookup.mjs +13 -0
- package/src/agent/orchestrator/session/cache/post-edit-marks.mjs +42 -0
- package/src/agent/orchestrator/session/cache/prefetch-cache.mjs +142 -0
- package/src/agent/orchestrator/session/cache/read-cache.mjs +319 -0
- package/src/agent/orchestrator/session/cache/scoped-cache-outcome.mjs +11 -0
- package/src/agent/orchestrator/session/cache/scoped-cache.mjs +361 -0
- package/src/agent/orchestrator/session/cache/util.mjs +49 -0
- package/src/agent/orchestrator/session/loop.mjs +1478 -0
- package/src/agent/orchestrator/session/manager.mjs +1975 -0
- package/src/agent/orchestrator/session/read-dedup.mjs +6 -0
- package/src/agent/orchestrator/session/result-classification.mjs +65 -0
- package/src/agent/orchestrator/session/save-session-worker.mjs +18 -0
- package/src/agent/orchestrator/session/store.mjs +624 -0
- package/src/agent/orchestrator/session/stream-watchdog.mjs +130 -0
- package/src/agent/orchestrator/session/tool-result-offload.mjs +166 -0
- package/src/agent/orchestrator/session/trim.mjs +491 -0
- package/src/agent/orchestrator/smart-bridge/CACHE-SHARD.md +115 -0
- package/src/agent/orchestrator/smart-bridge/bridge-llm.mjs +327 -0
- package/src/agent/orchestrator/smart-bridge/cache-obs.mjs +150 -0
- package/src/agent/orchestrator/smart-bridge/cache-strategy.mjs +228 -0
- package/src/agent/orchestrator/smart-bridge/index.mjs +215 -0
- package/src/agent/orchestrator/smart-bridge/profiles.mjs +37 -0
- package/src/agent/orchestrator/smart-bridge/registry.mjs +348 -0
- package/src/agent/orchestrator/smart-bridge/session-builder.mjs +116 -0
- package/src/agent/orchestrator/stall-policy.mjs +195 -0
- package/src/agent/orchestrator/tool-loop-guard.mjs +75 -0
- package/src/agent/orchestrator/tools/bash-policy-scan.mjs +77 -0
- package/src/agent/orchestrator/tools/bash-session.mjs +721 -0
- package/src/agent/orchestrator/tools/builtin/advisory-lock.mjs +171 -0
- package/src/agent/orchestrator/tools/builtin/arg-guard.mjs +455 -0
- package/src/agent/orchestrator/tools/builtin/atomic-write.mjs +236 -0
- package/src/agent/orchestrator/tools/builtin/bash-tool.mjs +480 -0
- package/src/agent/orchestrator/tools/builtin/binary-file.mjs +76 -0
- package/src/agent/orchestrator/tools/builtin/builtin-tools.mjs +256 -0
- package/src/agent/orchestrator/tools/builtin/cache-layers.mjs +386 -0
- package/src/agent/orchestrator/tools/builtin/cwd-utils.mjs +37 -0
- package/src/agent/orchestrator/tools/builtin/device-paths.mjs +154 -0
- package/src/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +292 -0
- package/src/agent/orchestrator/tools/builtin/diff-utils.mjs +109 -0
- package/src/agent/orchestrator/tools/builtin/edit-base-guard.mjs +58 -0
- package/src/agent/orchestrator/tools/builtin/edit-byte-plan.mjs +240 -0
- package/src/agent/orchestrator/tools/builtin/edit-byte-utils.mjs +113 -0
- package/src/agent/orchestrator/tools/builtin/edit-commit.mjs +74 -0
- package/src/agent/orchestrator/tools/builtin/edit-context-utils.mjs +242 -0
- package/src/agent/orchestrator/tools/builtin/edit-diagnostics.mjs +211 -0
- package/src/agent/orchestrator/tools/builtin/edit-engine.mjs +1364 -0
- package/src/agent/orchestrator/tools/builtin/edit-failure-context.mjs +126 -0
- package/src/agent/orchestrator/tools/builtin/edit-hint.mjs +141 -0
- package/src/agent/orchestrator/tools/builtin/edit-match-utils.mjs +194 -0
- package/src/agent/orchestrator/tools/builtin/edit-partial-write.mjs +60 -0
- package/src/agent/orchestrator/tools/builtin/edit-stale-refresh.mjs +168 -0
- package/src/agent/orchestrator/tools/builtin/edit-tool.mjs +173 -0
- package/src/agent/orchestrator/tools/builtin/edit-utf8-guard.mjs +48 -0
- package/src/agent/orchestrator/tools/builtin/fs-reachability.mjs +48 -0
- package/src/agent/orchestrator/tools/builtin/fuzzy-match.mjs +99 -0
- package/src/agent/orchestrator/tools/builtin/glob-walk.mjs +170 -0
- package/src/agent/orchestrator/tools/builtin/grep-formatting.mjs +113 -0
- package/src/agent/orchestrator/tools/builtin/hash-utils.mjs +6 -0
- package/src/agent/orchestrator/tools/builtin/list-formatting.mjs +7 -0
- package/src/agent/orchestrator/tools/builtin/list-tool.mjs +593 -0
- package/src/agent/orchestrator/tools/builtin/native-edit-runner.mjs +89 -0
- package/src/agent/orchestrator/tools/builtin/notebook-edit-tool.mjs +300 -0
- package/src/agent/orchestrator/tools/builtin/open-config-tool.mjs +26 -0
- package/src/agent/orchestrator/tools/builtin/path-diagnostics.mjs +152 -0
- package/src/agent/orchestrator/tools/builtin/path-locks.mjs +35 -0
- package/src/agent/orchestrator/tools/builtin/path-utils.mjs +201 -0
- package/src/agent/orchestrator/tools/builtin/read-args.mjs +103 -0
- package/src/agent/orchestrator/tools/builtin/read-batch.mjs +172 -0
- package/src/agent/orchestrator/tools/builtin/read-constants.mjs +40 -0
- package/src/agent/orchestrator/tools/builtin/read-formatting.mjs +118 -0
- package/src/agent/orchestrator/tools/builtin/read-image-resize.mjs +189 -0
- package/src/agent/orchestrator/tools/builtin/read-image.mjs +88 -0
- package/src/agent/orchestrator/tools/builtin/read-lines.mjs +12 -0
- package/src/agent/orchestrator/tools/builtin/read-mode-tool.mjs +455 -0
- package/src/agent/orchestrator/tools/builtin/read-open.mjs +190 -0
- package/src/agent/orchestrator/tools/builtin/read-range-index.mjs +271 -0
- package/src/agent/orchestrator/tools/builtin/read-ranges.mjs +26 -0
- package/src/agent/orchestrator/tools/builtin/read-single-tool.mjs +728 -0
- package/src/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +173 -0
- package/src/agent/orchestrator/tools/builtin/read-special-files.mjs +268 -0
- package/src/agent/orchestrator/tools/builtin/read-streaming.mjs +602 -0
- package/src/agent/orchestrator/tools/builtin/read-tool.mjs +530 -0
- package/src/agent/orchestrator/tools/builtin/read-windows.mjs +107 -0
- package/src/agent/orchestrator/tools/builtin/rename-tool.mjs +196 -0
- package/src/agent/orchestrator/tools/builtin/rg-runner.mjs +422 -0
- package/src/agent/orchestrator/tools/builtin/search-builders.mjs +158 -0
- package/src/agent/orchestrator/tools/builtin/search-tool.mjs +869 -0
- package/src/agent/orchestrator/tools/builtin/shell-analysis.mjs +653 -0
- package/src/agent/orchestrator/tools/builtin/shell-jobs.mjs +936 -0
- package/src/agent/orchestrator/tools/builtin/shell-output.mjs +36 -0
- package/src/agent/orchestrator/tools/builtin/shell-runtime.mjs +214 -0
- package/src/agent/orchestrator/tools/builtin/snapshot-helpers.mjs +143 -0
- package/src/agent/orchestrator/tools/builtin/snapshot-store.mjs +206 -0
- package/src/agent/orchestrator/tools/builtin/snapshot-validation.mjs +98 -0
- package/src/agent/orchestrator/tools/builtin/text-stats.mjs +69 -0
- package/src/agent/orchestrator/tools/builtin/windows-roots.mjs +23 -0
- package/src/agent/orchestrator/tools/builtin/write-tool.mjs +401 -0
- package/src/agent/orchestrator/tools/builtin.mjs +500 -0
- package/src/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +39 -0
- package/src/agent/orchestrator/tools/code-graph-tool-defs.mjs +24 -0
- package/src/agent/orchestrator/tools/code-graph.mjs +4095 -0
- package/src/agent/orchestrator/tools/cwd-tool.mjs +298 -0
- package/src/agent/orchestrator/tools/destructive-warning.mjs +323 -0
- package/src/agent/orchestrator/tools/edit-normalize.mjs +603 -0
- package/src/agent/orchestrator/tools/env-scrub.mjs +100 -0
- package/src/agent/orchestrator/tools/graph-binary-fetcher.mjs +144 -0
- package/src/agent/orchestrator/tools/graph-manifest.json +26 -0
- package/src/agent/orchestrator/tools/host-input.mjs +204 -0
- package/src/agent/orchestrator/tools/mutation-content-cache.mjs +67 -0
- package/src/agent/orchestrator/tools/mutation-planner.mjs +75 -0
- package/src/agent/orchestrator/tools/next-call-utils.mjs +48 -0
- package/src/agent/orchestrator/tools/patch-binary-fetcher.mjs +133 -0
- package/src/agent/orchestrator/tools/patch-manifest.json +26 -0
- package/src/agent/orchestrator/tools/patch-tool-defs.mjs +20 -0
- package/src/agent/orchestrator/tools/patch.mjs +2754 -0
- package/src/agent/orchestrator/tools/progress-message.mjs +118 -0
- package/src/agent/orchestrator/tools/result-compression.mjs +279 -0
- package/src/agent/orchestrator/tools/shell-command.mjs +865 -0
- package/src/agent/orchestrator/tools/shell-exec-policy.mjs +89 -0
- package/src/agent/orchestrator/tools/shell-policy-danger-target.mjs +27 -0
- package/src/agent/orchestrator/tools/shell-policy-imports.mjs +7 -0
- package/src/agent/orchestrator/tools/shell-policy.mjs +345 -0
- package/src/agent/orchestrator/tools/shell-snapshot.mjs +313 -0
- package/src/agent/orchestrator/workflow-store.mjs +93 -0
- package/src/agent/tool-defs.mjs +103 -0
- package/src/channels/backends/discord.mjs +784 -0
- package/src/channels/data/voice-runtime-manifest.json +138 -0
- package/src/channels/index.mjs +3229 -0
- package/src/channels/lib/cli-worker-host.mjs +12 -0
- package/src/channels/lib/config-lock.mjs +13 -0
- package/src/channels/lib/config.mjs +292 -0
- package/src/channels/lib/drop-trace.mjs +71 -0
- package/src/channels/lib/event-pipeline.mjs +81 -0
- package/src/channels/lib/event-queue.mjs +345 -0
- package/src/channels/lib/executor.mjs +168 -0
- package/src/channels/lib/format.mjs +188 -0
- package/src/channels/lib/holidays.mjs +138 -0
- package/src/channels/lib/hook-pipe-server.mjs +802 -0
- package/src/channels/lib/interaction-workflows.mjs +184 -0
- package/src/channels/lib/memory-client.mjs +149 -0
- package/src/channels/lib/output-forwarder.mjs +765 -0
- package/src/channels/lib/runtime-paths.mjs +479 -0
- package/src/channels/lib/scheduler.mjs +723 -0
- package/src/channels/lib/session-control.mjs +36 -0
- package/src/channels/lib/session-discovery.mjs +103 -0
- package/src/channels/lib/settings.mjs +11 -0
- package/src/channels/lib/state-file.mjs +68 -0
- package/src/channels/lib/status-snapshot.mjs +219 -0
- package/src/channels/lib/tool-format.mjs +140 -0
- package/src/channels/lib/transcript-discovery.mjs +195 -0
- package/src/channels/lib/voice-runtime-fetcher.mjs +734 -0
- package/src/channels/lib/webhook.mjs +1179 -0
- package/src/channels/lib/whisper-server.mjs +477 -0
- package/src/channels/tool-defs.mjs +170 -0
- package/src/daemon/host.mjs +118 -0
- package/src/daemon/mcp-transport.mjs +47 -0
- package/src/daemon/session.mjs +100 -0
- package/src/daemon/thin-client.mjs +71 -0
- package/src/daemon/transport.mjs +163 -0
- package/src/memory/data/runtime-manifest.json +40 -0
- package/src/memory/index.mjs +3305 -0
- package/src/memory/lib/agent-ipc.mjs +93 -0
- package/src/memory/lib/bridge-trace-queries.mjs +120 -0
- package/src/memory/lib/core-memory-store.mjs +330 -0
- package/src/memory/lib/embedding-provider.mjs +269 -0
- package/src/memory/lib/embedding-worker.mjs +323 -0
- package/src/memory/lib/llm-worker-host.mjs +17 -0
- package/src/memory/lib/memory-cycle.mjs +11 -0
- package/src/memory/lib/memory-cycle1.mjs +641 -0
- package/src/memory/lib/memory-cycle2.mjs +1284 -0
- package/src/memory/lib/memory-cycle3.mjs +540 -0
- package/src/memory/lib/memory-embed.mjs +299 -0
- package/src/memory/lib/memory-extraction.mjs +5 -0
- package/src/memory/lib/memory-maintenance-store.mjs +32 -0
- package/src/memory/lib/memory-ops-policy.mjs +190 -0
- package/src/memory/lib/memory-recall-id-patch.mjs +15 -0
- package/src/memory/lib/memory-recall-read-query.mjs +7 -0
- package/src/memory/lib/memory-recall-scope-filter.mjs +63 -0
- package/src/memory/lib/memory-recall-store.mjs +621 -0
- package/src/memory/lib/memory-retrievers.mjs +112 -0
- package/src/memory/lib/memory-score.mjs +71 -0
- package/src/memory/lib/memory-text-utils.mjs +58 -0
- package/src/memory/lib/memory.mjs +412 -0
- package/src/memory/lib/model-profile.mjs +85 -0
- package/src/memory/lib/pg/adapter.mjs +308 -0
- package/src/memory/lib/pg/process.mjs +360 -0
- package/src/memory/lib/pg/supervisor.mjs +396 -0
- package/src/memory/lib/project-id-resolver.mjs +86 -0
- package/src/memory/lib/runtime-fetcher.mjs +442 -0
- package/src/memory/lib/trace-store.mjs +728 -0
- package/src/memory/tool-defs.mjs +79 -0
- package/src/search/index.mjs +1173 -0
- package/src/search/lib/backends/anthropic-oauth.mjs +98 -0
- package/src/search/lib/backends/exa.mjs +50 -0
- package/src/search/lib/backends/firecrawl.mjs +61 -0
- package/src/search/lib/backends/gemini-api.mjs +83 -0
- package/src/search/lib/backends/grok-oauth.mjs +86 -0
- package/src/search/lib/backends/index.mjs +150 -0
- package/src/search/lib/backends/openai-api.mjs +144 -0
- package/src/search/lib/backends/openai-oauth.mjs +98 -0
- package/src/search/lib/backends/openai-web-search.mjs +76 -0
- package/src/search/lib/backends/tavily.mjs +55 -0
- package/src/search/lib/backends/xai-api.mjs +113 -0
- package/src/search/lib/cache.mjs +131 -0
- package/src/search/lib/config.mjs +192 -0
- package/src/search/lib/formatter.mjs +115 -0
- package/src/search/lib/provider-usage.mjs +67 -0
- package/src/search/lib/providers.mjs +47 -0
- package/src/search/lib/search-intent.mjs +109 -0
- package/src/search/lib/setup-handler.mjs +261 -0
- package/src/search/lib/state.mjs +201 -0
- package/src/search/lib/web-tools.mjs +1207 -0
- package/src/search/tool-defs.mjs +83 -0
- package/src/setup/defender-exclusion.mjs +183 -0
- package/src/shared/abort-controller.mjs +15 -0
- package/src/shared/atomic-file.mjs +420 -0
- package/src/shared/config.mjs +350 -0
- package/src/shared/daemon-recycle.mjs +108 -0
- package/src/shared/disable-claude-builtins.mjs +88 -0
- package/src/shared/err-text.mjs +12 -0
- package/src/shared/llm/cost.mjs +66 -0
- package/src/shared/llm/http-agent.mjs +123 -0
- package/src/shared/llm/index.mjs +41 -0
- package/src/shared/llm/pid-cleanup.mjs +27 -0
- package/src/shared/llm/usage-log.mjs +47 -0
- package/src/shared/plugin-paths.mjs +58 -0
- package/src/shared/schedules-store.mjs +70 -0
- package/src/shared/seed.mjs +119 -0
- package/src/shared/user-cwd.mjs +213 -0
- package/src/shared/user-data-guard.mjs +238 -0
- package/src/status/aggregator.mjs +584 -0
- package/src/status/server.mjs +413 -0
- package/tools.json +1653 -0
|
@@ -0,0 +1,1284 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'fs'
|
|
2
|
+
import { join } from 'path'
|
|
3
|
+
import { homedir as _homedir } from 'os'
|
|
4
|
+
import { resolveMaintenancePreset } from '../../shared/llm/index.mjs'
|
|
5
|
+
import { callBridgeLlm } from './agent-ipc.mjs'
|
|
6
|
+
import {
|
|
7
|
+
syncRootEmbedding, deleteRootEmbedding, flushEmbeddingDirty,
|
|
8
|
+
} from './memory-embed.mjs'
|
|
9
|
+
import { listCore, backfillCoreEmbeddings, CORE_SUMMARY_MAX } from './core-memory-store.mjs'
|
|
10
|
+
|
|
11
|
+
export const CYCLE2_ACTIVE_TARGET_CAP = 100
|
|
12
|
+
const TIER1_THRESHOLD = 0.78
|
|
13
|
+
|
|
14
|
+
const TIER2_LOW = 0.65
|
|
15
|
+
const LLM_JUDGE_CAP = 20
|
|
16
|
+
|
|
17
|
+
function throwIfAborted(signal) {
|
|
18
|
+
if (signal?.aborted) throw signal.reason ?? new Error('aborted')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Status-based verb whitelist. 3-tier policy: pending → active/archived,
|
|
22
|
+
// active → active/archived/update/merge.
|
|
23
|
+
const STATUS_ALLOWED_VERBS = {
|
|
24
|
+
pending: new Set(['active', 'archived']),
|
|
25
|
+
active: new Set(['active', 'archived', 'update', 'merge']),
|
|
26
|
+
}
|
|
27
|
+
const NON_ARCHIVE_VERBS = new Set(['active', 'update', 'merge'])
|
|
28
|
+
// Union of every primary (status) verb across all statuses, plus the two
|
|
29
|
+
// non-verb line kinds. Used by the stray-index shift guard to decide whether
|
|
30
|
+
// a `idx|id|verb` line had a leading row index prepended by the LLM.
|
|
31
|
+
const ALL_PRIMARY_VERBS = new Set(['active', 'archived', 'update', 'merge'])
|
|
32
|
+
const isShiftFollowToken = (tok) => {
|
|
33
|
+
const v = String(tok ?? '').trim().toLowerCase()
|
|
34
|
+
return ALL_PRIMARY_VERBS.has(v) || v === 'why' || v === 'core'
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function resourceDir() {
|
|
38
|
+
if (process.env.CLAUDE_PLUGIN_ROOT) return process.env.CLAUDE_PLUGIN_ROOT
|
|
39
|
+
throw new Error('CLAUDE_PLUGIN_ROOT env var required for prompt loading')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function invokeLlm(prompt, mode, preset, timeout, llmCall = callBridgeLlm) {
|
|
43
|
+
return await llmCall({
|
|
44
|
+
role: 'cycle2-agent',
|
|
45
|
+
taskType: 'maintenance',
|
|
46
|
+
mode,
|
|
47
|
+
preset,
|
|
48
|
+
timeout,
|
|
49
|
+
cwd: null,
|
|
50
|
+
}, prompt)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function buildPidMap(rowSets) {
|
|
54
|
+
const pids = [...new Set(rowSets.flat().map(r => r.project_id).filter(Boolean))].sort()
|
|
55
|
+
return new Map(pids.map((p, i) => [p, `P${i + 1}`]))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function formatEntriesForPromotePrompt(rows, pidMap, opts = {}) {
|
|
59
|
+
if (!rows || rows.length === 0) return '(none)'
|
|
60
|
+
const map = pidMap ?? buildPidMap([rows])
|
|
61
|
+
// When numbered, prefix each row with its 1-based prompt-order ordinal so the
|
|
62
|
+
// gate LLM can echo a row number it can see, instead of inventing one. The
|
|
63
|
+
// ordinal domain (1..N) and the 5-digit batch-id domain must stay disjoint —
|
|
64
|
+
// see the ordinalToId invariant in runUnifiedGate.
|
|
65
|
+
const numbered = opts.numbered === true
|
|
66
|
+
const lines = rows.map((r, i) => {
|
|
67
|
+
const tag = r.project_id ? (map.get(r.project_id) ?? 'C') : 'C'
|
|
68
|
+
const stat = r.status ? `[${r.status}]` : '[?]'
|
|
69
|
+
const prefix = numbered ? `${i + 1}. ` : '- '
|
|
70
|
+
return `${prefix}id:${r.id} ${stat} ${tag} ${r.category} s:${r.score ?? 'n'} el:${r.element} sm:${String(r.summary || '').slice(0, 100)}`
|
|
71
|
+
})
|
|
72
|
+
if (map.size === 0) return lines.join('\n')
|
|
73
|
+
const legend = [...map.entries()].map(([p, t]) => `${t}=${p}`).concat('C=COMMON').join(', ')
|
|
74
|
+
return `# pid: ${legend}\n` + lines.join('\n')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// User-curated rows from core_entries — id-less, no status, no score; the
|
|
78
|
+
// LLM only needs element + summary + project tag to detect overlap with
|
|
79
|
+
// candidate entries below. Format kept terse so the prompt budget stays small.
|
|
80
|
+
function formatUserCoreForPrompt(rows, pidMap) {
|
|
81
|
+
if (!rows || rows.length === 0) return '(none)'
|
|
82
|
+
const map = pidMap ?? new Map()
|
|
83
|
+
return rows.map(r => {
|
|
84
|
+
const tag = r.project_id ? (map.get(r.project_id) ?? 'C') : 'C'
|
|
85
|
+
const sm = String(r.summary || '').slice(0, 200)
|
|
86
|
+
return `- ${tag} ${r.category}: ${r.element}${sm && sm !== r.element ? ` — ${sm}` : ''}`
|
|
87
|
+
}).join('\n')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Parse pipe-format unified verdicts. Each line: <id>|<verb> [|...].
|
|
91
|
+
// Verbs validated against the row's current status via STATUS_ALLOWED_VERBS.
|
|
92
|
+
// Returns { actions, rejected } or null when no parseable lines.
|
|
93
|
+
function parseUnifiedFormat(raw, statusById, ordinalToId = null) {
|
|
94
|
+
if (raw == null) return null
|
|
95
|
+
const text = String(raw).trim()
|
|
96
|
+
if (!text) return { actions: [], rejected: new Set() }
|
|
97
|
+
const lines = text.split('\n')
|
|
98
|
+
const actions = []
|
|
99
|
+
const rejected = new Set()
|
|
100
|
+
const support = new Map()
|
|
101
|
+
let sawValid = false
|
|
102
|
+
// Resolve a first-field/merge token to a real batch id. The gate may echo
|
|
103
|
+
// either the exact 5-digit batch id OR the 1-based row ordinal shown in the
|
|
104
|
+
// numbered Entries block. The two domains are disjoint (asserted in
|
|
105
|
+
// runUnifiedGate), so an exact-id hit always wins and an unmatched value
|
|
106
|
+
// falls back to ordinal lookup; anything else is NaN (line treated invalid).
|
|
107
|
+
const resolveId = (tok) => {
|
|
108
|
+
const n = Number(String(tok ?? '').trim())
|
|
109
|
+
if (!Number.isFinite(n)) return NaN
|
|
110
|
+
if (statusById.has(n)) return n
|
|
111
|
+
if (ordinalToId && ordinalToId.has(n)) return ordinalToId.get(n)
|
|
112
|
+
return NaN
|
|
113
|
+
}
|
|
114
|
+
for (const rawLine of lines) {
|
|
115
|
+
const line = rawLine.trim()
|
|
116
|
+
if (!line) continue
|
|
117
|
+
if (line.startsWith('//') || line.startsWith('#')) continue
|
|
118
|
+
if (line.startsWith('```')) continue
|
|
119
|
+
const parts = line.split('|')
|
|
120
|
+
if (parts.length < 2) continue
|
|
121
|
+
// LLM sometimes prefixes a row index, emitting `idx|id|verdict` instead of
|
|
122
|
+
// `id|verdict`; parts[0] (the index) is a stray token and the line must be
|
|
123
|
+
// shifted before parsing. Strict invariant so a real 2-field `id|verdict`
|
|
124
|
+
// is never shifted into a 1-field line (which would throw on parts[1]):
|
|
125
|
+
// parts.length >= 3 AND parts[1] is a known batch id AND parts[2] is a
|
|
126
|
+
// valid primary verb / why / core (the shifted verdict slot).
|
|
127
|
+
// Trigger when EITHER parts[0] is not a known id (classic stray index) OR
|
|
128
|
+
// parts[0] IS known but parts[1] is not itself a valid verb — that covers
|
|
129
|
+
// `1|1|active`, where the stray index collides with a real batch id and the
|
|
130
|
+
// un-shifted reading would verb-reject the wrong row.
|
|
131
|
+
if (
|
|
132
|
+
parts.length >= 3 &&
|
|
133
|
+
statusById.has(Number(parts[1].trim())) &&
|
|
134
|
+
isShiftFollowToken(parts[2]) &&
|
|
135
|
+
(!statusById.has(Number(parts[0].trim())) || !isShiftFollowToken(parts[1]))
|
|
136
|
+
) {
|
|
137
|
+
parts.shift()
|
|
138
|
+
}
|
|
139
|
+
const entryId = resolveId(parts[0])
|
|
140
|
+
const action = parts[1].trim().toLowerCase()
|
|
141
|
+
if (!Number.isFinite(entryId) || !action) continue
|
|
142
|
+
const status = statusById.get(entryId)
|
|
143
|
+
if (!status) continue
|
|
144
|
+
// Only mark as parse-ok when the id is known to the batch; a response
|
|
145
|
+
// composed entirely of unknown ids would otherwise return parse-ok with
|
|
146
|
+
// zero actions/rejections, leaving the rows un-reviewed and re-queued.
|
|
147
|
+
sawValid = true
|
|
148
|
+
if (action === 'core') {
|
|
149
|
+
actions.push({ entry_id: entryId, action: 'core', core_summary: parts.slice(2).join('|').trim().slice(0, 120) })
|
|
150
|
+
continue
|
|
151
|
+
}
|
|
152
|
+
if (action === 'why') {
|
|
153
|
+
const kind = (parts[2] ?? '').trim().toUpperCase()
|
|
154
|
+
const reason = parts.slice(3).join('|').replace(/\s+/g, ' ').trim().slice(0, 240)
|
|
155
|
+
if ((kind === 'A' || kind === 'B') && reason) {
|
|
156
|
+
support.set(entryId, { kind, reason })
|
|
157
|
+
}
|
|
158
|
+
continue
|
|
159
|
+
}
|
|
160
|
+
const allowed = STATUS_ALLOWED_VERBS[status]
|
|
161
|
+
if (!allowed || !allowed.has(action)) {
|
|
162
|
+
process.stderr.write(`[cycle2] verb rejected: id=${entryId} status=${status} verb=${action}\n`)
|
|
163
|
+
rejected.add(entryId)
|
|
164
|
+
continue
|
|
165
|
+
}
|
|
166
|
+
if (action === 'update') {
|
|
167
|
+
actions.push({
|
|
168
|
+
entry_id: entryId, action,
|
|
169
|
+
element: (parts[2] ?? '').trim(),
|
|
170
|
+
summary: parts.slice(3).join('|').trim(),
|
|
171
|
+
})
|
|
172
|
+
} else if (action === 'merge') {
|
|
173
|
+
const targetId = resolveId(parts[2])
|
|
174
|
+
const sourceIds = [...new Set((parts[3] ?? '').split(',').map(s => resolveId(s)).filter(Number.isFinite))]
|
|
175
|
+
if (!Number.isFinite(targetId) || sourceIds.length === 0) {
|
|
176
|
+
process.stderr.write(`[cycle2] merge rejected: id=${entryId} invalid target/sources\n`)
|
|
177
|
+
rejected.add(entryId)
|
|
178
|
+
continue
|
|
179
|
+
}
|
|
180
|
+
if (targetId !== entryId && !sourceIds.includes(entryId)) {
|
|
181
|
+
process.stderr.write(
|
|
182
|
+
`[cycle2] merge rejected: id=${entryId} must be target or listed source (target=${targetId} sources=${sourceIds.join(',')})\n`,
|
|
183
|
+
)
|
|
184
|
+
rejected.add(entryId)
|
|
185
|
+
continue
|
|
186
|
+
}
|
|
187
|
+
actions.push({
|
|
188
|
+
entry_id: entryId, action,
|
|
189
|
+
target_id: targetId,
|
|
190
|
+
source_ids: sourceIds,
|
|
191
|
+
element: (parts[4] ?? '').trim(),
|
|
192
|
+
summary: parts.slice(5).join('|').trim(),
|
|
193
|
+
})
|
|
194
|
+
} else {
|
|
195
|
+
actions.push({ entry_id: entryId, action })
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (!sawValid && rejected.size === 0) return null
|
|
199
|
+
return { actions, rejected, support }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Batch CTE UPDATE for status-only verdicts (active/archived from pending or active rows).
|
|
203
|
+
// Trigger handles score recompute automatically — no app-side score writes.
|
|
204
|
+
async function applyBatchStatusVerdicts(db, batch, nowMs) {
|
|
205
|
+
if (!batch || batch.length === 0) return { promoted: 0, archived: 0 }
|
|
206
|
+
const valueRows = batch.map((item, i) => {
|
|
207
|
+
const base = i * 3
|
|
208
|
+
return `($${base + 1}::bigint, $${base + 2}::text, $${base + 3}::boolean)`
|
|
209
|
+
})
|
|
210
|
+
const params = []
|
|
211
|
+
for (const item of batch) {
|
|
212
|
+
params.push(item.entry_id, item.new_status, item.was_pending)
|
|
213
|
+
}
|
|
214
|
+
params.push(nowMs)
|
|
215
|
+
const lastParam = `$${params.length}`
|
|
216
|
+
const res = await db.query(
|
|
217
|
+
`WITH actions(entry_id, new_status, was_pending) AS (
|
|
218
|
+
VALUES ${valueRows.join(', ')}
|
|
219
|
+
)
|
|
220
|
+
UPDATE entries
|
|
221
|
+
SET status = a.new_status::entry_status,
|
|
222
|
+
last_seen_at = ${lastParam},
|
|
223
|
+
promoted_at = CASE
|
|
224
|
+
WHEN a.was_pending AND a.new_status = 'active' THEN ${lastParam}
|
|
225
|
+
ELSE promoted_at
|
|
226
|
+
END
|
|
227
|
+
FROM actions a
|
|
228
|
+
WHERE entries.id = a.entry_id AND entries.is_root = 1
|
|
229
|
+
RETURNING entries.id, entries.status, a.was_pending, a.new_status`,
|
|
230
|
+
params,
|
|
231
|
+
)
|
|
232
|
+
let promoted = 0
|
|
233
|
+
let archived = 0
|
|
234
|
+
for (const r of (res.rows ?? [])) {
|
|
235
|
+
if (r.was_pending && r.new_status === 'active') promoted += 1
|
|
236
|
+
else if (r.new_status === 'archived') archived += 1
|
|
237
|
+
}
|
|
238
|
+
return { promoted, archived }
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Generic status update for archived/active terminal transitions.
|
|
242
|
+
export async function applySimpleStatus(db, entryId, nextStatus) {
|
|
243
|
+
const res = await db.query(
|
|
244
|
+
`UPDATE entries SET status = $1 WHERE id = $2 AND is_root = 1`,
|
|
245
|
+
[nextStatus, entryId],
|
|
246
|
+
)
|
|
247
|
+
return Number(res.rowCount ?? res.affectedRows ?? 0) > 0
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export async function applyUpdate(db, entryId, element, summary, options = {}) {
|
|
251
|
+
const setClauses = []
|
|
252
|
+
const params = []
|
|
253
|
+
let paramIdx = 1
|
|
254
|
+
const newElement = (typeof element === 'string' && element.trim()) ? element.trim() : null
|
|
255
|
+
const newSummary = (typeof summary === 'string' && summary.trim()) ? summary.trim() : null
|
|
256
|
+
if (newElement) {
|
|
257
|
+
setClauses.push(`element = $${paramIdx++}`); params.push(newElement)
|
|
258
|
+
}
|
|
259
|
+
if (newSummary) {
|
|
260
|
+
setClauses.push(`summary = $${paramIdx++}`); params.push(newSummary)
|
|
261
|
+
setClauses.push('summary_hash = NULL')
|
|
262
|
+
}
|
|
263
|
+
if (setClauses.length === 0) return false
|
|
264
|
+
params.push(entryId)
|
|
265
|
+
const res = await db.query(
|
|
266
|
+
`UPDATE entries SET ${setClauses.join(', ')} WHERE id = $${paramIdx} AND is_root = 1`,
|
|
267
|
+
params,
|
|
268
|
+
)
|
|
269
|
+
if (Number(res.rowCount ?? res.affectedRows ?? 0) === 0) return false
|
|
270
|
+
await syncRootEmbedding(db, entryId, options)
|
|
271
|
+
return true
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export async function applyMerge(db, targetId, sourceIds, options = {}) {
|
|
275
|
+
const signal = options?.signal
|
|
276
|
+
throwIfAborted(signal)
|
|
277
|
+
if (!Number.isFinite(targetId)) return 0
|
|
278
|
+
const targetRes = await db.query(
|
|
279
|
+
`SELECT id, project_id FROM entries WHERE id = $1 AND is_root = 1`,
|
|
280
|
+
[targetId],
|
|
281
|
+
)
|
|
282
|
+
throwIfAborted(signal)
|
|
283
|
+
const target = targetRes.rows[0]
|
|
284
|
+
if (!target) return 0
|
|
285
|
+
let moved = 0
|
|
286
|
+
for (const src of sourceIds) {
|
|
287
|
+
throwIfAborted(signal)
|
|
288
|
+
const sid = Number(src)
|
|
289
|
+
if (!Number.isFinite(sid) || sid === targetId) continue
|
|
290
|
+
const srcRes = await db.query(
|
|
291
|
+
`SELECT id, project_id, status FROM entries WHERE id = $1 AND is_root = 1`,
|
|
292
|
+
[sid],
|
|
293
|
+
)
|
|
294
|
+
throwIfAborted(signal)
|
|
295
|
+
const srcRow = srcRes.rows[0]
|
|
296
|
+
if (!srcRow) continue
|
|
297
|
+
if (target.project_id !== srcRow.project_id) {
|
|
298
|
+
process.stderr.write(
|
|
299
|
+
`[cycle2] merge rejected: cross-pool (target=${targetId} project_id=${target.project_id ?? 'COMMON'} src=${sid} project_id=${srcRow.project_id ?? 'COMMON'})\n`,
|
|
300
|
+
)
|
|
301
|
+
continue
|
|
302
|
+
}
|
|
303
|
+
try {
|
|
304
|
+
// One source merge is the mutation unit: DB reassignment/archive plus
|
|
305
|
+
// embedding cleanup. The next abort checkpoint is before the next source.
|
|
306
|
+
await db.transaction(async (tx) => {
|
|
307
|
+
await tx.query(
|
|
308
|
+
`UPDATE entries SET chunk_root = $1, project_id = $2 WHERE chunk_root = $3 AND id != $4 AND is_root = 0`,
|
|
309
|
+
[targetId, target.project_id, sid, sid],
|
|
310
|
+
)
|
|
311
|
+
await tx.query(
|
|
312
|
+
`UPDATE entries SET status = 'archived' WHERE id = $1 AND is_root = 1`,
|
|
313
|
+
[sid],
|
|
314
|
+
)
|
|
315
|
+
})
|
|
316
|
+
await deleteRootEmbedding(db, sid)
|
|
317
|
+
moved += 1
|
|
318
|
+
} catch (err) {
|
|
319
|
+
process.stderr.write(`[cycle2] merge failed (target=${targetId} src=${sid}): ${err.message}\n`)
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return moved
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ─── phase_merge: cosine-similarity dedup pass ───────────────────────────────
|
|
326
|
+
|
|
327
|
+
function _pickKeeper(a, b) {
|
|
328
|
+
if ((a.score ?? 0) !== (b.score ?? 0)) return (a.score ?? 0) > (b.score ?? 0) ? a : b
|
|
329
|
+
if ((a.last_seen_at ?? 0) !== (b.last_seen_at ?? 0)) return (a.last_seen_at ?? 0) > (b.last_seen_at ?? 0) ? a : b
|
|
330
|
+
return a.id < b.id ? a : b
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function _llmJudgePair(summaryA, summaryB, siblingContext = [], options = {}) {
|
|
334
|
+
const signal = options?.signal
|
|
335
|
+
throwIfAborted(signal)
|
|
336
|
+
const llmCall = typeof options?.callLlm === 'function' ? options.callLlm : callBridgeLlm
|
|
337
|
+
const siblings = Array.isArray(siblingContext) && siblingContext.length > 0
|
|
338
|
+
? `\n\nSibling near-matches (recall context only — do not absorb these into the verdict):\n${siblingContext.slice(0, 5).map((p, i) => `${i + 1}. ${String(p.a?.summary ?? '')} ↔ ${String(p.b?.summary ?? '')}`).join('\n')}`
|
|
339
|
+
: ''
|
|
340
|
+
const prompt =
|
|
341
|
+
`Two memory entries below. Are they restating the same principle? Reply ONE WORD: merge or distinct.\n\nA: ${summaryA}\nB: ${summaryB}${siblings}`
|
|
342
|
+
try {
|
|
343
|
+
const raw = await llmCall({
|
|
344
|
+
role: 'cycle2-agent',
|
|
345
|
+
taskType: 'maintenance',
|
|
346
|
+
mode: 'cycle2-phase_merge_judge',
|
|
347
|
+
preset: 'HAIKU',
|
|
348
|
+
timeout: 30000,
|
|
349
|
+
cwd: null,
|
|
350
|
+
}, prompt)
|
|
351
|
+
throwIfAborted(signal)
|
|
352
|
+
return String(raw ?? '').trim().toLowerCase().startsWith('merge')
|
|
353
|
+
} catch (err) {
|
|
354
|
+
if (signal?.aborted) throw signal.reason ?? err
|
|
355
|
+
process.stderr.write(`[cycle2] phase_merge llm-judge error: ${err.message}\n`)
|
|
356
|
+
return false
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export async function runPhaseMerge(db, options = {}) {
|
|
361
|
+
const signal = options?.signal
|
|
362
|
+
throwIfAborted(signal)
|
|
363
|
+
// PG-side lateral nearest-neighbor via HNSW index — replaces JS O(n²) double loop.
|
|
364
|
+
const pairRes = await db.query(
|
|
365
|
+
`WITH active AS (
|
|
366
|
+
SELECT id, category, summary, score, last_seen_at, status, embedding, project_id
|
|
367
|
+
FROM entries
|
|
368
|
+
WHERE is_root = 1 AND status = 'active' AND embedding IS NOT NULL
|
|
369
|
+
)
|
|
370
|
+
SELECT a.id AS a_id, a.category AS a_category, a.summary AS a_summary, a.score AS a_score, a.last_seen_at AS a_last_seen_at, a.status AS a_status,
|
|
371
|
+
b.id AS b_id, b.category AS b_category, b.summary AS b_summary, b.score AS b_score, b.last_seen_at AS b_last_seen_at, b.status AS b_status,
|
|
372
|
+
1 - (a.embedding <=> b.embedding)::float8 AS sim
|
|
373
|
+
FROM active a
|
|
374
|
+
CROSS JOIN LATERAL (
|
|
375
|
+
SELECT id, category, summary, score, last_seen_at, status, embedding
|
|
376
|
+
FROM active inner_b
|
|
377
|
+
WHERE inner_b.id != a.id AND inner_b.category = a.category
|
|
378
|
+
AND inner_b.project_id IS NOT DISTINCT FROM a.project_id
|
|
379
|
+
ORDER BY inner_b.embedding <=> a.embedding
|
|
380
|
+
LIMIT 8
|
|
381
|
+
) b
|
|
382
|
+
WHERE a.id < b.id
|
|
383
|
+
AND 1 - (a.embedding <=> b.embedding) >= $1
|
|
384
|
+
ORDER BY sim DESC`,
|
|
385
|
+
[TIER2_LOW],
|
|
386
|
+
)
|
|
387
|
+
throwIfAborted(signal)
|
|
388
|
+
|
|
389
|
+
const tier1Pairs = []
|
|
390
|
+
const tier2Pairs = []
|
|
391
|
+
for (const row of pairRes.rows) {
|
|
392
|
+
throwIfAborted(signal)
|
|
393
|
+
const a = { id: row.a_id, category: row.a_category, summary: row.a_summary, score: row.a_score, last_seen_at: row.a_last_seen_at, status: row.a_status }
|
|
394
|
+
const b = { id: row.b_id, category: row.b_category, summary: row.b_summary, score: row.b_score, last_seen_at: row.b_last_seen_at, status: row.b_status }
|
|
395
|
+
if (row.sim >= TIER1_THRESHOLD) tier1Pairs.push({ a, b, sim: row.sim })
|
|
396
|
+
else tier2Pairs.push({ a, b, sim: row.sim })
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// No active/active similarity pairs is NOT a reason to skip the
|
|
400
|
+
// core_entries overlap sweep below — that pass archives active entries
|
|
401
|
+
// that restate a user-curated core row and is independent of intra-
|
|
402
|
+
// entry pairing. Falling through with merged=0 keeps the cross-table
|
|
403
|
+
// sweep running and the per-phase log shape intact.
|
|
404
|
+
let merged = 0
|
|
405
|
+
let llmCalls = 0
|
|
406
|
+
const mergedIds = new Set()
|
|
407
|
+
|
|
408
|
+
const doMerge = async (a, b, sim) => {
|
|
409
|
+
throwIfAborted(signal)
|
|
410
|
+
if (mergedIds.has(a.id) || mergedIds.has(b.id)) return
|
|
411
|
+
const keeper = _pickKeeper(a, b)
|
|
412
|
+
const loser = keeper.id === a.id ? b : a
|
|
413
|
+
const moved = await applyMerge(db, keeper.id, [loser.id], { signal })
|
|
414
|
+
if (moved > 0) {
|
|
415
|
+
merged += moved
|
|
416
|
+
mergedIds.add(loser.id)
|
|
417
|
+
process.stderr.write(
|
|
418
|
+
`[cycle2] phase_merge merged id=${loser.id} -> keeper=${keeper.id} category=${keeper.category} sim=${typeof sim === 'number' ? sim.toFixed(3) : '?'}\n`,
|
|
419
|
+
)
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Only tier1 pairs enter the LLM judge. Tier2 pairs (0.65 ≤ sim < 0.78)
|
|
424
|
+
// are recall context only — passed as sibling examples to the judge, never
|
|
425
|
+
// as judge input themselves, and never archived here.
|
|
426
|
+
for (const pair of tier1Pairs) {
|
|
427
|
+
throwIfAborted(signal)
|
|
428
|
+
if (llmCalls >= LLM_JUDGE_CAP) break
|
|
429
|
+
if (mergedIds.has(pair.a.id) || mergedIds.has(pair.b.id)) continue
|
|
430
|
+
llmCalls++
|
|
431
|
+
const shouldMerge = await _llmJudgePair(
|
|
432
|
+
String(pair.a.summary ?? ''),
|
|
433
|
+
String(pair.b.summary ?? ''),
|
|
434
|
+
tier2Pairs,
|
|
435
|
+
{ signal },
|
|
436
|
+
)
|
|
437
|
+
throwIfAborted(signal)
|
|
438
|
+
if (shouldMerge) await doMerge(pair.a, pair.b, pair.sim)
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Cross-table sweep: surface every active entry whose embedding sits near
|
|
442
|
+
// a user-curated core_entries row (sim ≥ TIER2_LOW for broad recall) and
|
|
443
|
+
// ask the LLM whether the entry is a restatement of that user rule. Only
|
|
444
|
+
// the LLM verdict moves the entry to archived — embedding sim alone is
|
|
445
|
+
// never authoritative. Project-scoped core only matches the same pool;
|
|
446
|
+
// COMMON core is global and may absorb duplicate generated project memory.
|
|
447
|
+
throwIfAborted(signal)
|
|
448
|
+
const coreOverlapRes = await db.query(
|
|
449
|
+
`WITH active_e AS (
|
|
450
|
+
SELECT id, project_id, summary, embedding
|
|
451
|
+
FROM entries
|
|
452
|
+
WHERE is_root = 1 AND status = 'active' AND embedding IS NOT NULL
|
|
453
|
+
)
|
|
454
|
+
SELECT e.id AS entry_id, e.summary AS entry_summary, c.core_id, c.core_summary, c.sim
|
|
455
|
+
FROM active_e e
|
|
456
|
+
CROSS JOIN LATERAL (
|
|
457
|
+
SELECT inner_c.id AS core_id, inner_c.summary AS core_summary,
|
|
458
|
+
1 - (e.embedding <=> inner_c.embedding)::float8 AS sim
|
|
459
|
+
FROM core_entries inner_c
|
|
460
|
+
WHERE inner_c.embedding IS NOT NULL
|
|
461
|
+
AND (inner_c.project_id IS NULL OR inner_c.project_id IS NOT DISTINCT FROM e.project_id)
|
|
462
|
+
ORDER BY
|
|
463
|
+
CASE WHEN inner_c.project_id IS NOT DISTINCT FROM e.project_id THEN 0 ELSE 1 END,
|
|
464
|
+
inner_c.embedding <=> e.embedding
|
|
465
|
+
LIMIT 1
|
|
466
|
+
) c
|
|
467
|
+
WHERE c.sim >= $1`,
|
|
468
|
+
[TIER1_THRESHOLD],
|
|
469
|
+
)
|
|
470
|
+
throwIfAborted(signal)
|
|
471
|
+
let coreOverlap = 0
|
|
472
|
+
for (const row of coreOverlapRes.rows) {
|
|
473
|
+
throwIfAborted(signal)
|
|
474
|
+
if (llmCalls >= LLM_JUDGE_CAP) break
|
|
475
|
+
llmCalls++
|
|
476
|
+
const verdictMerge = await _llmJudgePair(
|
|
477
|
+
String(row.entry_summary ?? ''),
|
|
478
|
+
String(row.core_summary ?? ''),
|
|
479
|
+
[],
|
|
480
|
+
{ signal },
|
|
481
|
+
)
|
|
482
|
+
throwIfAborted(signal)
|
|
483
|
+
if (!verdictMerge) continue
|
|
484
|
+
// Archiving one overlap and deleting its embedding is one mutation unit;
|
|
485
|
+
// cancellation resumes at the next row boundary.
|
|
486
|
+
const r = await db.query(
|
|
487
|
+
`UPDATE entries SET status = 'archived' WHERE id = $1 AND is_root = 1 AND status = 'active'`,
|
|
488
|
+
[Number(row.entry_id)],
|
|
489
|
+
)
|
|
490
|
+
if (Number(r.rowCount ?? r.affectedRows ?? 0) > 0) {
|
|
491
|
+
coreOverlap++
|
|
492
|
+
await deleteRootEmbedding(db, Number(row.entry_id))
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
throwIfAborted(signal)
|
|
496
|
+
if (coreOverlap > 0) {
|
|
497
|
+
process.stderr.write(
|
|
498
|
+
`[cycle2] phase_merge core_overlap archived=${coreOverlap} (LLM-judged restatements of user-curated core_entries)\n`,
|
|
499
|
+
)
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
process.stderr.write(
|
|
503
|
+
`[cycle2] phase_merge tier1_pairs=${tier1Pairs.length} tier2_pairs=${tier2Pairs.length}` +
|
|
504
|
+
` llm_calls=${llmCalls} merged=${merged} core_overlap=${coreOverlap}\n`,
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
return { merged, llm_calls: llmCalls, tier1_pairs: tier1Pairs.length, tier2_pairs: tier2Pairs.length, core_overlap: coreOverlap }
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// ─── Current rules digest cache ──────────────────────────────────────────────
|
|
511
|
+
|
|
512
|
+
let _currentRulesDigest = null
|
|
513
|
+
let _currentRulesDigestTs = 0
|
|
514
|
+
export function loadCurrentRulesDigest() {
|
|
515
|
+
const now = Date.now()
|
|
516
|
+
if (_currentRulesDigest && now - _currentRulesDigestTs < 60_000) return _currentRulesDigest
|
|
517
|
+
const sources = [
|
|
518
|
+
join(_homedir(), '.claude', 'CLAUDE.md'),
|
|
519
|
+
join(resourceDir(), 'rules', 'shared', '00-language.md'),
|
|
520
|
+
join(resourceDir(), 'rules', 'shared', '01-general.md'),
|
|
521
|
+
join(resourceDir(), 'rules', 'shared', '01-tool.md'),
|
|
522
|
+
join(resourceDir(), 'rules', 'shared', '04-memory.md'),
|
|
523
|
+
join(resourceDir(), 'rules', 'shared', '06-team.md'),
|
|
524
|
+
join(resourceDir(), 'rules', 'shared', '07-workflow.md'),
|
|
525
|
+
]
|
|
526
|
+
const parts = []
|
|
527
|
+
for (const p of sources) {
|
|
528
|
+
try {
|
|
529
|
+
if (!existsSync(p)) continue
|
|
530
|
+
const txt = readFileSync(p, 'utf8').trim()
|
|
531
|
+
if (txt) parts.push(`# Source: ${p}\n${txt}`)
|
|
532
|
+
} catch {}
|
|
533
|
+
}
|
|
534
|
+
const joined = parts.join('\n\n---\n\n')
|
|
535
|
+
const CAP = 40_000
|
|
536
|
+
_currentRulesDigest = joined.length > CAP ? joined.slice(0, CAP) + '\n…[truncated]' : joined
|
|
537
|
+
_currentRulesDigestTs = now
|
|
538
|
+
return _currentRulesDigest
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function uniqueIds(values) {
|
|
542
|
+
return [...new Set(values
|
|
543
|
+
.map(id => Number(id))
|
|
544
|
+
.filter(id => Number.isFinite(id)))]
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function validateUnifiedGate(parsed, statusById) {
|
|
548
|
+
const actions = Array.isArray(parsed?.actions) ? parsed.actions : []
|
|
549
|
+
const primary = actions.filter(a => a?.action !== 'core')
|
|
550
|
+
const verdictCounts = new Map()
|
|
551
|
+
for (const action of primary) {
|
|
552
|
+
const id = Number(action?.entry_id)
|
|
553
|
+
if (!Number.isFinite(id)) continue
|
|
554
|
+
verdictCounts.set(id, (verdictCounts.get(id) || 0) + 1)
|
|
555
|
+
}
|
|
556
|
+
const expectedIds = [...statusById.keys()]
|
|
557
|
+
const missingVerdictIds = expectedIds.filter(id => !verdictCounts.has(id))
|
|
558
|
+
const duplicateVerdictIds = [...verdictCounts.entries()]
|
|
559
|
+
.filter(([, count]) => count > 1)
|
|
560
|
+
.map(([id]) => id)
|
|
561
|
+
const support = parsed?.support instanceof Map ? parsed.support : new Map()
|
|
562
|
+
const coreIds = new Set(actions
|
|
563
|
+
.filter(a => a?.action === 'core')
|
|
564
|
+
.map(a => Number(a.entry_id))
|
|
565
|
+
.filter(id => Number.isFinite(id)))
|
|
566
|
+
const missingSupportIds = []
|
|
567
|
+
const missingCoreIds = []
|
|
568
|
+
for (const action of primary) {
|
|
569
|
+
if (!NON_ARCHIVE_VERBS.has(action?.action)) continue
|
|
570
|
+
const id = Number(action.entry_id)
|
|
571
|
+
if (!Number.isFinite(id)) continue
|
|
572
|
+
const coreId = action.action === 'merge' && Number.isFinite(Number(action.target_id))
|
|
573
|
+
? Number(action.target_id)
|
|
574
|
+
: id
|
|
575
|
+
const hasSupport = support.has(id) || (action.action === 'merge' && support.has(coreId))
|
|
576
|
+
if (!hasSupport) missingSupportIds.push(id)
|
|
577
|
+
if (!coreIds.has(coreId)) missingCoreIds.push(id)
|
|
578
|
+
}
|
|
579
|
+
return {
|
|
580
|
+
missingVerdictIds: uniqueIds(missingVerdictIds),
|
|
581
|
+
duplicateVerdictIds: uniqueIds(duplicateVerdictIds),
|
|
582
|
+
missingSupportIds: uniqueIds(missingSupportIds),
|
|
583
|
+
missingCoreIds: uniqueIds(missingCoreIds),
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function gateQualitySummary(quality) {
|
|
588
|
+
const parts = []
|
|
589
|
+
if (quality?.missingVerdictIds?.length) parts.push(`missing verdict ids=${quality.missingVerdictIds.join(',')}`)
|
|
590
|
+
if (quality?.duplicateVerdictIds?.length) parts.push(`duplicate verdict ids=${quality.duplicateVerdictIds.join(',')}`)
|
|
591
|
+
if (quality?.missingSupportIds?.length) parts.push(`missing why ids=${quality.missingSupportIds.join(',')}`)
|
|
592
|
+
if (quality?.missingCoreIds?.length) parts.push(`missing core ids=${quality.missingCoreIds.join(',')}`)
|
|
593
|
+
return parts.join('; ')
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function stripUnsupportedPromotions(parsed, unsupportedIds) {
|
|
597
|
+
const ids = new Set(uniqueIds(unsupportedIds))
|
|
598
|
+
if (ids.size === 0) return parsed
|
|
599
|
+
const rejected = new Set(parsed?.rejected || [])
|
|
600
|
+
for (const id of ids) rejected.add(id)
|
|
601
|
+
const actions = (parsed?.actions || []).filter(a => {
|
|
602
|
+
if (a?.action === 'core') return true
|
|
603
|
+
return !ids.has(Number(a?.entry_id))
|
|
604
|
+
})
|
|
605
|
+
return { ...parsed, actions, rejected }
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function requiredCoreIdForAction(action) {
|
|
609
|
+
if (action?.action === 'merge' && Number.isFinite(Number(action.target_id))) {
|
|
610
|
+
return Number(action.target_id)
|
|
611
|
+
}
|
|
612
|
+
return Number(action?.entry_id)
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// ─── Unified gate ────────────────────────────────────────────────────────────
|
|
616
|
+
|
|
617
|
+
// Single LLM pass over rows whose status is in {pending, active}.
|
|
618
|
+
// Returns { actions, rejected, parseOk } following parseUnifiedFormat shape.
|
|
619
|
+
export async function runUnifiedGate(db, rows, activeContext, config = {}, options = {}) {
|
|
620
|
+
const signal = options?.signal
|
|
621
|
+
throwIfAborted(signal)
|
|
622
|
+
if (!rows || rows.length === 0) return { actions: [], rejected: new Set(), parseOk: true }
|
|
623
|
+
const promptPath = join(resourceDir(), 'defaults', 'memory-promote-prompt.md')
|
|
624
|
+
if (!existsSync(promptPath)) {
|
|
625
|
+
throw new Error(`runCycle2: prompt file missing at ${promptPath}`)
|
|
626
|
+
}
|
|
627
|
+
const template = readFileSync(promptPath, 'utf8')
|
|
628
|
+
const userCoreRows = options.dataDir ? await listCore(options.dataDir, '*').catch(() => []) : []
|
|
629
|
+
throwIfAborted(signal)
|
|
630
|
+
const sharedPidMap = buildPidMap([activeContext ?? [], rows ?? [], userCoreRows ?? []])
|
|
631
|
+
const rulesDigest = loadCurrentRulesDigest() || '(no current rules digest available)'
|
|
632
|
+
const activeCount = activeContext?.length ?? 0
|
|
633
|
+
const activeCap = options.activeCap ?? CYCLE2_ACTIVE_TARGET_CAP
|
|
634
|
+
|
|
635
|
+
const prompt = template
|
|
636
|
+
.replace('{{CURRENT_RULES}}', rulesDigest)
|
|
637
|
+
.replace('{{USER_CORE}}', formatUserCoreForPrompt(userCoreRows, sharedPidMap))
|
|
638
|
+
.replace('{{CORE_MEMORY}}', formatEntriesForPromotePrompt(activeContext, sharedPidMap))
|
|
639
|
+
.replace('{{ITEMS}}', formatEntriesForPromotePrompt(rows, sharedPidMap, { numbered: true }))
|
|
640
|
+
.replace('{{ACTIVE_COUNT}}', String(activeCount))
|
|
641
|
+
.replace('{{ACTIVE_CAP}}', String(activeCap))
|
|
642
|
+
|
|
643
|
+
const preset = options.preset || resolveMaintenancePreset('cycle2')
|
|
644
|
+
const timeout = Number(config?.cycle2?.timeout ?? 600000)
|
|
645
|
+
const mode = 'cycle2-unified'
|
|
646
|
+
|
|
647
|
+
const previewRaw = (raw) => String(raw ?? '').replace(/\s+/g, ' ').slice(0, 200)
|
|
648
|
+
const callOnce = async (extraTag) => {
|
|
649
|
+
throwIfAborted(signal)
|
|
650
|
+
const p = extraTag ? `${prompt}\n\n[retry:${extraTag}]` : prompt
|
|
651
|
+
const raw = await invokeLlm(p, mode, preset, timeout, options.callLlm)
|
|
652
|
+
throwIfAborted(signal)
|
|
653
|
+
return raw
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const statusById = new Map(rows.map(r => [Number(r.id), String(r.status)]))
|
|
657
|
+
// Ordinal → batch-id map, keyed by 1-based prompt order (the same order the
|
|
658
|
+
// numbered Entries block uses). The gate may echo either the real 5-digit
|
|
659
|
+
// batch id or the row ordinal 1..N; the parser resolves both. The two domains
|
|
660
|
+
// MUST be disjoint or an ordinal could shadow a real id (ids are 5-digit,
|
|
661
|
+
// ordinals are <= rows.length, so disjointness always holds in practice).
|
|
662
|
+
// On a violation a row-number line is indistinguishable from an exact-id
|
|
663
|
+
// line, so no safe interpretation exists — skip this batch (gate failure)
|
|
664
|
+
// rather than risk applying a verdict to the wrong entry. The cycle itself
|
|
665
|
+
// proceeds; the batch re-queues for a later run.
|
|
666
|
+
const ordinalToId = new Map(rows.map((r, i) => [i + 1, Number(r.id)]))
|
|
667
|
+
const minBatchId = Math.min(...[...statusById.keys()])
|
|
668
|
+
if (Number.isFinite(minBatchId) && minBatchId <= rows.length) {
|
|
669
|
+
process.stderr.write(`[cycle2] batch id ${minBatchId} collides with ordinal range 1..${rows.length} — skipping batch (no safe id resolution)\n`)
|
|
670
|
+
return { actions: null, rejected: new Set(), parseOk: false }
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
process.stderr.write(`[cycle2-diag] unified prompt=${prompt.length} bytes; rows=${rows.length}\n`)
|
|
674
|
+
|
|
675
|
+
let raw
|
|
676
|
+
try {
|
|
677
|
+
raw = await callOnce(null)
|
|
678
|
+
} catch (err) {
|
|
679
|
+
if (signal?.aborted) throw signal.reason ?? err
|
|
680
|
+
process.stderr.write(`[cycle2] unified LLM error: ${err.message}\n`)
|
|
681
|
+
return { actions: null, rejected: new Set(), parseOk: false }
|
|
682
|
+
}
|
|
683
|
+
throwIfAborted(signal)
|
|
684
|
+
process.stderr.write(`[cycle2-diag] unified raw (first 1500): ${String(raw ?? '').replace(/\n/g, '⏎').slice(0, 1500)}\n`)
|
|
685
|
+
|
|
686
|
+
let parsed = parseUnifiedFormat(raw, statusById, ordinalToId)
|
|
687
|
+
let quality = parsed ? validateUnifiedGate(parsed, statusById) : null
|
|
688
|
+
const qualityIssue = () => gateQualitySummary(quality)
|
|
689
|
+
if (!parsed || qualityIssue()) {
|
|
690
|
+
throwIfAborted(signal)
|
|
691
|
+
const issue = parsed ? qualityIssue() : `unparseable (${previewRaw(raw)})`
|
|
692
|
+
process.stderr.write(`[cycle2] unified quality retry: ${issue}\n`)
|
|
693
|
+
// Preserve the first pass before retrying. A retry fired for a mere quality
|
|
694
|
+
// issue (e.g. a few missing verdicts) must not throw away an otherwise-valid
|
|
695
|
+
// first-pass parse if the retry comes back unparseable.
|
|
696
|
+
const firstParsed = parsed
|
|
697
|
+
const firstQuality = quality
|
|
698
|
+
try {
|
|
699
|
+
const retryTag = parsed
|
|
700
|
+
? 'complete-verdicts-with-why-and-core-lines'
|
|
701
|
+
: 'first-field-must-be-the-listed-row-number'
|
|
702
|
+
const raw2 = await callOnce(retryTag)
|
|
703
|
+
const retryParsed = parseUnifiedFormat(raw2, statusById, ordinalToId)
|
|
704
|
+
if (retryParsed) {
|
|
705
|
+
parsed = retryParsed
|
|
706
|
+
quality = validateUnifiedGate(retryParsed, statusById)
|
|
707
|
+
} else if (firstParsed) {
|
|
708
|
+
process.stderr.write(`[cycle2] unparseable after retry — falling back to first-pass parse (${previewRaw(raw2)})\n`)
|
|
709
|
+
parsed = firstParsed
|
|
710
|
+
quality = firstQuality
|
|
711
|
+
} else {
|
|
712
|
+
process.stderr.write(`[cycle2] unparseable after retry — skipping batch (${previewRaw(raw2)})\n`)
|
|
713
|
+
return { actions: null, rejected: new Set(), parseOk: false }
|
|
714
|
+
}
|
|
715
|
+
} catch (err) {
|
|
716
|
+
if (signal?.aborted) throw signal.reason ?? err
|
|
717
|
+
if (firstParsed) {
|
|
718
|
+
process.stderr.write(`[cycle2] retry LLM error: ${err.message} — falling back to first-pass parse\n`)
|
|
719
|
+
parsed = firstParsed
|
|
720
|
+
quality = firstQuality
|
|
721
|
+
} else {
|
|
722
|
+
process.stderr.write(`[cycle2] retry LLM error: ${err.message}\n`)
|
|
723
|
+
return { actions: null, rejected: new Set(), parseOk: false }
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
const finalIssue = gateQualitySummary(quality)
|
|
728
|
+
// duplicateVerdictIds are genuinely ambiguous (the same row got two conflicting
|
|
729
|
+
// verbs) — keep the full-skip. missingVerdictIds, by contrast, used to skip the
|
|
730
|
+
// WHOLE batch, so a handful of persistently-missing poison rows could livelock
|
|
731
|
+
// the gate. Partial-apply instead: keep the valid verdicts we did receive, just
|
|
732
|
+
// log the missing ids and leave those rows for a later run.
|
|
733
|
+
if (quality?.duplicateVerdictIds?.length) {
|
|
734
|
+
process.stderr.write(`[cycle2] duplicate verdict coverage after retry — skipping batch (${finalIssue})\n`)
|
|
735
|
+
return { actions: null, rejected: new Set(), parseOk: false }
|
|
736
|
+
}
|
|
737
|
+
if (quality?.missingVerdictIds?.length) {
|
|
738
|
+
process.stderr.write(`[cycle2] missing verdicts after retry — partial apply, leaving ids=${quality.missingVerdictIds.join(',')} for a later run (${finalIssue})\n`)
|
|
739
|
+
}
|
|
740
|
+
// A response made up solely of why/core lines parses "ok" yet carries zero
|
|
741
|
+
// primary (status-verb) verdicts. Without this guard parseOk stays true and
|
|
742
|
+
// the caller treats the batch as a clean no-op, masking the coverage failure
|
|
743
|
+
// and marking the rows reviewed. Fail the parse so the rows are re-queued.
|
|
744
|
+
const primaryCount = (parsed.actions || []).filter(a => a?.action !== 'core').length
|
|
745
|
+
if (rows.length > 0 && primaryCount === 0) {
|
|
746
|
+
process.stderr.write(`[cycle2] gate produced zero primary verdicts for ${rows.length} rows — failing parse\n`)
|
|
747
|
+
return { actions: null, rejected: new Set(), parseOk: false, missingIds: [...statusById.keys()] }
|
|
748
|
+
}
|
|
749
|
+
const incompletePromotionIds = uniqueIds([
|
|
750
|
+
...(quality?.missingSupportIds || []),
|
|
751
|
+
...(quality?.missingCoreIds || []),
|
|
752
|
+
])
|
|
753
|
+
if (incompletePromotionIds.length > 0) {
|
|
754
|
+
process.stderr.write(`[cycle2] incomplete non-archive verdicts rejected after retry ids=${incompletePromotionIds.join(',')} (${finalIssue})\n`)
|
|
755
|
+
parsed = stripUnsupportedPromotions(parsed, incompletePromotionIds)
|
|
756
|
+
}
|
|
757
|
+
return {
|
|
758
|
+
actions: parsed.actions,
|
|
759
|
+
rejected: parsed.rejected,
|
|
760
|
+
parseOk: true,
|
|
761
|
+
missingIds: quality?.missingVerdictIds || [],
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// ─── Sonnet cascade ──────────────────────────────────────────────────────────
|
|
766
|
+
|
|
767
|
+
// Sonnet re-judge over first-pass keep verdicts. Sonnet sees rules + summary
|
|
768
|
+
// and returns binary keep/drop. Failures fail-open (preserve first-pass).
|
|
769
|
+
async function sonnetCascade(candidates, rulesDigest, options = {}) {
|
|
770
|
+
const signal = options?.signal
|
|
771
|
+
throwIfAborted(signal)
|
|
772
|
+
if (!candidates || candidates.length === 0) return new Map()
|
|
773
|
+
const lines = candidates.map(c =>
|
|
774
|
+
`id:${c.id} status:${c.status} verb:${c.verb} cat:${c.category} el:${c.element} sm:${String(c.summary || '').slice(0, 200)}${c.core ? ` core:${String(c.core).slice(0, 200)}` : ''}`,
|
|
775
|
+
).join('\n')
|
|
776
|
+
const prompt = [
|
|
777
|
+
`Final gate over first-pass keep verdicts.`,
|
|
778
|
+
`Keep a candidate ONLY if it lands in one of three layers: L1 relationship/communication`,
|
|
779
|
+
`(user identity, address form, reply-style preferences, disliked patterns); L2 behavior rules`,
|
|
780
|
+
`(principles the user corrected/insisted on, hard safety boundaries, quality bars); or L3 current`,
|
|
781
|
+
`map (one-line project-landscape summaries, live long-running goals, environment anchors documented`,
|
|
782
|
+
`nowhere else). For a past decision/failure, keep only the one-line lesson that still constrains`,
|
|
783
|
+
`behavior, else drop. DROP anything whose source of truth is code, rules files, or skill docs, plus`,
|
|
784
|
+
`implementation specs, code-internal constants, measurements, resolved-bug stories, status snapshots,`,
|
|
785
|
+
`and duplicates of source-of-truth rules.`,
|
|
786
|
+
`When a candidate has a core: field, judge THAT extracted one-line lesson (the entry will live as`,
|
|
787
|
+
`that line), not the raw narrative in el:/sm:.`,
|
|
788
|
+
``,
|
|
789
|
+
`Source-of-truth rules (excerpt — DO NOT duplicate in memory):`,
|
|
790
|
+
String(rulesDigest || '').slice(0, 4000),
|
|
791
|
+
``,
|
|
792
|
+
`Candidates:`,
|
|
793
|
+
lines,
|
|
794
|
+
``,
|
|
795
|
+
`Reply one line per id: "<id>|keep" to retain, "<id>|drop" to reject.`,
|
|
796
|
+
`NO prose, NO preamble, NO meta-commentary. First character must be a digit.`,
|
|
797
|
+
].join('\n')
|
|
798
|
+
|
|
799
|
+
// Hardcoded — resolveMaintenancePreset falls back to first preset (HAIKU)
|
|
800
|
+
// when no binding exists, which would defeat the cascade. SONNET HIGH
|
|
801
|
+
// matches the worker pool's default preset id from agent-config.
|
|
802
|
+
const preset = options.cascadePreset || 'SONNET HIGH'
|
|
803
|
+
const llmCall = typeof options?.callLlm === 'function' ? options.callLlm : callBridgeLlm
|
|
804
|
+
let raw
|
|
805
|
+
try {
|
|
806
|
+
raw = await llmCall({
|
|
807
|
+
role: 'cycle2-agent',
|
|
808
|
+
taskType: 'maintenance',
|
|
809
|
+
mode: 'cycle2-cascade',
|
|
810
|
+
preset,
|
|
811
|
+
timeout: 600000,
|
|
812
|
+
cwd: null,
|
|
813
|
+
}, prompt)
|
|
814
|
+
} catch (err) {
|
|
815
|
+
if (signal?.aborted) throw signal.reason ?? err
|
|
816
|
+
process.stderr.write(`[cycle2] cascade error: ${err.message} — fail-open\n`)
|
|
817
|
+
return new Map()
|
|
818
|
+
}
|
|
819
|
+
throwIfAborted(signal)
|
|
820
|
+
|
|
821
|
+
const verdicts = new Map()
|
|
822
|
+
for (const line of String(raw ?? '').split('\n')) {
|
|
823
|
+
throwIfAborted(signal)
|
|
824
|
+
const trimmed = line.trim()
|
|
825
|
+
if (!trimmed) continue
|
|
826
|
+
if (trimmed.startsWith('//') || trimmed.startsWith('#') || trimmed.startsWith('```')) continue
|
|
827
|
+
const parts = trimmed.split('|')
|
|
828
|
+
if (parts.length < 2) continue
|
|
829
|
+
const id = Number(parts[0].trim())
|
|
830
|
+
const v = parts[1].trim().toLowerCase()
|
|
831
|
+
if (Number.isFinite(id) && (v === 'keep' || v === 'drop')) verdicts.set(id, v)
|
|
832
|
+
}
|
|
833
|
+
process.stderr.write(`[cycle2] cascade evaluated=${candidates.length} drops=${[...verdicts.values()].filter(v => v === 'drop').length}\n`)
|
|
834
|
+
return verdicts
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// ─── runCycle2 ───────────────────────────────────────────────────────────────
|
|
838
|
+
|
|
839
|
+
const _runCycle2InFlight = new WeakMap()
|
|
840
|
+
|
|
841
|
+
export async function runCycle2(db, config = {}, options = {}, dataDir = null) {
|
|
842
|
+
const signal = options?.signal
|
|
843
|
+
throwIfAborted(signal)
|
|
844
|
+
const partial = {
|
|
845
|
+
promoted: 0, archived: 0, merged: 0, updated: 0, kept: 0, rejected_verb: 0,
|
|
846
|
+
merge_rejected: 0,
|
|
847
|
+
missing_core_summary: 0,
|
|
848
|
+
core_embedding_backfill: 0,
|
|
849
|
+
rescore: { updated: 0 },
|
|
850
|
+
phase_merge: { merged: 0, llm_calls: 0, tier1_pairs: 0, tier2_pairs: 0, core_overlap: 0 },
|
|
851
|
+
cascade: { evaluated: 0, dropped: 0 },
|
|
852
|
+
}
|
|
853
|
+
if (_runCycle2InFlight.has(db)) {
|
|
854
|
+
process.stderr.write('[cycle2] skipped: already in flight for this db\n')
|
|
855
|
+
return { ok: true, ...partial, skippedInFlight: true }
|
|
856
|
+
}
|
|
857
|
+
const client = await db._pool.connect()
|
|
858
|
+
let gotLock = false
|
|
859
|
+
try {
|
|
860
|
+
throwIfAborted(signal)
|
|
861
|
+
const r = await client.query(`SELECT pg_try_advisory_lock(hashtext($1)) AS got`, ['mixdog.cycle2'])
|
|
862
|
+
gotLock = r.rows[0]?.got === true
|
|
863
|
+
} catch (err) {
|
|
864
|
+
client.release()
|
|
865
|
+
if (signal?.aborted) throw signal.reason ?? err
|
|
866
|
+
process.stderr.write(`[cycle2] advisory lock query failed: ${err.message}\n`)
|
|
867
|
+
return { ok: true, ...partial, skippedInFlight: true }
|
|
868
|
+
}
|
|
869
|
+
if (!gotLock) {
|
|
870
|
+
client.release()
|
|
871
|
+
process.stderr.write('[cycle2] skipped: advisory lock held by another worker\n')
|
|
872
|
+
return { ok: true, ...partial, skippedInFlight: true }
|
|
873
|
+
}
|
|
874
|
+
const _p = (async () => {
|
|
875
|
+
try {
|
|
876
|
+
const result = await _runCycle2Impl(db, config, options, dataDir)
|
|
877
|
+
return { ok: true, ...result }
|
|
878
|
+
} catch (e) {
|
|
879
|
+
if (signal?.aborted) throw signal.reason ?? e
|
|
880
|
+
return { ok: false, error: e.message, ...partial }
|
|
881
|
+
} finally {
|
|
882
|
+
try { await client.query(`SELECT pg_advisory_unlock(hashtext($1))`, ['mixdog.cycle2']) } catch {}
|
|
883
|
+
client.release()
|
|
884
|
+
}
|
|
885
|
+
})()
|
|
886
|
+
_runCycle2InFlight.set(db, _p)
|
|
887
|
+
try { return await _p }
|
|
888
|
+
finally { _runCycle2InFlight.delete(db) }
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
async function _runCycle2Impl(db, config = {}, options = {}, dataDir = null) {
|
|
892
|
+
const signal = options?.signal
|
|
893
|
+
throwIfAborted(signal)
|
|
894
|
+
const batchSize = Math.max(1, Number(config.batch_size ?? 50))
|
|
895
|
+
const activeTargetCap = Number.isFinite(Number(config.active_target_cap))
|
|
896
|
+
? Math.max(1, Number(config.active_target_cap))
|
|
897
|
+
: CYCLE2_ACTIVE_TARGET_CAP
|
|
898
|
+
const nowMs = Date.now()
|
|
899
|
+
|
|
900
|
+
const stats = {
|
|
901
|
+
promoted: 0, archived: 0, merged: 0,
|
|
902
|
+
updated: 0, kept: 0, rejected_verb: 0,
|
|
903
|
+
merge_rejected: 0,
|
|
904
|
+
missing_core_summary: 0,
|
|
905
|
+
core_embedding_backfill: 0,
|
|
906
|
+
rescore: { updated: 0 },
|
|
907
|
+
phase_merge: { merged: 0, llm_calls: 0, tier1_pairs: 0, tier2_pairs: 0, core_overlap: 0 },
|
|
908
|
+
cascade: { evaluated: 0, dropped: 0 },
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
if (dataDir) {
|
|
912
|
+
try {
|
|
913
|
+
stats.core_embedding_backfill = await backfillCoreEmbeddings(dataDir, { signal })
|
|
914
|
+
throwIfAborted(signal)
|
|
915
|
+
} catch (err) {
|
|
916
|
+
if (signal?.aborted) throw signal.reason ?? err
|
|
917
|
+
process.stderr.write(`[cycle2] core embedding backfill failed: ${err.message}\n`)
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
const activeCountRes = await db.query(
|
|
922
|
+
`SELECT COUNT(*) AS c FROM entries WHERE is_root = 1 AND status = 'active'`,
|
|
923
|
+
[],
|
|
924
|
+
)
|
|
925
|
+
throwIfAborted(signal)
|
|
926
|
+
const activeCount = Number(activeCountRes.rows[0]?.c ?? 0)
|
|
927
|
+
const reviewActiveRows = activeCount > activeTargetCap
|
|
928
|
+
|
|
929
|
+
// Rolling active re-review quota. Under cap, the unified selection below
|
|
930
|
+
// pulls only pending rows, so an already-promoted entry that later drifts
|
|
931
|
+
// stale or turns out to restate a rule file never gets re-judged — the
|
|
932
|
+
// over-cap path was historically the ONLY one that re-examined active.
|
|
933
|
+
// Reserve a bounded slice of batch slots for the stalest active rows so
|
|
934
|
+
// rule-duplicate / drifted promotions are archived continuously instead of
|
|
935
|
+
// sitting forever un-rechecked. Bounded count + reviewed_at rotation
|
|
936
|
+
// prevents eroding the set to zero (the original over-cap-only concern):
|
|
937
|
+
// only the oldest few are re-judged per cycle, and the gate — shown
|
|
938
|
+
// {{CURRENT_RULES}} — keeps genuine A/B entries and archives only
|
|
939
|
+
// restatements. Embedding dedup is skipped on purpose: rule restatements
|
|
940
|
+
// are often cross-language paraphrases whose cosine never clears the merge
|
|
941
|
+
// threshold, but the LLM gate catches the semantic overlap.
|
|
942
|
+
const activeRecheckQuota = reviewActiveRows
|
|
943
|
+
? 0
|
|
944
|
+
: Math.max(0, Math.min(Number(config.active_recheck_quota ?? 8), batchSize - 1))
|
|
945
|
+
const pendingLimit = batchSize - activeRecheckQuota
|
|
946
|
+
// Score direction depends on the phase. Under cap we are SEEDING the active
|
|
947
|
+
// set: evaluate the highest-value pending first so promotion-worthy rows
|
|
948
|
+
// reach the gate instead of starving behind low-score cycle1 churn. Over cap
|
|
949
|
+
// we are CONTRACTING: evaluate the lowest-score rows first to shed the
|
|
950
|
+
// weakest. (The active-recheck slice below stays ASC — demote weakest active
|
|
951
|
+
// first.)
|
|
952
|
+
const scoreDir = reviewActiveRows ? 'ASC' : 'DESC'
|
|
953
|
+
|
|
954
|
+
// Unified candidate selection. Pending rows (and, when over cap, active
|
|
955
|
+
// rows) reach the gate here; the reserved active-recheck slice is appended
|
|
956
|
+
// below. Cleanup of duplicates/stale user-core overlap also runs via
|
|
957
|
+
// phase_merge / cycle3.
|
|
958
|
+
const rowsRes = await db.query(`
|
|
959
|
+
SELECT id, element, category, summary, score, last_seen_at, project_id, status
|
|
960
|
+
FROM entries
|
|
961
|
+
WHERE is_root = 1
|
|
962
|
+
AND (status = 'pending' OR ($2::boolean AND status = 'active'))
|
|
963
|
+
ORDER BY
|
|
964
|
+
CASE status WHEN 'pending' THEN 0 WHEN 'active' THEN 1 END ASC,
|
|
965
|
+
reviewed_at ASC NULLS FIRST,
|
|
966
|
+
error_count ASC,
|
|
967
|
+
score ${scoreDir},
|
|
968
|
+
id ASC
|
|
969
|
+
LIMIT $1
|
|
970
|
+
`, [pendingLimit, reviewActiveRows])
|
|
971
|
+
throwIfAborted(signal)
|
|
972
|
+
const rows = rowsRes.rows
|
|
973
|
+
|
|
974
|
+
// Append the reserved rolling slice of stalest active rows (under-cap only;
|
|
975
|
+
// the over-cap branch already pulls active broadly). De-duped against the
|
|
976
|
+
// primary selection so an id never gets two verdicts in one batch.
|
|
977
|
+
if (activeRecheckQuota > 0 && activeCount > 0) {
|
|
978
|
+
const seen = new Set(rows.map(r => Number(r.id)))
|
|
979
|
+
const recheckRes = await db.query(`
|
|
980
|
+
SELECT id, element, category, summary, score, last_seen_at, project_id, status
|
|
981
|
+
FROM entries
|
|
982
|
+
WHERE is_root = 1 AND status = 'active'
|
|
983
|
+
ORDER BY reviewed_at ASC NULLS FIRST, score ASC, id ASC
|
|
984
|
+
LIMIT $1
|
|
985
|
+
`, [activeRecheckQuota])
|
|
986
|
+
throwIfAborted(signal)
|
|
987
|
+
for (const r of recheckRes.rows) {
|
|
988
|
+
if (!seen.has(Number(r.id))) rows.push(r)
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// Active snapshot for prompt context (do-not-duplicate reference).
|
|
993
|
+
const activeContextRes = await db.query(`
|
|
994
|
+
SELECT id, element, category, summary, score, last_seen_at, project_id, status
|
|
995
|
+
FROM entries
|
|
996
|
+
WHERE is_root = 1 AND status = 'active'
|
|
997
|
+
ORDER BY score DESC, last_seen_at DESC, id ASC
|
|
998
|
+
LIMIT 100
|
|
999
|
+
`, [])
|
|
1000
|
+
throwIfAborted(signal)
|
|
1001
|
+
const activeContext = activeContextRes.rows
|
|
1002
|
+
|
|
1003
|
+
const gateResult = rows.length > 0
|
|
1004
|
+
? await runUnifiedGate(db, rows, activeContext, config, { activeCap: activeTargetCap, preset: options.preset, dataDir, signal, callLlm: options.callLlm })
|
|
1005
|
+
: { actions: [], rejected: new Set(), parseOk: true }
|
|
1006
|
+
throwIfAborted(signal)
|
|
1007
|
+
// Surface a gate parse/coverage failure so the caller can distinguish a
|
|
1008
|
+
// clean no-op run from one where the LLM gate produced nothing usable.
|
|
1009
|
+
if (gateResult.parseOk === false) stats.gate_failed = true
|
|
1010
|
+
|
|
1011
|
+
const sweepCursor = nowMs
|
|
1012
|
+
|
|
1013
|
+
const rowsById = new Map(rows.map(r => [Number(r.id), r]))
|
|
1014
|
+
|
|
1015
|
+
// Cascade pre-pass: pull first-pass keeps (verb 'active') into Sonnet for
|
|
1016
|
+
// re-judge. update/merge/archived skip.
|
|
1017
|
+
const cascadeCandidates = []
|
|
1018
|
+
if (gateResult.actions) {
|
|
1019
|
+
// First-pass proposed core lines: under the pending-row transform the L2
|
|
1020
|
+
// lesson lives only in the core line, so thread it into the cascade.
|
|
1021
|
+
const proposedCoreById = new Map()
|
|
1022
|
+
for (const a of gateResult.actions) {
|
|
1023
|
+
if (a.action !== 'core') continue
|
|
1024
|
+
const id = Number(a.entry_id)
|
|
1025
|
+
const core = String(a.core_summary ?? '').replace(/\s+/g, ' ').trim()
|
|
1026
|
+
if (Number.isFinite(id) && core) proposedCoreById.set(id, core)
|
|
1027
|
+
}
|
|
1028
|
+
for (const a of gateResult.actions) {
|
|
1029
|
+
throwIfAborted(signal)
|
|
1030
|
+
if (a.action !== 'active') continue
|
|
1031
|
+
const row = rowsById.get(Number(a.entry_id))
|
|
1032
|
+
if (!row) continue
|
|
1033
|
+
cascadeCandidates.push({
|
|
1034
|
+
id: row.id, status: row.status, verb: a.action,
|
|
1035
|
+
category: row.category, element: row.element, summary: row.summary,
|
|
1036
|
+
core: proposedCoreById.get(Number(a.entry_id)) || '',
|
|
1037
|
+
})
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
const rulesDigest = loadCurrentRulesDigest() || ''
|
|
1042
|
+
let cascadeVerdicts = new Map()
|
|
1043
|
+
if (cascadeCandidates.length > 0) {
|
|
1044
|
+
cascadeVerdicts = await sonnetCascade(cascadeCandidates, rulesDigest, { ...options, signal })
|
|
1045
|
+
throwIfAborted(signal)
|
|
1046
|
+
stats.cascade.evaluated = cascadeCandidates.length
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// Apply actions.
|
|
1050
|
+
if (gateResult.actions) {
|
|
1051
|
+
const reviewedIds = []
|
|
1052
|
+
const rejectedActionIds = []
|
|
1053
|
+
const cascadeDropArchiveIds = []
|
|
1054
|
+
const statusBatch = []
|
|
1055
|
+
const coreSummaryById = new Map()
|
|
1056
|
+
const primaryActions = []
|
|
1057
|
+
|
|
1058
|
+
for (const a of gateResult.actions) {
|
|
1059
|
+
throwIfAborted(signal)
|
|
1060
|
+
if (a.action === 'core') {
|
|
1061
|
+
const id = Number(a.entry_id)
|
|
1062
|
+
const core = String(a.core_summary ?? '').replace(/\s+/g, ' ').trim().slice(0, CORE_SUMMARY_MAX)
|
|
1063
|
+
if (Number.isFinite(id) && core) coreSummaryById.set(id, core)
|
|
1064
|
+
} else {
|
|
1065
|
+
primaryActions.push(a)
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
const setCoreSummary = async (entryId, explicitSummary) => {
|
|
1070
|
+
const id = Number(entryId)
|
|
1071
|
+
if (!Number.isFinite(id)) return false
|
|
1072
|
+
let core = String(explicitSummary ?? '').replace(/\s+/g, ' ').trim().slice(0, CORE_SUMMARY_MAX)
|
|
1073
|
+
if (!core) return false
|
|
1074
|
+
await db.query(`UPDATE entries SET core_summary = $1 WHERE id = $2 AND is_root = 1`, [core, id])
|
|
1075
|
+
return true
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
for (const a of primaryActions) {
|
|
1079
|
+
throwIfAborted(signal)
|
|
1080
|
+
const id = Number(a.entry_id)
|
|
1081
|
+
if (!Number.isFinite(id)) continue
|
|
1082
|
+
const row = rowsById.get(id)
|
|
1083
|
+
if (!row) continue
|
|
1084
|
+
let accepted = false
|
|
1085
|
+
|
|
1086
|
+
try {
|
|
1087
|
+
const requiresCore = NON_ARCHIVE_VERBS.has(a.action)
|
|
1088
|
+
const coreId = requiredCoreIdForAction(a)
|
|
1089
|
+
const explicitCore = coreSummaryById.get(coreId) || coreSummaryById.get(id)
|
|
1090
|
+
if (requiresCore && !explicitCore) {
|
|
1091
|
+
stats.missing_core_summary += 1
|
|
1092
|
+
rejectedActionIds.push(id)
|
|
1093
|
+
process.stderr.write(`[cycle2] non-archive action rejected: missing explicit core line id=${id} action=${a.action}\n`)
|
|
1094
|
+
continue
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
// Cascade override: drop a tentatively-kept entry → archive.
|
|
1098
|
+
if (a.action === 'active' && cascadeVerdicts.get(id) === 'drop') {
|
|
1099
|
+
cascadeDropArchiveIds.push(id)
|
|
1100
|
+
accepted = true
|
|
1101
|
+
reviewedIds.push(id)
|
|
1102
|
+
continue
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
if (a.action === 'active') {
|
|
1106
|
+
if (row.status === 'pending') {
|
|
1107
|
+
statusBatch.push({ entry_id: id, new_status: 'active', was_pending: true })
|
|
1108
|
+
} else if (row.status === 'active') {
|
|
1109
|
+
stats.kept += 1
|
|
1110
|
+
}
|
|
1111
|
+
await setCoreSummary(id, explicitCore)
|
|
1112
|
+
accepted = true
|
|
1113
|
+
} else if (a.action === 'archived') {
|
|
1114
|
+
statusBatch.push({ entry_id: id, new_status: 'archived', was_pending: row.status === 'pending' })
|
|
1115
|
+
accepted = true
|
|
1116
|
+
} else if (a.action === 'update') {
|
|
1117
|
+
if (await applyUpdate(db, id, a.element, a.summary, { signal })) stats.updated += 1
|
|
1118
|
+
await setCoreSummary(id, explicitCore)
|
|
1119
|
+
accepted = true
|
|
1120
|
+
} else if (a.action === 'merge') {
|
|
1121
|
+
const sourceIds = Array.isArray(a.source_ids) ? a.source_ids : []
|
|
1122
|
+
const targetId = Number(a.target_id)
|
|
1123
|
+
if (!Number.isFinite(targetId) || sourceIds.length === 0) {
|
|
1124
|
+
stats.merge_rejected += 1
|
|
1125
|
+
rejectedActionIds.push(id)
|
|
1126
|
+
continue
|
|
1127
|
+
}
|
|
1128
|
+
if (targetId !== id && !sourceIds.map(Number).includes(id)) {
|
|
1129
|
+
stats.merge_rejected += 1
|
|
1130
|
+
rejectedActionIds.push(id)
|
|
1131
|
+
process.stderr.write(
|
|
1132
|
+
`[cycle2] merge rejected during apply: id=${id} target=${targetId} sources=${sourceIds.join(',')}\n`,
|
|
1133
|
+
)
|
|
1134
|
+
continue
|
|
1135
|
+
}
|
|
1136
|
+
// Bounded-erosion invariant: a merge may only consolidate entries
|
|
1137
|
+
// that are themselves candidates in this batch. Otherwise a single
|
|
1138
|
+
// rechecked active row could list source_ids pointing at active
|
|
1139
|
+
// entries outside the batch (e.g. ids drawn from the activeContext
|
|
1140
|
+
// reference list), and applyMerge would archive those too —
|
|
1141
|
+
// un-judged and beyond the rolling-recheck quota. Out-of-batch
|
|
1142
|
+
// target/source ids are rejected; a true duplicate of an existing
|
|
1143
|
+
// active entry is handled by the `archived` verdict instead.
|
|
1144
|
+
if (![targetId, ...sourceIds.map(Number)].every(mid => rowsById.has(mid))) {
|
|
1145
|
+
stats.merge_rejected += 1
|
|
1146
|
+
rejectedActionIds.push(id)
|
|
1147
|
+
process.stderr.write(
|
|
1148
|
+
`[cycle2] merge rejected: out-of-batch target/source (target=${targetId} sources=${sourceIds.join(',')})\n`,
|
|
1149
|
+
)
|
|
1150
|
+
continue
|
|
1151
|
+
}
|
|
1152
|
+
const moved = await applyMerge(db, targetId, sourceIds, { signal })
|
|
1153
|
+
throwIfAborted(signal)
|
|
1154
|
+
if (moved > 0) {
|
|
1155
|
+
stats.merged += moved
|
|
1156
|
+
if (typeof a.element === 'string' || typeof a.summary === 'string') {
|
|
1157
|
+
try { if (await applyUpdate(db, targetId, a.element, a.summary, { signal })) stats.updated += 1 }
|
|
1158
|
+
catch (err) {
|
|
1159
|
+
if (signal?.aborted) throw signal.reason ?? err
|
|
1160
|
+
process.stderr.write(`[cycle2] merge target update failed (target=${targetId}): ${err.message}\n`)
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
await setCoreSummary(targetId, explicitCore)
|
|
1164
|
+
accepted = true
|
|
1165
|
+
} else {
|
|
1166
|
+
stats.merge_rejected += 1
|
|
1167
|
+
rejectedActionIds.push(id)
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
if (accepted) reviewedIds.push(id)
|
|
1171
|
+
} catch (err) {
|
|
1172
|
+
if (signal?.aborted) throw signal.reason ?? err
|
|
1173
|
+
process.stderr.write(`[cycle2] action error (id=${id}): ${err.message}\n`)
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
if (statusBatch.length > 0) {
|
|
1178
|
+
// Status verdicts are applied as one SQL batch; checkpoint before the
|
|
1179
|
+
// batch and then again at the next cycle2 unit boundary.
|
|
1180
|
+
throwIfAborted(signal)
|
|
1181
|
+
const batchRes = await applyBatchStatusVerdicts(db, statusBatch, nowMs)
|
|
1182
|
+
stats.promoted += batchRes.promoted
|
|
1183
|
+
stats.archived += batchRes.archived
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
if (cascadeDropArchiveIds.length > 0) {
|
|
1187
|
+
throwIfAborted(signal)
|
|
1188
|
+
const r = await db.query(`UPDATE entries SET status = 'archived' WHERE id = ANY($1::bigint[]) AND is_root = 1`, [cascadeDropArchiveIds])
|
|
1189
|
+
stats.cascade.dropped += Number(r.rowCount ?? r.affectedRows ?? 0)
|
|
1190
|
+
stats.archived += Number(r.rowCount ?? r.affectedRows ?? 0)
|
|
1191
|
+
}
|
|
1192
|
+
if (reviewedIds.length > 0) {
|
|
1193
|
+
throwIfAborted(signal)
|
|
1194
|
+
await db.query(`UPDATE entries SET reviewed_at = $1 WHERE id = ANY($2::bigint[])`, [sweepCursor, reviewedIds])
|
|
1195
|
+
}
|
|
1196
|
+
if (rejectedActionIds.length > 0) {
|
|
1197
|
+
throwIfAborted(signal)
|
|
1198
|
+
await db.query(
|
|
1199
|
+
`UPDATE entries SET error_count = COALESCE(error_count, 0) + 1 WHERE id = ANY($1::bigint[])`,
|
|
1200
|
+
[[...new Set(rejectedActionIds)]],
|
|
1201
|
+
)
|
|
1202
|
+
}
|
|
1203
|
+
} else if (rows.length > 0) {
|
|
1204
|
+
// Parse failure — bump error_count, do not advance reviewed_at.
|
|
1205
|
+
for (const r of rows) {
|
|
1206
|
+
throwIfAborted(signal)
|
|
1207
|
+
try {
|
|
1208
|
+
await db.query(
|
|
1209
|
+
`UPDATE entries SET error_count = COALESCE(error_count, 0) + 1 WHERE id = $1`,
|
|
1210
|
+
[r.id],
|
|
1211
|
+
)
|
|
1212
|
+
} catch {}
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
// Rejected verb rows: advance reviewed_at + bump error_count so an all-reject
|
|
1217
|
+
// batch does not loop forever. error_count ASC sort pushes them to the back.
|
|
1218
|
+
if (gateResult.rejected && gateResult.rejected.size > 0) {
|
|
1219
|
+
stats.rejected_verb = gateResult.rejected.size
|
|
1220
|
+
for (const id of gateResult.rejected) {
|
|
1221
|
+
throwIfAborted(signal)
|
|
1222
|
+
try {
|
|
1223
|
+
await db.query(`UPDATE entries SET reviewed_at = $1 WHERE id = $2`, [sweepCursor, id])
|
|
1224
|
+
await db.query(
|
|
1225
|
+
`UPDATE entries SET error_count = COALESCE(error_count, 0) + 1 WHERE id = $1`,
|
|
1226
|
+
[id],
|
|
1227
|
+
)
|
|
1228
|
+
} catch {}
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
// Flush embeddings BEFORE phase_merge: newly promoted/dirty roots have
|
|
1233
|
+
// NULL embeddings until the dirty queue drains, and runPhaseMerge filters
|
|
1234
|
+
// on `embedding IS NOT NULL` for both the cosine dedup and the core-overlap
|
|
1235
|
+
// pass. Running the flush after the merge would skip those rows for an
|
|
1236
|
+
// entire cycle. Reordering ensures same-cycle dedup/core-overlap sees them.
|
|
1237
|
+
try {
|
|
1238
|
+
throwIfAborted(signal)
|
|
1239
|
+
const d = await flushEmbeddingDirty(db, { signal })
|
|
1240
|
+
throwIfAborted(signal)
|
|
1241
|
+
if (d.attempted > 0) {
|
|
1242
|
+
process.stderr.write(
|
|
1243
|
+
`[cycle2] embedding flush attempted=${d.attempted} ok=${d.succeeded} failed=${d.failed.length}\n`,
|
|
1244
|
+
)
|
|
1245
|
+
}
|
|
1246
|
+
} catch (err) {
|
|
1247
|
+
if (signal?.aborted) throw signal.reason ?? err
|
|
1248
|
+
process.stderr.write(`[cycle2] embedding flush failed: ${err.message}\n`)
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
// phase_merge: cosine dedup over active entries.
|
|
1252
|
+
const phaseMergeStats = await runPhaseMerge(db, { ...options, signal })
|
|
1253
|
+
throwIfAborted(signal)
|
|
1254
|
+
stats.phase_merge = phaseMergeStats
|
|
1255
|
+
|
|
1256
|
+
// Active-cap enforcement is delegated to the gate (phases 1-3): the prompt
|
|
1257
|
+
// exposes Active/cap counts and instructs aggressive `archived` verdicts on
|
|
1258
|
+
// overflow. No deterministic safety net here — if the gate ever fails to
|
|
1259
|
+
// contain growth, fix the prompt, not bolt a fallback back on.
|
|
1260
|
+
|
|
1261
|
+
process.stderr.write(
|
|
1262
|
+
`[cycle2] rescore=${stats.rescore.updated}` +
|
|
1263
|
+
` core_backfill=${stats.core_embedding_backfill}` +
|
|
1264
|
+
` active=${activeCount}/${activeTargetCap} review_active=${reviewActiveRows ? 1 : 0}` +
|
|
1265
|
+
` | gate promoted=${stats.promoted} archived=${stats.archived}` +
|
|
1266
|
+
` updated=${stats.updated} kept=${stats.kept}` +
|
|
1267
|
+
` rejected_verb=${stats.rejected_verb} merge_rejected=${stats.merge_rejected}` +
|
|
1268
|
+
` missing_core=${stats.missing_core_summary}` +
|
|
1269
|
+
` | cascade eval=${stats.cascade.evaluated} drop=${stats.cascade.dropped}` +
|
|
1270
|
+
` | phase_merge merged=${stats.phase_merge.merged} core_overlap=${stats.phase_merge.core_overlap || 0}` +
|
|
1271
|
+
` llm=${stats.phase_merge.llm_calls}\n`,
|
|
1272
|
+
)
|
|
1273
|
+
|
|
1274
|
+
return stats
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
export function parseInterval(s) {
|
|
1278
|
+
if (String(s).toLowerCase() === 'immediate') return 0
|
|
1279
|
+
const match = String(s).match(/^(\d+)(s|m|h)$/)
|
|
1280
|
+
if (!match) throw new Error(`[memory-cycle2] invalid interval config: ${s}`)
|
|
1281
|
+
const [, num, unit] = match
|
|
1282
|
+
const multiplier = { s: 1000, m: 60000, h: 3600000 }
|
|
1283
|
+
return Number(num) * multiplier[unit]
|
|
1284
|
+
}
|