nodedex 0.1.0
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/adapters/claude-code-watcher.mjs +336 -0
- package/adapters/hermes-statedb-watcher.mjs +234 -0
- package/adapters/nodedex-capture-core.mjs +129 -0
- package/adapters/nodedex-capture.mjs +169 -0
- package/dist/agent-protocol.d.ts +7 -0
- package/dist/agent-protocol.d.ts.map +1 -0
- package/dist/agent-protocol.js +38 -0
- package/dist/agent-protocol.js.map +1 -0
- package/dist/api-server.d.ts +5 -0
- package/dist/api-server.d.ts.map +1 -0
- package/dist/api-server.js +351 -0
- package/dist/api-server.js.map +1 -0
- package/dist/boot-env.d.ts +2 -0
- package/dist/boot-env.d.ts.map +1 -0
- package/dist/boot-env.js +12 -0
- package/dist/boot-env.js.map +1 -0
- package/dist/engine/__tests__/search-core.test.d.ts +2 -0
- package/dist/engine/__tests__/search-core.test.d.ts.map +1 -0
- package/dist/engine/__tests__/search-core.test.js +139 -0
- package/dist/engine/__tests__/search-core.test.js.map +1 -0
- package/dist/engine/ai-provider.d.ts +45 -0
- package/dist/engine/ai-provider.d.ts.map +1 -0
- package/dist/engine/ai-provider.js +5 -0
- package/dist/engine/ai-provider.js.map +1 -0
- package/dist/engine/embeddings.d.ts +51 -0
- package/dist/engine/embeddings.d.ts.map +1 -0
- package/dist/engine/embeddings.js +89 -0
- package/dist/engine/embeddings.js.map +1 -0
- package/dist/engine/providers/__tests__/failure-policy.test.d.ts +2 -0
- package/dist/engine/providers/__tests__/failure-policy.test.d.ts.map +1 -0
- package/dist/engine/providers/__tests__/failure-policy.test.js +134 -0
- package/dist/engine/providers/__tests__/failure-policy.test.js.map +1 -0
- package/dist/engine/providers/__tests__/model-caps.test.d.ts +2 -0
- package/dist/engine/providers/__tests__/model-caps.test.d.ts.map +1 -0
- package/dist/engine/providers/__tests__/model-caps.test.js +38 -0
- package/dist/engine/providers/__tests__/model-caps.test.js.map +1 -0
- package/dist/engine/providers/__tests__/openai-structured.test.d.ts +2 -0
- package/dist/engine/providers/__tests__/openai-structured.test.d.ts.map +1 -0
- package/dist/engine/providers/__tests__/openai-structured.test.js +73 -0
- package/dist/engine/providers/__tests__/openai-structured.test.js.map +1 -0
- package/dist/engine/providers/__tests__/usage-ledger.test.d.ts +2 -0
- package/dist/engine/providers/__tests__/usage-ledger.test.d.ts.map +1 -0
- package/dist/engine/providers/__tests__/usage-ledger.test.js +108 -0
- package/dist/engine/providers/__tests__/usage-ledger.test.js.map +1 -0
- package/dist/engine/providers/anthropic.d.ts +17 -0
- package/dist/engine/providers/anthropic.d.ts.map +1 -0
- package/dist/engine/providers/anthropic.js +125 -0
- package/dist/engine/providers/anthropic.js.map +1 -0
- package/dist/engine/providers/failure-policy.d.ts +56 -0
- package/dist/engine/providers/failure-policy.d.ts.map +1 -0
- package/dist/engine/providers/failure-policy.js +120 -0
- package/dist/engine/providers/failure-policy.js.map +1 -0
- package/dist/engine/providers/gemini.d.ts +22 -0
- package/dist/engine/providers/gemini.d.ts.map +1 -0
- package/dist/engine/providers/gemini.js +180 -0
- package/dist/engine/providers/gemini.js.map +1 -0
- package/dist/engine/providers/index.d.ts +8 -0
- package/dist/engine/providers/index.d.ts.map +1 -0
- package/dist/engine/providers/index.js +67 -0
- package/dist/engine/providers/index.js.map +1 -0
- package/dist/engine/providers/local.d.ts +12 -0
- package/dist/engine/providers/local.d.ts.map +1 -0
- package/dist/engine/providers/local.js +46 -0
- package/dist/engine/providers/local.js.map +1 -0
- package/dist/engine/providers/model-caps.d.ts +6 -0
- package/dist/engine/providers/model-caps.d.ts.map +1 -0
- package/dist/engine/providers/model-caps.js +49 -0
- package/dist/engine/providers/model-caps.js.map +1 -0
- package/dist/engine/providers/openai.d.ts +30 -0
- package/dist/engine/providers/openai.d.ts.map +1 -0
- package/dist/engine/providers/openai.js +309 -0
- package/dist/engine/providers/openai.js.map +1 -0
- package/dist/engine/providers/usage-ledger.d.ts +69 -0
- package/dist/engine/providers/usage-ledger.d.ts.map +1 -0
- package/dist/engine/providers/usage-ledger.js +209 -0
- package/dist/engine/providers/usage-ledger.js.map +1 -0
- package/dist/engine/search-core.d.ts +40 -0
- package/dist/engine/search-core.d.ts.map +1 -0
- package/dist/engine/search-core.js +109 -0
- package/dist/engine/search-core.js.map +1 -0
- package/dist/engine/vector-math.d.ts +5 -0
- package/dist/engine/vector-math.d.ts.map +1 -0
- package/dist/engine/vector-math.js +25 -0
- package/dist/engine/vector-math.js.map +1 -0
- package/dist/home-env.d.ts +26 -0
- package/dist/home-env.d.ts.map +1 -0
- package/dist/home-env.js +87 -0
- package/dist/home-env.js.map +1 -0
- package/dist/mcp-server.d.ts +13 -0
- package/dist/mcp-server.d.ts.map +1 -0
- package/dist/mcp-server.js +79 -0
- package/dist/mcp-server.js.map +1 -0
- package/dist/middleware/auth.d.ts +23 -0
- package/dist/middleware/auth.d.ts.map +1 -0
- package/dist/middleware/auth.js +104 -0
- package/dist/middleware/auth.js.map +1 -0
- package/dist/middleware/auto-recall.d.ts +7 -0
- package/dist/middleware/auto-recall.d.ts.map +1 -0
- package/dist/middleware/auto-recall.js +257 -0
- package/dist/middleware/auto-recall.js.map +1 -0
- package/dist/middleware/auto-reflect.d.ts +4 -0
- package/dist/middleware/auto-reflect.d.ts.map +1 -0
- package/dist/middleware/auto-reflect.js +5 -0
- package/dist/middleware/auto-reflect.js.map +1 -0
- package/dist/middleware/reflect/apply-flag-verdict.d.ts +27 -0
- package/dist/middleware/reflect/apply-flag-verdict.d.ts.map +1 -0
- package/dist/middleware/reflect/apply-flag-verdict.js +57 -0
- package/dist/middleware/reflect/apply-flag-verdict.js.map +1 -0
- package/dist/middleware/reflect/arc-entity-resolve.d.ts +29 -0
- package/dist/middleware/reflect/arc-entity-resolve.d.ts.map +1 -0
- package/dist/middleware/reflect/arc-entity-resolve.js +356 -0
- package/dist/middleware/reflect/arc-entity-resolve.js.map +1 -0
- package/dist/middleware/reflect/arc-inactivity-timer.d.ts +47 -0
- package/dist/middleware/reflect/arc-inactivity-timer.d.ts.map +1 -0
- package/dist/middleware/reflect/arc-inactivity-timer.js +175 -0
- package/dist/middleware/reflect/arc-inactivity-timer.js.map +1 -0
- package/dist/middleware/reflect/arc-pipeline.d.ts +33 -0
- package/dist/middleware/reflect/arc-pipeline.d.ts.map +1 -0
- package/dist/middleware/reflect/arc-pipeline.js +498 -0
- package/dist/middleware/reflect/arc-pipeline.js.map +1 -0
- package/dist/middleware/reflect/comprehend-pergroup.d.ts +100 -0
- package/dist/middleware/reflect/comprehend-pergroup.d.ts.map +1 -0
- package/dist/middleware/reflect/comprehend-pergroup.js +610 -0
- package/dist/middleware/reflect/comprehend-pergroup.js.map +1 -0
- package/dist/middleware/reflect/comprehend.d.ts +237 -0
- package/dist/middleware/reflect/comprehend.d.ts.map +1 -0
- package/dist/middleware/reflect/comprehend.js +706 -0
- package/dist/middleware/reflect/comprehend.js.map +1 -0
- package/dist/middleware/reflect/config.d.ts +34 -0
- package/dist/middleware/reflect/config.d.ts.map +1 -0
- package/dist/middleware/reflect/config.js +131 -0
- package/dist/middleware/reflect/config.js.map +1 -0
- package/dist/middleware/reflect/context.d.ts +138 -0
- package/dist/middleware/reflect/context.d.ts.map +1 -0
- package/dist/middleware/reflect/context.js +619 -0
- package/dist/middleware/reflect/context.js.map +1 -0
- package/dist/middleware/reflect/cost-breakdown.d.ts +69 -0
- package/dist/middleware/reflect/cost-breakdown.d.ts.map +1 -0
- package/dist/middleware/reflect/cost-breakdown.js +63 -0
- package/dist/middleware/reflect/cost-breakdown.js.map +1 -0
- package/dist/middleware/reflect/cost-guard.d.ts +102 -0
- package/dist/middleware/reflect/cost-guard.d.ts.map +1 -0
- package/dist/middleware/reflect/cost-guard.js +243 -0
- package/dist/middleware/reflect/cost-guard.js.map +1 -0
- package/dist/middleware/reflect/cost-pricing.d.ts +54 -0
- package/dist/middleware/reflect/cost-pricing.d.ts.map +1 -0
- package/dist/middleware/reflect/cost-pricing.js +148 -0
- package/dist/middleware/reflect/cost-pricing.js.map +1 -0
- package/dist/middleware/reflect/cross-group-link.d.ts +61 -0
- package/dist/middleware/reflect/cross-group-link.d.ts.map +1 -0
- package/dist/middleware/reflect/cross-group-link.js +212 -0
- package/dist/middleware/reflect/cross-group-link.js.map +1 -0
- package/dist/middleware/reflect/dedup-by-source-and-value.d.ts +70 -0
- package/dist/middleware/reflect/dedup-by-source-and-value.d.ts.map +1 -0
- package/dist/middleware/reflect/dedup-by-source-and-value.js +0 -0
- package/dist/middleware/reflect/dedup-by-source-and-value.js.map +1 -0
- package/dist/middleware/reflect/describe-roots.d.ts +58 -0
- package/dist/middleware/reflect/describe-roots.d.ts.map +1 -0
- package/dist/middleware/reflect/describe-roots.js +266 -0
- package/dist/middleware/reflect/describe-roots.js.map +1 -0
- package/dist/middleware/reflect/flag-reviewer-startup.d.ts +16 -0
- package/dist/middleware/reflect/flag-reviewer-startup.d.ts.map +1 -0
- package/dist/middleware/reflect/flag-reviewer-startup.js +107 -0
- package/dist/middleware/reflect/flag-reviewer-startup.js.map +1 -0
- package/dist/middleware/reflect/flag-reviewer.d.ts +69 -0
- package/dist/middleware/reflect/flag-reviewer.d.ts.map +1 -0
- package/dist/middleware/reflect/flag-reviewer.js +520 -0
- package/dist/middleware/reflect/flag-reviewer.js.map +1 -0
- package/dist/middleware/reflect/inline-dedup.d.ts +26 -0
- package/dist/middleware/reflect/inline-dedup.d.ts.map +1 -0
- package/dist/middleware/reflect/inline-dedup.js +131 -0
- package/dist/middleware/reflect/inline-dedup.js.map +1 -0
- package/dist/middleware/reflect/justify-decisions.d.ts +37 -0
- package/dist/middleware/reflect/justify-decisions.d.ts.map +1 -0
- package/dist/middleware/reflect/justify-decisions.js +159 -0
- package/dist/middleware/reflect/justify-decisions.js.map +1 -0
- package/dist/middleware/reflect/nl-accept.d.ts +35 -0
- package/dist/middleware/reflect/nl-accept.d.ts.map +1 -0
- package/dist/middleware/reflect/nl-accept.js +167 -0
- package/dist/middleware/reflect/nl-accept.js.map +1 -0
- package/dist/middleware/reflect/pass0.d.ts +20 -0
- package/dist/middleware/reflect/pass0.d.ts.map +1 -0
- package/dist/middleware/reflect/pass0.js +423 -0
- package/dist/middleware/reflect/pass0.js.map +1 -0
- package/dist/middleware/reflect/pass1.d.ts +17 -0
- package/dist/middleware/reflect/pass1.d.ts.map +1 -0
- package/dist/middleware/reflect/pass1.js +241 -0
- package/dist/middleware/reflect/pass1.js.map +1 -0
- package/dist/middleware/reflect/pass2-quarantine.d.ts +129 -0
- package/dist/middleware/reflect/pass2-quarantine.d.ts.map +1 -0
- package/dist/middleware/reflect/pass2-quarantine.js +272 -0
- package/dist/middleware/reflect/pass2-quarantine.js.map +1 -0
- package/dist/middleware/reflect/pass2-seams.d.ts +205 -0
- package/dist/middleware/reflect/pass2-seams.d.ts.map +1 -0
- package/dist/middleware/reflect/pass2-seams.js +279 -0
- package/dist/middleware/reflect/pass2-seams.js.map +1 -0
- package/dist/middleware/reflect/pass2-split-orchestrator.d.ts +37 -0
- package/dist/middleware/reflect/pass2-split-orchestrator.d.ts.map +1 -0
- package/dist/middleware/reflect/pass2-split-orchestrator.js +531 -0
- package/dist/middleware/reflect/pass2-split-orchestrator.js.map +1 -0
- package/dist/middleware/reflect/pass2.d.ts +17 -0
- package/dist/middleware/reflect/pass2.d.ts.map +1 -0
- package/dist/middleware/reflect/pass2.js +324 -0
- package/dist/middleware/reflect/pass2.js.map +1 -0
- package/dist/middleware/reflect/pass2a.d.ts +141 -0
- package/dist/middleware/reflect/pass2a.d.ts.map +1 -0
- package/dist/middleware/reflect/pass2a.js +404 -0
- package/dist/middleware/reflect/pass2a.js.map +1 -0
- package/dist/middleware/reflect/pass2b.d.ts +108 -0
- package/dist/middleware/reflect/pass2b.d.ts.map +1 -0
- package/dist/middleware/reflect/pass2b.js +480 -0
- package/dist/middleware/reflect/pass2b.js.map +1 -0
- package/dist/middleware/reflect/pass2c.d.ts +113 -0
- package/dist/middleware/reflect/pass2c.d.ts.map +1 -0
- package/dist/middleware/reflect/pass2c.js +360 -0
- package/dist/middleware/reflect/pass2c.js.map +1 -0
- package/dist/middleware/reflect/pass3-batch.d.ts +62 -0
- package/dist/middleware/reflect/pass3-batch.d.ts.map +1 -0
- package/dist/middleware/reflect/pass3-batch.js +139 -0
- package/dist/middleware/reflect/pass3-batch.js.map +1 -0
- package/dist/middleware/reflect/pass3.d.ts +23 -0
- package/dist/middleware/reflect/pass3.d.ts.map +1 -0
- package/dist/middleware/reflect/pass3.js +371 -0
- package/dist/middleware/reflect/pass3.js.map +1 -0
- package/dist/middleware/reflect/pass4-slice.d.ts +25 -0
- package/dist/middleware/reflect/pass4-slice.d.ts.map +1 -0
- package/dist/middleware/reflect/pass4-slice.js +315 -0
- package/dist/middleware/reflect/pass4-slice.js.map +1 -0
- package/dist/middleware/reflect/pass4.d.ts +30 -0
- package/dist/middleware/reflect/pass4.d.ts.map +1 -0
- package/dist/middleware/reflect/pass4.js +193 -0
- package/dist/middleware/reflect/pass4.js.map +1 -0
- package/dist/middleware/reflect/pass5.d.ts +22 -0
- package/dist/middleware/reflect/pass5.d.ts.map +1 -0
- package/dist/middleware/reflect/pass5.js +178 -0
- package/dist/middleware/reflect/pass5.js.map +1 -0
- package/dist/middleware/reflect/pass_judge.d.ts +44 -0
- package/dist/middleware/reflect/pass_judge.d.ts.map +1 -0
- package/dist/middleware/reflect/pass_judge.js +263 -0
- package/dist/middleware/reflect/pass_judge.js.map +1 -0
- package/dist/middleware/reflect/pipeline-flags.d.ts +140 -0
- package/dist/middleware/reflect/pipeline-flags.d.ts.map +1 -0
- package/dist/middleware/reflect/pipeline-flags.js +314 -0
- package/dist/middleware/reflect/pipeline-flags.js.map +1 -0
- package/dist/middleware/reflect/pipeline.d.ts +237 -0
- package/dist/middleware/reflect/pipeline.d.ts.map +1 -0
- package/dist/middleware/reflect/pipeline.js +3114 -0
- package/dist/middleware/reflect/pipeline.js.map +1 -0
- package/dist/middleware/reflect/promptOverride.d.ts +14 -0
- package/dist/middleware/reflect/promptOverride.d.ts.map +1 -0
- package/dist/middleware/reflect/promptOverride.js +28 -0
- package/dist/middleware/reflect/promptOverride.js.map +1 -0
- package/dist/middleware/reflect/provenance-check.d.ts +48 -0
- package/dist/middleware/reflect/provenance-check.d.ts.map +1 -0
- package/dist/middleware/reflect/provenance-check.js +180 -0
- package/dist/middleware/reflect/provenance-check.js.map +1 -0
- package/dist/middleware/reflect/provenance-reviewer.d.ts +52 -0
- package/dist/middleware/reflect/provenance-reviewer.d.ts.map +1 -0
- package/dist/middleware/reflect/provenance-reviewer.js +253 -0
- package/dist/middleware/reflect/provenance-reviewer.js.map +1 -0
- package/dist/middleware/reflect/prune-collapsed-types.d.ts +11 -0
- package/dist/middleware/reflect/prune-collapsed-types.d.ts.map +1 -0
- package/dist/middleware/reflect/prune-collapsed-types.js +32 -0
- package/dist/middleware/reflect/prune-collapsed-types.js.map +1 -0
- package/dist/middleware/reflect/recognize-root.d.ts +75 -0
- package/dist/middleware/reflect/recognize-root.d.ts.map +1 -0
- package/dist/middleware/reflect/recognize-root.js +204 -0
- package/dist/middleware/reflect/recognize-root.js.map +1 -0
- package/dist/middleware/reflect/render-agent-flag.d.ts +25 -0
- package/dist/middleware/reflect/render-agent-flag.d.ts.map +1 -0
- package/dist/middleware/reflect/render-agent-flag.js +39 -0
- package/dist/middleware/reflect/render-agent-flag.js.map +1 -0
- package/dist/middleware/reflect/retrieve-graph-slice.d.ts +54 -0
- package/dist/middleware/reflect/retrieve-graph-slice.d.ts.map +1 -0
- package/dist/middleware/reflect/retrieve-graph-slice.js +173 -0
- package/dist/middleware/reflect/retrieve-graph-slice.js.map +1 -0
- package/dist/middleware/reflect/root-relatedness.d.ts +31 -0
- package/dist/middleware/reflect/root-relatedness.d.ts.map +1 -0
- package/dist/middleware/reflect/root-relatedness.js +92 -0
- package/dist/middleware/reflect/root-relatedness.js.map +1 -0
- package/dist/middleware/reflect/schema-heal.d.ts +22 -0
- package/dist/middleware/reflect/schema-heal.d.ts.map +1 -0
- package/dist/middleware/reflect/schema-heal.js +119 -0
- package/dist/middleware/reflect/schema-heal.js.map +1 -0
- package/dist/middleware/reflect/schema-validator.d.ts +85 -0
- package/dist/middleware/reflect/schema-validator.d.ts.map +1 -0
- package/dist/middleware/reflect/schema-validator.js +196 -0
- package/dist/middleware/reflect/schema-validator.js.map +1 -0
- package/dist/middleware/reflect/stage-audit-graph.d.ts +115 -0
- package/dist/middleware/reflect/stage-audit-graph.d.ts.map +1 -0
- package/dist/middleware/reflect/stage-audit-graph.js +563 -0
- package/dist/middleware/reflect/stage-audit-graph.js.map +1 -0
- package/dist/middleware/reflect/stage-d-resolve-graph.d.ts +87 -0
- package/dist/middleware/reflect/stage-d-resolve-graph.d.ts.map +1 -0
- package/dist/middleware/reflect/stage-d-resolve-graph.js +256 -0
- package/dist/middleware/reflect/stage-d-resolve-graph.js.map +1 -0
- package/dist/middleware/reflect/synthesizeFromSceneCard.d.ts +15 -0
- package/dist/middleware/reflect/synthesizeFromSceneCard.d.ts.map +1 -0
- package/dist/middleware/reflect/synthesizeFromSceneCard.js +91 -0
- package/dist/middleware/reflect/synthesizeFromSceneCard.js.map +1 -0
- package/dist/middleware/reflect/types.d.ts +261 -0
- package/dist/middleware/reflect/types.d.ts.map +1 -0
- package/dist/middleware/reflect/types.js +3 -0
- package/dist/middleware/reflect/types.js.map +1 -0
- package/dist/middleware/reflect/v2-integrate.d.ts +120 -0
- package/dist/middleware/reflect/v2-integrate.d.ts.map +1 -0
- package/dist/middleware/reflect/v2-integrate.js +388 -0
- package/dist/middleware/reflect/v2-integrate.js.map +1 -0
- package/dist/middleware/reflect/v2-judge.d.ts +44 -0
- package/dist/middleware/reflect/v2-judge.d.ts.map +1 -0
- package/dist/middleware/reflect/v2-judge.js +191 -0
- package/dist/middleware/reflect/v2-judge.js.map +1 -0
- package/dist/relation-sets.d.ts +2 -0
- package/dist/relation-sets.d.ts.map +1 -0
- package/dist/relation-sets.js +35 -0
- package/dist/relation-sets.js.map +1 -0
- package/dist/routes/__tests__/flags.test.d.ts +2 -0
- package/dist/routes/__tests__/flags.test.d.ts.map +1 -0
- package/dist/routes/__tests__/flags.test.js +257 -0
- package/dist/routes/__tests__/flags.test.js.map +1 -0
- package/dist/routes/__tests__/models-catalog.test.d.ts +2 -0
- package/dist/routes/__tests__/models-catalog.test.d.ts.map +1 -0
- package/dist/routes/__tests__/models-catalog.test.js +130 -0
- package/dist/routes/__tests__/models-catalog.test.js.map +1 -0
- package/dist/routes/__tests__/reflect-pause-drain.test.d.ts +2 -0
- package/dist/routes/__tests__/reflect-pause-drain.test.d.ts.map +1 -0
- package/dist/routes/__tests__/reflect-pause-drain.test.js +38 -0
- package/dist/routes/__tests__/reflect-pause-drain.test.js.map +1 -0
- package/dist/routes/__tests__/spend-pause-drain.test.d.ts +2 -0
- package/dist/routes/__tests__/spend-pause-drain.test.d.ts.map +1 -0
- package/dist/routes/__tests__/spend-pause-drain.test.js +38 -0
- package/dist/routes/__tests__/spend-pause-drain.test.js.map +1 -0
- package/dist/routes/admin.d.ts +49 -0
- package/dist/routes/admin.d.ts.map +1 -0
- package/dist/routes/admin.js +471 -0
- package/dist/routes/admin.js.map +1 -0
- package/dist/routes/blocks.d.ts +4 -0
- package/dist/routes/blocks.d.ts.map +1 -0
- package/dist/routes/blocks.js +893 -0
- package/dist/routes/blocks.js.map +1 -0
- package/dist/routes/chat-proxy.d.ts +5 -0
- package/dist/routes/chat-proxy.d.ts.map +1 -0
- package/dist/routes/chat-proxy.js +225 -0
- package/dist/routes/chat-proxy.js.map +1 -0
- package/dist/routes/conversations.d.ts +4 -0
- package/dist/routes/conversations.d.ts.map +1 -0
- package/dist/routes/conversations.js +139 -0
- package/dist/routes/conversations.js.map +1 -0
- package/dist/routes/flags.d.ts +4 -0
- package/dist/routes/flags.d.ts.map +1 -0
- package/dist/routes/flags.js +151 -0
- package/dist/routes/flags.js.map +1 -0
- package/dist/routes/inject.d.ts +4 -0
- package/dist/routes/inject.d.ts.map +1 -0
- package/dist/routes/inject.js +183 -0
- package/dist/routes/inject.js.map +1 -0
- package/dist/routes/mcp-http.d.ts +5 -0
- package/dist/routes/mcp-http.d.ts.map +1 -0
- package/dist/routes/mcp-http.js +94 -0
- package/dist/routes/mcp-http.js.map +1 -0
- package/dist/routes/quarantine.d.ts +4 -0
- package/dist/routes/quarantine.d.ts.map +1 -0
- package/dist/routes/quarantine.js +66 -0
- package/dist/routes/quarantine.js.map +1 -0
- package/dist/routes/recall.d.ts +5 -0
- package/dist/routes/recall.d.ts.map +1 -0
- package/dist/routes/recall.js +573 -0
- package/dist/routes/recall.js.map +1 -0
- package/dist/routes/reflect.d.ts +5 -0
- package/dist/routes/reflect.d.ts.map +1 -0
- package/dist/routes/reflect.js +231 -0
- package/dist/routes/reflect.js.map +1 -0
- package/dist/routes/session.d.ts +4 -0
- package/dist/routes/session.d.ts.map +1 -0
- package/dist/routes/session.js +418 -0
- package/dist/routes/session.js.map +1 -0
- package/dist/routes/state.d.ts +116 -0
- package/dist/routes/state.d.ts.map +1 -0
- package/dist/routes/state.js +621 -0
- package/dist/routes/state.js.map +1 -0
- package/dist/routes/usage.d.ts +3 -0
- package/dist/routes/usage.d.ts.map +1 -0
- package/dist/routes/usage.js +141 -0
- package/dist/routes/usage.js.map +1 -0
- package/dist/routes/workspace.d.ts +5 -0
- package/dist/routes/workspace.d.ts.map +1 -0
- package/dist/routes/workspace.js +435 -0
- package/dist/routes/workspace.js.map +1 -0
- package/dist/server.d.ts +13 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +298 -0
- package/dist/server.js.map +1 -0
- package/dist/store/__tests__/backup.test.d.ts +2 -0
- package/dist/store/__tests__/backup.test.d.ts.map +1 -0
- package/dist/store/__tests__/backup.test.js +53 -0
- package/dist/store/__tests__/backup.test.js.map +1 -0
- package/dist/store/__tests__/quality.test.d.ts +2 -0
- package/dist/store/__tests__/quality.test.d.ts.map +1 -0
- package/dist/store/__tests__/quality.test.js +75 -0
- package/dist/store/__tests__/quality.test.js.map +1 -0
- package/dist/store/backup.d.ts +14 -0
- package/dist/store/backup.d.ts.map +1 -0
- package/dist/store/backup.js +95 -0
- package/dist/store/backup.js.map +1 -0
- package/dist/store/database.d.ts +407 -0
- package/dist/store/database.d.ts.map +1 -0
- package/dist/store/database.js +2004 -0
- package/dist/store/database.js.map +1 -0
- package/dist/store/quality.d.ts +25 -0
- package/dist/store/quality.d.ts.map +1 -0
- package/dist/store/quality.js +48 -0
- package/dist/store/quality.js.map +1 -0
- package/dist/tools/__tests__/assemble-block-chains.test.d.ts +2 -0
- package/dist/tools/__tests__/assemble-block-chains.test.d.ts.map +1 -0
- package/dist/tools/__tests__/assemble-block-chains.test.js +118 -0
- package/dist/tools/__tests__/assemble-block-chains.test.js.map +1 -0
- package/dist/tools/__tests__/filter-roots-by-concepts.test.d.ts +2 -0
- package/dist/tools/__tests__/filter-roots-by-concepts.test.d.ts.map +1 -0
- package/dist/tools/__tests__/filter-roots-by-concepts.test.js +68 -0
- package/dist/tools/__tests__/filter-roots-by-concepts.test.js.map +1 -0
- package/dist/tools/__tests__/flag-surface.test.d.ts +2 -0
- package/dist/tools/__tests__/flag-surface.test.d.ts.map +1 -0
- package/dist/tools/__tests__/flag-surface.test.js +130 -0
- package/dist/tools/__tests__/flag-surface.test.js.map +1 -0
- package/dist/tools/core.d.ts +5 -0
- package/dist/tools/core.d.ts.map +1 -0
- package/dist/tools/core.js +962 -0
- package/dist/tools/core.js.map +1 -0
- package/dist/tools/derive.d.ts +5 -0
- package/dist/tools/derive.d.ts.map +1 -0
- package/dist/tools/derive.js +182 -0
- package/dist/tools/derive.js.map +1 -0
- package/dist/tools/flag-surface.d.ts +26 -0
- package/dist/tools/flag-surface.d.ts.map +1 -0
- package/dist/tools/flag-surface.js +59 -0
- package/dist/tools/flag-surface.js.map +1 -0
- package/dist/tools/helpers.d.ts +99 -0
- package/dist/tools/helpers.d.ts.map +1 -0
- package/dist/tools/helpers.js +243 -0
- package/dist/tools/helpers.js.map +1 -0
- package/dist/tools/projects.d.ts +5 -0
- package/dist/tools/projects.d.ts.map +1 -0
- package/dist/tools/projects.js +175 -0
- package/dist/tools/projects.js.map +1 -0
- package/dist/tools/system.d.ts +5 -0
- package/dist/tools/system.d.ts.map +1 -0
- package/dist/tools/system.js +1361 -0
- package/dist/tools/system.js.map +1 -0
- package/dist/tools/tasks.d.ts +5 -0
- package/dist/tools/tasks.d.ts.map +1 -0
- package/dist/tools/tasks.js +289 -0
- package/dist/tools/tasks.js.map +1 -0
- package/package.json +69 -0
- package/scripts/nodedex-entry.mjs +396 -0
- package/tui-dist/App.js +185 -0
- package/tui-dist/api.js +197 -0
- package/tui-dist/cli.js +53 -0
- package/tui-dist/components.js +63 -0
- package/tui-dist/config.js +242 -0
- package/tui-dist/connect-snippets.js +98 -0
- package/tui-dist/feed.js +51 -0
- package/tui-dist/health.js +465 -0
- package/tui-dist/hooks.js +23 -0
- package/tui-dist/memory.js +220 -0
- package/tui-dist/onboarding.js +498 -0
- package/tui-dist/review.js +193 -0
- package/tui-dist/servers.js +556 -0
- package/tui-dist/smoke.js +15 -0
- package/tui-dist/theme.js +106 -0
|
@@ -0,0 +1,3114 @@
|
|
|
1
|
+
import { stampQualityScore } from "../../store/quality.js";
|
|
2
|
+
import { getLLMProvider } from "../../engine/providers/index.js";
|
|
3
|
+
import { blockEmbeddingText } from "../../engine/embeddings.js";
|
|
4
|
+
import { v4 as uuidv4 } from "uuid";
|
|
5
|
+
import * as fs from "fs";
|
|
6
|
+
import * as path from "path";
|
|
7
|
+
import { buildProjectContext, buildPreSearchContext, buildDuplicateAlerts, buildItemContext, reflectTokenStats, embeddingStats } from "./context.js";
|
|
8
|
+
import { getThinkingBudget } from "./config.js";
|
|
9
|
+
import { callPass2LLM } from "./pass2.js";
|
|
10
|
+
import { runPass2Split } from "./pass2-split-orchestrator.js";
|
|
11
|
+
import { buildCostBreakdown } from "./cost-breakdown.js";
|
|
12
|
+
import { validateUniqueSchema, schemaMismatchReason, demoteForSave } from "./schema-validator.js";
|
|
13
|
+
import { callPass3Batched } from "./pass3-batch.js";
|
|
14
|
+
import { applyArcEntityCanonicalNames } from "./arc-entity-resolve.js";
|
|
15
|
+
import { recognizeRootsForArc, applyRootRemap, recognizerEnabled } from "./recognize-root.js";
|
|
16
|
+
import { resolveArcEntitiesForItems } from "./stage-d-resolve-graph.js";
|
|
17
|
+
import { callPass4LLM, chunkForPass4 } from "./pass4.js";
|
|
18
|
+
import { buildPass4Slice, pass4SliceEnabled, pass4SliceMinGraph } from "./pass4-slice.js";
|
|
19
|
+
import { callPass5LLM } from "./pass5.js";
|
|
20
|
+
import { CAUSAL_TRAVERSAL_RELS } from "../../relation-sets.js";
|
|
21
|
+
import { dedupBySourceAndValue } from "./dedup-by-source-and-value.js";
|
|
22
|
+
import { writePipelineFlag } from "./pipeline-flags.js";
|
|
23
|
+
import { flagBlockExcerptInline } from "./provenance-check.js";
|
|
24
|
+
import { inlineDedupEnabled, dedupNewBlocksInline } from "./inline-dedup.js";
|
|
25
|
+
export { reflectTokenStats };
|
|
26
|
+
// ─── Reflect debug log ────────────────────────────────────────────────────────
|
|
27
|
+
const REFLECT_LOG_PATH = path.join(process.cwd(), "data", "reflect-last.json");
|
|
28
|
+
const REFLECT_TURNS_DIR = path.join(process.cwd(), "data", "reflect-turns");
|
|
29
|
+
export function getReflectLogPath() { return REFLECT_LOG_PATH; }
|
|
30
|
+
// Turn-log sequence number. Two failure modes fixed 2026-06-12:
|
|
31
|
+
// (1) the old `if (!checkpoint) _turnCounter++` guard meant v2 per-turn runs
|
|
32
|
+
// (which always arrive WITH a fresh checkpoint from the front-half) never
|
|
33
|
+
// advanced the counter → every run overwrote turn-00.json;
|
|
34
|
+
// (2) the counter was process-local starting at 0 → every server restart
|
|
35
|
+
// overwrote the previous process's logs.
|
|
36
|
+
// Now: the counter scan-initializes from the existing files once per process and
|
|
37
|
+
// increments at WRITE time — every written log gets a unique, monotonic file.
|
|
38
|
+
let _turnCounter = 0;
|
|
39
|
+
let _turnCounterInited = false;
|
|
40
|
+
/** Pure: next turn-log number given existing file names. Exported for tests. */
|
|
41
|
+
export function computeNextTurnNumber(names) {
|
|
42
|
+
let max = -1;
|
|
43
|
+
for (const n of names) {
|
|
44
|
+
const m = /^turn-(\d+)\.json$/.exec(n);
|
|
45
|
+
if (m) {
|
|
46
|
+
const v = parseInt(m[1], 10);
|
|
47
|
+
if (v > max)
|
|
48
|
+
max = v;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return max + 1;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Resolve a WITHIN-BATCH reference to a created block's label.
|
|
55
|
+
*
|
|
56
|
+
* A ref can be an item id — v1 `item_N` OR v2 `group::local_id` — or an
|
|
57
|
+
* existing graph label. The old resolvers gated on `startsWith("item_")`,
|
|
58
|
+
* a v1-ism: every v2 within-batch based_on / supersedes / semantic edge fell
|
|
59
|
+
* into the treat-as-label branch and was SILENTLY DROPPED at save
|
|
60
|
+
* (found 2026-06-13 via the graph-vs-log audit: all 5 decisions wired at item
|
|
61
|
+
* level, zero based_on edges in the graph; likely depressed v2 connectivity
|
|
62
|
+
* in every prior A/B). Item-map FIRST — it is authoritative for this batch,
|
|
63
|
+
* and demote-at-save updates it to the final label before it's read — then
|
|
64
|
+
* label fallback; an item-shaped ref that created no block must NEVER be
|
|
65
|
+
* treated as a label.
|
|
66
|
+
*/
|
|
67
|
+
export function resolveWithinBatchRefLabel(ref, itemIdToLabel) {
|
|
68
|
+
const viaMap = itemIdToLabel.get(ref);
|
|
69
|
+
if (viaMap)
|
|
70
|
+
return { label: viaMap, viaItemMap: true };
|
|
71
|
+
if (ref.startsWith("item_") || ref.includes("::"))
|
|
72
|
+
return null; // item ref with no created block
|
|
73
|
+
return { label: ref, viaItemMap: false };
|
|
74
|
+
}
|
|
75
|
+
/** Wipes the per-turn log directory — call at benchmark start. */
|
|
76
|
+
export function clearTurnLogs() {
|
|
77
|
+
try {
|
|
78
|
+
if (fs.existsSync(REFLECT_TURNS_DIR)) {
|
|
79
|
+
for (const f of fs.readdirSync(REFLECT_TURNS_DIR)) {
|
|
80
|
+
fs.unlinkSync(path.join(REFLECT_TURNS_DIR, f));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
_turnCounter = 0;
|
|
84
|
+
_turnCounterInited = true; // dir is known-empty — no rescan needed
|
|
85
|
+
}
|
|
86
|
+
catch { /* non-critical */ }
|
|
87
|
+
}
|
|
88
|
+
function writeReflectLog(entry) {
|
|
89
|
+
try {
|
|
90
|
+
fs.mkdirSync(path.dirname(REFLECT_LOG_PATH), { recursive: true });
|
|
91
|
+
fs.writeFileSync(REFLECT_LOG_PATH, JSON.stringify({ ts: new Date().toISOString(), ...entry }, null, 2));
|
|
92
|
+
}
|
|
93
|
+
catch { /* non-critical */ }
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Recover a drifted/missing `from_item_id` on Pass-3 `new_blocks` by type-matching each
|
|
97
|
+
* unlinked block to an as-yet-unclaimed Pass-2 item of the same type. Mirrors the
|
|
98
|
+
* save-loop fallback, but is hoisted ahead of the mandatory-item accounting guard so a
|
|
99
|
+
* single drifted id no longer discards the entire arc (every correctly-built block
|
|
100
|
+
* included). Mutates `newBlocks` in place; returns the count recovered.
|
|
101
|
+
*
|
|
102
|
+
* Only touches blocks whose `from_item_id` is absent or points at no real Pass-2 item —
|
|
103
|
+
* a valid id is never reassigned. Matching is greedy against unclaimed items, so the SET
|
|
104
|
+
* of from_item_ids ends up covering every accounted item even if an individual pairing
|
|
105
|
+
* is approximate (the block content was already built correctly by Pass 3; this only
|
|
106
|
+
* re-establishes the item↔block join used for accounting, relation-wiring, provenance).
|
|
107
|
+
*/
|
|
108
|
+
export function recoverDriftedFromItemIds(newBlocks, classified) {
|
|
109
|
+
if (!Array.isArray(newBlocks) || newBlocks.length === 0)
|
|
110
|
+
return 0;
|
|
111
|
+
const validIds = new Set(classified.map((i) => i.id));
|
|
112
|
+
const claimed = new Set(newBlocks
|
|
113
|
+
.map((b) => b.from_item_id)
|
|
114
|
+
.filter((id) => typeof id === "string" && validIds.has(id)));
|
|
115
|
+
let recovered = 0;
|
|
116
|
+
for (const b of newBlocks) {
|
|
117
|
+
if (typeof b.from_item_id === "string" && validIds.has(b.from_item_id))
|
|
118
|
+
continue;
|
|
119
|
+
const match = classified.find((i) => i.type === b.is_a && !claimed.has(i.id));
|
|
120
|
+
if (match) {
|
|
121
|
+
b.from_item_id = match.id;
|
|
122
|
+
claimed.add(match.id);
|
|
123
|
+
recovered++;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return recovered;
|
|
127
|
+
}
|
|
128
|
+
function writeTurnLog(turnData) {
|
|
129
|
+
try {
|
|
130
|
+
fs.mkdirSync(REFLECT_TURNS_DIR, { recursive: true });
|
|
131
|
+
if (!_turnCounterInited) {
|
|
132
|
+
_turnCounter = computeNextTurnNumber(fs.readdirSync(REFLECT_TURNS_DIR));
|
|
133
|
+
_turnCounterInited = true;
|
|
134
|
+
}
|
|
135
|
+
const n = String(_turnCounter).padStart(2, "0");
|
|
136
|
+
const turnPath = path.join(REFLECT_TURNS_DIR, `turn-${n}.json`);
|
|
137
|
+
// `turn` is the log SEQUENCE number (file identity), injected here so it can
|
|
138
|
+
// never disagree with the filename.
|
|
139
|
+
fs.writeFileSync(turnPath, JSON.stringify({ ...turnData, turn: _turnCounter }, null, 2));
|
|
140
|
+
_turnCounter++;
|
|
141
|
+
}
|
|
142
|
+
catch { /* non-critical */ }
|
|
143
|
+
}
|
|
144
|
+
// ─── Exported deterministic rules (tested in __tests__/pipeline-rules.test.ts) ─
|
|
145
|
+
/** Pre-populates extends_item from Pass 1 extends_id when Pass 2 left it blank. */
|
|
146
|
+
export function prePopulateExtendsItem(pass1Items, pass2Items) {
|
|
147
|
+
const pass1Map = new Map(pass1Items.map(i => [i.id, i]));
|
|
148
|
+
for (const classified of pass2Items) {
|
|
149
|
+
if (!classified.extends_item) {
|
|
150
|
+
const p1Item = pass1Map.get(classified.id);
|
|
151
|
+
if (p1Item?.extends_id)
|
|
152
|
+
classified.extends_item = p1Item.extends_id;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Seam contract: if Pass 2 changed an item's type but didn't set review_reason,
|
|
158
|
+
* stamp "type_override". Pass 2's prompt (pass2.ts:100) tells the model to set
|
|
159
|
+
* the flag when overriding, but the model sometimes forgets — and downstream
|
|
160
|
+
* consumers (Tier 1B validator, agent UI) can't distinguish a model-asserted
|
|
161
|
+
* reclassification from a kept Pass 1 type without it. Charter rule 4: don't
|
|
162
|
+
* coordinate via LLM restraint; enforce the seam contract in code.
|
|
163
|
+
*
|
|
164
|
+
* Stamp ONLY when (a) the Pass 1 item exists for this id, (b) types differ,
|
|
165
|
+
* (c) review_reason is empty — never overwrites a Pass 2-set reason (graph_align,
|
|
166
|
+
* novel_type, weak_match, etc.). Returns the count of items stamped.
|
|
167
|
+
*/
|
|
168
|
+
export function stampTypeOverrides(pass1Items, pass2Items) {
|
|
169
|
+
const pass1TypeById = new Map(pass1Items.map(p1 => [p1.id, p1.provisional_type]));
|
|
170
|
+
let stamped = 0;
|
|
171
|
+
for (const item of pass2Items) {
|
|
172
|
+
const pass1Type = pass1TypeById.get(item.id);
|
|
173
|
+
if (pass1Type && pass1Type !== item.type && !item.review_reason) {
|
|
174
|
+
item.review_reason = "type_override";
|
|
175
|
+
stamped++;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return stamped;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Code dedup guard — collapses Pass 2 items with byte-identical normalized text but
|
|
182
|
+
* different type. This is the structurally-determined slice of cross-type dedup: the LLM
|
|
183
|
+
* emits the same sentence twice under two provisional types (e.g. fact + constraint), and
|
|
184
|
+
* Pass 2 inconsistently catches it (~1/3 of runs in the refund A/B). Identical text is not
|
|
185
|
+
* a semantic call — it is structural — so code is the right layer (charter rule 3).
|
|
186
|
+
*
|
|
187
|
+
* Charter compliance:
|
|
188
|
+
* - Rule 3 (match-to-competence): exact-text equality is structurally determined.
|
|
189
|
+
* - Rule 6 (catches a failure, never overrides a success): no-op when Pass 2 already
|
|
190
|
+
* collapsed the twins. Only fires on the failure case.
|
|
191
|
+
* - Rule 6 (falsifiable): the test is normalized-text equality — checkable.
|
|
192
|
+
* - Rule 7 (deterministic on probabilistic input): only catches IDENTICAL text;
|
|
193
|
+
* paraphrase/near-dup remains Pass 2's job (semantic judgment, LLM competence).
|
|
194
|
+
* The function deliberately does NOT extend to embedding similarity.
|
|
195
|
+
* - Identical text rules out a "genuine role-split": a real role-split has DIFFERENT
|
|
196
|
+
* content reflecting two different epistemic roles (e.g. decision "chose Y" + a
|
|
197
|
+
* separately-stated constraint Y the decision creates). Two items with byte-identical
|
|
198
|
+
* text are redundancy by definition, even if Pass 2 wired an edge between them — the
|
|
199
|
+
* edge is a self-referential redundancy, vestigial after collapse, and is dropped.
|
|
200
|
+
*
|
|
201
|
+
* Winner pick (when collapsing): prefer the item with non-empty causal wiring (it carries
|
|
202
|
+
* the structural role); else prefer a non-fact type (more-specific epistemic role —
|
|
203
|
+
* matches the LLM's own "kept as the more specific constraint type" choice in the firing
|
|
204
|
+
* runs); else earliest by id.
|
|
205
|
+
*
|
|
206
|
+
* Returns the kept set + a record of every drop with its merge target. Cross-references
|
|
207
|
+
* in surviving items' triggered_by_items / based_on_items are rewired from the dropped
|
|
208
|
+
* id to the winner; any self-reference that results from the merge is stripped.
|
|
209
|
+
*/
|
|
210
|
+
export function dedupIdenticalEssenceTwins(items) {
|
|
211
|
+
const normalize = (s) => (s || "").toLowerCase().trim().replace(/\s+/g, " ");
|
|
212
|
+
const groups = new Map();
|
|
213
|
+
for (const item of items) {
|
|
214
|
+
const key = normalize(item.text);
|
|
215
|
+
if (!key)
|
|
216
|
+
continue;
|
|
217
|
+
const arr = groups.get(key);
|
|
218
|
+
if (arr)
|
|
219
|
+
arr.push(item);
|
|
220
|
+
else
|
|
221
|
+
groups.set(key, [item]);
|
|
222
|
+
}
|
|
223
|
+
const dropIds = new Set();
|
|
224
|
+
const dropped = [];
|
|
225
|
+
const replacement = new Map(); // dropped id → kept id, for rewiring
|
|
226
|
+
for (const group of groups.values()) {
|
|
227
|
+
if (group.length < 2)
|
|
228
|
+
continue;
|
|
229
|
+
const types = new Set(group.map(i => i.type));
|
|
230
|
+
if (types.size < 2)
|
|
231
|
+
continue; // same-type dups are Pass 2 STEP I's job, not ours
|
|
232
|
+
const hasWiring = (i) => (i.triggered_by_items?.length || 0) + (i.based_on_items?.length || 0) > 0;
|
|
233
|
+
const sorted = [...group].sort((a, b) => {
|
|
234
|
+
const aw = hasWiring(a) ? 1 : 0;
|
|
235
|
+
const bw = hasWiring(b) ? 1 : 0;
|
|
236
|
+
if (aw !== bw)
|
|
237
|
+
return bw - aw;
|
|
238
|
+
const aFact = a.type === "fact" ? 1 : 0;
|
|
239
|
+
const bFact = b.type === "fact" ? 1 : 0;
|
|
240
|
+
if (aFact !== bFact)
|
|
241
|
+
return aFact - bFact; // non-fact first
|
|
242
|
+
return a.id.localeCompare(b.id);
|
|
243
|
+
});
|
|
244
|
+
const winner = sorted[0];
|
|
245
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
246
|
+
const loser = sorted[i];
|
|
247
|
+
dropIds.add(loser.id);
|
|
248
|
+
replacement.set(loser.id, winner.id);
|
|
249
|
+
dropped.push({
|
|
250
|
+
id: loser.id,
|
|
251
|
+
mergedInto: winner.id,
|
|
252
|
+
reason: `identical normalized text — ${loser.type} merged into ${winner.type}`,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (dropIds.size === 0)
|
|
257
|
+
return { kept: items, dropped };
|
|
258
|
+
const kept = items
|
|
259
|
+
.filter(i => !dropIds.has(i.id))
|
|
260
|
+
.map(i => {
|
|
261
|
+
const rewire = (refs) =>
|
|
262
|
+
// Map dropped → winner, deduplicate, then drop any self-reference that emerged from the merge
|
|
263
|
+
Array.from(new Set((refs || []).map(ref => replacement.get(ref) ?? ref))).filter(r => r !== i.id);
|
|
264
|
+
// extends_item is a single within-batch id ref. Rewire dropped→winner; strip if the merge
|
|
265
|
+
// makes it self-referential — otherwise it dangles at a deleted twin → spurious
|
|
266
|
+
// extends_item_unresolved skip downstream (pipeline.ts resolution). supersedes_ref/resolved_ref
|
|
267
|
+
// are external graph LABELS, not within-batch ids, so they can't dangle here — left untouched.
|
|
268
|
+
const rewiredExtends = i.extends_item ? (replacement.get(i.extends_item) ?? i.extends_item) : i.extends_item;
|
|
269
|
+
return {
|
|
270
|
+
...i,
|
|
271
|
+
triggered_by_items: rewire(i.triggered_by_items || []),
|
|
272
|
+
based_on_items: rewire(i.based_on_items || []),
|
|
273
|
+
extends_item: rewiredExtends === i.id ? undefined : rewiredExtends,
|
|
274
|
+
};
|
|
275
|
+
});
|
|
276
|
+
return { kept, dropped };
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Tier 1C (2026-05-24): unique{}-content dedup.
|
|
280
|
+
*
|
|
281
|
+
* A block's identity is its content.unique{} (the structured data), NOT its
|
|
282
|
+
* essence (a paraphrasable summary — see [[feedback-identity-is-unique-not-label]]).
|
|
283
|
+
* dedupIdenticalEssenceTwins compares essence TEXT and so misses two real
|
|
284
|
+
* duplicate patterns:
|
|
285
|
+
* - CROSS-TYPE: same claim emitted under two type labels with different essence
|
|
286
|
+
* wording AND different field NAMES — e.g. decision{value:"Chalice",reason:…}
|
|
287
|
+
* vs dead_end{approach:"Chalice",reason:…}. Same data, different shape.
|
|
288
|
+
* - PARAPHRASE: same claim, same type, slightly different essence wording but
|
|
289
|
+
* identical unique{} values.
|
|
290
|
+
*
|
|
291
|
+
* This groups items by their normalized unique{} VALUE-SET. Field NAMES are
|
|
292
|
+
* ignored — only the values matter, because field names are interchangeable
|
|
293
|
+
* across types for the same claim. Identical value-sets collapse to one.
|
|
294
|
+
*
|
|
295
|
+
* Safe against FRAGMENTATION: fragments carry DIFFERENT unique{} values (different
|
|
296
|
+
* slices of one larger thing), so they never share a value-set key and are left
|
|
297
|
+
* untouched. Only EXACT normalized value-set matches collapse — which makes the
|
|
298
|
+
* false-positive risk near zero (byte-identical structured data IS the same claim).
|
|
299
|
+
*
|
|
300
|
+
* Winner selection prefers, in order: schema-valid (Tier 1B validateUniqueSchema)
|
|
301
|
+
* → has causal wiring → non-fact → lowest id. The schema-valid preference means
|
|
302
|
+
* the structurally-correct block survives (the dead_end{approach,reason} beats the
|
|
303
|
+
* mis-typed decision{value,reason} for the same Chalice claim).
|
|
304
|
+
*
|
|
305
|
+
* Runs after dedupIdenticalEssenceTwins, behind NODEDEX_CODE_DEDUP. Charter rule 7
|
|
306
|
+
* (deterministic guard on probabilistic input) + rule 2 (pre-commit collapse only).
|
|
307
|
+
*/
|
|
308
|
+
export function dedupIdenticalUniqueValues(items) {
|
|
309
|
+
const normalize = (s) => (s || "").toLowerCase().trim().replace(/\s+/g, " ");
|
|
310
|
+
const valueSetKey = (item) => {
|
|
311
|
+
const u = item.unique || {};
|
|
312
|
+
const vals = [];
|
|
313
|
+
for (const v of Object.values(u)) {
|
|
314
|
+
if (v === null || v === undefined)
|
|
315
|
+
continue;
|
|
316
|
+
const s = normalize(String(v));
|
|
317
|
+
if (s === "")
|
|
318
|
+
continue;
|
|
319
|
+
vals.push(s);
|
|
320
|
+
}
|
|
321
|
+
if (vals.length === 0)
|
|
322
|
+
return ""; // nothing to compare (e.g. project/process)
|
|
323
|
+
return vals.sort().join(" ¦ ");
|
|
324
|
+
};
|
|
325
|
+
const groups = new Map();
|
|
326
|
+
for (const item of items) {
|
|
327
|
+
const key = valueSetKey(item);
|
|
328
|
+
if (!key)
|
|
329
|
+
continue;
|
|
330
|
+
const arr = groups.get(key);
|
|
331
|
+
if (arr)
|
|
332
|
+
arr.push(item);
|
|
333
|
+
else
|
|
334
|
+
groups.set(key, [item]);
|
|
335
|
+
}
|
|
336
|
+
const dropIds = new Set();
|
|
337
|
+
const dropped = [];
|
|
338
|
+
const replacement = new Map(); // dropped id → kept id, for rewiring
|
|
339
|
+
const schemaValid = (i) => validateUniqueSchema(i.type, i.unique || {}).ok;
|
|
340
|
+
const hasWiring = (i) => (i.triggered_by_items?.length || 0) + (i.based_on_items?.length || 0) > 0;
|
|
341
|
+
for (const group of groups.values()) {
|
|
342
|
+
if (group.length < 2)
|
|
343
|
+
continue;
|
|
344
|
+
const sorted = [...group].sort((a, b) => {
|
|
345
|
+
const av = schemaValid(a) ? 1 : 0;
|
|
346
|
+
const bv = schemaValid(b) ? 1 : 0;
|
|
347
|
+
if (av !== bv)
|
|
348
|
+
return bv - av; // schema-valid first
|
|
349
|
+
const aw = hasWiring(a) ? 1 : 0;
|
|
350
|
+
const bw = hasWiring(b) ? 1 : 0;
|
|
351
|
+
if (aw !== bw)
|
|
352
|
+
return bw - aw; // wired first
|
|
353
|
+
const aFact = a.type === "fact" ? 1 : 0;
|
|
354
|
+
const bFact = b.type === "fact" ? 1 : 0;
|
|
355
|
+
if (aFact !== bFact)
|
|
356
|
+
return aFact - bFact; // non-fact first
|
|
357
|
+
return a.id.localeCompare(b.id); // stable tiebreak
|
|
358
|
+
});
|
|
359
|
+
const winner = sorted[0];
|
|
360
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
361
|
+
const loser = sorted[i];
|
|
362
|
+
dropIds.add(loser.id);
|
|
363
|
+
replacement.set(loser.id, winner.id);
|
|
364
|
+
const crossType = loser.type !== winner.type;
|
|
365
|
+
dropped.push({
|
|
366
|
+
id: loser.id,
|
|
367
|
+
mergedInto: winner.id,
|
|
368
|
+
reason: `identical unique{} value-set — ${loser.type} merged into ${winner.type}${crossType ? " (cross-type)" : " (same-type paraphrase)"}`,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
if (dropIds.size === 0)
|
|
373
|
+
return { kept: items, dropped };
|
|
374
|
+
const kept = items
|
|
375
|
+
.filter(i => !dropIds.has(i.id))
|
|
376
|
+
.map(i => {
|
|
377
|
+
const rewire = (refs) =>
|
|
378
|
+
// Map dropped → winner, deduplicate, then drop any self-reference that emerged from the merge
|
|
379
|
+
Array.from(new Set((refs || []).map(ref => replacement.get(ref) ?? ref))).filter(r => r !== i.id);
|
|
380
|
+
// extends_item is a single within-batch id ref. Rewire dropped→winner; strip if the merge
|
|
381
|
+
// makes it self-referential — otherwise it dangles at a deleted twin → spurious
|
|
382
|
+
// extends_item_unresolved skip downstream (pipeline.ts resolution). supersedes_ref/resolved_ref
|
|
383
|
+
// are external graph LABELS, not within-batch ids, so they can't dangle here — left untouched.
|
|
384
|
+
const rewiredExtends = i.extends_item ? (replacement.get(i.extends_item) ?? i.extends_item) : i.extends_item;
|
|
385
|
+
return {
|
|
386
|
+
...i,
|
|
387
|
+
triggered_by_items: rewire(i.triggered_by_items || []),
|
|
388
|
+
based_on_items: rewire(i.based_on_items || []),
|
|
389
|
+
extends_item: rewiredExtends === i.id ? undefined : rewiredExtends,
|
|
390
|
+
};
|
|
391
|
+
});
|
|
392
|
+
return { kept, dropped };
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* FOLD constituent reason-facts into the state-change unit they justify.
|
|
396
|
+
*
|
|
397
|
+
* The problem (traversal-proven 2026-06-04, [[project-fragmentation-not-worth-fold-2026-06-04]]):
|
|
398
|
+
* the pipeline's over-extraction is GRANULARITY, not WORTH. Pass 1 fragments one
|
|
399
|
+
* coherent unit — a decision/dead_end/constraint and its REASONS — into many
|
|
400
|
+
* separate blocks (e.g. decision "raised beds" + standalone facts "better
|
|
401
|
+
* drainage" / "soil control" / "easier weeding"). Every fragment is genuine
|
|
402
|
+
* residue, so JUDGE (which keeps/drops WHOLE items) correctly keeps them all and
|
|
403
|
+
* physically CANNOT consolidate. WORTH and GRANULARITY are different jobs at
|
|
404
|
+
* different seams: worth = JUDGE (residue vs scaffolding); granularity = here.
|
|
405
|
+
*
|
|
406
|
+
* The fix is FOLD, not dedup. Dedup = "A and B are the same claim, keep one."
|
|
407
|
+
* Fold = "B is A's REASON; it already belongs inside A's rationale — absorb it,
|
|
408
|
+
* don't scatter it as a redundant standalone block." Charter §3: the graph grows
|
|
409
|
+
* by ENRICHMENT, not just addition. A block that merely restates a parent's reason
|
|
410
|
+
* carries no distinct meaning forward.
|
|
411
|
+
*
|
|
412
|
+
* Why HERE (post-2c, pre-Pass-3) and not Pass 1/2a or JUDGE:
|
|
413
|
+
* - A fact sits on MULTIPLE within-batch paths; foldability needs the FULL wired
|
|
414
|
+
* relationship set, which only exists after Pass 2c wired causality. At
|
|
415
|
+
* classify time (2a) the path picture is unknown — you'd fold blind.
|
|
416
|
+
* - Pass 1 has weak restraint/decomposition (charter Rule 4: "a fix that relies
|
|
417
|
+
* on the LLM holding back will fail") — fragmentation ORIGINATES there.
|
|
418
|
+
* - JUDGE is worth, not granularity.
|
|
419
|
+
*
|
|
420
|
+
* Detection is STRUCTURAL / deterministic (charter Rule 3 — count wired edges,
|
|
421
|
+
* never semantic similarity). A fact F folds into unit U iff:
|
|
422
|
+
* (1) F.type === "fact" — only facts fold; units never do.
|
|
423
|
+
* (2) EXACTLY ONE classified item references F at all, counting based_on +
|
|
424
|
+
* triggered_by + extends_item — the "sole-constituent / no shared anchor"
|
|
425
|
+
* guard. >1 referrer → KEEP (a shared root like a shelf-life fact reused by
|
|
426
|
+
* two decisions). This is the un-fenced over-folding risk, made REAL by
|
|
427
|
+
* counting the wiring instead of guessing.
|
|
428
|
+
* (3) that single referrer U references F via based_on_items — the RATIONALE
|
|
429
|
+
* edge. We deliberately do NOT fold on triggered_by/extends alone: a fact
|
|
430
|
+
* that merely TRIGGERED a decision is often independently valuable (e.g.
|
|
431
|
+
* "budget cut 40%"), whereas a based_on fact is a pure justification. This
|
|
432
|
+
* is MORE conservative than the design memo's "based_on/prompted_by" wording
|
|
433
|
+
* — chosen on the asymmetric-cost rule (a false fold loses data permanently;
|
|
434
|
+
* a redundant block is mere clutter), matching the code-dedup stance above.
|
|
435
|
+
* (4) U.type ∈ {decision, dead_end, constraint} — only state-change units have a
|
|
436
|
+
* `reason` slot to absorb into.
|
|
437
|
+
* (5) F has NO outgoing reason edge (based_on/extends) — a true leaf. A fact with
|
|
438
|
+
* its own provenance is not a pure constituent; folding would dangle it.
|
|
439
|
+
*
|
|
440
|
+
* ENRICH, never delete (charter Rule 2 safe): F's content is appended to U's
|
|
441
|
+
* unique.reason (if not already substantively present — 2b often already filled
|
|
442
|
+
* it, in which case F was pure redundancy), then F is removed from the classified
|
|
443
|
+
* set so Pass 3 never builds it as a standalone block. Nothing is deleted — F was
|
|
444
|
+
* never a block; its meaning is RELOCATED into the unit it always belonged to.
|
|
445
|
+
*
|
|
446
|
+
* Source/mode-independent: the agent's own "I chose X because Y" folds the same as
|
|
447
|
+
* a user-sourced reason. Default OFF (NODEDEX_FOLD_CONSTITUENTS=1).
|
|
448
|
+
*/
|
|
449
|
+
const FOLD_STATE_CHANGE_TYPES = new Set(["decision", "dead_end", "constraint"]);
|
|
450
|
+
export function foldConstituentFacts(items) {
|
|
451
|
+
const folded = [];
|
|
452
|
+
const foldedIds = new Set();
|
|
453
|
+
// Inverted reference index over the WIRED within-batch edges.
|
|
454
|
+
// - rationaleRefs[F] = units U with based_on F (F is U's justification).
|
|
455
|
+
// - anyRefs[F] = every item referencing F via based_on | triggered_by |
|
|
456
|
+
// extends — the "no shared anchor" guard population.
|
|
457
|
+
const rationaleRefs = new Map();
|
|
458
|
+
const anyRefs = new Map();
|
|
459
|
+
const push = (m, key, v) => {
|
|
460
|
+
const a = m.get(key);
|
|
461
|
+
if (a)
|
|
462
|
+
a.push(v);
|
|
463
|
+
else
|
|
464
|
+
m.set(key, [v]);
|
|
465
|
+
};
|
|
466
|
+
for (const u of items) {
|
|
467
|
+
for (const ref of u.based_on_items || []) {
|
|
468
|
+
push(rationaleRefs, ref, u);
|
|
469
|
+
push(anyRefs, ref, u);
|
|
470
|
+
}
|
|
471
|
+
for (const ref of u.triggered_by_items || []) {
|
|
472
|
+
push(anyRefs, ref, u);
|
|
473
|
+
}
|
|
474
|
+
if (u.extends_item)
|
|
475
|
+
push(anyRefs, u.extends_item, u);
|
|
476
|
+
}
|
|
477
|
+
for (const f of items) {
|
|
478
|
+
if (f.type !== "fact")
|
|
479
|
+
continue; // guard 1
|
|
480
|
+
const refs = anyRefs.get(f.id) || [];
|
|
481
|
+
if (refs.length !== 1)
|
|
482
|
+
continue; // guard 2 — sole referrer
|
|
483
|
+
const u = refs[0];
|
|
484
|
+
if (!(u.based_on_items || []).includes(f.id))
|
|
485
|
+
continue; // guard 3 — rationale edge
|
|
486
|
+
if (!FOLD_STATE_CHANGE_TYPES.has(u.type))
|
|
487
|
+
continue; // guard 4 — unit absorbs
|
|
488
|
+
if ((f.based_on_items || []).length > 0 || f.extends_item)
|
|
489
|
+
continue; // guard 5 — F is a leaf
|
|
490
|
+
// ENRICH (Rule 2 safe) — relocate F's claim into U's rationale. F's `text` is
|
|
491
|
+
// the human-readable claim at this stage (essence is derived later by Pass 3);
|
|
492
|
+
// fall back to the first non-empty unique{} value.
|
|
493
|
+
const claim = ((f.text || "").trim())
|
|
494
|
+
|| (Object.values(f.unique || {}).map(v => (v ?? "").toString().trim()).find(v => v !== "") || "");
|
|
495
|
+
if (!u.unique)
|
|
496
|
+
u.unique = {};
|
|
497
|
+
const existing = (u.unique.reason || "").trim();
|
|
498
|
+
const normExisting = existing.toLowerCase().replace(/\s+/g, " ");
|
|
499
|
+
const normClaimHead = claim.toLowerCase().replace(/\s+/g, " ").slice(0, 40);
|
|
500
|
+
let enriched = false;
|
|
501
|
+
if (claim && normClaimHead && !normExisting.includes(normClaimHead)) {
|
|
502
|
+
u.unique.reason = existing ? `${existing}; ${claim}` : claim;
|
|
503
|
+
enriched = true;
|
|
504
|
+
}
|
|
505
|
+
// Drop F's now-absorbed edge from U so it does not dangle at an item Pass 3
|
|
506
|
+
// never builds. (By guard 2, U is the ONLY referrer — nothing else to rewire.)
|
|
507
|
+
u.based_on_items = (u.based_on_items || []).filter(r => r !== f.id);
|
|
508
|
+
foldedIds.add(f.id);
|
|
509
|
+
folded.push({
|
|
510
|
+
id: f.id,
|
|
511
|
+
foldedInto: u.id,
|
|
512
|
+
enriched,
|
|
513
|
+
reason: `constituent reason-fact folded into ${u.type} ${u.id}`
|
|
514
|
+
+ (enriched ? " (rationale enriched)" : " (already in rationale — redundant block removed)"),
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
if (foldedIds.size === 0)
|
|
518
|
+
return { kept: items, folded };
|
|
519
|
+
return { kept: items.filter(i => !foldedIds.has(i.id)), folded };
|
|
520
|
+
}
|
|
521
|
+
/** Returns true if a block label already exists — label-level dedup. */
|
|
522
|
+
export function isDuplicateLabel(label, allBlocks) {
|
|
523
|
+
return allBlocks.some(b => b.label === label);
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Commit this turn's pending blocks → 'active', but ONLY those STILL pending.
|
|
527
|
+
*
|
|
528
|
+
* A block created this turn (status='pending') can be archived in the interim by a
|
|
529
|
+
* SAME-TURN supersede: Pass 4 creates a `supersedes` edge, and database.ts:981 archives
|
|
530
|
+
* a superseded decision/blueprint. The old blind loop set every pending id to 'active'
|
|
531
|
+
* unconditionally → it RESURRECTED the just-archived block, undoing the supersede-archive
|
|
532
|
+
* (the activate-pending race; surfaced by the 5-turn deep test 2026-05-26, T5 retrospective).
|
|
533
|
+
*
|
|
534
|
+
* This rule lives HERE (the committer), not in the DB layer, deliberately: only the committer
|
|
535
|
+
* has the context "these are THIS turn's new blocks." The DB can't enforce "archived↛active"
|
|
536
|
+
* because that transition is legitimately used for reactivation-on-recreate (database.ts:603).
|
|
537
|
+
* The committer reads each block's CURRENT status (not the stale create-time snapshot) and
|
|
538
|
+
* commits only the still-pending ones; an interim archive is respected.
|
|
539
|
+
*
|
|
540
|
+
* Pure-ish: takes a minimal structural db (getBlock + updateBlock) so it's unit-testable
|
|
541
|
+
* without a real DB. Returns counts for logging/audit.
|
|
542
|
+
*/
|
|
543
|
+
export function activatePendingBlocks(db, pendingIds) {
|
|
544
|
+
let activated = 0;
|
|
545
|
+
let skippedArchived = 0;
|
|
546
|
+
for (const id of pendingIds) {
|
|
547
|
+
const b = db.getBlock(id);
|
|
548
|
+
if (b && b.status === "pending") {
|
|
549
|
+
db.updateBlock(id, { status: "active" });
|
|
550
|
+
activated++;
|
|
551
|
+
}
|
|
552
|
+
else if (b && b.status === "archived") {
|
|
553
|
+
skippedArchived++; // archived in-turn (same-turn supersede) — leave it; never resurrect
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
return { activated, skippedArchived };
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Relation dedup guard — prevents exact duplicate relations.
|
|
560
|
+
* Only skips when source + type + target are all identical.
|
|
561
|
+
* Does NOT collapse different relation types to the same target —
|
|
562
|
+
* prompted_by, based_on, and extends are semantically distinct even when
|
|
563
|
+
* they point at the same block.
|
|
564
|
+
*/
|
|
565
|
+
export function shouldSkipRelation(sourceId, targetId, relType, db) {
|
|
566
|
+
if (sourceId === targetId)
|
|
567
|
+
return true; // self-referential relation
|
|
568
|
+
const existing = db.getRelations(sourceId)
|
|
569
|
+
.filter(r => r.direction === "outgoing" && r.target_id === targetId);
|
|
570
|
+
return existing.some(r => r.type === relType);
|
|
571
|
+
}
|
|
572
|
+
/** Returns true if the project prefix is recognised (known or just created this batch). */
|
|
573
|
+
export function isKnownProject(project, knownProjects, newProjectLabels) {
|
|
574
|
+
return knownProjects.has(project) || newProjectLabels.has(project);
|
|
575
|
+
}
|
|
576
|
+
/** Returns true if the label has an acceptable number of underscore-separated segments. */
|
|
577
|
+
export function isValidLabelSegmentCount(label, knownBlockTypes) {
|
|
578
|
+
const segs = label.split("_");
|
|
579
|
+
if (segs.length <= 4)
|
|
580
|
+
return true;
|
|
581
|
+
const hasCompound = knownBlockTypes.has(segs.slice(1, 3).join("_")) ||
|
|
582
|
+
knownBlockTypes.has(segs.slice(2, 4).join("_"));
|
|
583
|
+
return hasCompound;
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Bug 3 fix (2026-05-28): resolve the effective parent for a project_creates[]
|
|
587
|
+
* entry. Pass 3's `parent` is optional and the LLM effectively never sets it
|
|
588
|
+
* (0 part_of edges across 10 transcripts in the scale audit), but Pass 0's
|
|
589
|
+
* scope_project IS the parent by design for any sub-project in the same batch.
|
|
590
|
+
*
|
|
591
|
+
* Rule:
|
|
592
|
+
* - If pass3 set `parent` explicitly → respect it (don't override LLM intent).
|
|
593
|
+
* - Else, if scope_project exists AND the projDef isn't the scope itself →
|
|
594
|
+
* default parent = scope.
|
|
595
|
+
* - Else → no parent (top-level project).
|
|
596
|
+
*
|
|
597
|
+
* Charter rule 3 — structurally-determined transformation belongs in code, not
|
|
598
|
+
* delegated to LLM judgment.
|
|
599
|
+
*/
|
|
600
|
+
export function resolveProjectParent(projDef, scopeProjectLabel) {
|
|
601
|
+
if (projDef.parent)
|
|
602
|
+
return projDef.parent;
|
|
603
|
+
if (scopeProjectLabel && projDef.label !== scopeProjectLabel)
|
|
604
|
+
return scopeProjectLabel;
|
|
605
|
+
return undefined;
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Bug 2 fix (2026-05-28): repair labels that use a multi-word type's literal
|
|
609
|
+
* form (`dead_end`, or any custom multi-word type) where the canonical rule
|
|
610
|
+
* (pass3.ts:50) requires hyphens within the type dimension (`dead-end`).
|
|
611
|
+
*
|
|
612
|
+
* Empirical: 2/6 dead_end blocks in the 2026-05-28 scale audit were buggy
|
|
613
|
+
* underscore-form, adding a phantom dimension to label-parsers. Same anti-
|
|
614
|
+
* pattern as Bug 3 — a structurally-determined transformation delegated to
|
|
615
|
+
* LLM judgment (charter rule 3). Pure idempotent function: a clean label is
|
|
616
|
+
* unchanged.
|
|
617
|
+
*
|
|
618
|
+
* Anchoring: replaces only `_${type}_` (within-label) and trailing `_${type}`
|
|
619
|
+
* (end-of-label) — never matches coincidental concept-token occurrences.
|
|
620
|
+
* Idempotent: hyphenated form has no underscores to find on a second pass.
|
|
621
|
+
*/
|
|
622
|
+
export function normalizeMultiWordTypeInLabel(label, multiWordTypes) {
|
|
623
|
+
if (!label || typeof label !== "string")
|
|
624
|
+
return label;
|
|
625
|
+
let result = label;
|
|
626
|
+
for (const t of multiWordTypes) {
|
|
627
|
+
if (!t.includes("_"))
|
|
628
|
+
continue;
|
|
629
|
+
const hyphenated = t.replaceAll("_", "-");
|
|
630
|
+
// Within-label: project_dead_end_concept → project_dead-end_concept
|
|
631
|
+
result = result.split(`_${t}_`).join(`_${hyphenated}_`);
|
|
632
|
+
// End-of-label: project_dead_end → project_dead-end (rare; defensive)
|
|
633
|
+
if (result.endsWith(`_${t}`)) {
|
|
634
|
+
result = result.slice(0, result.length - t.length) + hyphenated;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
return result;
|
|
638
|
+
}
|
|
639
|
+
// ─── chain_id post-processing ─────────────────────────────────────────────────
|
|
640
|
+
// Stamps a shared chain_id on blocks connected by prompted_by, based_on, or extends.
|
|
641
|
+
function stampFlowRolesAndChains(savedLabels, allBlocks, db) {
|
|
642
|
+
if (savedLabels.length === 0)
|
|
643
|
+
return;
|
|
644
|
+
const labelToId = new Map();
|
|
645
|
+
for (const b of allBlocks)
|
|
646
|
+
labelToId.set(b.label, b.id);
|
|
647
|
+
const newIds = new Set(savedLabels.map(l => labelToId.get(l)).filter(Boolean));
|
|
648
|
+
if (newIds.size === 0)
|
|
649
|
+
return;
|
|
650
|
+
// chain_id clustering uses the shared causal-thread set (relation-sets.ts) —
|
|
651
|
+
// unified 2026-06-05 with read-side traversal + Pass 5 assembly so the three
|
|
652
|
+
// "chain" notions agree on which edges are the causal thread. This ADDS
|
|
653
|
+
// supports / supersedes / superseded_by / resolves (supports alone is ~half of
|
|
654
|
+
// all causal edges) so supports-linked residue — e.g. a user's lived failure
|
|
655
|
+
// wired as "specific instance SUPPORTS the general dead-end" — joins its arc
|
|
656
|
+
// instead of orphaning. Validated by a clustering simulation (C:/tmp): pulls
|
|
657
|
+
// orphans into their arc, keeps genuinely edge-less blocks out, no cross-arc
|
|
658
|
+
// over-merge (components stay within-project + topically coherent).
|
|
659
|
+
const allRels = db.getAllRelations(false).filter((r) => r.status === "active" && CAUSAL_TRAVERSAL_RELS.has(r.type));
|
|
660
|
+
const modifiedChainIds = new Set();
|
|
661
|
+
// Build adjacency graph among new blocks only (within-batch connections)
|
|
662
|
+
const graph = new Map();
|
|
663
|
+
for (const id of newIds)
|
|
664
|
+
graph.set(id, new Set());
|
|
665
|
+
for (const rel of allRels) {
|
|
666
|
+
if (newIds.has(rel.source_id) && newIds.has(rel.target_id)) {
|
|
667
|
+
graph.get(rel.source_id).add(rel.target_id);
|
|
668
|
+
graph.get(rel.target_id).add(rel.source_id);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
// BFS — find connected components within this batch and stamp shared chain_id
|
|
672
|
+
const visited = new Set();
|
|
673
|
+
for (const startId of newIds) {
|
|
674
|
+
if (visited.has(startId))
|
|
675
|
+
continue;
|
|
676
|
+
const component = [];
|
|
677
|
+
const queue = [startId];
|
|
678
|
+
while (queue.length > 0) {
|
|
679
|
+
const id = queue.shift();
|
|
680
|
+
if (visited.has(id))
|
|
681
|
+
continue;
|
|
682
|
+
visited.add(id);
|
|
683
|
+
component.push(id);
|
|
684
|
+
for (const neighbor of graph.get(id) ?? []) {
|
|
685
|
+
if (!visited.has(neighbor))
|
|
686
|
+
queue.push(neighbor);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
if (component.length >= 2) {
|
|
690
|
+
const chainId = uuidv4();
|
|
691
|
+
for (const id of component) {
|
|
692
|
+
db.updateBlock(id, { chain_id: chainId });
|
|
693
|
+
const b = allBlocks.find((x) => x.id === id);
|
|
694
|
+
if (b)
|
|
695
|
+
b.chain_id = chainId;
|
|
696
|
+
}
|
|
697
|
+
modifiedChainIds.add(chainId);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
// Cross-batch stitching: connect new blocks to existing blocks via CHAIN_RELs.
|
|
701
|
+
const idToBlock = new Map();
|
|
702
|
+
for (const b of allBlocks)
|
|
703
|
+
idToBlock.set(b.id, b);
|
|
704
|
+
for (const newId of newIds) {
|
|
705
|
+
const newBlock = idToBlock.get(newId);
|
|
706
|
+
if (!newBlock)
|
|
707
|
+
continue;
|
|
708
|
+
for (const rel of allRels) {
|
|
709
|
+
const newIsSource = rel.source_id === newId && !newIds.has(rel.target_id);
|
|
710
|
+
const newIsTarget = rel.target_id === newId && !newIds.has(rel.source_id);
|
|
711
|
+
if (!newIsSource && !newIsTarget)
|
|
712
|
+
continue;
|
|
713
|
+
const existingId = newIsSource ? rel.target_id : rel.source_id;
|
|
714
|
+
const existingBlock = db.getBlock(existingId);
|
|
715
|
+
if (!existingBlock || existingBlock.status === "archived")
|
|
716
|
+
continue;
|
|
717
|
+
const existingChainId = existingBlock.chain_id ?? null;
|
|
718
|
+
const newChainId = newBlock.chain_id ?? null;
|
|
719
|
+
if (existingChainId && newChainId) {
|
|
720
|
+
if (existingChainId !== newChainId) {
|
|
721
|
+
// blk_ chain_ids are canonical (assigned by Pass 5, saved in DB).
|
|
722
|
+
// UUID chain_ids are ephemeral (scratch, assigned during this batch's BFS).
|
|
723
|
+
// Canonical always beats ephemeral — never let a UUID overwrite a blk_ chain.
|
|
724
|
+
const existingIsCanonical = existingChainId.startsWith("blk_");
|
|
725
|
+
const newIsCanonical = newChainId.startsWith("blk_");
|
|
726
|
+
const winner = (existingIsCanonical && !newIsCanonical) ? existingChainId
|
|
727
|
+
: (!existingIsCanonical && newIsCanonical) ? newChainId
|
|
728
|
+
: (existingChainId < newChainId ? existingChainId : newChainId);
|
|
729
|
+
const loser = winner === existingChainId ? newChainId : existingChainId;
|
|
730
|
+
const toMerge = allBlocks.filter((b) => b.chain_id === loser);
|
|
731
|
+
for (const b of toMerge) {
|
|
732
|
+
db.updateBlock(b.id, { chain_id: winner });
|
|
733
|
+
b.chain_id = winner;
|
|
734
|
+
}
|
|
735
|
+
db.updateBlock(existingId, { chain_id: winner });
|
|
736
|
+
newBlock.chain_id = winner;
|
|
737
|
+
modifiedChainIds.delete(loser);
|
|
738
|
+
modifiedChainIds.add(winner);
|
|
739
|
+
}
|
|
740
|
+
else {
|
|
741
|
+
modifiedChainIds.add(existingChainId);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
else if (existingChainId && !newChainId) {
|
|
745
|
+
db.updateBlock(newId, { chain_id: existingChainId });
|
|
746
|
+
newBlock.chain_id = existingChainId;
|
|
747
|
+
modifiedChainIds.add(existingChainId);
|
|
748
|
+
}
|
|
749
|
+
else if (!existingChainId && newChainId) {
|
|
750
|
+
db.updateBlock(existingId, { chain_id: newChainId });
|
|
751
|
+
modifiedChainIds.add(newChainId);
|
|
752
|
+
}
|
|
753
|
+
else {
|
|
754
|
+
const chainId = uuidv4();
|
|
755
|
+
db.updateBlock(newId, { chain_id: chainId });
|
|
756
|
+
db.updateBlock(existingId, { chain_id: chainId });
|
|
757
|
+
newBlock.chain_id = chainId;
|
|
758
|
+
modifiedChainIds.add(chainId);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
764
|
+
// PIPELINE ORCHESTRATOR
|
|
765
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
766
|
+
export async function runAutoReflect(db, agentResponse, loadedBlocks, _userMessage, agentThinking, embeddings, recalledBlocks, agentId, checkpoint,
|
|
767
|
+
// ── DEBT 5 Phase 2: turn identity for conversation_turns persistence ──
|
|
768
|
+
// Required when NODEDEX_ARC_EXTRACTION=1 + agentId is set. When the flag
|
|
769
|
+
// is on AND all three (agentId + turnNumber) are present, the pipeline
|
|
770
|
+
// INSERTs a conversation_turns row at start, runs Pass 0-1, UPDATEs with
|
|
771
|
+
// pass01_output_json, then early-returns (Pass 2-5 will run at arc trigger,
|
|
772
|
+
// Phase 3). When the flag is off OR turn identity is missing, behaves
|
|
773
|
+
// identically to today (no new code paths fire). Per Variant A §2.1, §2.6.
|
|
774
|
+
turnNumber, turnName) {
|
|
775
|
+
const empty = { saved: 0, updated: 0, skipped: 0, saved_labels: [], uncertain_count: 0, created_blocks: [], updated_blocks: [] };
|
|
776
|
+
const provider = getLLMProvider();
|
|
777
|
+
if (!provider.isAvailable())
|
|
778
|
+
return empty;
|
|
779
|
+
const stateLabel = agentId ? `agent_session_state_${agentId}` : "agent_session_state";
|
|
780
|
+
const geminiCreatedBy = agentId ? `gemini_reflect_${agentId.slice(0, 8)}` : "gemini_reflect";
|
|
781
|
+
const KNOWN_BLOCK_TYPES = new Set([
|
|
782
|
+
"fact", "question", "hypothesis", "decision", "dead_end", "insight",
|
|
783
|
+
"preference", "constraint", "blueprint", "process",
|
|
784
|
+
"note", "project", "event", "entity",
|
|
785
|
+
// reasoning_chain/metric/claim collapsed → insight/fact (2026-06-15)
|
|
786
|
+
]);
|
|
787
|
+
// Skip only EMPTY turns. NODEDEX_EXTRACT_ALL_SOURCES disables the LENGTH heuristic
|
|
788
|
+
// entirely (length != residue — a 12-char user turn can be a decision); empty turns
|
|
789
|
+
// then self-handle (extraction finds nothing -> JUDGE keeps nothing -> no blocks).
|
|
790
|
+
// NODEDEX_MIN_TURN_CHARS tunes the floor (default 10 — extraction itself, not length, is
|
|
791
|
+
// the real filter; a short turn with no residue simply yields 0 blocks). Raise it to cut
|
|
792
|
+
// pipeline calls on tiny turns when running a PAID extraction model (free models = $0).
|
|
793
|
+
const extractAllSources = process.env.NODEDEX_EXTRACT_ALL_SOURCES === "1";
|
|
794
|
+
const minTurnChars = (() => {
|
|
795
|
+
const n = parseInt(process.env.NODEDEX_MIN_TURN_CHARS ?? "", 10);
|
|
796
|
+
return Number.isFinite(n) && n >= 0 ? n : 10;
|
|
797
|
+
})();
|
|
798
|
+
const combinedLength = (agentResponse?.length ?? 0) + (agentThinking?.length ?? 0);
|
|
799
|
+
if (!extractAllSources && combinedLength < minTurnChars) {
|
|
800
|
+
console.log(`Auto-Reflect: skipping trivial turn (< ${minTurnChars} chars)`);
|
|
801
|
+
return empty;
|
|
802
|
+
}
|
|
803
|
+
// ─── DEBT 5 Phase 2: arc-extraction mode — capture turn at start ──────────
|
|
804
|
+
// Required gating: flag on AND we have an agent+turn identity. Captures the
|
|
805
|
+
// transcript even if Pass 0/1 fail (charter Rule 2 spirit: don't lose source).
|
|
806
|
+
// The row gets UPDATEd to status='pass01_done' after Pass JUDGE completes.
|
|
807
|
+
// If the flag is off OR identity is missing → _conversationTurnId stays null
|
|
808
|
+
// and the existing per-turn pipeline runs unchanged.
|
|
809
|
+
let _conversationTurnId = null;
|
|
810
|
+
const _arcExtractionOn = process.env.NODEDEX_ARC_EXTRACTION === "1";
|
|
811
|
+
if (_arcExtractionOn && agentId && turnNumber !== undefined) {
|
|
812
|
+
try {
|
|
813
|
+
const transcriptJson = JSON.stringify({
|
|
814
|
+
user_message: _userMessage ?? "",
|
|
815
|
+
agent_response: agentResponse,
|
|
816
|
+
agent_thinking: agentThinking ?? "",
|
|
817
|
+
});
|
|
818
|
+
const existing = db.getConversationTurnByAgentTurn(agentId, turnNumber);
|
|
819
|
+
if (existing) {
|
|
820
|
+
// Idempotency: pipeline re-fire for same (agent, turn) — reuse the row.
|
|
821
|
+
// Common path: reflect-queue retry after rate-limit puts the same job back.
|
|
822
|
+
_conversationTurnId = existing.id;
|
|
823
|
+
console.log(`[arc-extract] re-entering existing conversation_turn id=${existing.id} status=${existing.status} (agent=${agentId.slice(0, 8)} turn=${turnNumber})`);
|
|
824
|
+
}
|
|
825
|
+
else {
|
|
826
|
+
const row = db.createConversationTurn({
|
|
827
|
+
agent_id: agentId,
|
|
828
|
+
turn_number: turnNumber,
|
|
829
|
+
turn_name: turnName ?? null,
|
|
830
|
+
transcript_json: transcriptJson,
|
|
831
|
+
});
|
|
832
|
+
_conversationTurnId = row.id;
|
|
833
|
+
console.log(`[arc-extract] captured conversation_turn id=${row.id} agent=${agentId.slice(0, 8)} turn=${turnNumber}`);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
catch (e) {
|
|
837
|
+
console.warn(`[arc-extract] conversation_turn INSERT failed (${e?.message}) — falling back to per-turn pipeline (no arc capture for this turn)`);
|
|
838
|
+
_conversationTurnId = null;
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
else if (_arcExtractionOn) {
|
|
842
|
+
// Flag on but identity missing — possible misconfig. Log once + degrade.
|
|
843
|
+
console.warn(`[arc-extract] NODEDEX_ARC_EXTRACTION=1 but agentId/turnNumber missing (agentId=${agentId ?? "<none>"} turnNumber=${turnNumber ?? "<none>"}) — running per-turn pipeline (no arc capture)`);
|
|
844
|
+
}
|
|
845
|
+
// ARC CAPTURE — ALWAYS lazy (v1 retired & disabled 2026-06-22). The v2 arc engine
|
|
846
|
+
// re-reads the RAW transcript (arc-pipeline.ts) and IGNORES per-turn pass01 items;
|
|
847
|
+
// v1 — the only thing that ever consumed them — is now disabled, so running Pass 0-1
|
|
848
|
+
// at capture is pure waste with NO consumer. The transcript is already stored above;
|
|
849
|
+
// persist an EMPTY pass01 (status → pass01_done = the arc-ready signal) and return,
|
|
850
|
+
// SKIPPING Pass 0-1 BEFORE any LLM call. The NODEDEX_V2_LAZY_CAPTURE gate is dropped:
|
|
851
|
+
// capture is unconditionally lazy now (the v2 arc always reads raw).
|
|
852
|
+
if (_conversationTurnId) {
|
|
853
|
+
try {
|
|
854
|
+
db.updateConversationTurnPass01(_conversationTurnId, JSON.stringify({ scene_card: null, items: [] }));
|
|
855
|
+
console.log(`[arc-extract] lazy-capture: stored raw, SKIPPED Pass 0-1 (v2 reads raw at arc) — id=${_conversationTurnId}`);
|
|
856
|
+
}
|
|
857
|
+
catch (e) {
|
|
858
|
+
console.warn(`[arc-extract] lazy-capture persist failed id=${_conversationTurnId}: ${e?.message}`);
|
|
859
|
+
}
|
|
860
|
+
return empty;
|
|
861
|
+
}
|
|
862
|
+
try {
|
|
863
|
+
const allBlocks = db.getAllBlocks();
|
|
864
|
+
const allRels = db.getAllRelations(false);
|
|
865
|
+
// ── Orphan heal: set project_id on blocks whose label prefix matches a project ──
|
|
866
|
+
{
|
|
867
|
+
const projectMap = new Map(allBlocks.filter((b) => b.type === "project").map((b) => [b.label, b]));
|
|
868
|
+
for (const block of allBlocks) {
|
|
869
|
+
if (block.type === "project" || block.project_id)
|
|
870
|
+
continue;
|
|
871
|
+
const parts = block.label.split("_");
|
|
872
|
+
const proj = projectMap.get(parts[0]);
|
|
873
|
+
if (!proj)
|
|
874
|
+
continue;
|
|
875
|
+
db.updateBlock(block.id, { project_id: proj.id });
|
|
876
|
+
block.project_id = proj.id; // keep in-memory allBlocks current
|
|
877
|
+
console.log(`Auto-Reflect: healed orphan "${block.label}" → project "${proj.label}"`);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
// ── Read session state ──
|
|
881
|
+
let recentSaves = "";
|
|
882
|
+
let prevEntityMap = [];
|
|
883
|
+
try {
|
|
884
|
+
const stateBlock = db.getBlock(stateLabel);
|
|
885
|
+
if (stateBlock) {
|
|
886
|
+
const sc = JSON.parse(stateBlock.content);
|
|
887
|
+
const recents = sc.gemini_recent_saves || [];
|
|
888
|
+
if (recents.length > 0) {
|
|
889
|
+
recentSaves = recents.map((r) => {
|
|
890
|
+
let line = `- ${r.label}: "${r.essence}"`;
|
|
891
|
+
if (r.values)
|
|
892
|
+
line += ` [values: ${r.values}]`;
|
|
893
|
+
return line;
|
|
894
|
+
}).join("\n");
|
|
895
|
+
}
|
|
896
|
+
if (sc.prev_turn_context?.entity_map) {
|
|
897
|
+
prevEntityMap = sc.prev_turn_context.entity_map;
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
catch { /* */ }
|
|
902
|
+
// ── Agent-saved blocks this turn (last 20 min, non-Gemini) ──
|
|
903
|
+
const twentyMinutesAgo = Date.now() - 20 * 60 * 1000;
|
|
904
|
+
const agentSavedBlocks = allBlocks
|
|
905
|
+
.filter((b) => {
|
|
906
|
+
if (!b.created_at || b.type === "project")
|
|
907
|
+
return false;
|
|
908
|
+
if (typeof b.created_by === "string" && b.created_by.startsWith("gemini_reflect"))
|
|
909
|
+
return false;
|
|
910
|
+
return new Date(b.created_at).getTime() >= twentyMinutesAgo;
|
|
911
|
+
})
|
|
912
|
+
.map((b) => {
|
|
913
|
+
let unique = {};
|
|
914
|
+
try {
|
|
915
|
+
unique = (typeof b.content === "string" ? JSON.parse(b.content) : b.content)?.unique ?? {};
|
|
916
|
+
}
|
|
917
|
+
catch { /* */ }
|
|
918
|
+
return { id: b.id, label: b.label, type: b.type, essence: b.essence || "", unique };
|
|
919
|
+
});
|
|
920
|
+
if (agentSavedBlocks.length > 0) {
|
|
921
|
+
console.log(`Auto-Reflect: ${agentSavedBlocks.length} agent-saved block(s) this turn`);
|
|
922
|
+
}
|
|
923
|
+
// ── Known project roots ──
|
|
924
|
+
const knownRoots = allBlocks
|
|
925
|
+
.filter((b) => b.type === "project")
|
|
926
|
+
.map((b) => ({ label: b.label, essence: b.essence || "" }));
|
|
927
|
+
const allProjectPrefixes = new Set(knownRoots.map((r) => r.label));
|
|
928
|
+
// Infer active project from loaded blocks
|
|
929
|
+
const activeProjectPrefixes = new Set();
|
|
930
|
+
for (const id of loadedBlocks) {
|
|
931
|
+
const b = allBlocks.find((x) => x.id === id);
|
|
932
|
+
if (!b)
|
|
933
|
+
continue;
|
|
934
|
+
const prefix = (b.label || "").split("_")[0];
|
|
935
|
+
if (allProjectPrefixes.has(prefix))
|
|
936
|
+
activeProjectPrefixes.add(prefix);
|
|
937
|
+
}
|
|
938
|
+
if (activeProjectPrefixes.size === 0) {
|
|
939
|
+
for (const p of allProjectPrefixes)
|
|
940
|
+
activeProjectPrefixes.add(p);
|
|
941
|
+
}
|
|
942
|
+
// Full project context for Pass 3
|
|
943
|
+
const { context: projectContext, reflectedIds: _reflectedIds0 } = buildProjectContext(allBlocks, allRels, allProjectPrefixes, loadedBlocks);
|
|
944
|
+
db.stampReflectedAt(_reflectedIds0);
|
|
945
|
+
// ── PASS 1 + 2: Extract & Classify ──
|
|
946
|
+
let pass1 = null;
|
|
947
|
+
let pass2 = null;
|
|
948
|
+
let _pass0Raw = null;
|
|
949
|
+
let _sceneCard;
|
|
950
|
+
let _pass1Thinking = "";
|
|
951
|
+
let _pass2Thinking = "";
|
|
952
|
+
let _pass2Context = "";
|
|
953
|
+
let _pass4Thinking = "";
|
|
954
|
+
let _pass4Result = null;
|
|
955
|
+
// Pass 5 chain-assembly result captured for the turn log. The chains carry
|
|
956
|
+
// the per-chain `reasoning` field (commit 450d631) — debug instrumentation
|
|
957
|
+
// that has no graph-persistence path (chain blocks store arc/conclusion/essence
|
|
958
|
+
// only), so the turn log is where it's read post-run. Without this, the
|
|
959
|
+
// reasoning the LLM produces is dropped on the floor.
|
|
960
|
+
let _pass5Result = null;
|
|
961
|
+
let _pass0Provider;
|
|
962
|
+
let _pass1Provider;
|
|
963
|
+
let _pass2Provider;
|
|
964
|
+
let _pass3Provider;
|
|
965
|
+
let _pass4Provider;
|
|
966
|
+
let _pass5Provider;
|
|
967
|
+
// Pass 2 split orchestrator returns auditable per-stage counts (2a/2b/2c
|
|
968
|
+
// throughput, seam α verdicts, re-fill salvage outcomes, quarantine
|
|
969
|
+
// writes). Captured only when NODEDEX_PASS2_SPLIT=1 fires the split
|
|
970
|
+
// branch — surfaces in writeTurnLog so split runs are debuggable
|
|
971
|
+
// without querying the quarantine table directly. Per
|
|
972
|
+
// PASS2-SPLIT-DESIGN.md §7 telemetry requirement.
|
|
973
|
+
let _pass2SplitAudit;
|
|
974
|
+
// JUDGE (precision filter between Pass 1 and Pass 2) — flag-gated via
|
|
975
|
+
// NODEDEX_WORTH_JUDGE_ENABLED. Captures the per-call provider trail + the verdicts
|
|
976
|
+
// for the turn log so drops can be content-audited (per the lesson: verdict lives
|
|
977
|
+
// in the pass-log reasoning, never in block counts).
|
|
978
|
+
let _passJudgeProvider;
|
|
979
|
+
let _passJudgeKeptCount;
|
|
980
|
+
let _passJudgeDropped = [];
|
|
981
|
+
let _passJudgeAnchorOverrides = [];
|
|
982
|
+
// debt-4 Stage A — per-pass wall time tracking. Local to this runReflect call
|
|
983
|
+
// (resets implicitly per turn since it's a fresh closure). Each pass measured
|
|
984
|
+
// by Date.now() bracketing around the await callPassNLLM(...) site.
|
|
985
|
+
// Surfaces in turn-log so a future reader can see where time goes per pass,
|
|
986
|
+
// not just aggregate cost. The current cost_breakdown only covers $$;
|
|
987
|
+
// wall_ms covers time (the two correlate but aren't identical — thinking
|
|
988
|
+
// budget dominates time while output tokens dominate cost).
|
|
989
|
+
const _passWallMs = {};
|
|
990
|
+
// debt-4 Stage A — embedding stats are accumulated globally (same lifecycle
|
|
991
|
+
// as reflectTokenStats). Capture snapshot at runReflect start so the
|
|
992
|
+
// turn-log can record per-turn DELTA (consumers can also reconstruct from
|
|
993
|
+
// cross-turn diff). This is the hidden time tax exposed: ~100+ sequential
|
|
994
|
+
// embedding calls per moderate turn at ~300ms each = ~30s/turn invisible
|
|
995
|
+
// to cost_breakdown today.
|
|
996
|
+
const _embStart = { calls: embeddingStats.calls, ms_total: embeddingStats.ms_total, input_chars: embeddingStats.input_chars };
|
|
997
|
+
// IDs of blocks written as status='pending' during Pass 3 — activated after Pass 4 succeeds.
|
|
998
|
+
// Ensures blocks are invisible to users until the full pipeline (through Pass 4) completes.
|
|
999
|
+
const p3PendingBlockIds = [];
|
|
1000
|
+
// ── Resume from checkpoint or run passes fresh ──────────────────────────────
|
|
1001
|
+
// Restore the outputs of every pass that already completed successfully.
|
|
1002
|
+
// Each pass always runs fresh — it receives only the previous passes' outputs,
|
|
1003
|
+
// never any partial work from its own aborted attempt.
|
|
1004
|
+
if (checkpoint?.pass0) {
|
|
1005
|
+
_sceneCard = checkpoint.pass0.sceneCard;
|
|
1006
|
+
_pass0Raw = checkpoint.pass0.raw;
|
|
1007
|
+
}
|
|
1008
|
+
if (checkpoint?.pass1Items)
|
|
1009
|
+
pass1 = { items: checkpoint.pass1Items };
|
|
1010
|
+
if (checkpoint?.pass2Classified)
|
|
1011
|
+
pass2 = { skipped: [], classified: checkpoint.pass2Classified };
|
|
1012
|
+
if (checkpoint?.p3PendingBlockIds)
|
|
1013
|
+
p3PendingBlockIds.push(...checkpoint.p3PendingBlockIds);
|
|
1014
|
+
if (!pass2) {
|
|
1015
|
+
// Open blueprints context needed by Pass 0 and Pass 1
|
|
1016
|
+
const openBlueprints = allBlocks
|
|
1017
|
+
.filter((b) => b.type === "blueprint" && b.status === "active")
|
|
1018
|
+
.map((b) => ({ label: b.label, essence: b.essence || "" }));
|
|
1019
|
+
if (!pass1) {
|
|
1020
|
+
// ⚠ v1 SCENE-CARD FRONT-HALF — RETIRED & DISABLED (2026-06-22). v2 (COMPREHEND)
|
|
1021
|
+
// ALWAYS supplies pass1/pass2 via a checkpoint (resumeFrom:'pass3' on both the
|
|
1022
|
+
// per-turn and arc paths), so this block is unreachable on every live path.
|
|
1023
|
+
// Guarded to FAIL LOUD on a routing regression rather than silently extracting
|
|
1024
|
+
// through the retired scene-card engine. The Pass 0-1 code below is kept verbatim
|
|
1025
|
+
// for the follow-up deletion PR (which removes pass0.ts / pass1.ts /
|
|
1026
|
+
// synthesizeFromSceneCard.ts and this whole block).
|
|
1027
|
+
throw new Error("v1 scene-card pipeline is retired and disabled — Pass 1 must arrive from a v2 " +
|
|
1028
|
+
"COMPREHEND checkpoint. Reaching runAutoReflect's Pass 0-1 path indicates an " +
|
|
1029
|
+
"extraction routing bug (see arc-pipeline.ts / routes/state.ts).");
|
|
1030
|
+
}
|
|
1031
|
+
// ─── DEBT 5 Phase 2: arc-extraction flag-gated early-return ──────────
|
|
1032
|
+
// If we captured a conversation_turn at start (flag on + identity present),
|
|
1033
|
+
// persist Pass 0-1 output and skip Pass 2-5. Pass 2-5 will run at the arc-
|
|
1034
|
+
// extract trigger time (Phase 3) over consolidated input from all
|
|
1035
|
+
// pass01_done turns in the conversation. Per Variant A §2.6.
|
|
1036
|
+
//
|
|
1037
|
+
// The empty-items case STILL persists pass01_done (with empty items[]) —
|
|
1038
|
+
// the conversation_turns record is the canonical source-of-truth even
|
|
1039
|
+
// when this turn yielded nothing extractable; Phase 3 must distinguish
|
|
1040
|
+
// "ran-and-empty" from "didn't-run-yet" via status.
|
|
1041
|
+
if (_conversationTurnId) {
|
|
1042
|
+
const pass01Output = {
|
|
1043
|
+
scene_card: _pass0Raw ?? null,
|
|
1044
|
+
items: pass1?.items ?? [],
|
|
1045
|
+
};
|
|
1046
|
+
try {
|
|
1047
|
+
db.updateConversationTurnPass01(_conversationTurnId, JSON.stringify(pass01Output));
|
|
1048
|
+
console.log(`[arc-extract] persisted Pass 0-1 to conversation_turn id=${_conversationTurnId} items=${pass1?.items.length ?? 0} — Pass 2-5 deferred to arc trigger`);
|
|
1049
|
+
}
|
|
1050
|
+
catch (e) {
|
|
1051
|
+
// Most likely cause: row already past 'captured' (idempotency re-fire on
|
|
1052
|
+
// a turn whose pass01 was previously saved). Tolerate, log, move on.
|
|
1053
|
+
console.warn(`[arc-extract] updateConversationTurnPass01 failed for id=${_conversationTurnId}: ${e?.message} — Pass 0-1 may already be persisted from prior run`);
|
|
1054
|
+
}
|
|
1055
|
+
writeReflectLog({ pass1, pass2: null, pass3: null });
|
|
1056
|
+
return empty;
|
|
1057
|
+
}
|
|
1058
|
+
if (!pass1 || pass1.items.length === 0) {
|
|
1059
|
+
writeReflectLog({ pass1, pass2: null, pass3: null });
|
|
1060
|
+
console.log("Auto-Reflect Pass 1: nothing extracted — skipping passes 2 and 3");
|
|
1061
|
+
return empty;
|
|
1062
|
+
}
|
|
1063
|
+
// Re-derive active project from Pass 0 output.
|
|
1064
|
+
// loadedBlocks is empty on external triggers (no agent session) — the fallback
|
|
1065
|
+
// at line ~358 adds ALL projects, so activeProjectPrefixes is wrong by this point.
|
|
1066
|
+
// Pass 0 identifies the correct project in two places:
|
|
1067
|
+
// 1. projects[] — when the transcript explicitly names the project
|
|
1068
|
+
// 2. technologies[].project — always populated from KNOWN PROJECTS matching
|
|
1069
|
+
// Use both; technologies[].project is the reliable fallback when projects[] is empty.
|
|
1070
|
+
const pass0ProjectNames = new Set();
|
|
1071
|
+
for (const p of (_pass0Raw?.projects ?? [])) {
|
|
1072
|
+
pass0ProjectNames.add(p.name);
|
|
1073
|
+
}
|
|
1074
|
+
for (const t of (_pass0Raw?.technologies ?? [])) {
|
|
1075
|
+
if (t.project)
|
|
1076
|
+
pass0ProjectNames.add(t.project);
|
|
1077
|
+
}
|
|
1078
|
+
const pass0Matched = [...pass0ProjectNames].filter(n => allProjectPrefixes.has(n));
|
|
1079
|
+
if (pass0Matched.length > 0) {
|
|
1080
|
+
activeProjectPrefixes.clear();
|
|
1081
|
+
for (const n of pass0Matched)
|
|
1082
|
+
activeProjectPrefixes.add(n);
|
|
1083
|
+
}
|
|
1084
|
+
// If Pass 0 found nothing known (brand-new project), keep the existing set
|
|
1085
|
+
// Pre-search: build targeted context for Pass 2 (always fresh — queries current graph state)
|
|
1086
|
+
const pass2Context = await buildPreSearchContext(pass1.items, allBlocks, allRels, knownRoots, embeddings ?? null, activeProjectPrefixes);
|
|
1087
|
+
_pass2Context = pass2Context;
|
|
1088
|
+
// Thinking budget — tiered by Pass 1 item count, capped by NODEDEX_THINKING_BUDGET
|
|
1089
|
+
const itemCount = pass1.items.length;
|
|
1090
|
+
const p2Budget = getThinkingBudget(itemCount <= 3 ? 512 : itemCount <= 10 ? 4096 : 8192);
|
|
1091
|
+
console.log(`Auto-Reflect: ${itemCount} item(s) → thinking budget P2=${p2Budget}`);
|
|
1092
|
+
// Pass 2: always runs fresh — receives Pass 1 items + Pass 0 scene card.
|
|
1093
|
+
// Flag-gated routing per PASS2-SPLIT-DESIGN.md §4 (parallel migration).
|
|
1094
|
+
// DEFAULT-ON FLIP 2026-05-25 (§9 Week 4-5): split is now the default path;
|
|
1095
|
+
// set NODEDEX_PASS2_SPLIT=0 for emergency rollback to monolith pass2.ts
|
|
1096
|
+
// (kept in tree per §9, removed in a later commit after the stabilization
|
|
1097
|
+
// window). Flip rationale: cost gate closed + reconciled to dashboard
|
|
1098
|
+
// within ~1% (all 4 fixtures Acceptable+), Bug-3 quarantine containment
|
|
1099
|
+
// audit-confirmed, truncation gone. Reversible (this flag) + failures
|
|
1100
|
+
// contained (quarantine) — see commit + sticky. ON → split orchestrator
|
|
1101
|
+
// (2a → seam α → 2b → seam β → 2c → composer + quarantine routing). Result
|
|
1102
|
+
// shape is identical so the downstream graft + Pass 3 don't care.
|
|
1103
|
+
let p2;
|
|
1104
|
+
const _t2 = Date.now();
|
|
1105
|
+
if (process.env.NODEDEX_PASS2_SPLIT !== "0") {
|
|
1106
|
+
const sourceSessionId = agentId ?? "default";
|
|
1107
|
+
const split = await runPass2Split(provider, db, pass1.items, pass2Context, prevEntityMap, p2Budget, _sceneCard, sourceSessionId);
|
|
1108
|
+
p2 = {
|
|
1109
|
+
result: split.result,
|
|
1110
|
+
thinking: split.thinking,
|
|
1111
|
+
rateLimited: split.rateLimited,
|
|
1112
|
+
model: split.model,
|
|
1113
|
+
attempts: split.attempts,
|
|
1114
|
+
};
|
|
1115
|
+
_pass2SplitAudit = split.splitAudit;
|
|
1116
|
+
}
|
|
1117
|
+
else {
|
|
1118
|
+
p2 = await callPass2LLM(provider, pass1.items, pass2Context, prevEntityMap, p2Budget, _sceneCard);
|
|
1119
|
+
}
|
|
1120
|
+
_pass2Thinking = p2.thinking;
|
|
1121
|
+
_pass2Provider = { model: p2.model, attempts: p2.attempts };
|
|
1122
|
+
_passWallMs.pass2 = Date.now() - _t2;
|
|
1123
|
+
// Pre-populate extends_item from Pass 1 extends_id
|
|
1124
|
+
if (p2.result) {
|
|
1125
|
+
prePopulateExtendsItem(pass1.items, p2.result.classified);
|
|
1126
|
+
}
|
|
1127
|
+
if (!p2.result || p2.result.classified.length === 0) {
|
|
1128
|
+
writeReflectLog({ pass1, pass2: p2.result, pass3: null });
|
|
1129
|
+
if (p2.rateLimited || !p2.result) {
|
|
1130
|
+
const reason = p2.rateLimited ? "rate limited" : "API failure";
|
|
1131
|
+
console.log(`Auto-Reflect Pass 2: ${reason} — re-queuing with Pass 0+1 output`);
|
|
1132
|
+
return { ...empty, checkpoint: { resumeFrom: 'pass2', pass0: { sceneCard: _sceneCard, raw: _pass0Raw }, pass1Items: pass1.items } };
|
|
1133
|
+
}
|
|
1134
|
+
console.log("Auto-Reflect Pass 2: nothing classified — skipping pass 3");
|
|
1135
|
+
return empty;
|
|
1136
|
+
}
|
|
1137
|
+
pass2 = p2.result;
|
|
1138
|
+
}
|
|
1139
|
+
// Seam contract enforcement: code-stamp "type_override" when Pass 2 changed
|
|
1140
|
+
// an item's type but didn't set review_reason. See stampTypeOverrides() docs.
|
|
1141
|
+
if (pass1) {
|
|
1142
|
+
const stamped = stampTypeOverrides(pass1.items, pass2.classified);
|
|
1143
|
+
if (stamped > 0) {
|
|
1144
|
+
console.log(`Auto-Reflect Pass 2: code-stamped type_override on ${stamped} item(s) (Pass 2 changed type without setting review_reason)`);
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
// Merge causal_wiring[] into classified items (schema separates them for reasoning order)
|
|
1148
|
+
if (pass2.causal_wiring && Array.isArray(pass2.causal_wiring)) {
|
|
1149
|
+
const wiringMap = new Map();
|
|
1150
|
+
for (const w of pass2.causal_wiring) {
|
|
1151
|
+
if (w.item_id)
|
|
1152
|
+
wiringMap.set(w.item_id, w);
|
|
1153
|
+
}
|
|
1154
|
+
for (const item of pass2.classified) {
|
|
1155
|
+
const wiring = wiringMap.get(item.id);
|
|
1156
|
+
if (wiring) {
|
|
1157
|
+
item.triggered_by_items = wiring.triggered_by ?? [];
|
|
1158
|
+
item.based_on_items = wiring.based_on ?? [];
|
|
1159
|
+
}
|
|
1160
|
+
else {
|
|
1161
|
+
if (!item.triggered_by_items)
|
|
1162
|
+
item.triggered_by_items = [];
|
|
1163
|
+
if (!item.based_on_items)
|
|
1164
|
+
item.based_on_items = [];
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
// Log Pass 2 skipped items (graph existence check)
|
|
1169
|
+
if (pass2.skipped && pass2.skipped.length > 0) {
|
|
1170
|
+
for (const s of pass2.skipped) {
|
|
1171
|
+
console.log(`Auto-Reflect Pass 2: skipped item ${s.id} — ${s.reason}`);
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
// Falsifiable-skip guard: a Pass 2 skip is legitimate only if its reason
|
|
1175
|
+
// cites something verifiable — a real label or essence fragment from
|
|
1176
|
+
// allBlocks (an "already in graph" skip), OR a sibling item id from this
|
|
1177
|
+
// batch (an intra-batch-duplicate skip — Pass 2 Q0 STEP I sometimes
|
|
1178
|
+
// routes here despite the prompt's "drop, don't skip" instruction). A
|
|
1179
|
+
// skip citing nothing verifiable is unfalsifiable; the item is rescued
|
|
1180
|
+
// into classified[] with its Pass 1 provisional type.
|
|
1181
|
+
// No phrase matching — pure verification against real state or batch
|
|
1182
|
+
// siblings. Deterministic backstop for the STATE CONVENTION rule.
|
|
1183
|
+
if (pass2.skipped && pass2.skipped.length > 0 && pass1) {
|
|
1184
|
+
const knownLabels = allBlocks
|
|
1185
|
+
.map((b) => (b.label || "").toLowerCase())
|
|
1186
|
+
.filter((l) => l.length >= 4);
|
|
1187
|
+
const knownEssences = allBlocks
|
|
1188
|
+
.map((b) => (b.essence || "").toLowerCase().trim())
|
|
1189
|
+
.filter((e) => e.length >= 20);
|
|
1190
|
+
// Same-batch sibling item ids (e.g. "item_8", "syn_1"). A skip whose
|
|
1191
|
+
// reason cites a sibling is a legitimate intra-batch-duplicate skip —
|
|
1192
|
+
// the sibling exists in pass1.items, so the citation is verifiable.
|
|
1193
|
+
const siblingIds = (pass1?.items || [])
|
|
1194
|
+
.map((i) => (i.id || "").toLowerCase())
|
|
1195
|
+
.filter((id) => id.length >= 3);
|
|
1196
|
+
const citesRealState = (reason) => {
|
|
1197
|
+
const r = (reason || "").toLowerCase();
|
|
1198
|
+
if (knownLabels.some((lbl) => r.includes(lbl)))
|
|
1199
|
+
return true;
|
|
1200
|
+
if (siblingIds.some((id) => r.includes(id)))
|
|
1201
|
+
return true;
|
|
1202
|
+
// Any 20-char window of a real essence appearing in the reason = a citation
|
|
1203
|
+
for (const ess of knownEssences) {
|
|
1204
|
+
for (let i = 0; i + 20 <= ess.length; i++) {
|
|
1205
|
+
if (r.includes(ess.slice(i, i + 20)))
|
|
1206
|
+
return true;
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
return false;
|
|
1210
|
+
};
|
|
1211
|
+
const rescued = [];
|
|
1212
|
+
pass2.skipped = pass2.skipped.filter(s => {
|
|
1213
|
+
if (citesRealState(s.reason || ""))
|
|
1214
|
+
return true; // verifiable — keep the skip
|
|
1215
|
+
const item = pass1?.items.find((i) => i.id === s.id);
|
|
1216
|
+
if (!item)
|
|
1217
|
+
return true; // cannot rescue — leave as skip
|
|
1218
|
+
rescued.push({
|
|
1219
|
+
id: item.id,
|
|
1220
|
+
text: item.text,
|
|
1221
|
+
type: item.provisional_type,
|
|
1222
|
+
project: "",
|
|
1223
|
+
unique: {},
|
|
1224
|
+
classification_reasoning: `[rescued by guard — Pass 2 skip claim could not be verified against PROJECT GRAPH or any sibling item in this batch; reverting to provisional_type=${item.provisional_type}]`,
|
|
1225
|
+
triggered_by_items: [],
|
|
1226
|
+
based_on_items: [],
|
|
1227
|
+
});
|
|
1228
|
+
return false;
|
|
1229
|
+
});
|
|
1230
|
+
if (rescued.length > 0) {
|
|
1231
|
+
console.warn(`Auto-Reflect Pass 2 guard: rescued ${rescued.length} item(s) with unverifiable skip claims — types: ${rescued.map(r => r.type).join(',')}`);
|
|
1232
|
+
pass2.classified.push(...rescued);
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
// SLICE 1 SUB-STEP 1.4 — collected duplicate pairs detected by D2 dedup.
|
|
1236
|
+
// Hoisted out of the dedup block scope so the post-Pass-3 Stage FLAG writer
|
|
1237
|
+
// (after stampFlowRolesAndChains) can iterate them, resolve item_id →
|
|
1238
|
+
// block_id via itemIdToLabel + db.getBlock, and writePipelineFlag for each.
|
|
1239
|
+
// Empty array on per-turn paths where no D2 detect runs.
|
|
1240
|
+
const atomicDupCandidates = [];
|
|
1241
|
+
// DEBT 5 Slice 3 Part 4 — Stage D cross-arc resolve results, hoisted like
|
|
1242
|
+
// atomicDupCandidates so the post-Pass-3 flag writer (Touchpoint B) can
|
|
1243
|
+
// resolve item_id → block_id and write cross_arc_dup_candidate flags.
|
|
1244
|
+
// Empty unless arc mode + NODEDEX_STAGE_D_ENABLED + Stage D found attach/flag.
|
|
1245
|
+
let stageDEntries = [];
|
|
1246
|
+
// Code dedup guard (charter rule 3/6) — DEFAULT ON since 2026-05-24. Validated
|
|
1247
|
+
// across 4 domains (framework-choice, rl-fixed, garden-blight, refund): 0 false
|
|
1248
|
+
// collapses, stays idle when no exact dups exist. Set NODEDEX_CODE_DEDUP=0 to disable.
|
|
1249
|
+
// Two deterministic, exact-match-only steps; never extends to embedding similarity
|
|
1250
|
+
// (that is semantic judgment — Pass 2's job, not code's). Conservative on role-splits.
|
|
1251
|
+
if (process.env.NODEDEX_CODE_DEDUP !== "0") {
|
|
1252
|
+
// Step 1 — identical-essence-text twins (cross-type). The original guard.
|
|
1253
|
+
{
|
|
1254
|
+
const before = pass2.classified.length;
|
|
1255
|
+
const { kept, dropped } = dedupIdenticalEssenceTwins(pass2.classified);
|
|
1256
|
+
if (dropped.length > 0) {
|
|
1257
|
+
console.log(`[code-dedup] collapsed ${dropped.length} identical-essence twin(s) (${before} → ${kept.length}):`);
|
|
1258
|
+
for (const d of dropped)
|
|
1259
|
+
console.log(` - dropped ${d.id} → merged into ${d.mergedInto} | ${d.reason}`);
|
|
1260
|
+
pass2.classified = kept;
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
// Step 2 (Tier 1C) — identical unique{} value-set. Catches cross-type AND
|
|
1264
|
+
// same-type-paraphrase dups that differ in essence wording but carry the
|
|
1265
|
+
// same structured data. Identity = unique{}, not essence. Runs after Step 1
|
|
1266
|
+
// on the already-reduced set. Winner = schema-valid block (Tier 1B validator).
|
|
1267
|
+
{
|
|
1268
|
+
const before = pass2.classified.length;
|
|
1269
|
+
const { kept, dropped } = dedupIdenticalUniqueValues(pass2.classified);
|
|
1270
|
+
if (dropped.length > 0) {
|
|
1271
|
+
console.log(`[code-dedup] collapsed ${dropped.length} identical-unique{}-value twin(s) (${before} → ${kept.length}):`);
|
|
1272
|
+
for (const d of dropped)
|
|
1273
|
+
console.log(` - dropped ${d.id} → merged into ${d.mergedInto} | ${d.reason}`);
|
|
1274
|
+
pass2.classified = kept;
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
// Step 3 (DEBT 5 D2 §2.5.1) — dedup by (source_excerpt, primary_value).
|
|
1278
|
+
// Catches what Step 1 (essence) and Step 2 (unique-value) miss: items
|
|
1279
|
+
// with DIFFERENT unique-value-text but pinned to THE SAME source line
|
|
1280
|
+
// (e.g., arc-mode where two turns each extracted "the celebration of
|
|
1281
|
+
// family-style menu" and the LLM phrased the value slightly differently
|
|
1282
|
+
// across turns — different unique.value, same source_excerpt → still
|
|
1283
|
+
// the same observation).
|
|
1284
|
+
//
|
|
1285
|
+
// This is the provider-agnostic dedup per D2: identity = (where it came
|
|
1286
|
+
// from + what's the canonical fact), NOT label or type. Per
|
|
1287
|
+
// [[project-pass1-pass2a-provider-drift-2026-05-30]] this is the
|
|
1288
|
+
// structural answer to LLM-typing variance across providers.
|
|
1289
|
+
//
|
|
1290
|
+
// Only fires on items with non-empty excerpt — pre-Debt-5 atomic blocks
|
|
1291
|
+
// (NULL/empty source_excerpt) are conservatively left as-is.
|
|
1292
|
+
//
|
|
1293
|
+
// SLICE 1 SUB-STEP 1.4 behavior change: D2 now DETECTS (flags) instead
|
|
1294
|
+
// of auto-dropping. Both blocks survive to Pass 3 + DB write; after
|
|
1295
|
+
// createBlock loop, the Stage FLAG writer below (post-stampFlowRoles)
|
|
1296
|
+
// resolves item_ids → block_ids and writes pipeline_flags rows for
|
|
1297
|
+
// the async LLM reviewer (Slice 2) to decide merge/leave/split.
|
|
1298
|
+
// Per user direction: system FLAGS, reasoning MERGES.
|
|
1299
|
+
{
|
|
1300
|
+
const { duplicates } = dedupBySourceAndValue(pass2.classified);
|
|
1301
|
+
if (duplicates.length > 0) {
|
|
1302
|
+
console.log(`[code-dedup D2] DETECTED ${duplicates.length} (source_excerpt, primary_value)-twin(s) — FLAGGING (not dropping):`);
|
|
1303
|
+
for (const d of duplicates)
|
|
1304
|
+
console.log(` - flag candidate ${d.id} as duplicate of ${d.duplicate_of}`);
|
|
1305
|
+
atomicDupCandidates.push(...duplicates);
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
// ── FOLD constituent reason-facts (granularity fix; default OFF) ─────────────
|
|
1310
|
+
// Over-extraction is GRANULARITY not worth: Pass 1 fragments a decision + its
|
|
1311
|
+
// reasons into separate blocks (traversal-proven 2026-06-04). JUDGE (keep/drop
|
|
1312
|
+
// whole items) cannot consolidate fragments. foldConstituentFacts absorbs a
|
|
1313
|
+
// sole-constituent reason-fact INTO the state-change unit it justifies (enrich
|
|
1314
|
+
// unique.reason) so it is not built as a redundant standalone block. ENRICH not
|
|
1315
|
+
// delete (Rule 2 — content relocated, nothing lost). Runs AFTER code-dedup (so
|
|
1316
|
+
// based_on references are already consistent) and BEFORE Pass 3 build. Detection
|
|
1317
|
+
// is structural/deterministic (Rule 3 — counts wired based_on edges, never
|
|
1318
|
+
// semantic similarity). Mode-independent (arc + per-turn). Default OFF:
|
|
1319
|
+
// NODEDEX_FOLD_CONSTITUENTS=1. Design + guards:
|
|
1320
|
+
// [[project-fragmentation-not-worth-fold-2026-06-04]].
|
|
1321
|
+
if (process.env.NODEDEX_FOLD_CONSTITUENTS === "1") {
|
|
1322
|
+
const before = pass2.classified.length;
|
|
1323
|
+
const { kept, folded } = foldConstituentFacts(pass2.classified);
|
|
1324
|
+
if (folded.length > 0) {
|
|
1325
|
+
console.log(`[fold] folded ${folded.length} constituent reason-fact(s) (${before} → ${kept.length}):`);
|
|
1326
|
+
for (const f of folded)
|
|
1327
|
+
console.log(` - ${f.id} → ${f.foldedInto} | ${f.reason}`);
|
|
1328
|
+
pass2.classified = kept;
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
// Outer-scope state — populated by Pass 3 (normal run) or restored from pending blocks (pass4 retry)
|
|
1332
|
+
const result = { saved: 0, updated: 0, skipped: 0, saved_labels: [], uncertain_count: 0, created_blocks: [], updated_blocks: [] };
|
|
1333
|
+
const pipelineSkips = [];
|
|
1334
|
+
const _pendingRecentSaves = [];
|
|
1335
|
+
let analysis = null;
|
|
1336
|
+
let geminiThinking = "";
|
|
1337
|
+
if (checkpoint?.resumeFrom !== 'pass4') {
|
|
1338
|
+
// Thinking budget for Pass 3 — cap at 2048 to prevent Flash over-deliberation
|
|
1339
|
+
const p3ThinkBudget = getThinkingBudget(pass1
|
|
1340
|
+
? (pass1.items.length <= 3 ? 1024 : 2048)
|
|
1341
|
+
: 2048);
|
|
1342
|
+
console.log(`Auto-Reflect Pass 2: ${pass2.classified.length} blocks to build (${pass2.skipped?.length ?? 0} skipped by graph check) → proceeding to Pass 3 (thinking=${p3ThinkBudget})`);
|
|
1343
|
+
// Semantic duplicate pre-filter
|
|
1344
|
+
let duplicateAlerts = "";
|
|
1345
|
+
if (embeddings?.isAvailable()) {
|
|
1346
|
+
try {
|
|
1347
|
+
duplicateAlerts = await buildDuplicateAlerts(pass2.classified, allBlocks, embeddings);
|
|
1348
|
+
}
|
|
1349
|
+
catch { /* non-critical */ }
|
|
1350
|
+
}
|
|
1351
|
+
// Per-item neighborhood context for Pass 3
|
|
1352
|
+
let itemContext = {};
|
|
1353
|
+
try {
|
|
1354
|
+
itemContext = await buildItemContext(pass2.classified, allBlocks, allRels, embeddings ?? null);
|
|
1355
|
+
}
|
|
1356
|
+
catch { /* non-critical */ }
|
|
1357
|
+
// Filter superseded blocks from project context — prevents Pass 3 from deduping
|
|
1358
|
+
// a new block against the block it's meant to replace (supersedes_ref conflict)
|
|
1359
|
+
const supersededLabels = new Set(pass2.classified
|
|
1360
|
+
.filter(item => item.supersedes_ref)
|
|
1361
|
+
.map(item => item.supersedes_ref));
|
|
1362
|
+
const p3AllBlocks = supersededLabels.size > 0
|
|
1363
|
+
? allBlocks.filter((b) => !supersededLabels.has(b.label))
|
|
1364
|
+
: allBlocks;
|
|
1365
|
+
let p3ProjectContext = projectContext;
|
|
1366
|
+
if (supersededLabels.size > 0) {
|
|
1367
|
+
const { context: filteredCtx, reflectedIds: _reflectedIds1 } = buildProjectContext(p3AllBlocks, allRels, allProjectPrefixes, loadedBlocks);
|
|
1368
|
+
db.stampReflectedAt(_reflectedIds1);
|
|
1369
|
+
p3ProjectContext = filteredCtx;
|
|
1370
|
+
}
|
|
1371
|
+
if (supersededLabels.size > 0) {
|
|
1372
|
+
console.log(`Auto-Reflect Pass 3: filtered ${supersededLabels.size} superseded block(s) from project context: [${[...supersededLabels].join(", ")}]`);
|
|
1373
|
+
}
|
|
1374
|
+
// ── DEBT 5 Slice 1 Sub-step 1.3 — apply Stage C canonical names ──────────
|
|
1375
|
+
// Between Pass 2 and Pass 3 in ARC mode only: walk Pass 2 classified items,
|
|
1376
|
+
// for each item that appears in a Stage C entity cluster, overwrite its
|
|
1377
|
+
// .project field with the cluster's canonical_name. Pass 3 trusts Pass 2's
|
|
1378
|
+
// project field verbatim (pass3.ts:75-77), so this is the seam where the
|
|
1379
|
+
// 5-projects-for-1-arc fix lands without modifying Pass 3's prompt.
|
|
1380
|
+
//
|
|
1381
|
+
// Degrade path: when checkpoint.arcEntityResolution is undefined (Stage C
|
|
1382
|
+
// skipped/failed OR non-arc per-turn runs), the helper is a no-op — items
|
|
1383
|
+
// keep Pass 2's per-turn names. Phase 11 5-projects-bug is the cost; we
|
|
1384
|
+
// accept it as the original behavior, not a regression.
|
|
1385
|
+
if (pass2.classified.length > 0 && checkpoint?.arcEntityResolution) {
|
|
1386
|
+
const result = applyArcEntityCanonicalNames(pass2.classified, checkpoint.arcEntityResolution);
|
|
1387
|
+
pass2 = { skipped: pass2.skipped, classified: result.items };
|
|
1388
|
+
console.log(`Auto-Reflect Pass 3 (arc): canonicalized ${result.renamed_count} item(s) across ${result.clusters_used} cluster(s); ${result.unmatched_item_ids.length} item(s) kept per-turn names`);
|
|
1389
|
+
}
|
|
1390
|
+
// ── Recognition Layer step 2 — RECOGNIZER (root-fork fix; default OFF) ───────
|
|
1391
|
+
// After Stage C named the clusters, ask for each NEW-root candidate: does this
|
|
1392
|
+
// cluster actually belong to an EXISTING root (same domain + same owner)? On a
|
|
1393
|
+
// confident attach, rewrite the cluster's .project to that root's EXACT label so
|
|
1394
|
+
// Pass 3's root-create finds it (no fork). Else keep the new root (the safe fork
|
|
1395
|
+
// per §1; the post-hoc AUDIT-heal pass surfaces fork-pairs for the agent/user).
|
|
1396
|
+
// "Stage D for roots" — LLM-primary, judges on the root DESCRIPTION (essence) +
|
|
1397
|
+
// scope, with the 5 guards (recognize-root.ts). Default OFF
|
|
1398
|
+
// (NODEDEX_RECOGNIZER_ENABLED=1), arc mode only. Graceful degrade on any error.
|
|
1399
|
+
if (recognizerEnabled() &&
|
|
1400
|
+
checkpoint?.arcEntityResolution &&
|
|
1401
|
+
pass2.classified.length > 0 &&
|
|
1402
|
+
knownRoots.length > 0) {
|
|
1403
|
+
try {
|
|
1404
|
+
const rec = await recognizeRootsForArc({ provider, items: pass2.classified, knownRoots });
|
|
1405
|
+
if (rec.remap.length > 0) {
|
|
1406
|
+
const applied = applyRootRemap(pass2.classified, rec.remap);
|
|
1407
|
+
pass2 = { skipped: pass2.skipped, classified: applied.items };
|
|
1408
|
+
console.log(`Auto-Reflect Recognizer (arc): ${rec.attached} attach / ${rec.candidates} candidate(s); rewrote ${applied.rewritten} item(s) — ${rec.llm_calls} LLM call(s)`);
|
|
1409
|
+
}
|
|
1410
|
+
else if (rec.candidates > 0) {
|
|
1411
|
+
console.log(`Auto-Reflect Recognizer (arc): 0 attach / ${rec.candidates} candidate(s) kept as new — ${rec.llm_calls} LLM call(s)`);
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
catch (e) {
|
|
1415
|
+
console.warn(`[recognizer] threw (${e?.message}) — keeping Stage C names (extraction continues)`);
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
// ── DEBT 5 Slice 3 Part 4 — STAGE D: cross-arc resolve (Touchpoint A) ────────
|
|
1419
|
+
// After Stage C applied canonical names, ask for each classified item: does
|
|
1420
|
+
// this entity ALREADY EXIST in the graph (attach), or is it a different-owner
|
|
1421
|
+
// collision / ambiguous (flag)? Stage D DETECTS + JUDGES + FLAGS only — it does
|
|
1422
|
+
// NOT mutate the graph (the async reviewer ACTS; charter flag-vs-auto-act,
|
|
1423
|
+
// PIPELINE-FIRST-PRINCIPLES §4 Insight 3). Results are drained into
|
|
1424
|
+
// cross_arc_dup_candidate flags at Touchpoint B (after the createBlock loop,
|
|
1425
|
+
// when item→block_id is resolvable).
|
|
1426
|
+
//
|
|
1427
|
+
// Default OFF — opt-in via NODEDEX_STAGE_D_ENABLED=1. Arc mode only (needs the
|
|
1428
|
+
// Stage C resolution that runs at arc time). The cost gate (minIdentityForLLM)
|
|
1429
|
+
// bounds spend to items with a plausible duplicate candidate. Graceful degrade:
|
|
1430
|
+
// any failure here is caught and logged — it must never block extraction.
|
|
1431
|
+
if (process.env.NODEDEX_STAGE_D_ENABLED === "1" &&
|
|
1432
|
+
checkpoint?.arcEntityResolution &&
|
|
1433
|
+
pass2.classified.length > 0) {
|
|
1434
|
+
try {
|
|
1435
|
+
const batch = await resolveArcEntitiesForItems({ db, provider, items: pass2.classified });
|
|
1436
|
+
stageDEntries = batch.entries;
|
|
1437
|
+
console.log(`Auto-Reflect Stage D (arc): resolved ${batch.items_resolved} item(s) — ${batch.attached} attach, ${batch.flagged} flag, ${batch.llm_calls} LLM call(s)`);
|
|
1438
|
+
}
|
|
1439
|
+
catch (e) {
|
|
1440
|
+
console.warn(`[stage-d] resolve threw (${e?.message}) — skipping cross-arc flags (extraction continues)`);
|
|
1441
|
+
stageDEntries = [];
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
// ── PASS 3: Build ──
|
|
1445
|
+
// callPass3Batched is a drop-in for callPass3LLM: when NODEDEX_PASS3_BATCH=1 AND
|
|
1446
|
+
// the item count exceeds the batch size it splits the write into chunks (the model
|
|
1447
|
+
// gives up past ~25 items in one call); otherwise it delegates to a single call =
|
|
1448
|
+
// byte-identical to before. Cross-chunk relations wire server-side at save.
|
|
1449
|
+
const _t3 = Date.now();
|
|
1450
|
+
const p3 = await callPass3Batched(provider, pass2.classified, knownRoots, p3ProjectContext, agentSavedBlocks, p3ThinkBudget, duplicateAlerts, itemContext);
|
|
1451
|
+
_passWallMs.pass3 = Date.now() - _t3;
|
|
1452
|
+
analysis = p3.analysis;
|
|
1453
|
+
geminiThinking = p3.geminiThinking;
|
|
1454
|
+
const rateLimited = p3.rateLimited;
|
|
1455
|
+
_pass3Provider = { model: p3.model, attempts: p3.attempts };
|
|
1456
|
+
writeReflectLog({ pass1, pass2, pass3: analysis, pass4: null });
|
|
1457
|
+
if (!analysis) {
|
|
1458
|
+
const reason = rateLimited ? "rate limited" : "API failure";
|
|
1459
|
+
console.warn(`Auto-Reflect Pass 3: ${reason} — saving ${pass2.classified.length} classified items for re-queue`);
|
|
1460
|
+
return { ...empty, checkpoint: { resumeFrom: 'pass3', pass0: { sceneCard: _sceneCard, raw: _pass0Raw }, pass1Items: pass1?.items, pass2Classified: pass2.classified } };
|
|
1461
|
+
}
|
|
1462
|
+
// ── Recover a drifted/missing from_item_id BEFORE the accounting guards ──
|
|
1463
|
+
// Pass 3 (the model) must echo each block's source item id as from_item_id, but
|
|
1464
|
+
// Gemini occasionally omits or drifts it on ONE block in a large batched write.
|
|
1465
|
+
// The save loop already has a type-match fallback for this (see ~"inferred
|
|
1466
|
+
// from_item_id by type match" below), but it runs DOWNSTREAM of the mandatory-item
|
|
1467
|
+
// guard — so a single unrecovered id there would discard the WHOLE analysis (every
|
|
1468
|
+
// correctly-built block included) before the recovery ever runs. Hoist the same
|
|
1469
|
+
// recovery here so the guard evaluates the recovered state: one drifted id no
|
|
1470
|
+
// longer nukes the entire arc.
|
|
1471
|
+
{
|
|
1472
|
+
const _recovered = recoverDriftedFromItemIds(analysis.new_blocks, pass2.classified);
|
|
1473
|
+
if (_recovered > 0)
|
|
1474
|
+
console.log(`Auto-Reflect Pass 3: recovered ${_recovered} drifted/missing from_item_id(s) by pre-accounting type-match`);
|
|
1475
|
+
}
|
|
1476
|
+
// Truncation detection: if Pass 3 accounts for far fewer items than Pass 2 sent,
|
|
1477
|
+
// the model returned a syntactically valid but incomplete response. Re-queue.
|
|
1478
|
+
{
|
|
1479
|
+
const substantiveItems = pass2.classified.filter(i => i.type !== "task").length;
|
|
1480
|
+
const accounted = (analysis.new_blocks?.length ?? 0)
|
|
1481
|
+
+ (analysis.skip_reasons?.length ?? 0)
|
|
1482
|
+
+ (analysis.updates?.length ?? 0);
|
|
1483
|
+
if (substantiveItems >= 3 && accounted < Math.ceil(substantiveItems / 2)) {
|
|
1484
|
+
console.warn(`Auto-Reflect Pass 3: truncated response detected (${accounted} accounted / ${substantiveItems} expected) — re-queuing`);
|
|
1485
|
+
return { ...empty, checkpoint: { resumeFrom: 'pass3', pass0: { sceneCard: _sceneCard, raw: _pass0Raw }, pass1Items: pass1?.items, pass2Classified: pass2.classified } };
|
|
1486
|
+
}
|
|
1487
|
+
// Mandatory item check: verify all tier-1/tier-3 items are accounted for.
|
|
1488
|
+
// Tier 1 — decision/constraint/dead_end/blueprint + supersedes_ref items.
|
|
1489
|
+
// Tier 3 — facts with no existing neighborhood match (new state data).
|
|
1490
|
+
// If any are missing from new_blocks/skip_reasons/updates → re-queue.
|
|
1491
|
+
const MANDATORY_PASS3_TYPES = new Set(["decision", "constraint", "dead_end", "blueprint"]);
|
|
1492
|
+
const mandatoryItems = pass2.classified.filter(i => {
|
|
1493
|
+
if (MANDATORY_PASS3_TYPES.has(i.type) || !!i.supersedes_ref)
|
|
1494
|
+
return true; // tier 1
|
|
1495
|
+
if (i.type === "fact") {
|
|
1496
|
+
const ctx = itemContext[i.id];
|
|
1497
|
+
return !ctx || ctx === "(no existing match — create new block)"; // tier 3
|
|
1498
|
+
}
|
|
1499
|
+
return false;
|
|
1500
|
+
});
|
|
1501
|
+
if (mandatoryItems.length > 0) {
|
|
1502
|
+
const fromItemIds = new Set((analysis.new_blocks || []).map((b) => b.from_item_id).filter(Boolean));
|
|
1503
|
+
const skipItemIds = new Set((analysis.skip_reasons || []).map((s) => s.item_id).filter(Boolean));
|
|
1504
|
+
const updateBlockIds = new Set((analysis.updates || []).map((u) => u.block_id).filter(Boolean));
|
|
1505
|
+
const missing = mandatoryItems.filter(i => !fromItemIds.has(i.id) && !skipItemIds.has(i.id) && !updateBlockIds.has(i.id));
|
|
1506
|
+
if (missing.length > 0) {
|
|
1507
|
+
const labels = missing.map(i => `${i.id}[${i.type}]`).join(", ");
|
|
1508
|
+
console.warn(`Auto-Reflect Pass 3: mandatory item(s) unaccounted: ${labels} — re-queuing`);
|
|
1509
|
+
return { ...empty, checkpoint: { resumeFrom: 'pass3', pass0: { sceneCard: _sceneCard, raw: _pass0Raw }, pass1Items: pass1?.items, pass2Classified: pass2.classified } };
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
// ── Supersedes-ref guard: items with supersedes_ref must be in new_blocks, not updates ──
|
|
1514
|
+
// The prompt says "supersedes_ref → ALWAYS new_blocks + supersedes relation, NEVER updates".
|
|
1515
|
+
// If the model violates this, warn and let it through (updates[] still processes, but the
|
|
1516
|
+
// superseded block won't be properly archived as history).
|
|
1517
|
+
{
|
|
1518
|
+
const supersedesItemIds = new Set(pass2.classified.filter(i => !!i.supersedes_ref).map(i => i.id));
|
|
1519
|
+
if (supersedesItemIds.size > 0) {
|
|
1520
|
+
const newBlockItemIds = new Set((analysis.new_blocks || []).map((b) => b.from_item_id).filter(Boolean));
|
|
1521
|
+
for (const itemId of supersedesItemIds) {
|
|
1522
|
+
if (!newBlockItemIds.has(itemId)) {
|
|
1523
|
+
const item = pass2.classified.find(i => i.id === itemId);
|
|
1524
|
+
console.warn(`Auto-Reflect Pass 3 guard: supersedes_ref item "${itemId}" [${item?.type}] not in new_blocks[] — superseded block may not be properly archived`);
|
|
1525
|
+
pipelineSkips.push({ label: itemId, reason: `supersedes_ref_not_in_new_blocks` });
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
result.skipped = (analysis.skip_reasons || []).length;
|
|
1531
|
+
// ── Scope project: the guaranteed home for cross-cutting blocks ──
|
|
1532
|
+
// Pass 0 names one overarching subject per transcript. Ensure it exists as a
|
|
1533
|
+
// root BEFORE the project_creates[] loop so that:
|
|
1534
|
+
// (1) the sentinel guard in the build loop has somewhere to re-home blocks
|
|
1535
|
+
// (2) sub-projects in project_creates[] can default their `parent` to the
|
|
1536
|
+
// scope project and find it in allBlocks immediately (Bug 3 fix —
|
|
1537
|
+
// structurally-determined nesting belongs in code, not delegated to
|
|
1538
|
+
// LLM judgment via the optional pass3 `parent` field; charter rule 3).
|
|
1539
|
+
const SENTINEL_PROJECT_NAMES = new Set(["null", "undefined", "unknown", "general", "misc", "none", ""]);
|
|
1540
|
+
// A COMPREHEND group_id leaking as a project name ("group_1", "g-2") is a
|
|
1541
|
+
// placeholder, not a root — treat it as sentinel so it is re-homed (v1) rather than
|
|
1542
|
+
// becoming a root. In v2 the converter (comprehendResultToPass2Items) already
|
|
1543
|
+
// guarantees a real item.project; this is the WRITE-seam backstop.
|
|
1544
|
+
const GROUP_ID_PLACEHOLDER = /^(?:group|g)[-_]?\d+$/i;
|
|
1545
|
+
const isSentinelProject = (p) => p == null
|
|
1546
|
+
|| SENTINEL_PROJECT_NAMES.has(String(p).trim().toLowerCase())
|
|
1547
|
+
|| GROUP_ID_PLACEHOLDER.test(String(p).trim());
|
|
1548
|
+
const newProjectLabels = new Set();
|
|
1549
|
+
const scopeProjectLabel = typeof _pass0Raw?.scope_project?.name === "string" && !isSentinelProject(_pass0Raw.scope_project.name)
|
|
1550
|
+
? _pass0Raw.scope_project.name.trim()
|
|
1551
|
+
: undefined;
|
|
1552
|
+
if (scopeProjectLabel) {
|
|
1553
|
+
let scopeProj = allBlocks.find((b) => b.label === scopeProjectLabel && b.type === "project");
|
|
1554
|
+
if (!scopeProj) {
|
|
1555
|
+
const dbScope = db.getBlock(scopeProjectLabel);
|
|
1556
|
+
if (dbScope && dbScope.type === "project") {
|
|
1557
|
+
scopeProj = dbScope;
|
|
1558
|
+
allBlocks.push(dbScope);
|
|
1559
|
+
}
|
|
1560
|
+
else {
|
|
1561
|
+
scopeProj = db.createBlock({
|
|
1562
|
+
label: scopeProjectLabel, type: "project", status: "active",
|
|
1563
|
+
essence: _pass0Raw?.scope_project?.scope || `Overarching subject: ${scopeProjectLabel}`,
|
|
1564
|
+
content: { is_a: "project", concepts: [], unique: {} },
|
|
1565
|
+
ttl: "permanent", source: "Auto-Reflect", created_by: geminiCreatedBy,
|
|
1566
|
+
});
|
|
1567
|
+
stampQualityScore(db, scopeProj, []); // Bug 1 fix: pipeline-created blocks must have quality_score set, else recall filters reject them at q=0
|
|
1568
|
+
allBlocks.push(scopeProj);
|
|
1569
|
+
console.log(`Auto-Reflect: ensured scope project root "${scopeProjectLabel}"`);
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
newProjectLabels.add(scopeProjectLabel);
|
|
1573
|
+
}
|
|
1574
|
+
// ── Create new project roots ──
|
|
1575
|
+
for (const projDef of (analysis.project_creates || [])) {
|
|
1576
|
+
if (!projDef?.label || !projDef?.essence)
|
|
1577
|
+
continue;
|
|
1578
|
+
// Bug 3 fix: default `parent` to scope_project for non-scope sub-projects.
|
|
1579
|
+
// See resolveProjectParent doc for full rationale.
|
|
1580
|
+
const desiredParent = resolveProjectParent(projDef, scopeProjectLabel);
|
|
1581
|
+
const existingProj = allBlocks.find((b) => b.label === projDef.label && b.type === "project");
|
|
1582
|
+
if (existingProj) {
|
|
1583
|
+
// Project already exists — still wire parent relation if specified and not yet present
|
|
1584
|
+
if (desiredParent) {
|
|
1585
|
+
const alreadyLinked = db.getRelations(existingProj.id).some((r) => r.type === "part_of");
|
|
1586
|
+
if (!alreadyLinked) {
|
|
1587
|
+
const parentBlock = allBlocks.find((b) => b.label === desiredParent && b.type === "project")
|
|
1588
|
+
?? db.getBlock(desiredParent);
|
|
1589
|
+
if (parentBlock) {
|
|
1590
|
+
db.createRelation({ source_id: existingProj.id, target_id: parentBlock.id, type: "part_of", bidirectional: false });
|
|
1591
|
+
console.log(`Auto-Reflect: retroactively linked existing project "${projDef.label}" → parent "${desiredParent}"`);
|
|
1592
|
+
}
|
|
1593
|
+
else {
|
|
1594
|
+
console.warn(`Auto-Reflect: existing project "${projDef.label}" — parent "${desiredParent}" not found, skipping nest`);
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
continue;
|
|
1599
|
+
}
|
|
1600
|
+
const newProj = db.createBlock({
|
|
1601
|
+
label: projDef.label, type: "project", status: "active",
|
|
1602
|
+
essence: projDef.essence,
|
|
1603
|
+
content: { is_a: "project", concepts: [], unique: {} },
|
|
1604
|
+
ttl: "permanent", source: "Auto-Reflect", created_by: geminiCreatedBy,
|
|
1605
|
+
});
|
|
1606
|
+
stampQualityScore(db, newProj, []); // Bug 1 fix
|
|
1607
|
+
allBlocks.push(newProj);
|
|
1608
|
+
newProjectLabels.add(projDef.label);
|
|
1609
|
+
// Wire nested root → parent if specified (collection-member pattern)
|
|
1610
|
+
if (desiredParent) {
|
|
1611
|
+
const parentBlock = allBlocks.find((b) => b.label === desiredParent && b.type === "project")
|
|
1612
|
+
?? db.getBlock(desiredParent);
|
|
1613
|
+
if (parentBlock) {
|
|
1614
|
+
db.createRelation({ source_id: newProj.id, target_id: parentBlock.id, type: "part_of", bidirectional: false });
|
|
1615
|
+
console.log(`Auto-Reflect: created project root "${projDef.label}" (nested under "${desiredParent}")`);
|
|
1616
|
+
}
|
|
1617
|
+
else {
|
|
1618
|
+
console.warn(`Auto-Reflect: created project root "${projDef.label}" — parent "${desiredParent}" not found in graph, skipping nest`);
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
else {
|
|
1622
|
+
console.log(`Auto-Reflect: created project root "${projDef.label}"`);
|
|
1623
|
+
}
|
|
1624
|
+
const prefix = projDef.label;
|
|
1625
|
+
const orphans = allBlocks.filter((b) => b.type !== "project" && b.label.startsWith(prefix + "_") && !b.project_id);
|
|
1626
|
+
for (const orphan of orphans) {
|
|
1627
|
+
db.updateBlock(orphan.id, { project_id: newProj.id });
|
|
1628
|
+
orphan.project_id = newProj.id;
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
// Priority-tier ordering
|
|
1632
|
+
const TIER1 = new Set(["dead_end"]);
|
|
1633
|
+
const TIER2 = new Set(["decision", "constraint", "preference"]);
|
|
1634
|
+
const rawBlocks = (analysis.new_blocks || []).filter((b) => b?.essence && b?.label);
|
|
1635
|
+
const tier1 = rawBlocks.filter((b) => TIER1.has(b.is_a) || (b.relations || []).some((r) => r.type === "contradicts"));
|
|
1636
|
+
const tier2 = rawBlocks.filter((b) => !tier1.includes(b) && TIER2.has(b.is_a));
|
|
1637
|
+
const tier3 = rawBlocks.filter((b) => !tier1.includes(b) && !tier2.includes(b));
|
|
1638
|
+
const orderedBlocks = [...tier1, ...tier2, ...tier3];
|
|
1639
|
+
// Review map: item_id → review_reason from Pass 2
|
|
1640
|
+
const reviewMap = new Map();
|
|
1641
|
+
for (const item of pass2.classified) {
|
|
1642
|
+
if (item.review_reason)
|
|
1643
|
+
reviewMap.set(item.id, item.review_reason);
|
|
1644
|
+
}
|
|
1645
|
+
// Source-type map: item_id → source_type provenance from Pass 2 (debt-3
|
|
1646
|
+
// demote-edge sets "seam_demoted"). Looked up at createBlock via
|
|
1647
|
+
// blockDef.from_item_id, mirroring reviewMap. Monolith path never sets it →
|
|
1648
|
+
// map empty → source_type defaults to "agent_derived" (backward compatible).
|
|
1649
|
+
const sourceTypeMap = new Map();
|
|
1650
|
+
for (const item of pass2.classified) {
|
|
1651
|
+
if (item.source_type)
|
|
1652
|
+
sourceTypeMap.set(item.id, item.source_type);
|
|
1653
|
+
}
|
|
1654
|
+
// DEBT 5 D3 (§2.3.2) Phase 5: item_id → source_excerpt. Pinned from Pass 1's
|
|
1655
|
+
// excerpt field, carried through Pass 2a re-join (mirrors text re-join),
|
|
1656
|
+
// through composeForDownstream into Pass2Item.excerpt. Pass 3 reads via
|
|
1657
|
+
// blockDef.from_item_id at createBlock to populate blocks.source_excerpt
|
|
1658
|
+
// column. Empty string when Pass 1 left it empty (defensive — Pass 2a logs
|
|
1659
|
+
// a re-join miss but doesn't fail). Map keeps non-empty values only so
|
|
1660
|
+
// createBlock falls back to NULL for items without provenance.
|
|
1661
|
+
const sourceExcerptMap = new Map();
|
|
1662
|
+
for (const item of pass2.classified) {
|
|
1663
|
+
if (item.excerpt && item.excerpt.length > 0)
|
|
1664
|
+
sourceExcerptMap.set(item.id, item.excerpt);
|
|
1665
|
+
}
|
|
1666
|
+
// Seed type set
|
|
1667
|
+
const seedTypeNames = new Set([
|
|
1668
|
+
"artifact", "constraint", "dead_end", "decision", "draft", "fact", "insight",
|
|
1669
|
+
"note", "process", "project", "question", "task",
|
|
1670
|
+
"hypothesis", "preference", "blueprint", "event", "entity",
|
|
1671
|
+
// reasoning_chain/metric/claim collapsed → insight/fact (2026-06-15)
|
|
1672
|
+
]);
|
|
1673
|
+
const allDbTypes = db.getBlockTypes?.() ?? [];
|
|
1674
|
+
allDbTypes.filter((t) => !seedTypeNames.has(t.name)).forEach((t) => seedTypeNames.add(t.name));
|
|
1675
|
+
// Cache the multi-word type set once — used by normalizeMultiWordTypeInLabel
|
|
1676
|
+
// to repair labels the LLM occasionally writes with underscores in the type
|
|
1677
|
+
// segment (e.g. `garden_dead_end_x`) where the rule is hyphens-within-dimension
|
|
1678
|
+
// (`garden_dead-end_x`). Bug 2 fix, 2026-05-28.
|
|
1679
|
+
const MULTI_WORD_TYPES = new Set([...seedTypeNames].filter((t) => t.includes("_")));
|
|
1680
|
+
// from_item_id → assembled label, for server-side extends_item resolution
|
|
1681
|
+
const itemIdToLabel = new Map();
|
|
1682
|
+
const pendingTriggeredBy = [];
|
|
1683
|
+
// item_id → triggered_by_items from Pass 2, for TS-layer fallback when Pass 3 omits triggered_by
|
|
1684
|
+
const itemTriggeredByItems = new Map();
|
|
1685
|
+
for (const item of pass2.classified) {
|
|
1686
|
+
if (item.id && Array.isArray(item.triggered_by_items)) {
|
|
1687
|
+
itemTriggeredByItems.set(item.id, item.triggered_by_items);
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
for (const _blockDefRaw of orderedBlocks) {
|
|
1691
|
+
if (!_blockDefRaw?.essence || !_blockDefRaw?.label)
|
|
1692
|
+
continue;
|
|
1693
|
+
// Assemble label from object
|
|
1694
|
+
let blockDef = _blockDefRaw;
|
|
1695
|
+
let labelFromObject = false;
|
|
1696
|
+
if (typeof blockDef.label === "object" && blockDef.label !== null) {
|
|
1697
|
+
const lp = blockDef.label;
|
|
1698
|
+
// Sentinel-project guard: Pass 3 gave no real project → re-home to the
|
|
1699
|
+
// scope project so "null"/"" can never become a project root.
|
|
1700
|
+
if (isSentinelProject(lp.project) && scopeProjectLabel)
|
|
1701
|
+
lp.project = scopeProjectLabel;
|
|
1702
|
+
if (lp.project && lp.type && lp.concept) {
|
|
1703
|
+
const parts = lp.subgroup
|
|
1704
|
+
? [lp.project, lp.subgroup, lp.type, lp.concept]
|
|
1705
|
+
: [lp.project, lp.type, lp.concept];
|
|
1706
|
+
blockDef = { ...blockDef, label: parts.join("_") };
|
|
1707
|
+
labelFromObject = true;
|
|
1708
|
+
}
|
|
1709
|
+
else {
|
|
1710
|
+
console.warn(`Auto-Reflect: skipped block — incomplete label object`);
|
|
1711
|
+
pipelineSkips.push({ label: JSON.stringify(_blockDefRaw.label), reason: "incomplete_label_object" });
|
|
1712
|
+
result.skipped++;
|
|
1713
|
+
continue;
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
// Bug 2 fix (2026-05-28): normalize multi-word type segments in the
|
|
1717
|
+
// block's own label AND in cross-block references that target labels
|
|
1718
|
+
// with the same convention. Catches LLM cases where the type's literal
|
|
1719
|
+
// underscore form leaked through into the label (`dead_end` → `dead-end`).
|
|
1720
|
+
// Idempotent; refs starting with blk_/__item_ref__/__based_on__ are
|
|
1721
|
+
// markers, not labels — leave them alone.
|
|
1722
|
+
blockDef = {
|
|
1723
|
+
...blockDef,
|
|
1724
|
+
label: normalizeMultiWordTypeInLabel(blockDef.label, MULTI_WORD_TYPES),
|
|
1725
|
+
...(Array.isArray(blockDef.triggered_by) ? {
|
|
1726
|
+
triggered_by: blockDef.triggered_by.map((r) => typeof r === "string" && !r.startsWith("blk_") && !r.startsWith("__item_ref__") && !r.startsWith("__based_on__")
|
|
1727
|
+
? normalizeMultiWordTypeInLabel(r, MULTI_WORD_TYPES)
|
|
1728
|
+
: r),
|
|
1729
|
+
} : {}),
|
|
1730
|
+
...(Array.isArray(blockDef.based_on) ? {
|
|
1731
|
+
based_on: blockDef.based_on.map((r) => typeof r === "string" && !r.startsWith("blk_")
|
|
1732
|
+
? normalizeMultiWordTypeInLabel(r, MULTI_WORD_TYPES)
|
|
1733
|
+
: r),
|
|
1734
|
+
} : {}),
|
|
1735
|
+
...(Array.isArray(blockDef.relations) ? {
|
|
1736
|
+
relations: blockDef.relations.map((rel) => rel && typeof rel === "object" && typeof rel.target_id === "string"
|
|
1737
|
+
&& !rel.target_id.startsWith("blk_") && rel.target_id !== "null"
|
|
1738
|
+
? { ...rel, target_id: normalizeMultiWordTypeInLabel(rel.target_id, MULTI_WORD_TYPES) }
|
|
1739
|
+
: rel),
|
|
1740
|
+
} : {}),
|
|
1741
|
+
};
|
|
1742
|
+
// Project root validation — if project is unknown, auto-create rather than reject
|
|
1743
|
+
// (mirrors the flat-string path below; project_creates[] is a hint not a gate)
|
|
1744
|
+
if (labelFromObject) {
|
|
1745
|
+
const lp = _blockDefRaw.label;
|
|
1746
|
+
const blockProject = lp.project;
|
|
1747
|
+
if (!isKnownProject(blockProject, allProjectPrefixes, newProjectLabels)) {
|
|
1748
|
+
const existingProj = db.getBlock(blockProject);
|
|
1749
|
+
if (existingProj && existingProj.type === "project") {
|
|
1750
|
+
allBlocks.push(existingProj);
|
|
1751
|
+
}
|
|
1752
|
+
else {
|
|
1753
|
+
const autoProj = db.createBlock({
|
|
1754
|
+
label: blockProject, type: "project", status: "active",
|
|
1755
|
+
essence: `Auto-created project for '${blockProject}' domain`,
|
|
1756
|
+
content: { is_a: "project", unique: {}, concepts: [] },
|
|
1757
|
+
ttl: "permanent", source: "Auto-Reflect", created_by: geminiCreatedBy,
|
|
1758
|
+
});
|
|
1759
|
+
stampQualityScore(db, autoProj, []); // Bug 1 fix
|
|
1760
|
+
allBlocks.push(autoProj);
|
|
1761
|
+
newProjectLabels.add(blockProject);
|
|
1762
|
+
console.log(`Auto-Reflect: auto-created project root "${blockProject}" for "${blockDef.label}"`);
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
// Label dedup — merge computed metadata into existing block instead of skipping
|
|
1767
|
+
if (isDuplicateLabel(blockDef.label, allBlocks)) {
|
|
1768
|
+
const existing = allBlocks.find((b) => b.label === blockDef.label) ?? db.getBlock(blockDef.label);
|
|
1769
|
+
if (existing) {
|
|
1770
|
+
// Apply concepts if existing block has none
|
|
1771
|
+
let existingConcepts = [];
|
|
1772
|
+
try {
|
|
1773
|
+
existingConcepts = JSON.parse(typeof existing.concepts === "string" ? existing.concepts : "[]");
|
|
1774
|
+
}
|
|
1775
|
+
catch { /* */ }
|
|
1776
|
+
const newConcepts = blockDef.concepts || [];
|
|
1777
|
+
if (existingConcepts.length === 0 && newConcepts.length > 0) {
|
|
1778
|
+
const existingContent = typeof existing.content === "string" ? (JSON.parse(existing.content) || {}) : (existing.content || {});
|
|
1779
|
+
existingContent.concepts = newConcepts;
|
|
1780
|
+
db.updateBlock(existing.id, { concepts: newConcepts, content: JSON.stringify(existingContent) });
|
|
1781
|
+
}
|
|
1782
|
+
// Create prompted_by relations from blockDef.triggered_by (resolved inline — triggeredByIds not yet declared)
|
|
1783
|
+
for (const ref of (blockDef.triggered_by || [])) {
|
|
1784
|
+
if (ref.startsWith("__item_ref__")) {
|
|
1785
|
+
pendingTriggeredBy.push({ sourceId: existing.id, labelRef: ref });
|
|
1786
|
+
continue;
|
|
1787
|
+
}
|
|
1788
|
+
const tb = ref.startsWith("blk_") ? db.getBlock(ref) : allBlocks.find((bl) => bl.label === ref) ?? null;
|
|
1789
|
+
if (tb) {
|
|
1790
|
+
if (!shouldSkipRelation(existing.id, tb.id, "prompted_by", db))
|
|
1791
|
+
db.createRelation({ source_id: existing.id, target_id: tb.id, type: "prompted_by", bidirectional: false });
|
|
1792
|
+
}
|
|
1793
|
+
else {
|
|
1794
|
+
pendingTriggeredBy.push({ sourceId: existing.id, labelRef: ref });
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
// Create based_on relations
|
|
1798
|
+
for (const ref of (blockDef.based_on || [])) {
|
|
1799
|
+
const targetBlock = ref.startsWith("blk_")
|
|
1800
|
+
? db.getBlock(ref)
|
|
1801
|
+
: allBlocks.find((b) => b.label === ref) ?? null;
|
|
1802
|
+
if (!targetBlock) {
|
|
1803
|
+
pendingTriggeredBy.push({ sourceId: existing.id, labelRef: `__based_on__${ref}` });
|
|
1804
|
+
continue;
|
|
1805
|
+
}
|
|
1806
|
+
if (!shouldSkipRelation(existing.id, targetBlock.id, "based_on", db))
|
|
1807
|
+
db.createRelation({ source_id: existing.id, target_id: targetBlock.id, type: "based_on", bidirectional: false });
|
|
1808
|
+
}
|
|
1809
|
+
// Create other relations
|
|
1810
|
+
const ALLOWED_RELS_MERGE = new Set(["contradicts", "based_on", "related_to", "resolves", "supports", "prompted_by", "extends", "supersedes", "superseded_by", "derived_from", "affects"]);
|
|
1811
|
+
for (const rel of (blockDef.relations || [])) {
|
|
1812
|
+
if (!rel?.type || !rel?.target_id || rel.type === "part_of" || rel.type === "null" || rel.target_id === "null")
|
|
1813
|
+
continue;
|
|
1814
|
+
const relType = rel.type === "triggered_by" ? "prompted_by" : rel.type;
|
|
1815
|
+
if (!ALLOWED_RELS_MERGE.has(relType))
|
|
1816
|
+
continue;
|
|
1817
|
+
const targetBlock = db.getBlock(rel.target_id) ?? allBlocks.find((b) => b.label === rel.target_id) ?? null;
|
|
1818
|
+
if (!targetBlock)
|
|
1819
|
+
continue;
|
|
1820
|
+
if (!shouldSkipRelation(existing.id, targetBlock.id, relType, db))
|
|
1821
|
+
db.createRelation({ source_id: existing.id, target_id: targetBlock.id, type: relType, bidirectional: false });
|
|
1822
|
+
}
|
|
1823
|
+
console.log(`Auto-Reflect: duplicate_label merged — "${blockDef.label}" (applied: concepts=${newConcepts.length})`);
|
|
1824
|
+
pipelineSkips.push({ label: blockDef.label, reason: "duplicate_merged" });
|
|
1825
|
+
}
|
|
1826
|
+
else {
|
|
1827
|
+
pipelineSkips.push({ label: blockDef.label, reason: "duplicate_label" });
|
|
1828
|
+
}
|
|
1829
|
+
result.skipped++;
|
|
1830
|
+
continue;
|
|
1831
|
+
}
|
|
1832
|
+
// Segment validation (max 4 underscore-separated segments)
|
|
1833
|
+
if (!labelFromObject) {
|
|
1834
|
+
if (!isValidLabelSegmentCount(blockDef.label, KNOWN_BLOCK_TYPES)) {
|
|
1835
|
+
const segs = blockDef.label.split("_");
|
|
1836
|
+
console.warn(`Auto-Reflect: rejected "${blockDef.label}" — ${segs.length} segments (max 4)`);
|
|
1837
|
+
pipelineSkips.push({ label: blockDef.label, reason: `too_many_segments: ${segs.length}` });
|
|
1838
|
+
result.skipped++;
|
|
1839
|
+
continue;
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
// is_a validation
|
|
1843
|
+
if (blockDef.is_a && !seedTypeNames.has(blockDef.is_a)) {
|
|
1844
|
+
console.warn(`Auto-Reflect: rejected "${blockDef.label}" — unknown is_a "${blockDef.is_a}"`);
|
|
1845
|
+
pipelineSkips.push({ label: blockDef.label, reason: `unknown_type: "${blockDef.is_a}"` });
|
|
1846
|
+
result.skipped++;
|
|
1847
|
+
continue;
|
|
1848
|
+
}
|
|
1849
|
+
// Embedding
|
|
1850
|
+
let embedding;
|
|
1851
|
+
if (embeddings?.isAvailable()) {
|
|
1852
|
+
const embText = blockEmbeddingText({ essence: blockDef.essence, concepts: blockDef.concepts });
|
|
1853
|
+
embedding = await embeddings.embed(embText) ?? undefined;
|
|
1854
|
+
}
|
|
1855
|
+
// Infer project from label prefix
|
|
1856
|
+
const relations = [...(blockDef.relations || [])];
|
|
1857
|
+
const projectBlocks = allBlocks.filter((b) => b.type === "project");
|
|
1858
|
+
let inferredProj = projectBlocks.find((p) => blockDef.label.startsWith(p.label + "_"));
|
|
1859
|
+
if (!inferredProj) {
|
|
1860
|
+
const prefix = blockDef.label.split("_")[0];
|
|
1861
|
+
if (prefix && prefix !== blockDef.label) {
|
|
1862
|
+
const existingProj = db.getBlock(prefix);
|
|
1863
|
+
if (existingProj && existingProj.type === "project") {
|
|
1864
|
+
inferredProj = existingProj;
|
|
1865
|
+
allBlocks.push(existingProj);
|
|
1866
|
+
}
|
|
1867
|
+
else {
|
|
1868
|
+
const autoProj = db.createBlock({
|
|
1869
|
+
label: prefix, type: "project", status: "active",
|
|
1870
|
+
essence: `Auto-created project for '${prefix}' domain`,
|
|
1871
|
+
content: { is_a: "project", unique: {}, concepts: [] },
|
|
1872
|
+
ttl: "permanent", source: "Auto-Reflect", created_by: geminiCreatedBy,
|
|
1873
|
+
});
|
|
1874
|
+
stampQualityScore(db, autoProj, []); // Bug 1 fix
|
|
1875
|
+
allBlocks.push(autoProj);
|
|
1876
|
+
newProjectLabels.add(prefix);
|
|
1877
|
+
inferredProj = autoProj;
|
|
1878
|
+
console.log(`Auto-Reflect: auto-created project root "${prefix}" for "${blockDef.label}"`);
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
// TS-layer fallback: if Pass 3 omitted triggered_by, synthesize from Pass 2 triggered_by_items.
|
|
1883
|
+
// Pass 3 is required to output triggered_by but Gemini sometimes omits it despite schema enforcement.
|
|
1884
|
+
// triggered_by_items values are either item IDs ("item_N") or existing block labels.
|
|
1885
|
+
if (!Array.isArray(blockDef.triggered_by) || blockDef.triggered_by.length === 0) {
|
|
1886
|
+
let fromItemId = blockDef.from_item_id;
|
|
1887
|
+
// Secondary fallback: if from_item_id is missing, match by type against unmatched Pass 2 items
|
|
1888
|
+
if (!fromItemId) {
|
|
1889
|
+
const usedItemIds = new Set(itemIdToLabel.keys());
|
|
1890
|
+
const matchedItem = pass2.classified.find((item) => item.type === blockDef.is_a && !usedItemIds.has(item.id));
|
|
1891
|
+
if (matchedItem) {
|
|
1892
|
+
fromItemId = matchedItem.id;
|
|
1893
|
+
blockDef = { ...blockDef, from_item_id: fromItemId };
|
|
1894
|
+
console.log(`Auto-Reflect: inferred from_item_id="${fromItemId}" for "${blockDef.label}" by type match`);
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1897
|
+
const p2Items = fromItemId ? (itemTriggeredByItems.get(fromItemId) ?? []) : [];
|
|
1898
|
+
if (p2Items.length > 0) {
|
|
1899
|
+
const synthesized = [];
|
|
1900
|
+
for (const ref of p2Items) {
|
|
1901
|
+
if (ref.startsWith("item_")) {
|
|
1902
|
+
// item ID → resolve after itemIdToLabel is populated (add to pending)
|
|
1903
|
+
// We can't resolve yet since other blocks may not have labels yet —
|
|
1904
|
+
// store as a special marker to process in second pass
|
|
1905
|
+
synthesized.push(`__item_ref__${ref}`);
|
|
1906
|
+
}
|
|
1907
|
+
else {
|
|
1908
|
+
// existing block label — use directly
|
|
1909
|
+
synthesized.push(ref);
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
if (synthesized.length > 0)
|
|
1913
|
+
blockDef = { ...blockDef, triggered_by: synthesized };
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
// Resolve triggered_by
|
|
1917
|
+
const triggeredByIds = [];
|
|
1918
|
+
const unresolvedTriggeredByRefs = [];
|
|
1919
|
+
for (const ref of (blockDef.triggered_by || [])) {
|
|
1920
|
+
if (ref.startsWith("blk_")) {
|
|
1921
|
+
const b = db.getBlock(ref);
|
|
1922
|
+
if (b)
|
|
1923
|
+
triggeredByIds.push(b.id);
|
|
1924
|
+
}
|
|
1925
|
+
else if (ref.startsWith("__item_ref__")) {
|
|
1926
|
+
// Synthesized item ID reference — defer to second pass once itemIdToLabel is fully populated
|
|
1927
|
+
const itemId = ref.slice("__item_ref__".length);
|
|
1928
|
+
unresolvedTriggeredByRefs.push(`__item_ref__${itemId}`);
|
|
1929
|
+
}
|
|
1930
|
+
else {
|
|
1931
|
+
const b = allBlocks.find((bl) => bl.label === ref);
|
|
1932
|
+
if (b) {
|
|
1933
|
+
triggeredByIds.push(b.id);
|
|
1934
|
+
}
|
|
1935
|
+
else {
|
|
1936
|
+
unresolvedTriggeredByRefs.push(ref);
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
const hasTrigger = triggeredByIds.length > 0;
|
|
1941
|
+
const explicitReviewReason = blockDef.review_reason ||
|
|
1942
|
+
(blockDef.from_item_id ? (reviewMap.get(blockDef.from_item_id) ?? null) : null);
|
|
1943
|
+
// Tier 1B demote-at-save (2026-06-12, default-OFF NODEDEX_SAVE_DEMOTE=1):
|
|
1944
|
+
// the v2 path skips seam-α, so demotable insights (observation present,
|
|
1945
|
+
// implication unfillable) were arriving here FLAGGED instead of demoted.
|
|
1946
|
+
// Apply the SAME universal DEMOTE_TARGETS equivalence at the final seam —
|
|
1947
|
+
// exact-case only; label's type segment renamed so label/type agree; any
|
|
1948
|
+
// shape mismatch falls through to the soft flag below (capture-first).
|
|
1949
|
+
const demotion = process.env.NODEDEX_SAVE_DEMOTE !== "0"
|
|
1950
|
+
? demoteForSave(blockDef.is_a, (blockDef.unique || {}), blockDef.label)
|
|
1951
|
+
: null;
|
|
1952
|
+
if (demotion) {
|
|
1953
|
+
console.log(`Auto-Reflect Tier1B: demoted ${demotion.from_type}→${demotion.type} at save (required field unfillable): ${demotion.label}`);
|
|
1954
|
+
blockDef.is_a = demotion.type;
|
|
1955
|
+
blockDef.unique = demotion.unique;
|
|
1956
|
+
blockDef.label = demotion.label;
|
|
1957
|
+
}
|
|
1958
|
+
// Tier 1B (2026-05-24): type ↔ unique{} schema validator. Soft mode —
|
|
1959
|
+
// flags mismatches via review_status, never rejects. Charter rule 2 (never
|
|
1960
|
+
// delete vetted blocks) + rule 6 (guards catch failure, never override success).
|
|
1961
|
+
// Source of truth for schemas: docs/reference/block-types.md (verified 2026-05-24).
|
|
1962
|
+
const schemaCheck = validateUniqueSchema(blockDef.is_a, (blockDef.unique || {}));
|
|
1963
|
+
const schemaReason = schemaMismatchReason(schemaCheck);
|
|
1964
|
+
const reviewReason = explicitReviewReason && schemaReason
|
|
1965
|
+
? `${explicitReviewReason} | ${schemaReason}`
|
|
1966
|
+
: (explicitReviewReason || schemaReason);
|
|
1967
|
+
// Block provenance: default "agent_derived"; demote-edge marks "seam_demoted"
|
|
1968
|
+
// (looked up by from_item_id, mirroring reviewMap). Only the split path with
|
|
1969
|
+
// NODEDEX_SEAM_ALPHA_DEMOTE sets this — monolith leaves the map empty.
|
|
1970
|
+
// Save-time demote (above) marks "save_demoted" — distinct value so the
|
|
1971
|
+
// audit trail shows WHICH seam applied the equivalence.
|
|
1972
|
+
const blockSourceType = demotion
|
|
1973
|
+
? "save_demoted"
|
|
1974
|
+
: (blockDef.from_item_id ? sourceTypeMap.get(blockDef.from_item_id) : undefined);
|
|
1975
|
+
// DEBT 5 D3 (§2.3.2) Phase 5: line-level provenance pinning. Look up
|
|
1976
|
+
// Pass 1's excerpt by from_item_id and pass to createBlock as
|
|
1977
|
+
// source_excerpt (separate column from content.raw_excerpt — which is
|
|
1978
|
+
// the LLM-emitted Pass 3 justification, may differ across runs).
|
|
1979
|
+
// source_excerpt is STABLE across runs because Pass 1 produces it once
|
|
1980
|
+
// per turn and arc re-extract re-uses the same pass01_output_json.
|
|
1981
|
+
// Falls back to undefined when from_item_id is missing OR Pass 1 left
|
|
1982
|
+
// excerpt empty → createBlock writes NULL → dedup logic (D2) treats
|
|
1983
|
+
// NULL as "pre-Debt-5 atomic" / no-pin and falls through to label dedup.
|
|
1984
|
+
const blockSourceExcerpt = blockDef.from_item_id ? sourceExcerptMap.get(blockDef.from_item_id) : undefined;
|
|
1985
|
+
const created = db.createBlock({
|
|
1986
|
+
label: blockDef.label,
|
|
1987
|
+
type: blockDef.is_a || "note",
|
|
1988
|
+
status: "pending", // activated after Pass 4 — invisible to users until pipeline completes
|
|
1989
|
+
essence: blockDef.essence,
|
|
1990
|
+
content: {
|
|
1991
|
+
is_a: blockDef.is_a,
|
|
1992
|
+
unique: blockDef.unique || {},
|
|
1993
|
+
...(blockDef.has && Object.keys(blockDef.has).length ? { has: blockDef.has } : {}),
|
|
1994
|
+
relations,
|
|
1995
|
+
concepts: blockDef.concepts || [],
|
|
1996
|
+
concepts_source: "gemini_reflect",
|
|
1997
|
+
novelty_reason: blockDef.novelty_reason || "",
|
|
1998
|
+
raw_excerpt: blockDef.raw_excerpt || "",
|
|
1999
|
+
},
|
|
2000
|
+
concepts: blockDef.concepts || [],
|
|
2001
|
+
ttl: blockDef.ttl || "permanent",
|
|
2002
|
+
source: "Auto-Reflect",
|
|
2003
|
+
...(blockSourceType ? { source_type: blockSourceType } : {}),
|
|
2004
|
+
...(blockSourceExcerpt ? { source_excerpt: blockSourceExcerpt } : {}),
|
|
2005
|
+
embedding,
|
|
2006
|
+
created_by: geminiCreatedBy,
|
|
2007
|
+
});
|
|
2008
|
+
stampQualityScore(db, created, blockDef.concepts || []); // Bug 1 fix: pipeline-created blocks must have quality_score set, else recall filters reject them at q=0
|
|
2009
|
+
p3PendingBlockIds.push(created.id);
|
|
2010
|
+
// Inline PROVENANCE check (followup 2026-06-20): the LLM extractor can
|
|
2011
|
+
// FABRICATE source_excerpt. We have the excerpt + the transcript right here,
|
|
2012
|
+
// so verify the quote actually appears in the transcript and FLAG it if not
|
|
2013
|
+
// (flag-don't-act: the block stays, the reviewer/agent judges; best-effort,
|
|
2014
|
+
// never throws). Inline because the transcript is in hand — works arc AND
|
|
2015
|
+
// per-turn, no block_extractions lookup; only 'missing' (totally-wrong) flags.
|
|
2016
|
+
if (blockSourceExcerpt) {
|
|
2017
|
+
flagBlockExcerptInline(db.db, created.id, blockSourceExcerpt, `${_userMessage ?? ""}\n${agentThinking ?? ""}\n${agentResponse ?? ""}`);
|
|
2018
|
+
}
|
|
2019
|
+
if (schemaReason) {
|
|
2020
|
+
console.warn(`Auto-Reflect schema-validator: ${blockDef.label} ${schemaReason}`);
|
|
2021
|
+
}
|
|
2022
|
+
if (reviewReason) {
|
|
2023
|
+
db.updateBlock(created.id, { review_status: "needs_review", review_reason: reviewReason });
|
|
2024
|
+
result.uncertain_count++;
|
|
2025
|
+
}
|
|
2026
|
+
// 4-part label: auto-create subgroup entity if needed
|
|
2027
|
+
let subgroupBlock = null;
|
|
2028
|
+
const labelParts = blockDef.label.split("_");
|
|
2029
|
+
if (labelParts.length >= 4 && inferredProj &&
|
|
2030
|
+
!KNOWN_BLOCK_TYPES.has(labelParts[1]) && KNOWN_BLOCK_TYPES.has(labelParts[2])) {
|
|
2031
|
+
const subgroupLabel = `${labelParts[0]}_${labelParts[1]}`;
|
|
2032
|
+
subgroupBlock = allBlocks.find((b) => b.label === subgroupLabel) ?? db.getBlock(subgroupLabel);
|
|
2033
|
+
if (!subgroupBlock) {
|
|
2034
|
+
subgroupBlock = db.createBlock({
|
|
2035
|
+
label: subgroupLabel, type: "entity", status: "active",
|
|
2036
|
+
essence: `${labelParts[1]} sub-group within ${labelParts[0]}`,
|
|
2037
|
+
content: { is_a: "entity", unique: {}, concepts: [] },
|
|
2038
|
+
ttl: "permanent", source: "Auto-Reflect", created_by: geminiCreatedBy,
|
|
2039
|
+
project_id: inferredProj.id,
|
|
2040
|
+
});
|
|
2041
|
+
stampQualityScore(db, subgroupBlock, []); // Bug 1 fix
|
|
2042
|
+
allBlocks.push(subgroupBlock);
|
|
2043
|
+
console.log(`Auto-Reflect: created subgroup "${subgroupLabel}" under "${inferredProj.label}"`);
|
|
2044
|
+
}
|
|
2045
|
+
}
|
|
2046
|
+
// project_id — set directly on the block (subgroup entity or project root)
|
|
2047
|
+
const projectOwner = subgroupBlock ?? inferredProj;
|
|
2048
|
+
if (projectOwner) {
|
|
2049
|
+
db.updateBlock(created.id, { project_id: projectOwner.id });
|
|
2050
|
+
created.project_id = projectOwner.id;
|
|
2051
|
+
}
|
|
2052
|
+
// prompted_by from triggered_by
|
|
2053
|
+
for (const targetId of triggeredByIds) {
|
|
2054
|
+
if (!shouldSkipRelation(created.id, targetId, "prompted_by", db))
|
|
2055
|
+
db.createRelation({ source_id: created.id, target_id: targetId, type: "prompted_by", bidirectional: false });
|
|
2056
|
+
}
|
|
2057
|
+
// Defer unresolved triggered_by refs
|
|
2058
|
+
for (const ref of unresolvedTriggeredByRefs) {
|
|
2059
|
+
pendingTriggeredBy.push({ sourceId: created.id, labelRef: ref });
|
|
2060
|
+
}
|
|
2061
|
+
// based_on from Pass 3 top-level based_on[] field (same resolution as triggered_by)
|
|
2062
|
+
for (const ref of (blockDef.based_on || [])) {
|
|
2063
|
+
const targetBlock = ref.startsWith("blk_")
|
|
2064
|
+
? db.getBlock(ref)
|
|
2065
|
+
: allBlocks.find((b) => b.label === ref) ?? null;
|
|
2066
|
+
if (!targetBlock) {
|
|
2067
|
+
pendingTriggeredBy.push({ sourceId: created.id, labelRef: `__based_on__${ref}` });
|
|
2068
|
+
continue;
|
|
2069
|
+
}
|
|
2070
|
+
if (!shouldSkipRelation(created.id, targetBlock.id, "based_on", db)) {
|
|
2071
|
+
db.createRelation({ source_id: created.id, target_id: targetBlock.id, type: "based_on", bidirectional: false });
|
|
2072
|
+
console.log(`Auto-Reflect: based_on (Pass 3) — "${blockDef.label}" → "${targetBlock.label}"`);
|
|
2073
|
+
}
|
|
2074
|
+
}
|
|
2075
|
+
// Extra relations
|
|
2076
|
+
const ALLOWED_RELS = new Set(["contradicts", "based_on", "related_to", "resolves", "supports", "prompted_by", "extends", "supersedes", "superseded_by", "derived_from", "affects"]);
|
|
2077
|
+
for (const rel of (blockDef.relations || [])) {
|
|
2078
|
+
if (!rel?.type || !rel?.target_id || rel.type === "part_of" || rel.type === "null" || rel.target_id === "null")
|
|
2079
|
+
continue;
|
|
2080
|
+
// triggered_by is a Pass 3 alias for prompted_by — translate it
|
|
2081
|
+
const relType = rel.type === "triggered_by" ? "prompted_by" : rel.type;
|
|
2082
|
+
if (!ALLOWED_RELS.has(relType)) {
|
|
2083
|
+
console.warn(`Auto-Reflect: relation type "${rel.type}" on "${blockDef.label}" not in ALLOWED_RELS — dropped`);
|
|
2084
|
+
pipelineSkips.push({ label: blockDef.label, reason: `relation_type_not_allowed: ${rel.type} → ${rel.target_id}` });
|
|
2085
|
+
continue;
|
|
2086
|
+
}
|
|
2087
|
+
const targetBlock = db.getBlock(rel.target_id) ?? allBlocks.find((b) => b.label === rel.target_id) ?? null;
|
|
2088
|
+
if (!targetBlock)
|
|
2089
|
+
continue;
|
|
2090
|
+
if (!shouldSkipRelation(created.id, targetBlock.id, relType, db))
|
|
2091
|
+
db.createRelation({ source_id: created.id, target_id: targetBlock.id, type: relType, bidirectional: false });
|
|
2092
|
+
}
|
|
2093
|
+
// Quality score
|
|
2094
|
+
let qScore = 1;
|
|
2095
|
+
{
|
|
2096
|
+
const qc = typeof created.content === "string" ? (JSON.parse(created.content) || {}) : (created.content || {});
|
|
2097
|
+
if (qc.is_a)
|
|
2098
|
+
qScore++;
|
|
2099
|
+
if (qc.unique && Object.keys(qc.unique).length >= 2)
|
|
2100
|
+
qScore++;
|
|
2101
|
+
if ((blockDef.concepts || []).length >= 3)
|
|
2102
|
+
qScore++;
|
|
2103
|
+
if (db.getRelations(created.id).length > 0)
|
|
2104
|
+
qScore++;
|
|
2105
|
+
qScore = Math.min(qScore, 5);
|
|
2106
|
+
db.updateBlock(created.id, { quality_score: qScore });
|
|
2107
|
+
}
|
|
2108
|
+
if (blockDef.from_item_id)
|
|
2109
|
+
itemIdToLabel.set(blockDef.from_item_id, blockDef.label);
|
|
2110
|
+
allBlocks.push(created);
|
|
2111
|
+
result.saved++;
|
|
2112
|
+
result.saved_labels.push(blockDef.label);
|
|
2113
|
+
result.created_blocks.push({
|
|
2114
|
+
label: blockDef.label,
|
|
2115
|
+
type: blockDef.is_a || "note",
|
|
2116
|
+
quality: qScore,
|
|
2117
|
+
project: inferredProj?.label ?? blockDef.label.split("_")[0] ?? "unknown",
|
|
2118
|
+
});
|
|
2119
|
+
const uniqueVals = blockDef.unique && typeof blockDef.unique === "object" && Object.keys(blockDef.unique).length > 0
|
|
2120
|
+
? Object.entries(blockDef.unique).map(([k, v]) => `${k}: ${v}`).join(", ")
|
|
2121
|
+
: undefined;
|
|
2122
|
+
_pendingRecentSaves.push({ label: blockDef.label, essence: blockDef.essence, values: uniqueVals });
|
|
2123
|
+
}
|
|
2124
|
+
// Second-pass triggered_by resolution
|
|
2125
|
+
if (pendingTriggeredBy.length > 0) {
|
|
2126
|
+
for (const { sourceId, labelRef } of pendingTriggeredBy) {
|
|
2127
|
+
// Resolve __item_ref__ markers from TS-layer synthesis fallback
|
|
2128
|
+
let resolvedRef = labelRef;
|
|
2129
|
+
if (labelRef.startsWith("__item_ref__")) {
|
|
2130
|
+
const itemId = labelRef.slice("__item_ref__".length);
|
|
2131
|
+
const resolvedLabel = itemIdToLabel.get(itemId);
|
|
2132
|
+
if (!resolvedLabel)
|
|
2133
|
+
continue;
|
|
2134
|
+
resolvedRef = resolvedLabel;
|
|
2135
|
+
}
|
|
2136
|
+
const isBased = labelRef.startsWith("__based_on__");
|
|
2137
|
+
if (isBased)
|
|
2138
|
+
resolvedRef = labelRef.slice("__based_on__".length);
|
|
2139
|
+
const target = allBlocks.find((b) => b.label === resolvedRef);
|
|
2140
|
+
if (!target)
|
|
2141
|
+
continue;
|
|
2142
|
+
const relType = isBased ? "based_on" : "prompted_by";
|
|
2143
|
+
if (shouldSkipRelation(sourceId, target.id, relType, db))
|
|
2144
|
+
continue;
|
|
2145
|
+
db.createRelation({ source_id: sourceId, target_id: target.id, type: relType, bidirectional: false });
|
|
2146
|
+
console.log(`Auto-Reflect: deferred ${relType} resolved — "${resolvedRef}"`);
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
// Shared fresh lookup for server-side resolution — allBlocks is built incrementally during
|
|
2150
|
+
// the Pass 3 loop but may miss blocks if any were skipped. A fresh DB fetch is authoritative.
|
|
2151
|
+
const freshBlockByLabel = new Map(db.getAllBlocks().map((b) => [b.label, b]));
|
|
2152
|
+
// Server-side extends_item resolution
|
|
2153
|
+
if (itemIdToLabel.size > 0) {
|
|
2154
|
+
for (const item of pass2.classified) {
|
|
2155
|
+
if (!item.extends_item)
|
|
2156
|
+
continue;
|
|
2157
|
+
const sourceLabel = itemIdToLabel.get(item.id);
|
|
2158
|
+
let targetLabel = itemIdToLabel.get(item.extends_item);
|
|
2159
|
+
if (!targetLabel && !item.extends_item.startsWith("item_") && !item.extends_item.includes("::")) {
|
|
2160
|
+
targetLabel = item.extends_item; // Pass 2 emitted a label (target was skipped); item-shaped refs (v1 item_ / v2 ::) never are
|
|
2161
|
+
}
|
|
2162
|
+
if (!sourceLabel || !targetLabel || sourceLabel === targetLabel) {
|
|
2163
|
+
if (!sourceLabel || !targetLabel) {
|
|
2164
|
+
const missing = !sourceLabel ? `source item_id=${item.id}` : `target extends_item=${item.extends_item}`;
|
|
2165
|
+
console.warn(`Auto-Reflect: extends_item resolution skipped — ${missing} not in itemIdToLabel and not a label`);
|
|
2166
|
+
pipelineSkips.push({ label: item.id, reason: `extends_item_unresolved: ${missing}` });
|
|
2167
|
+
}
|
|
2168
|
+
continue;
|
|
2169
|
+
}
|
|
2170
|
+
const sourceBlock = freshBlockByLabel.get(sourceLabel);
|
|
2171
|
+
const targetBlock = freshBlockByLabel.get(targetLabel) ?? db.getBlock(targetLabel);
|
|
2172
|
+
if (!sourceBlock || !targetBlock)
|
|
2173
|
+
continue;
|
|
2174
|
+
const existingRels = db.getRelations(sourceBlock.id);
|
|
2175
|
+
if (existingRels.some((r) => r.type === "extends" && r.target_id === targetBlock.id))
|
|
2176
|
+
continue;
|
|
2177
|
+
db.createRelation({ source_id: sourceBlock.id, target_id: targetBlock.id, type: "extends", bidirectional: false });
|
|
2178
|
+
console.log(`Auto-Reflect: extends resolved server-side — "${sourceLabel}" → "${targetLabel}"`);
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
// Server-side based_on_items resolution
|
|
2182
|
+
// Mirrors triggered_by_items translation — Pass 2 based_on_items[] → based_on relations in DB.
|
|
2183
|
+
// Values are item IDs ("item_N") resolved via itemIdToLabel, or existing block labels.
|
|
2184
|
+
for (const item of pass2.classified) {
|
|
2185
|
+
if (!Array.isArray(item.based_on_items) || item.based_on_items.length === 0)
|
|
2186
|
+
continue;
|
|
2187
|
+
const sourceLabel = itemIdToLabel.get(item.id);
|
|
2188
|
+
if (!sourceLabel)
|
|
2189
|
+
continue;
|
|
2190
|
+
const sourceBlock = freshBlockByLabel.get(sourceLabel);
|
|
2191
|
+
if (!sourceBlock)
|
|
2192
|
+
continue;
|
|
2193
|
+
for (const ref of item.based_on_items) {
|
|
2194
|
+
const resolved = resolveWithinBatchRefLabel(ref, itemIdToLabel);
|
|
2195
|
+
if (!resolved)
|
|
2196
|
+
continue;
|
|
2197
|
+
const targetBlock = freshBlockByLabel.get(resolved.label)
|
|
2198
|
+
?? (resolved.viaItemMap ? null : db.getBlock(resolved.label));
|
|
2199
|
+
if (!targetBlock)
|
|
2200
|
+
continue;
|
|
2201
|
+
if (shouldSkipRelation(sourceBlock.id, targetBlock.id, "based_on", db))
|
|
2202
|
+
continue;
|
|
2203
|
+
db.createRelation({ source_id: sourceBlock.id, target_id: targetBlock.id, type: "based_on", bidirectional: false });
|
|
2204
|
+
console.log(`Auto-Reflect: based_on resolved server-side — "${sourceLabel}" → "${targetBlock.label}"`);
|
|
2205
|
+
}
|
|
2206
|
+
}
|
|
2207
|
+
// Server-side triggered_by_items resolution (2026-06-15 — the prompted_by edge-loss fix).
|
|
2208
|
+
// MIRRORS the based_on_items loop above. prompted_by was the ONE causal relation never
|
|
2209
|
+
// given a server-side resolver: cefbb2d (May) added one for based_on, and later supersedes
|
|
2210
|
+
// / the semantic rels got theirs, but triggered_by was left on the fragile inline path that
|
|
2211
|
+
// resolves the Pass 3 LLM's RE-EMITTED LABELS during the save loop. That broke two ways —
|
|
2212
|
+
// the Pass 3 LLM silently dropped forward edges it was handed, and demote-at-save renamed a
|
|
2213
|
+
// target out from under a label reference (a `_insight_` → `_fact_` demote orphaned every
|
|
2214
|
+
// prompted_by pointing at the old label). Reading the AUTHORITATIVE item.triggered_by_items
|
|
2215
|
+
// (item-ids) here, AFTER all blocks are saved + demoted, via the demote-aware item-map, fixes
|
|
2216
|
+
// both — identical to how based_on works. shouldSkipRelation dedupes against the inline path.
|
|
2217
|
+
// (2026-06-15 aquarium arc: a blueprint whose only edge was prompted_by was orphaned; live
|
|
2218
|
+
// graphs carried 0 prompted_by edges as a result.)
|
|
2219
|
+
for (const item of pass2.classified) {
|
|
2220
|
+
if (!Array.isArray(item.triggered_by_items) || item.triggered_by_items.length === 0)
|
|
2221
|
+
continue;
|
|
2222
|
+
const sourceLabel = itemIdToLabel.get(item.id);
|
|
2223
|
+
if (!sourceLabel)
|
|
2224
|
+
continue;
|
|
2225
|
+
const sourceBlock = freshBlockByLabel.get(sourceLabel);
|
|
2226
|
+
if (!sourceBlock)
|
|
2227
|
+
continue;
|
|
2228
|
+
for (const ref of item.triggered_by_items) {
|
|
2229
|
+
const resolved = resolveWithinBatchRefLabel(ref, itemIdToLabel);
|
|
2230
|
+
if (!resolved)
|
|
2231
|
+
continue;
|
|
2232
|
+
const targetBlock = freshBlockByLabel.get(resolved.label)
|
|
2233
|
+
?? (resolved.viaItemMap ? null : db.getBlock(resolved.label));
|
|
2234
|
+
if (!targetBlock)
|
|
2235
|
+
continue;
|
|
2236
|
+
if (shouldSkipRelation(sourceBlock.id, targetBlock.id, "prompted_by", db))
|
|
2237
|
+
continue;
|
|
2238
|
+
db.createRelation({ source_id: sourceBlock.id, target_id: targetBlock.id, type: "prompted_by", bidirectional: false });
|
|
2239
|
+
console.log(`Auto-Reflect: prompted_by resolved server-side — "${sourceLabel}" → "${targetBlock.label}"`);
|
|
2240
|
+
}
|
|
2241
|
+
}
|
|
2242
|
+
// Server-side within-batch supersedes resolution (Pass 2 supersedes_ref = item ID)
|
|
2243
|
+
// When Pass 2 sets supersedes_ref to an item ID (not a block label), resolve it here.
|
|
2244
|
+
// Within-batch test = map membership, NOT an id-prefix (v2 ids are group::local).
|
|
2245
|
+
for (const item of pass2.classified) {
|
|
2246
|
+
if (!item.supersedes_ref || !itemIdToLabel.has(item.supersedes_ref))
|
|
2247
|
+
continue;
|
|
2248
|
+
const sourceLabel = itemIdToLabel.get(item.id);
|
|
2249
|
+
if (!sourceLabel)
|
|
2250
|
+
continue;
|
|
2251
|
+
const sourceBlock = freshBlockByLabel.get(sourceLabel);
|
|
2252
|
+
if (!sourceBlock)
|
|
2253
|
+
continue;
|
|
2254
|
+
const targetLabel = itemIdToLabel.get(item.supersedes_ref);
|
|
2255
|
+
if (!targetLabel)
|
|
2256
|
+
continue;
|
|
2257
|
+
const targetBlock = freshBlockByLabel.get(targetLabel);
|
|
2258
|
+
if (!targetBlock)
|
|
2259
|
+
continue;
|
|
2260
|
+
if (shouldSkipRelation(sourceBlock.id, targetBlock.id, "supersedes", db))
|
|
2261
|
+
continue;
|
|
2262
|
+
db.createRelation({ source_id: sourceBlock.id, target_id: targetBlock.id, type: "supersedes", bidirectional: false });
|
|
2263
|
+
console.log(`Auto-Reflect: supersedes resolved server-side (within-batch) — "${sourceLabel}" → "${targetLabel}"`);
|
|
2264
|
+
}
|
|
2265
|
+
// Server-side semantic relations resolution (Pass 2 relations[] field)
|
|
2266
|
+
// Handles contradicts, supports, resolves, derived_from, affects wired by Pass 2 Q5.
|
|
2267
|
+
// Values are item IDs ("item_N") or existing block labels — same pattern as based_on_items.
|
|
2268
|
+
const SEMANTIC_RELS = new Set(["contradicts", "supports", "resolves", "derived_from", "affects", "related_to"]);
|
|
2269
|
+
for (const item of pass2.classified) {
|
|
2270
|
+
if (!Array.isArray(item.relations) || item.relations.length === 0)
|
|
2271
|
+
continue;
|
|
2272
|
+
const sourceLabel = itemIdToLabel.get(item.id);
|
|
2273
|
+
if (!sourceLabel)
|
|
2274
|
+
continue;
|
|
2275
|
+
const sourceBlock = freshBlockByLabel.get(sourceLabel);
|
|
2276
|
+
if (!sourceBlock)
|
|
2277
|
+
continue;
|
|
2278
|
+
for (const rel of item.relations) {
|
|
2279
|
+
if (!rel?.type || !rel?.target || !SEMANTIC_RELS.has(rel.type))
|
|
2280
|
+
continue;
|
|
2281
|
+
const resolved = resolveWithinBatchRefLabel(rel.target, itemIdToLabel);
|
|
2282
|
+
if (!resolved)
|
|
2283
|
+
continue;
|
|
2284
|
+
const targetBlock = freshBlockByLabel.get(resolved.label)
|
|
2285
|
+
?? (resolved.viaItemMap ? null : db.getBlock(resolved.label));
|
|
2286
|
+
if (!targetBlock)
|
|
2287
|
+
continue;
|
|
2288
|
+
if (shouldSkipRelation(sourceBlock.id, targetBlock.id, rel.type, db))
|
|
2289
|
+
continue;
|
|
2290
|
+
db.createRelation({ source_id: sourceBlock.id, target_id: targetBlock.id, type: rel.type, bidirectional: false });
|
|
2291
|
+
console.log(`Auto-Reflect: ${rel.type} resolved server-side — "${sourceLabel}" → "${targetBlock.label}"`);
|
|
2292
|
+
}
|
|
2293
|
+
}
|
|
2294
|
+
// Stamp chain_id on connected groups
|
|
2295
|
+
stampFlowRolesAndChains(result.saved_labels, allBlocks, db);
|
|
2296
|
+
// ── SLICE 1 SUB-STEP 1.4 — STAGE FLAG writer ──────────────────────────────
|
|
2297
|
+
// For each (source_excerpt, primary_value)-twin DETECTED by D2 dedup
|
|
2298
|
+
// earlier in this run, write a pipeline_flags row of type='atomic_dup_
|
|
2299
|
+
// candidate'. The async LLM reviewer (Slice 2) will consume these and
|
|
2300
|
+
// decide merge/leave/split with full graph context.
|
|
2301
|
+
//
|
|
2302
|
+
// Resolution: dedup detected at the item-id level (e.g., "item_T1_3");
|
|
2303
|
+
// pipeline_flags FK requires block_id. We resolve via itemIdToLabel
|
|
2304
|
+
// (populated during createBlock loop) → db.getBlock(label) → block.id.
|
|
2305
|
+
// When either side fails to resolve, we skip the flag (graceful degrade;
|
|
2306
|
+
// the duplicate already exists in the graph, just without the flag —
|
|
2307
|
+
// Slice 2 AUDIT will catch the missed ones later).
|
|
2308
|
+
//
|
|
2309
|
+
// origin_range_id is NULL here — pipeline.ts doesn't know arc range_id
|
|
2310
|
+
// (arc-pipeline.ts owns range creation, after runAutoReflect returns).
|
|
2311
|
+
// Stage AUDIT (Slice 2) can backfill if needed; for Sub-step 1.4 the
|
|
2312
|
+
// flag without range linkage is still actionable for the reviewer.
|
|
2313
|
+
if (atomicDupCandidates.length > 0) {
|
|
2314
|
+
let flagsWritten = 0;
|
|
2315
|
+
let flagsSkipped = 0;
|
|
2316
|
+
const rawDb = db.rawDb ?? db.db;
|
|
2317
|
+
if (!rawDb) {
|
|
2318
|
+
console.warn(`[stage-flag] cannot write ${atomicDupCandidates.length} flag(s) — no raw DB handle`);
|
|
2319
|
+
}
|
|
2320
|
+
else {
|
|
2321
|
+
for (const dup of atomicDupCandidates) {
|
|
2322
|
+
try {
|
|
2323
|
+
const loserLabel = itemIdToLabel.get(dup.id);
|
|
2324
|
+
const winnerLabel = itemIdToLabel.get(dup.duplicate_of);
|
|
2325
|
+
if (!loserLabel || !winnerLabel) {
|
|
2326
|
+
flagsSkipped++;
|
|
2327
|
+
continue;
|
|
2328
|
+
}
|
|
2329
|
+
const loserBlock = db.getBlock(loserLabel);
|
|
2330
|
+
const winnerBlock = db.getBlock(winnerLabel);
|
|
2331
|
+
if (!loserBlock || !winnerBlock) {
|
|
2332
|
+
flagsSkipped++;
|
|
2333
|
+
continue;
|
|
2334
|
+
}
|
|
2335
|
+
writePipelineFlag(rawDb, {
|
|
2336
|
+
flag_type: 'atomic_dup_candidate',
|
|
2337
|
+
block_id_a: loserBlock.id,
|
|
2338
|
+
block_id_b: winnerBlock.id,
|
|
2339
|
+
criteria: {
|
|
2340
|
+
detected_by: 'dedup_by_source_and_value',
|
|
2341
|
+
dedup_key: dup.key,
|
|
2342
|
+
loser_item_id: dup.id,
|
|
2343
|
+
winner_item_id: dup.duplicate_of,
|
|
2344
|
+
loser_label: loserLabel,
|
|
2345
|
+
winner_label: winnerLabel,
|
|
2346
|
+
},
|
|
2347
|
+
scope_check: 'unknown',
|
|
2348
|
+
origin_writer: 'stage_flag_dedup',
|
|
2349
|
+
origin_range_id: null,
|
|
2350
|
+
});
|
|
2351
|
+
flagsWritten++;
|
|
2352
|
+
}
|
|
2353
|
+
catch (e) {
|
|
2354
|
+
console.warn(`[stage-flag] writePipelineFlag failed for ${dup.id}: ${e?.message}`);
|
|
2355
|
+
flagsSkipped++;
|
|
2356
|
+
}
|
|
2357
|
+
}
|
|
2358
|
+
if (flagsWritten > 0 || flagsSkipped > 0) {
|
|
2359
|
+
console.log(`[stage-flag] wrote ${flagsWritten} atomic_dup_candidate flag(s), skipped ${flagsSkipped} (block resolution failed)`);
|
|
2360
|
+
}
|
|
2361
|
+
}
|
|
2362
|
+
}
|
|
2363
|
+
// ── DEBT 5 Slice 3 Part 4 — STAGE D flag writer (Touchpoint B) ──────────────
|
|
2364
|
+
// Drain the cross-arc resolve results (Touchpoint A) into cross_arc_dup_candidate
|
|
2365
|
+
// flags, now that item_id → block_id is resolvable via itemIdToLabel. Same
|
|
2366
|
+
// pattern + same graceful-degrade as the atomic_dup writer above. Stage D
|
|
2367
|
+
// FLAGS only — the async reviewer (flag-reviewer.ts) decides merge/leave and is
|
|
2368
|
+
// the sole actor that mutates the graph.
|
|
2369
|
+
// block_id_a = THIS arc's newly-created block (the new item)
|
|
2370
|
+
// block_id_b = the existing graph block it matched against (now set for
|
|
2371
|
+
// flag_for_review too, not just attach_existing)
|
|
2372
|
+
// Which side WINS is decided at REVIEW time, not here. For attach_existing the
|
|
2373
|
+
// existing block (b) is canonical; for flag_for_review (owner-unknown) the winner
|
|
2374
|
+
// is undetermined — the agent/reviewer/user adjudicates (often the owned new block
|
|
2375
|
+
// adopts the orphan). So a/b are just "the pair", NOT a fixed loser/winner.
|
|
2376
|
+
if (stageDEntries.length > 0) {
|
|
2377
|
+
let sdWritten = 0, sdSkipped = 0;
|
|
2378
|
+
const rawDb = db.rawDb ?? db.db;
|
|
2379
|
+
if (!rawDb) {
|
|
2380
|
+
console.warn(`[stage-d] cannot write ${stageDEntries.length} flag(s) — no raw DB handle`);
|
|
2381
|
+
}
|
|
2382
|
+
else {
|
|
2383
|
+
for (const e of stageDEntries) {
|
|
2384
|
+
try {
|
|
2385
|
+
const newLabel = itemIdToLabel.get(e.item_id);
|
|
2386
|
+
if (!newLabel) {
|
|
2387
|
+
sdSkipped++;
|
|
2388
|
+
continue;
|
|
2389
|
+
} // item wasn't written as a block (skipped) — nothing to flag
|
|
2390
|
+
const newBlock = db.getBlock(newLabel);
|
|
2391
|
+
if (!newBlock) {
|
|
2392
|
+
sdSkipped++;
|
|
2393
|
+
continue;
|
|
2394
|
+
}
|
|
2395
|
+
writePipelineFlag(rawDb, {
|
|
2396
|
+
flag_type: 'cross_arc_dup_candidate',
|
|
2397
|
+
block_id_a: newBlock.id,
|
|
2398
|
+
block_id_b: e.matched_block_id ?? null,
|
|
2399
|
+
criteria: {
|
|
2400
|
+
detected_by: 'stage_d_resolve',
|
|
2401
|
+
decision: e.decision, // 'attach_existing' | 'flag_for_review'
|
|
2402
|
+
resolved_by: e.resolved_by, // 'code_exact' | 'llm' | 'no_candidates'
|
|
2403
|
+
new_item_id: e.item_id,
|
|
2404
|
+
new_label: newLabel,
|
|
2405
|
+
matched_label: e.matched_block_label ?? null,
|
|
2406
|
+
reasoning: e.reasoning,
|
|
2407
|
+
flag_reason: e.flag_reason ?? null,
|
|
2408
|
+
},
|
|
2409
|
+
// attach_existing = resolver judged same scope; flag_for_review = owner unknown.
|
|
2410
|
+
scope_check: e.decision === 'attach_existing' ? 'same' : 'unknown',
|
|
2411
|
+
origin_writer: 'stage_d_resolve',
|
|
2412
|
+
origin_range_id: null,
|
|
2413
|
+
});
|
|
2414
|
+
sdWritten++;
|
|
2415
|
+
}
|
|
2416
|
+
catch (err) {
|
|
2417
|
+
console.warn(`[stage-d] writePipelineFlag failed for ${e.item_id}: ${err?.message}`);
|
|
2418
|
+
sdSkipped++;
|
|
2419
|
+
}
|
|
2420
|
+
}
|
|
2421
|
+
if (sdWritten > 0 || sdSkipped > 0) {
|
|
2422
|
+
console.log(`[stage-d] wrote ${sdWritten} cross_arc_dup_candidate flag(s), skipped ${sdSkipped} (block resolution failed)`);
|
|
2423
|
+
}
|
|
2424
|
+
}
|
|
2425
|
+
}
|
|
2426
|
+
// Process updates
|
|
2427
|
+
const ALLOWED_RELS = new Set(["contradicts", "based_on", "related_to", "resolves", "supports", "prompted_by", "extends", "supersedes", "superseded_by", "derived_from", "affects"]);
|
|
2428
|
+
for (const updateDef of (analysis.updates || [])) {
|
|
2429
|
+
if (!updateDef?.block_id)
|
|
2430
|
+
continue;
|
|
2431
|
+
const existing = db.getBlock(updateDef.block_id);
|
|
2432
|
+
if (!existing)
|
|
2433
|
+
continue;
|
|
2434
|
+
const content = typeof existing.content === "string"
|
|
2435
|
+
? JSON.parse(existing.content)
|
|
2436
|
+
: Object.assign({}, existing.content);
|
|
2437
|
+
if (updateDef.unique_patch && typeof updateDef.unique_patch === "object") {
|
|
2438
|
+
content.unique = { ...(content.unique || {}), ...updateDef.unique_patch };
|
|
2439
|
+
}
|
|
2440
|
+
const blockUpdates = { content };
|
|
2441
|
+
if (updateDef.essence)
|
|
2442
|
+
blockUpdates.essence = updateDef.essence;
|
|
2443
|
+
if (updateDef.ttl) {
|
|
2444
|
+
console.warn(`Auto-Reflect: ttl "${updateDef.ttl}" on update for "${existing.label}" ignored — updates do not apply ttl changes`);
|
|
2445
|
+
pipelineSkips.push({ label: existing.label, reason: `update_ttl_ignored: ${updateDef.ttl}` });
|
|
2446
|
+
}
|
|
2447
|
+
db.updateBlock(updateDef.block_id, blockUpdates, updateDef.reason || "Auto-Reflect Update");
|
|
2448
|
+
result.updated++;
|
|
2449
|
+
result.updated_blocks.push({ label: existing.label, type: existing.type });
|
|
2450
|
+
if (updateDef.relations_add && Array.isArray(updateDef.relations_add)) {
|
|
2451
|
+
for (const rel of updateDef.relations_add) {
|
|
2452
|
+
if (!rel?.type || !rel?.target_id)
|
|
2453
|
+
continue;
|
|
2454
|
+
// triggered_by is a Pass 3 alias for prompted_by — translate it
|
|
2455
|
+
const relType = rel.type === "triggered_by" ? "prompted_by" : rel.type;
|
|
2456
|
+
if (!ALLOWED_RELS.has(relType)) {
|
|
2457
|
+
console.warn(`Auto-Reflect: relation type "${rel.type}" in relations_add on "${existing.label}" not in ALLOWED_RELS — dropped`);
|
|
2458
|
+
pipelineSkips.push({ label: existing.label, reason: `update_relation_type_not_allowed: ${rel.type} → ${rel.target_id}` });
|
|
2459
|
+
continue;
|
|
2460
|
+
}
|
|
2461
|
+
const targetBlock = db.getBlock(rel.target_id) ?? allBlocks.find((b) => b.label === rel.target_id) ?? null;
|
|
2462
|
+
if (!targetBlock)
|
|
2463
|
+
continue;
|
|
2464
|
+
if (!shouldSkipRelation(existing.id, targetBlock.id, relType, db)) {
|
|
2465
|
+
db.createRelation({ source_id: existing.id, target_id: targetBlock.id, type: relType, bidirectional: false });
|
|
2466
|
+
console.log(`Auto-Reflect: added ${relType} link on "${existing.label}" → "${targetBlock.label}"`);
|
|
2467
|
+
}
|
|
2468
|
+
}
|
|
2469
|
+
}
|
|
2470
|
+
const updContent = typeof existing.content === "string"
|
|
2471
|
+
? (JSON.parse(existing.content) || {})
|
|
2472
|
+
: (existing.content || {});
|
|
2473
|
+
const updUnique = updContent.unique && typeof updContent.unique === "object" && Object.keys(updContent.unique).length > 0
|
|
2474
|
+
? Object.entries(updContent.unique).map(([k, v]) => `${k}: ${v}`).join(", ")
|
|
2475
|
+
: undefined;
|
|
2476
|
+
_pendingRecentSaves.push({
|
|
2477
|
+
label: existing.label,
|
|
2478
|
+
essence: updateDef.essence || existing.essence || "",
|
|
2479
|
+
update_note: updateDef.reason || "fields updated",
|
|
2480
|
+
values: updUnique,
|
|
2481
|
+
});
|
|
2482
|
+
}
|
|
2483
|
+
}
|
|
2484
|
+
else {
|
|
2485
|
+
// ── resumeFrom 'pass4': restore result from blocks already written as 'pending' in DB ──
|
|
2486
|
+
// Pass 3 completed on a prior attempt; blocks exist in DB as status='pending'.
|
|
2487
|
+
// Restore result state so Pass 4 can wire relations, then activate blocks after.
|
|
2488
|
+
const pendingBlocksInDb = db.getAllBlocks().filter((b) => p3PendingBlockIds.includes(b.id));
|
|
2489
|
+
for (const b of pendingBlocksInDb) {
|
|
2490
|
+
result.saved++;
|
|
2491
|
+
result.saved_labels.push(b.label);
|
|
2492
|
+
result.created_blocks.push({
|
|
2493
|
+
label: b.label,
|
|
2494
|
+
type: b.type,
|
|
2495
|
+
quality: b.quality_score ?? 1,
|
|
2496
|
+
project: b.label.split('_')[0] ?? 'unknown',
|
|
2497
|
+
});
|
|
2498
|
+
const bContent = typeof b.content === "string" ? (JSON.parse(b.content) || {}) : (b.content || {});
|
|
2499
|
+
const bUnique = bContent.unique && typeof bContent.unique === "object" && Object.keys(bContent.unique).length > 0
|
|
2500
|
+
? Object.entries(bContent.unique).map(([k, v]) => `${k}: ${v}`).join(", ")
|
|
2501
|
+
: undefined;
|
|
2502
|
+
_pendingRecentSaves.push({ label: b.label, essence: b.essence || "", values: bUnique });
|
|
2503
|
+
if (!allBlocks.find((x) => x.id === b.id))
|
|
2504
|
+
allBlocks.push(b);
|
|
2505
|
+
}
|
|
2506
|
+
console.log(`Auto-Reflect Pass 4 retry: restored ${result.saved} pending block(s) from DB`);
|
|
2507
|
+
} // end if (checkpoint?.resumeFrom !== 'pass4')
|
|
2508
|
+
// ── PASS 4: Connect ──
|
|
2509
|
+
if (result.saved > 0) {
|
|
2510
|
+
// ── INLINE DEDUP (recognize-before-write) ──
|
|
2511
|
+
// Merge this-turn's cross-turn duplicates BEFORE Pass 4 links them, so no
|
|
2512
|
+
// spurious `extends` edge forms between a block and its own restatement
|
|
2513
|
+
// (which the reviewer would then read as "elaboration, keep both" and
|
|
2514
|
+
// refuse to merge — the async-after-linking failure). Serves compounding:
|
|
2515
|
+
// one residue → one block. Default-off NODEDEX_INLINE_DEDUP; the async
|
|
2516
|
+
// AUDIT stays the cross-session backstop. Losers are archived, so the
|
|
2517
|
+
// freshBlocks re-query below naturally excludes them from Pass 4 / Pass 5.
|
|
2518
|
+
if (inlineDedupEnabled() && p3PendingBlockIds.length > 0) {
|
|
2519
|
+
try {
|
|
2520
|
+
const { flagsWritten, merges, routed, mergedAway, mergedAwayLabels } = await dedupNewBlocksInline(db, provider, p3PendingBlockIds);
|
|
2521
|
+
if (mergedAway.size > 0) {
|
|
2522
|
+
for (let i = p3PendingBlockIds.length - 1; i >= 0; i--) {
|
|
2523
|
+
if (mergedAway.has(p3PendingBlockIds[i]))
|
|
2524
|
+
p3PendingBlockIds.splice(i, 1);
|
|
2525
|
+
}
|
|
2526
|
+
result.saved_labels = result.saved_labels.filter((l) => !mergedAwayLabels.has(l));
|
|
2527
|
+
result.saved = Math.max(0, result.saved - mergedAway.size);
|
|
2528
|
+
}
|
|
2529
|
+
console.log(`Auto-Reflect inline-dedup: ${flagsWritten} flag(s), ${merges} merged, ${routed} routed-to-agent (before Pass 4)`);
|
|
2530
|
+
}
|
|
2531
|
+
catch (e) {
|
|
2532
|
+
console.warn(`Auto-Reflect inline-dedup failed (continuing to Pass 4): ${e?.message ?? e}`);
|
|
2533
|
+
}
|
|
2534
|
+
}
|
|
2535
|
+
const freshBlocks = db.getAllBlocks();
|
|
2536
|
+
const freshRels = db.getAllRelations(false);
|
|
2537
|
+
const savedLabelSet = new Set(result.saved_labels);
|
|
2538
|
+
// Pass 4's job is to link THIS-TURN's new blocks to the EXISTING graph from prior sessions.
|
|
2539
|
+
// The PROJECT GRAPH context must therefore exclude this-turn's just-saved blocks — otherwise
|
|
2540
|
+
// Pass 4 can match a new block against itself and emit self-referential supersedes.
|
|
2541
|
+
// (Also prevents stampReflectedAt from prematurely stamping the new blocks.)
|
|
2542
|
+
// Gap ⑤ (scale): feed Pass 4 a RETRIEVED SLICE instead of the whole-graph
|
|
2543
|
+
// dump — but only above the small-graph threshold (below it the dump is
|
|
2544
|
+
// cheaper than k retrievals). Default OFF; identical output contract.
|
|
2545
|
+
let freshContext;
|
|
2546
|
+
let _reflectedIds2;
|
|
2547
|
+
let p4SliceMode = false;
|
|
2548
|
+
if (pass4SliceEnabled() && freshBlocks.length >= pass4SliceMinGraph()) {
|
|
2549
|
+
p4SliceMode = true;
|
|
2550
|
+
const newBlocksRaw = freshBlocks.filter((b) => savedLabelSet.has(b.label));
|
|
2551
|
+
const slice = buildPass4Slice(db, newBlocksRaw);
|
|
2552
|
+
freshContext = slice.context;
|
|
2553
|
+
_reflectedIds2 = slice.reflectedIds;
|
|
2554
|
+
console.log(`Auto-Reflect Pass 4: slice mode — ${_reflectedIds2.length} candidate block(s) from ${freshBlocks.length} total`);
|
|
2555
|
+
}
|
|
2556
|
+
else {
|
|
2557
|
+
const contextBlocks = freshBlocks.filter((b) => !savedLabelSet.has(b.label));
|
|
2558
|
+
const built = buildProjectContext(contextBlocks, freshRels, allProjectPrefixes, loadedBlocks);
|
|
2559
|
+
freshContext = built.context;
|
|
2560
|
+
_reflectedIds2 = built.reflectedIds;
|
|
2561
|
+
}
|
|
2562
|
+
db.stampReflectedAt(_reflectedIds2);
|
|
2563
|
+
const freshBlockById = new Map(freshBlocks.map((b) => [b.id, b]));
|
|
2564
|
+
const p4RelMap = new Map();
|
|
2565
|
+
for (const r of freshRels) {
|
|
2566
|
+
if (r.status !== "active" || r.type === "part_of")
|
|
2567
|
+
continue;
|
|
2568
|
+
if (!p4RelMap.has(r.source_id))
|
|
2569
|
+
p4RelMap.set(r.source_id, []);
|
|
2570
|
+
p4RelMap.get(r.source_id).push({ type: r.type, targetId: r.target_id });
|
|
2571
|
+
}
|
|
2572
|
+
const newBlocksForP4 = freshBlocks
|
|
2573
|
+
.filter((b) => savedLabelSet.has(b.label))
|
|
2574
|
+
.map((b) => {
|
|
2575
|
+
let uniqueFields = "";
|
|
2576
|
+
try {
|
|
2577
|
+
const c = typeof b.content === "string" ? JSON.parse(b.content) : b.content;
|
|
2578
|
+
const u = c?.unique ?? {};
|
|
2579
|
+
const pairs = Object.entries(u)
|
|
2580
|
+
.filter(([, v]) => v && String(v).trim())
|
|
2581
|
+
.map(([k, v]) => `${k}: "${String(v).slice(0, 80)}"`)
|
|
2582
|
+
.slice(0, 4);
|
|
2583
|
+
if (pairs.length > 0)
|
|
2584
|
+
uniqueFields = ` | unique: { ${pairs.join(", ")} }`;
|
|
2585
|
+
}
|
|
2586
|
+
catch { /* skip */ }
|
|
2587
|
+
const rels = p4RelMap.get(b.id) ?? [];
|
|
2588
|
+
const chainLines = rels
|
|
2589
|
+
.filter((r) => ["prompted_by", "based_on", "supersedes", "extends", "derived_from"].includes(r.type))
|
|
2590
|
+
.slice(0, 4)
|
|
2591
|
+
.map((r) => {
|
|
2592
|
+
const linked = freshBlockById.get(r.targetId);
|
|
2593
|
+
if (!linked)
|
|
2594
|
+
return null;
|
|
2595
|
+
let linkedUnique = "";
|
|
2596
|
+
try {
|
|
2597
|
+
const lc = typeof linked.content === "string" ? JSON.parse(linked.content) : linked.content;
|
|
2598
|
+
const lu = lc?.unique ?? {};
|
|
2599
|
+
const lp = Object.entries(lu).filter(([, v]) => v).map(([k, v]) => `${k}:"${String(v).slice(0, 60)}"`).slice(0, 2);
|
|
2600
|
+
if (lp.length)
|
|
2601
|
+
linkedUnique = ` {${lp.join(", ")}}`;
|
|
2602
|
+
}
|
|
2603
|
+
catch { /* skip */ }
|
|
2604
|
+
return ` ${r.type}→ ${linked.label} — "${(linked.essence || "").slice(0, 80)}"${linkedUnique}`;
|
|
2605
|
+
})
|
|
2606
|
+
.filter(Boolean);
|
|
2607
|
+
return {
|
|
2608
|
+
id: b.id,
|
|
2609
|
+
label: b.label,
|
|
2610
|
+
type: b.type,
|
|
2611
|
+
essence: b.essence || "",
|
|
2612
|
+
uniqueFields,
|
|
2613
|
+
chain: chainLines,
|
|
2614
|
+
};
|
|
2615
|
+
});
|
|
2616
|
+
if (newBlocksForP4.length > 0) {
|
|
2617
|
+
// ── Batched emission (2026-07-03) ──────────────────────────────────────
|
|
2618
|
+
// ONE call per ≤NODEDEX_PASS4_BATCH new blocks (default 20), mirroring
|
|
2619
|
+
// fill_2b. The input-side slice was always capped; the OUTPUT grows with
|
|
2620
|
+
// the new-block count — 157 new blocks in one call blew the model's output
|
|
2621
|
+
// cap in the dogfood run (truncated twice → whole pass failed → every
|
|
2622
|
+
// cross-group conclusion orphaned). Batching bounds each call's output and
|
|
2623
|
+
// isolates failures: a truncated batch loses only its own links.
|
|
2624
|
+
// Relations still accumulate and apply AFTER all batches (same contract as
|
|
2625
|
+
// the old single call), so the rate-limit checkpoint semantics are
|
|
2626
|
+
// unchanged: nothing is applied on a mid-run rate limit, and the retry
|
|
2627
|
+
// re-runs the whole pass (createRelation is idempotent regardless).
|
|
2628
|
+
const p4Queue = chunkForPass4(newBlocksForP4);
|
|
2629
|
+
const p4BatchTotal = p4Queue.length;
|
|
2630
|
+
const p4Relations = [];
|
|
2631
|
+
let p4AnySuccess = false;
|
|
2632
|
+
let p4RateLimited = false;
|
|
2633
|
+
let p4Processed = 0;
|
|
2634
|
+
let p4Splits = 0;
|
|
2635
|
+
// A FAILED batch (truncation-shaped: result null, not rate-limited) is REQUEUED
|
|
2636
|
+
// BY BISECTION: output size scales with batch size, so halving and retrying both
|
|
2637
|
+
// halves is the retry that can actually succeed — unlike the provider's same-size
|
|
2638
|
+
// retry, which is a no-op at the model's output ceiling. Split floor of 4 (halves
|
|
2639
|
+
// of ≥2): a 2-3 block batch that still fails is not a size problem — skip it.
|
|
2640
|
+
// Split budget caps the extra calls so a systemically-failing provider can't loop.
|
|
2641
|
+
const P4_MAX_SPLITS = 8;
|
|
2642
|
+
const p4ThinkingParts = [];
|
|
2643
|
+
const _t4 = Date.now();
|
|
2644
|
+
while (p4Queue.length > 0) {
|
|
2645
|
+
const batch = p4Queue.shift();
|
|
2646
|
+
p4Processed++;
|
|
2647
|
+
// Slice mode: rebuild the candidate slice for THIS batch only — tighter,
|
|
2648
|
+
// more relevant context per call. Whole-graph mode: the context is the
|
|
2649
|
+
// existing graph (independent of the new blocks), so reuse it.
|
|
2650
|
+
let batchContext = freshContext;
|
|
2651
|
+
if (p4SliceMode && (p4BatchTotal > 1 || p4Splits > 0)) {
|
|
2652
|
+
const raw = batch.map((nb) => freshBlockById.get(nb.id)).filter((b) => !!b);
|
|
2653
|
+
batchContext = buildPass4Slice(db, raw).context;
|
|
2654
|
+
}
|
|
2655
|
+
const p4Budget = getThinkingBudget(batch.length <= 5 ? 1024 : 2048);
|
|
2656
|
+
const p4 = await callPass4LLM(provider, batch, batchContext, p4Budget, _sceneCard);
|
|
2657
|
+
if (!_pass4Provider || p4.model)
|
|
2658
|
+
_pass4Provider = { model: p4.model, attempts: p4.attempts };
|
|
2659
|
+
if (p4.rateLimited) {
|
|
2660
|
+
p4RateLimited = true;
|
|
2661
|
+
break;
|
|
2662
|
+
}
|
|
2663
|
+
if (p4.thinking)
|
|
2664
|
+
p4ThinkingParts.push(p4.thinking);
|
|
2665
|
+
const total = p4BatchTotal + p4Splits;
|
|
2666
|
+
if (p4.result) {
|
|
2667
|
+
p4AnySuccess = true;
|
|
2668
|
+
if (p4.result.relations?.length)
|
|
2669
|
+
p4Relations.push(...p4.result.relations);
|
|
2670
|
+
if (total > 1)
|
|
2671
|
+
console.log(`Auto-Reflect Pass 4: batch ${p4Processed}/${total} (${batch.length} block(s)) → ${p4.result.relations?.length ?? 0} relation(s)`);
|
|
2672
|
+
}
|
|
2673
|
+
else if (batch.length >= 4 && p4Splits < P4_MAX_SPLITS) {
|
|
2674
|
+
const mid = Math.ceil(batch.length / 2);
|
|
2675
|
+
p4Queue.unshift(batch.slice(0, mid), batch.slice(mid));
|
|
2676
|
+
p4Splits++;
|
|
2677
|
+
console.log(`Auto-Reflect Pass 4: batch ${p4Processed}/${total} (${batch.length} block(s)) failed → split ${mid}+${batch.length - mid}, requeued`);
|
|
2678
|
+
}
|
|
2679
|
+
else {
|
|
2680
|
+
console.log(`Auto-Reflect Pass 4: batch ${p4Processed}/${total} (${batch.length} block(s)) failed — skipped (${batch.length} block(s) left unlinked this run)`);
|
|
2681
|
+
}
|
|
2682
|
+
}
|
|
2683
|
+
_passWallMs.pass4 = Date.now() - _t4;
|
|
2684
|
+
if (p4RateLimited) {
|
|
2685
|
+
// Blocks are in DB as 'pending' — save checkpoint so Pass 4 retries with those blocks.
|
|
2686
|
+
// Do NOT activate pending blocks yet; they stay invisible until Pass 4 completes.
|
|
2687
|
+
console.log(`Auto-Reflect Pass 4: rate limited — ${p3PendingBlockIds.length} block(s) kept pending, re-queuing`);
|
|
2688
|
+
return { ...empty, checkpoint: {
|
|
2689
|
+
resumeFrom: 'pass4',
|
|
2690
|
+
pass0: { sceneCard: _sceneCard, raw: _pass0Raw },
|
|
2691
|
+
pass1Items: pass1?.items,
|
|
2692
|
+
pass2Classified: pass2?.classified,
|
|
2693
|
+
p3PendingBlockIds: p3PendingBlockIds.length > 0 ? p3PendingBlockIds : undefined,
|
|
2694
|
+
} };
|
|
2695
|
+
}
|
|
2696
|
+
const p4Merged = p4AnySuccess ? { relations: p4Relations } : null;
|
|
2697
|
+
_pass4Thinking = p4ThinkingParts.join("\n");
|
|
2698
|
+
_pass4Result = p4Merged;
|
|
2699
|
+
writeReflectLog({ pass1, pass2, pass3: analysis, pass4: p4Merged });
|
|
2700
|
+
if (p4Merged?.relations?.length) {
|
|
2701
|
+
const PASS4_ALLOWED = new Set(["extends", "supersedes", "superseded_by", "prompted_by", "based_on", "resolves"]);
|
|
2702
|
+
let linked = 0;
|
|
2703
|
+
for (const rel of p4Merged.relations) {
|
|
2704
|
+
if (!rel?.source_id || !rel?.type || !rel?.target_id)
|
|
2705
|
+
continue;
|
|
2706
|
+
if (!PASS4_ALLOWED.has(rel.type))
|
|
2707
|
+
continue;
|
|
2708
|
+
const src = freshBlocks.find((b) => b.label === rel.source_id || b.id === rel.source_id);
|
|
2709
|
+
const tgt = freshBlocks.find((b) => b.label === rel.target_id || b.id === rel.target_id);
|
|
2710
|
+
if (!src || !tgt || src.id === tgt.id)
|
|
2711
|
+
continue;
|
|
2712
|
+
// Skip project-type blocks — they are containers, not knowledge
|
|
2713
|
+
if (src.type === 'project' || tgt.type === 'project')
|
|
2714
|
+
continue;
|
|
2715
|
+
// Skip intra-batch: Pass 2 already wired same-batch relations
|
|
2716
|
+
if (savedLabelSet.has(src.label) && savedLabelSet.has(tgt.label))
|
|
2717
|
+
continue;
|
|
2718
|
+
const EXCLUDED_REL_PREFIXES = new Set(["agent-meta", "system"]);
|
|
2719
|
+
if (EXCLUDED_REL_PREFIXES.has((src.label || "").split("_")[0]) ||
|
|
2720
|
+
EXCLUDED_REL_PREFIXES.has((tgt.label || "").split("_")[0]))
|
|
2721
|
+
continue;
|
|
2722
|
+
if (shouldSkipRelation(src.id, tgt.id, rel.type, db))
|
|
2723
|
+
continue;
|
|
2724
|
+
// Skip if reverse relation already exists (prevents bidirectional cycles)
|
|
2725
|
+
if (db.getRelations(tgt.id).some(r => r.direction === "outgoing" && r.target_id === src.id))
|
|
2726
|
+
continue;
|
|
2727
|
+
db.createRelation({ source_id: src.id, target_id: tgt.id, type: rel.type, bidirectional: false });
|
|
2728
|
+
console.log(`Auto-Reflect Pass 4: "${src.label}" --[${rel.type}]--> "${tgt.label}"`);
|
|
2729
|
+
linked++;
|
|
2730
|
+
}
|
|
2731
|
+
if (linked > 0)
|
|
2732
|
+
console.log(`Auto-Reflect Pass 4: ${linked} relation(s) applied`);
|
|
2733
|
+
}
|
|
2734
|
+
}
|
|
2735
|
+
}
|
|
2736
|
+
// Activate pending blocks — Pass 4 has completed (or was skipped — no new blocks).
|
|
2737
|
+
// Blocks are now visible to users and appear in graph navigation.
|
|
2738
|
+
if (p3PendingBlockIds.length > 0) {
|
|
2739
|
+
const { activated, skippedArchived } = activatePendingBlocks(db, p3PendingBlockIds);
|
|
2740
|
+
console.log(`Auto-Reflect: activated ${activated}/${p3PendingBlockIds.length} pending block(s)` +
|
|
2741
|
+
(skippedArchived > 0 ? ` (${skippedArchived} archived in-turn by same-turn supersede — left as history)` : ""));
|
|
2742
|
+
}
|
|
2743
|
+
// ── PASS 5: Chain Assembly ──
|
|
2744
|
+
if (result.saved >= 2) {
|
|
2745
|
+
const freshBlocks5 = db.getAllBlocks();
|
|
2746
|
+
const freshRels5 = db.getAllRelations(false);
|
|
2747
|
+
const savedLabelSet5 = new Set(result.saved_labels);
|
|
2748
|
+
const freshBlockById5 = new Map(freshBlocks5.map((b) => [b.id, b]));
|
|
2749
|
+
const p5Blocks = freshBlocks5
|
|
2750
|
+
.filter((b) => savedLabelSet5.has(b.label))
|
|
2751
|
+
.map((b) => ({
|
|
2752
|
+
id: b.id,
|
|
2753
|
+
label: b.label,
|
|
2754
|
+
type: b.type,
|
|
2755
|
+
essence: b.essence || "",
|
|
2756
|
+
}));
|
|
2757
|
+
// Pass 5 sees the full causal-thread set (relation-sets.ts), same as the
|
|
2758
|
+
// chain_id clustering above — so supports-linked blocks (the dominant
|
|
2759
|
+
// evidential edge) are visible to assembly and can join a chain block,
|
|
2760
|
+
// not just the narrow prompted_by/based_on/supersedes spine. The Pass 5
|
|
2761
|
+
// prompt's connector list is aligned to match (pass5.ts).
|
|
2762
|
+
const p5Rels = freshRels5.filter((r) => r.status === "active" &&
|
|
2763
|
+
CAUSAL_TRAVERSAL_RELS.has(r.type) &&
|
|
2764
|
+
(savedLabelSet5.has(freshBlockById5.get(r.source_id)?.label ?? "") ||
|
|
2765
|
+
savedLabelSet5.has(freshBlockById5.get(r.target_id)?.label ?? "")));
|
|
2766
|
+
const _t5 = Date.now();
|
|
2767
|
+
const p5Response = await callPass5LLM(provider, p5Blocks, p5Rels);
|
|
2768
|
+
_passWallMs.pass5 = Date.now() - _t5;
|
|
2769
|
+
const p5 = p5Response.result;
|
|
2770
|
+
_pass5Result = p5; // capture for turn-log persistence (carries per-chain reasoning)
|
|
2771
|
+
_pass5Provider = { model: p5Response.model, attempts: p5Response.attempts }; // debt-4 §3: pass5 now has own provider slot
|
|
2772
|
+
if (p5?.chains?.length) {
|
|
2773
|
+
// Track all blk_ chain_ids assigned this pass for straggler sweep
|
|
2774
|
+
const assignedChainIds = new Map(); // blk_chainId → chain block id
|
|
2775
|
+
for (const chain of p5.chains) {
|
|
2776
|
+
let chainBlock = db.getBlock(chain.chain_label);
|
|
2777
|
+
const chainContent = {
|
|
2778
|
+
is_a: "chain",
|
|
2779
|
+
unique: { arc: chain.arc, ...(chain.conclusion ? { conclusion: chain.conclusion } : {}) },
|
|
2780
|
+
concepts: [],
|
|
2781
|
+
};
|
|
2782
|
+
if (!chainBlock) {
|
|
2783
|
+
chainBlock = db.createBlock({
|
|
2784
|
+
label: chain.chain_label,
|
|
2785
|
+
type: "chain",
|
|
2786
|
+
status: "active",
|
|
2787
|
+
essence: chain.chain_essence,
|
|
2788
|
+
content: chainContent,
|
|
2789
|
+
ttl: "permanent",
|
|
2790
|
+
source: "Auto-Reflect",
|
|
2791
|
+
created_by: geminiCreatedBy,
|
|
2792
|
+
});
|
|
2793
|
+
// Compute quality score — createBlock always initializes to 0
|
|
2794
|
+
if (chainBlock) {
|
|
2795
|
+
let qScore = 0;
|
|
2796
|
+
if (chain.chain_essence?.trim())
|
|
2797
|
+
qScore++;
|
|
2798
|
+
if (chainContent.is_a)
|
|
2799
|
+
qScore++;
|
|
2800
|
+
const uFields = Object.values(chainContent.unique).filter(v => v && String(v).trim());
|
|
2801
|
+
if (uFields.length >= 2)
|
|
2802
|
+
qScore++;
|
|
2803
|
+
db.updateBlock(chainBlock.id, { quality_score: Math.min(qScore, 5) });
|
|
2804
|
+
}
|
|
2805
|
+
}
|
|
2806
|
+
else {
|
|
2807
|
+
db.updateBlock(chainBlock.id, { essence: chain.chain_essence, content: chainContent });
|
|
2808
|
+
}
|
|
2809
|
+
if (chainBlock) {
|
|
2810
|
+
for (const memberLabel of chain.members) {
|
|
2811
|
+
const member = freshBlocks5.find((b) => b.label === memberLabel);
|
|
2812
|
+
if (member) {
|
|
2813
|
+
// chain_id column: backward-compat write. Single-attribution.
|
|
2814
|
+
// When Pass 5 emits overlapping chains, the LAST chain processed
|
|
2815
|
+
// wins this column (UI Kahn toposort, derive.ts, straggler sweep
|
|
2816
|
+
// still consume it). Pre-debt-4 this was the ONLY membership
|
|
2817
|
+
// record and overlapping arcs lost members silently (S1.3).
|
|
2818
|
+
db.updateBlock(member.id, { chain_id: chainBlock.id });
|
|
2819
|
+
// member_of relation: many-to-many. Preserves ALL memberships
|
|
2820
|
+
// across overlapping chains. Idempotent — createRelation
|
|
2821
|
+
// returns the existing row on duplicate insert. Per debt-4 §2.3.
|
|
2822
|
+
// The chain block's `members[]` field carries the CANONICAL
|
|
2823
|
+
// ORDERED narrative; this relation is the unordered fact.
|
|
2824
|
+
db.createRelation({
|
|
2825
|
+
source_id: member.id,
|
|
2826
|
+
target_id: chainBlock.id,
|
|
2827
|
+
type: "member_of",
|
|
2828
|
+
created_by: geminiCreatedBy,
|
|
2829
|
+
});
|
|
2830
|
+
assignedChainIds.set(member.id, chainBlock.id);
|
|
2831
|
+
}
|
|
2832
|
+
}
|
|
2833
|
+
const projectLabel = chain.chain_label.split("_")[0];
|
|
2834
|
+
const projectBlock = freshBlocks5.find((b) => b.label === projectLabel && b.type === "project");
|
|
2835
|
+
if (projectBlock && !chainBlock.project_id) {
|
|
2836
|
+
db.updateBlock(chainBlock.id, { project_id: projectBlock.id });
|
|
2837
|
+
}
|
|
2838
|
+
console.log(`Auto-Reflect Pass 5: chain "${chain.chain_label}" assembled (${chain.members.length} members)`);
|
|
2839
|
+
}
|
|
2840
|
+
}
|
|
2841
|
+
// Straggler sweep — Pass 5 LLM may omit connected blocks from members[].
|
|
2842
|
+
// Any freshly-saved block still holding a UUID chain_id (not "blk_" prefixed)
|
|
2843
|
+
// that has a causal relation to a confirmed chain member gets patched in.
|
|
2844
|
+
const freshRels5Final = db.getAllRelations(false).filter((r) => r.status === "active");
|
|
2845
|
+
const CAUSAL_TYPES = new Set(["prompted_by", "based_on", "extends", "supersedes"]);
|
|
2846
|
+
for (const b of freshBlocks5) {
|
|
2847
|
+
if (!savedLabelSet5.has(b.label))
|
|
2848
|
+
continue;
|
|
2849
|
+
if (!b.chain_id || String(b.chain_id).startsWith("blk_"))
|
|
2850
|
+
continue; // already patched or no chain
|
|
2851
|
+
// This block has a UUID chain_id — check if any causal relation connects it to a patched member
|
|
2852
|
+
const connectedChainId = (() => {
|
|
2853
|
+
for (const rel of freshRels5Final) {
|
|
2854
|
+
if (!CAUSAL_TYPES.has(rel.type))
|
|
2855
|
+
continue;
|
|
2856
|
+
if (rel.source_id !== b.id && rel.target_id !== b.id)
|
|
2857
|
+
continue;
|
|
2858
|
+
const otherId = rel.source_id === b.id ? rel.target_id : rel.source_id;
|
|
2859
|
+
if (assignedChainIds.has(otherId))
|
|
2860
|
+
return assignedChainIds.get(otherId);
|
|
2861
|
+
}
|
|
2862
|
+
return null;
|
|
2863
|
+
})();
|
|
2864
|
+
if (connectedChainId) {
|
|
2865
|
+
// Dual-write: chain_id column (backward-compat) + member_of
|
|
2866
|
+
// relation (many-to-many). Per debt-4 §2.3. Same idempotency as
|
|
2867
|
+
// the main Pass 5 loop — createRelation dedups by (source,target,type).
|
|
2868
|
+
db.updateBlock(b.id, { chain_id: connectedChainId });
|
|
2869
|
+
db.createRelation({
|
|
2870
|
+
source_id: b.id,
|
|
2871
|
+
target_id: connectedChainId,
|
|
2872
|
+
type: "member_of",
|
|
2873
|
+
created_by: geminiCreatedBy,
|
|
2874
|
+
});
|
|
2875
|
+
console.log(`Auto-Reflect Pass 5: straggler "${b.label}" swept into chain ${connectedChainId}`);
|
|
2876
|
+
}
|
|
2877
|
+
}
|
|
2878
|
+
}
|
|
2879
|
+
// UUID cleanup — any new block still holding a UUID chain_id after the straggler
|
|
2880
|
+
// sweep was never canonicalized by Pass 5 (no committed conclusion in its cluster).
|
|
2881
|
+
// Clear it: the block is standalone. A UUID chain_id pointing to nothing is worse
|
|
2882
|
+
// than no chain_id.
|
|
2883
|
+
// NOTE: freshBlocks5 was loaded before Pass 5 ran, so b.chain_id may be stale.
|
|
2884
|
+
// Re-read from DB to get the current value before deciding to clear.
|
|
2885
|
+
for (const b of freshBlocks5) {
|
|
2886
|
+
if (!savedLabelSet5.has(b.label))
|
|
2887
|
+
continue;
|
|
2888
|
+
const current = db.getBlock(b.id);
|
|
2889
|
+
if (!current?.chain_id || String(current.chain_id).startsWith("blk_"))
|
|
2890
|
+
continue;
|
|
2891
|
+
db.updateBlock(b.id, { chain_id: null });
|
|
2892
|
+
}
|
|
2893
|
+
}
|
|
2894
|
+
// flow_role: NO LONGER SET BY THE PIPELINE (removed 2026-05-18, commits c2412a6 + a92c44f).
|
|
2895
|
+
// Pass 2/3/5 no longer write it; deriveFlowRole() + applyFlowRoleOverrides() were deleted.
|
|
2896
|
+
// Pipeline-generated blocks have flow_role = null. Still written by:
|
|
2897
|
+
// - tools/derive.ts (workspace_derive): outcome on derived, cause on inputs
|
|
2898
|
+
// - tools/core.ts (workspace_remember): agent-supplied value
|
|
2899
|
+
// UI computes chain display order from prompted_by edges via Kahn's toposort.
|
|
2900
|
+
// Persist session state
|
|
2901
|
+
{
|
|
2902
|
+
let stateBlock = db.getBlock(stateLabel);
|
|
2903
|
+
if (!stateBlock) {
|
|
2904
|
+
stateBlock = db.createBlock({
|
|
2905
|
+
label: stateLabel, type: "process", status: "active",
|
|
2906
|
+
essence: `Session state${agentId ? ` for agent ${agentId.slice(0, 8)}` : ""}`,
|
|
2907
|
+
content: {}, ttl: "permanent", source: "Auto-Reflect",
|
|
2908
|
+
created_by: agentId || undefined,
|
|
2909
|
+
});
|
|
2910
|
+
if (stateBlock)
|
|
2911
|
+
stampQualityScore(db, stateBlock, []); // Bug 1 fix
|
|
2912
|
+
}
|
|
2913
|
+
if (stateBlock) {
|
|
2914
|
+
let sc = {};
|
|
2915
|
+
try {
|
|
2916
|
+
sc = JSON.parse(stateBlock.content);
|
|
2917
|
+
}
|
|
2918
|
+
catch { /* */ }
|
|
2919
|
+
if (_pendingRecentSaves.length > 0) {
|
|
2920
|
+
const existing = sc.gemini_recent_saves || [];
|
|
2921
|
+
sc.gemini_recent_saves = [..._pendingRecentSaves, ...existing].slice(0, 8);
|
|
2922
|
+
}
|
|
2923
|
+
sc.gemini_last_review = {
|
|
2924
|
+
thinking: geminiThinking || "",
|
|
2925
|
+
output: analysis,
|
|
2926
|
+
saved: result.saved,
|
|
2927
|
+
skipped: result.skipped,
|
|
2928
|
+
skip_reasons: analysis?.skip_reasons || [],
|
|
2929
|
+
ts: new Date().toISOString(),
|
|
2930
|
+
};
|
|
2931
|
+
const resolvedRefs = pass2.classified
|
|
2932
|
+
.filter((item) => item.resolved_ref)
|
|
2933
|
+
.map((item) => ({ reference: item.text.slice(0, 60), resolved_to: item.resolved_ref }));
|
|
2934
|
+
sc.prev_turn_context = {
|
|
2935
|
+
active_blocks: knownRoots.map((r) => r.label),
|
|
2936
|
+
entity_map: resolvedRefs,
|
|
2937
|
+
in_flight: "",
|
|
2938
|
+
};
|
|
2939
|
+
sc.total_reflect_sessions = (sc.total_reflect_sessions || 0) + 1;
|
|
2940
|
+
db.updateBlock(stateLabel, { content: JSON.stringify(sc) });
|
|
2941
|
+
}
|
|
2942
|
+
}
|
|
2943
|
+
// ── Per-turn debug log ──
|
|
2944
|
+
writeTurnLog({
|
|
2945
|
+
turn: _turnCounter,
|
|
2946
|
+
ts: new Date().toISOString(),
|
|
2947
|
+
// Per-pass provider trail: which model actually produced each pass + the attempt
|
|
2948
|
+
// sequence (primary→fallback escalation on truncation/429). This is what makes the
|
|
2949
|
+
// run-to-run non-determinism visible — without it, a slow divergent run is unattributable.
|
|
2950
|
+
providers: {
|
|
2951
|
+
pass0: _pass0Provider,
|
|
2952
|
+
pass1: _pass1Provider,
|
|
2953
|
+
pass_judge: _passJudgeProvider,
|
|
2954
|
+
pass2: _pass2Provider,
|
|
2955
|
+
pass3: _pass3Provider,
|
|
2956
|
+
pass4: _pass4Provider,
|
|
2957
|
+
pass5: _pass5Provider,
|
|
2958
|
+
},
|
|
2959
|
+
pass0: {
|
|
2960
|
+
scene_card_text: _sceneCard ?? null,
|
|
2961
|
+
raw: _pass0Raw,
|
|
2962
|
+
},
|
|
2963
|
+
pass1: pass1 ? {
|
|
2964
|
+
thinking: _pass1Thinking || undefined,
|
|
2965
|
+
item_count: pass1.items.length,
|
|
2966
|
+
items: pass1.items.map(i => ({
|
|
2967
|
+
id: i.id,
|
|
2968
|
+
provisional_type: i.provisional_type,
|
|
2969
|
+
source: i.source,
|
|
2970
|
+
text: i.text,
|
|
2971
|
+
excerpt: i.excerpt?.slice(0, 120),
|
|
2972
|
+
extends_id: i.extends_id,
|
|
2973
|
+
extraction_reasoning: i.extraction_reasoning,
|
|
2974
|
+
})),
|
|
2975
|
+
} : null,
|
|
2976
|
+
// JUDGE pass audit trail — present only when NODEDEX_WORTH_JUDGE_ENABLED=1 fired this turn.
|
|
2977
|
+
// dropped[] lets us content-verify each drop offline (item_id + reason_category + optional notes).
|
|
2978
|
+
// anchor_overrides[] records ids the judge wanted to drop but were saved because a kept item
|
|
2979
|
+
// extends them (the asymmetric-cost ref-cleanup rule).
|
|
2980
|
+
pass_judge: _passJudgeProvider ? {
|
|
2981
|
+
kept_count: _passJudgeKeptCount,
|
|
2982
|
+
dropped_count: _passJudgeDropped.length,
|
|
2983
|
+
dropped: _passJudgeDropped,
|
|
2984
|
+
anchor_overrides: _passJudgeAnchorOverrides,
|
|
2985
|
+
} : undefined,
|
|
2986
|
+
pass2: pass2 ? {
|
|
2987
|
+
thinking: _pass2Thinking || undefined,
|
|
2988
|
+
context: _pass2Context || undefined,
|
|
2989
|
+
item_count: pass2.classified.length,
|
|
2990
|
+
skipped: pass2.skipped?.length ? pass2.skipped : undefined,
|
|
2991
|
+
items: pass2.classified.map(i => ({
|
|
2992
|
+
id: i.id,
|
|
2993
|
+
type: i.type,
|
|
2994
|
+
text: i.text,
|
|
2995
|
+
unique: i.unique,
|
|
2996
|
+
triggered_by_items: i.triggered_by_items,
|
|
2997
|
+
based_on_items: i.based_on_items,
|
|
2998
|
+
extends_item: i.extends_item,
|
|
2999
|
+
supersedes_ref: i.supersedes_ref,
|
|
3000
|
+
review_reason: i.review_reason,
|
|
3001
|
+
classification_reasoning: i.classification_reasoning,
|
|
3002
|
+
// v2 COMPREHEND reasoning (2026-06-12): the conversion carries these on the
|
|
3003
|
+
// items, but this WHITELIST is what reaches the turn-log — omitting them
|
|
3004
|
+
// here silently re-lost the reasoning a second time (caught live: the
|
|
3005
|
+
// first fix updated the conversion, verified by unit test, and the log
|
|
3006
|
+
// still showed nothing — the writer was the second strip point).
|
|
3007
|
+
keep_reason: i.keep_reason,
|
|
3008
|
+
type_reasoning: i.type_reasoning,
|
|
3009
|
+
// Per-relation semantic wiring + its reasoning (Pass 2c, commit 450d631).
|
|
3010
|
+
// Persisted here because the graph relations table has no `reason` column —
|
|
3011
|
+
// the turn log is the only readable home for this debug instrumentation.
|
|
3012
|
+
relations: i.relations,
|
|
3013
|
+
note: i.note,
|
|
3014
|
+
})),
|
|
3015
|
+
} : null,
|
|
3016
|
+
pass3: analysis ? {
|
|
3017
|
+
thinking: geminiThinking || undefined,
|
|
3018
|
+
project_creates: analysis.project_creates || [],
|
|
3019
|
+
new_blocks: (analysis.new_blocks || []).map((b) => ({
|
|
3020
|
+
label: typeof b.label === "object"
|
|
3021
|
+
? [b.label.project, b.label.subgroup, b.label.type, b.label.concept].filter(Boolean).join("_")
|
|
3022
|
+
: b.label,
|
|
3023
|
+
is_a: b.is_a,
|
|
3024
|
+
essence: b.essence,
|
|
3025
|
+
unique: b.unique,
|
|
3026
|
+
triggered_by: b.triggered_by,
|
|
3027
|
+
novelty_reason: b.novelty_reason,
|
|
3028
|
+
})),
|
|
3029
|
+
skip_reasons: analysis.skip_reasons || [],
|
|
3030
|
+
updates: (analysis.updates || []).map((u) => ({ block_id: u.block_id, reason: u.reason })),
|
|
3031
|
+
} : null,
|
|
3032
|
+
pass4: (_pass4Thinking || _pass4Result) ? {
|
|
3033
|
+
thinking: _pass4Thinking || undefined,
|
|
3034
|
+
relations: _pass4Result?.relations ?? [],
|
|
3035
|
+
} : undefined,
|
|
3036
|
+
// Pass 5 chain assembly. Each chain carries its per-chain `reasoning`
|
|
3037
|
+
// (commit 450d631: WHY-these-members + WHY-this-conclusion). Chain BLOCKS
|
|
3038
|
+
// store only arc/conclusion/essence, so the turn log is the only readable
|
|
3039
|
+
// home for the reasoning — the debug payoff the deep test relies on.
|
|
3040
|
+
pass5: _pass5Result?.chains?.length ? {
|
|
3041
|
+
chains: _pass5Result.chains,
|
|
3042
|
+
} : undefined,
|
|
3043
|
+
pipeline_skips: pipelineSkips.length > 0 ? pipelineSkips : undefined,
|
|
3044
|
+
// Pass 2 split-orchestrator audit (per PASS2-SPLIT-DESIGN.md §7).
|
|
3045
|
+
// Surfaces: 2a/2b/2c throughput, seam α verdicts, re-fill salvage
|
|
3046
|
+
// outcomes, quarantine writes. Present only when the split path ran
|
|
3047
|
+
// this turn (NODEDEX_PASS2_SPLIT=1). Undefined on monolith path — the
|
|
3048
|
+
// turn-log reader can use presence/absence to detect which path ran.
|
|
3049
|
+
pass2_split_audit: _pass2SplitAudit,
|
|
3050
|
+
// Per-pass $$ cost telemetry. Extracted to cost-breakdown.ts for unit
|
|
3051
|
+
// testability — see cost-breakdown.test.ts for the three meaningful
|
|
3052
|
+
// states (ran:false / ran:true+priced / ran:true+null) verified.
|
|
3053
|
+
// Per PASS2-SPLIT-DESIGN.md §7 + debt-4 §3 + S1.1 (checkpoint NULL fix).
|
|
3054
|
+
cost_breakdown: buildCostBreakdown({
|
|
3055
|
+
pass0: _pass0Provider,
|
|
3056
|
+
pass1: _pass1Provider,
|
|
3057
|
+
pass_judge: _passJudgeProvider,
|
|
3058
|
+
pass2: _pass2Provider,
|
|
3059
|
+
pass3: _pass3Provider,
|
|
3060
|
+
pass4: _pass4Provider,
|
|
3061
|
+
pass5: _pass5Provider,
|
|
3062
|
+
// Stage C ran upstream in arc-pipeline.ts; its provider trail is
|
|
3063
|
+
// pinned on the checkpoint (slice-1.2 design contract). When Stage C
|
|
3064
|
+
// gracefully degraded (LLM returned null), arcEntityResolution.model
|
|
3065
|
+
// stays undefined → ran:false here, matching the pass5 pattern.
|
|
3066
|
+
// Followup #2 from project-slice1-verified-2026-05-31 (cost slot).
|
|
3067
|
+
pass_c_resolve: checkpoint?.arcEntityResolution?.model
|
|
3068
|
+
? { model: checkpoint.arcEntityResolution.model, attempts: checkpoint.arcEntityResolution.attempts }
|
|
3069
|
+
: undefined,
|
|
3070
|
+
}, reflectTokenStats),
|
|
3071
|
+
// debt-4 Stage A — per-pass WALL TIME. Covers what cost_breakdown's $$
|
|
3072
|
+
// doesn't: where TIME goes. Thinking-budget-heavy passes (Pass 3 at
|
|
3073
|
+
// 4096, Pass 2c at dynamic 1024-8192) dominate time while output tokens
|
|
3074
|
+
// dominate cost — the two metrics correlate but aren't identical.
|
|
3075
|
+
// Populated only for passes that actually ran this turn (omitted on
|
|
3076
|
+
// checkpoint-resume non-contributions, consistent with cost_breakdown's
|
|
3077
|
+
// ran:false semantics).
|
|
3078
|
+
// v2 front-half stage timings (COMPREHEND / judge / 2b fill / justify /
|
|
3079
|
+
// crosslink / integrate) arrive via the checkpoint — the front-half runs
|
|
3080
|
+
// before this function. Merged here so ONE field answers "where did the
|
|
3081
|
+
// wall time go" for both halves (2026-06-12: the front-half was the
|
|
3082
|
+
// unmeasured majority of arc wall time).
|
|
3083
|
+
pass_wall_ms: checkpoint?.v2WallMs ? { ...checkpoint.v2WallMs, ..._passWallMs } : _passWallMs,
|
|
3084
|
+
// v2 front-half per-stage COST (USD), the cost twin of the wall timings —
|
|
3085
|
+
// closes the "front-half spend invisible in cost_breakdown" gap. Attributed
|
|
3086
|
+
// from the usage ledger over each stage's window (v2-integrate.ts). Only
|
|
3087
|
+
// present on v2 runs; undefined on v1/checkpoint-less turns.
|
|
3088
|
+
v2_front_cost_usd: checkpoint?.v2FrontCostUsd,
|
|
3089
|
+
// debt-4 Stage A — embedding telemetry. ~100+ sequential embedding API
|
|
3090
|
+
// calls per moderate turn was a HIDDEN time tax (~15-20% of per-turn
|
|
3091
|
+
// wall time) invisible to cost_breakdown. Now surfaced. Per-turn DELTAS
|
|
3092
|
+
// (calls/ms/chars accumulated globally; we subtract the start snapshot).
|
|
3093
|
+
// Stage B (embedding batching) will use this as the baseline to prove
|
|
3094
|
+
// its saving against.
|
|
3095
|
+
embedding_stats: {
|
|
3096
|
+
calls: embeddingStats.calls - _embStart.calls,
|
|
3097
|
+
ms_total: embeddingStats.ms_total - _embStart.ms_total,
|
|
3098
|
+
input_chars: embeddingStats.input_chars - _embStart.input_chars,
|
|
3099
|
+
},
|
|
3100
|
+
result: {
|
|
3101
|
+
saved: result.saved,
|
|
3102
|
+
saved_labels: result.saved_labels,
|
|
3103
|
+
skipped: result.skipped,
|
|
3104
|
+
updated: result.updated,
|
|
3105
|
+
},
|
|
3106
|
+
});
|
|
3107
|
+
return result;
|
|
3108
|
+
}
|
|
3109
|
+
catch (e) {
|
|
3110
|
+
console.error("Auto-Reflect: pipeline error", e);
|
|
3111
|
+
return empty;
|
|
3112
|
+
}
|
|
3113
|
+
}
|
|
3114
|
+
//# sourceMappingURL=pipeline.js.map
|