@remnic/core 9.3.726 → 9.3.728
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/dist/access-boundary.d.ts +5 -5
- package/dist/access-boundary.js +22 -22
- package/dist/access-cli.js +45 -45
- package/dist/access-http.d.ts +20 -5
- package/dist/access-http.js +27 -27
- package/dist/access-mcp.d.ts +5 -5
- package/dist/access-mcp.js +26 -26
- package/dist/access-operations-batch.js +23 -23
- package/dist/access-operations.d.ts +6 -6
- package/dist/access-operations.js +25 -25
- package/dist/access-schema.d.ts +4 -4
- package/dist/access-schema.js +3 -3
- package/dist/{access-service-B2PlPtxh.d.ts → access-service-Bgpjxo4f.d.ts} +36 -4
- package/dist/access-service.d.ts +5 -5
- package/dist/access-service.js +21 -21
- package/dist/access-surface-catalog.d.ts +5 -5
- package/dist/action-confidence.d.ts +1 -1
- package/dist/active-memory-bridge.d.ts +1 -1
- package/dist/active-recall.d.ts +1 -1
- package/dist/active-recall.js +3 -3
- package/dist/behavior-learner.d.ts +1 -1
- package/dist/behavior-signals.d.ts +1 -1
- package/dist/bootstrap.d.ts +4 -4
- package/dist/briefing.d.ts +1 -1
- package/dist/briefing.js +7 -7
- package/dist/buffer-surprise-report.d.ts +1 -1
- package/dist/buffer.d.ts +1 -1
- package/dist/calibration.d.ts +1 -1
- package/dist/capabilities.d.ts +44 -2
- package/dist/capabilities.js +5 -3
- package/dist/{capsule-crypto-GWVG7LGC.js → capsule-crypto-7FJQINUR.js} +2 -2
- package/dist/{catalog-CzUb-GyD.d.ts → catalog-bMczlO2G.d.ts} +1 -1
- package/dist/causal-behavior.d.ts +1 -1
- package/dist/causal-consolidation.d.ts +1 -1
- package/dist/causal-consolidation.js +8 -8
- package/dist/causal-trajectory-graph.d.ts +1 -1
- package/dist/{chunk-OWHERGF2.js → chunk-2NLLXCJG.js} +2 -2
- package/dist/{chunk-BIA7ELIB.js → chunk-36SEDFAU.js} +3 -3
- package/dist/{chunk-UQSVUEN2.js → chunk-37436GML.js} +2 -2
- package/dist/{chunk-DVRHLN2O.js → chunk-3RCBIOOJ.js} +18 -2
- package/dist/{chunk-DVRHLN2O.js.map → chunk-3RCBIOOJ.js.map} +1 -1
- package/dist/{chunk-RRHHQYJS.js → chunk-3TSTGI6F.js} +2 -2
- package/dist/{chunk-5OOA7SGX.js → chunk-4ESAR77N.js} +2 -2
- package/dist/{chunk-FO3HO52S.js → chunk-4H4F3BFL.js} +2 -2
- package/dist/{chunk-BEUCACTV.js → chunk-4NOI577P.js} +2 -2
- package/dist/{chunk-BBIE7W52.js → chunk-4NR2PDZ5.js} +2 -2
- package/dist/{chunk-WGTWGDR5.js → chunk-76NHR6ZL.js} +5 -5
- package/dist/{chunk-PKMQK2WY.js → chunk-773DPI6T.js} +4 -4
- package/dist/{chunk-MQVKHFIU.js → chunk-7RLCCNHU.js} +8 -8
- package/dist/{chunk-XGTBNVIU.js → chunk-AAD2ZS6P.js} +11 -1
- package/dist/chunk-AAD2ZS6P.js.map +1 -0
- package/dist/{chunk-EZNDNMRH.js → chunk-AQLOP5F4.js} +2 -2
- package/dist/{chunk-UNNDFYUX.js → chunk-ARV3AUOM.js} +5 -5
- package/dist/{chunk-NPL7VDM6.js → chunk-ASKXIAHD.js} +2 -2
- package/dist/{chunk-TQQH2FTX.js → chunk-BQCHEV7O.js} +106 -15
- package/dist/{chunk-TQQH2FTX.js.map → chunk-BQCHEV7O.js.map} +1 -1
- package/dist/{chunk-ZA2S2VLL.js → chunk-C2PG7IEH.js} +5 -2
- package/dist/chunk-C2PG7IEH.js.map +1 -0
- package/dist/{chunk-K4ZNARDO.js → chunk-CBWI22JN.js} +2 -2
- package/dist/{chunk-ZOHMPFOB.js → chunk-CKFLLAIS.js} +3 -3
- package/dist/{chunk-JPUT5NGH.js → chunk-DMQWV6SQ.js} +2 -2
- package/dist/{chunk-F3XZN24Z.js → chunk-DRUDC2UX.js} +2 -2
- package/dist/{chunk-KJR66XOM.js → chunk-EY6MDMHK.js} +2 -2
- package/dist/{chunk-3TUT4PWB.js → chunk-FVZ2ISIX.js} +2 -2
- package/dist/{chunk-C4X4QGIO.js → chunk-G6NKALRN.js} +5 -2
- package/dist/chunk-G6NKALRN.js.map +1 -0
- package/dist/{chunk-6SQQOJ4U.js → chunk-GKRLPHPU.js} +2 -2
- package/dist/{chunk-PLG6JHQS.js → chunk-H5BWGCRZ.js} +43 -42
- package/dist/chunk-H5BWGCRZ.js.map +1 -0
- package/dist/{chunk-2NBP25BG.js → chunk-HGNOCPZX.js} +171 -79
- package/dist/chunk-HGNOCPZX.js.map +1 -0
- package/dist/{chunk-5NWYQUQC.js → chunk-HOHVIVDR.js} +2 -2
- package/dist/{chunk-DCKNMLEQ.js → chunk-I5REUWLH.js} +11 -5
- package/dist/chunk-I5REUWLH.js.map +1 -0
- package/dist/{chunk-6WAJQKBS.js → chunk-JYLFZWUX.js} +2 -2
- package/dist/{chunk-5W7CTZ2E.js → chunk-KQAFEZQX.js} +4 -4
- package/dist/{chunk-UKTDXTWX.js → chunk-M4AYVEWS.js} +7 -7
- package/dist/{chunk-YTZ3VGC7.js → chunk-M4V2JVSZ.js} +2 -2
- package/dist/{chunk-TA3Y6GEX.js → chunk-MLKWJOAJ.js} +2 -2
- package/dist/{chunk-DQXPJ6HD.js → chunk-O6YH5F47.js} +4 -4
- package/dist/{chunk-7AG54AF3.js → chunk-PUR2CKX2.js} +1 -1
- package/dist/chunk-PUR2CKX2.js.map +1 -0
- package/dist/{chunk-OAHOL3ZC.js → chunk-QNEUZIAH.js} +152 -62
- package/dist/chunk-QNEUZIAH.js.map +1 -0
- package/dist/{chunk-Q6D75LVH.js → chunk-UM5AWJNQ.js} +79 -17
- package/dist/chunk-UM5AWJNQ.js.map +1 -0
- package/dist/{chunk-53EEQYE5.js → chunk-UTHQPSRC.js} +8 -8
- package/dist/{chunk-BB57OZWC.js → chunk-UVPL565C.js} +2 -2
- package/dist/{chunk-6AFVIOVZ.js → chunk-V4GNSDFH.js} +2 -2
- package/dist/{chunk-EHISUJFN.js → chunk-WAS3NV46.js} +6 -3
- package/dist/chunk-WAS3NV46.js.map +1 -0
- package/dist/{chunk-UVYI6VIX.js → chunk-X7Y7WX73.js} +1 -1
- package/dist/{chunk-V7CMKMHM.js → chunk-Y4YD5TYW.js} +3 -3
- package/dist/{chunk-7NS7AWMZ.js → chunk-YGIQORJ3.js} +3 -3
- package/dist/{chunk-MDBZTH7N.js → chunk-YKBHOBJK.js} +44 -10
- package/dist/chunk-YKBHOBJK.js.map +1 -0
- package/dist/{chunk-3MS3QVX6.js → chunk-YOLZS7NY.js} +2 -2
- package/dist/{chunk-6OIDDKAA.js → chunk-YTU7WKYN.js} +35 -9
- package/dist/chunk-YTU7WKYN.js.map +1 -0
- package/dist/{cli-IOlDHSBT.d.ts → cli-BLME420h.d.ts} +3 -3
- package/dist/cli.d.ts +6 -6
- package/dist/cli.js +49 -49
- package/dist/compat/codex-cli-fallback.d.ts +1 -0
- package/dist/compat/codex-cli-fallback.js +22 -0
- package/dist/compat/codex-cli-fallback.js.map +1 -0
- package/dist/compat/codex-thread-key.d.ts +1 -0
- package/dist/compat/codex-thread-key.js +18 -0
- package/dist/compat/codex-thread-key.js.map +1 -0
- package/dist/compounding/engine.d.ts +1 -1
- package/dist/compounding/engine.js +7 -7
- package/dist/compounding/preference-consolidator.d.ts +1 -1
- package/dist/compression-optimizer.d.ts +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.js +3 -3
- package/dist/connectors/codex-materialize-runner.d.ts +1 -1
- package/dist/connectors/codex-materialize-runner.js +7 -7
- package/dist/connectors/codex-materialize.d.ts +1 -1
- package/dist/connectors/index.d.ts +1 -1
- package/dist/connectors/index.js +7 -7
- package/dist/consolidation-provenance-check.d.ts +1 -1
- package/dist/consolidation-undo.d.ts +1 -1
- package/dist/contradiction/index.d.ts +1 -1
- package/dist/conversation-index/backend.d.ts +1 -1
- package/dist/conversation-index/chunker.d.ts +1 -1
- package/dist/conversation-index/faiss-adapter.d.ts +1 -1
- package/dist/conversation-index/indexer.d.ts +1 -1
- package/dist/conversation-index/search.d.ts +1 -1
- package/dist/day-summary.d.ts +1 -1
- package/dist/delinearize.d.ts +1 -1
- package/dist/direct-answer-wiring.d.ts +1 -1
- package/dist/direct-answer.d.ts +1 -1
- package/dist/embedding-fallback.d.ts +1 -1
- package/dist/embedding-fallback.js +2 -1
- package/dist/enrichment/index.d.ts +1 -1
- package/dist/entity-retrieval.d.ts +1 -1
- package/dist/entity-retrieval.js +7 -7
- package/dist/entity-schema.d.ts +1 -1
- package/dist/explicit-capture.d.ts +4 -4
- package/dist/extraction-faithfulness.d.ts +1 -1
- package/dist/extraction-faithfulness.js +2 -2
- package/dist/extraction-judge-telemetry.d.ts +1 -1
- package/dist/extraction-judge-training.d.ts +1 -1
- package/dist/extraction-judge.d.ts +1 -1
- package/dist/extraction.d.ts +1 -1
- package/dist/extraction.js +3 -2
- package/dist/faithfulness-config.d.ts +1 -0
- package/dist/faithfulness-config.js +1 -1
- package/dist/fallback-llm.d.ts +1 -1
- package/dist/{first-start-migration-PG5HBC3K.js → first-start-migration-22LPFHD5.js} +6 -2
- package/dist/first-start-migration-22LPFHD5.js.map +1 -0
- package/dist/graph-dashboard-diff.d.ts +1 -1
- package/dist/graph-dashboard-key.d.ts +1 -1
- package/dist/graph-dashboard-parser.d.ts +1 -1
- package/dist/graph-edge-reinforcement.d.ts +1 -1
- package/dist/graph-snapshot.d.ts +1 -1
- package/dist/graph.d.ts +1 -1
- package/dist/identity-continuity.d.ts +1 -1
- package/dist/importance.d.ts +1 -1
- package/dist/index.d.ts +9 -9
- package/dist/index.js +64 -64
- package/dist/intent.d.ts +1 -1
- package/dist/lcm/engine.d.ts +1 -1
- package/dist/lcm/engine.js +2 -2
- package/dist/lcm/index.d.ts +1 -1
- package/dist/lcm/index.js +7 -7
- package/dist/lcm/tools.d.ts +1 -1
- package/dist/lifecycle.d.ts +1 -1
- package/dist/live-connectors-runner.d.ts +1 -1
- package/dist/local-llm.d.ts +1 -1
- package/dist/local-model-endpoint.d.ts +9 -4
- package/dist/local-model-endpoint.js +1 -1
- package/dist/maintenance/memory-governance.d.ts +1 -1
- package/dist/maintenance/memory-governance.js +7 -7
- package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +7 -7
- package/dist/maintenance/rebuild-memory-projection.js +8 -8
- package/dist/mcp-memory-inspector-app.d.ts +5 -5
- package/dist/memory-action-policy.d.ts +1 -1
- package/dist/memory-cache.d.ts +1 -1
- package/dist/memory-lifecycle-ledger-utils.d.ts +1 -1
- package/dist/memory-projection-store.d.ts +1 -1
- package/dist/memory-provenance.d.ts +1 -1
- package/dist/memory-worth-outcomes.d.ts +1 -1
- package/dist/models-json.d.ts +1 -1
- package/dist/namespaces/migrate.d.ts +2 -2
- package/dist/namespaces/migrate.js +14 -13
- package/dist/namespaces/principal.d.ts +1 -1
- package/dist/namespaces/search.d.ts +1 -1
- package/dist/namespaces/search.js +6 -5
- package/dist/namespaces/storage.d.ts +2 -2
- package/dist/namespaces/storage.js +7 -7
- package/dist/native-knowledge.d.ts +1 -1
- package/dist/operator-toolkit.d.ts +1 -1
- package/dist/operator-toolkit.js +20 -20
- package/dist/orchestration/maintenance.d.ts +2 -2
- package/dist/orchestration/maintenance.js +9 -9
- package/dist/{orchestrator-C_OMSIMZ.d.ts → orchestrator-DqPAz8ot.d.ts} +3 -3
- package/dist/orchestrator.d.ts +4 -4
- package/dist/orchestrator.js +33 -33
- package/dist/patterns-cli.d.ts +1 -1
- package/dist/policy-runtime.d.ts +1 -1
- package/dist/provenance.d.ts +1 -1
- package/dist/provenance.js +1 -1
- package/dist/qmd-recall-cache.d.ts +1 -1
- package/dist/qmd.d.ts +1 -1
- package/dist/recall-disclosure-escalation.d.ts +1 -1
- package/dist/recall-explain-renderer.d.ts +1 -1
- package/dist/recall-explain-renderer.js +3 -3
- package/dist/recall-planner-llm.d.ts +1 -1
- package/dist/recall-state.d.ts +1 -1
- package/dist/recall-tag-filter.d.ts +1 -1
- package/dist/recall-xray-cli.d.ts +1 -1
- package/dist/recall-xray-cli.js +4 -4
- package/dist/recall-xray-renderer.d.ts +1 -1
- package/dist/recall-xray-renderer.js +3 -3
- package/dist/recall-xray.d.ts +1 -1
- package/dist/recall-xray.js +2 -2
- package/dist/resolve-auth-token.d.ts +1 -1
- package/dist/resume-bundles.js +4 -4
- package/dist/retrieval-agents.d.ts +1 -1
- package/dist/retrieval-tiers.d.ts +1 -1
- package/dist/routing/engine.d.ts +1 -1
- package/dist/routing/store.d.ts +1 -1
- package/dist/search/embed-helper.d.ts +1 -1
- package/dist/search/embed-helper.js +2 -1
- package/dist/search/factory.d.ts +1 -1
- package/dist/search/factory.js +5 -4
- package/dist/search/index.d.ts +1 -1
- package/dist/search/index.js +9 -8
- package/dist/search/lancedb-backend.d.ts +1 -1
- package/dist/search/meilisearch-backend.d.ts +1 -1
- package/dist/search/noop-backend.d.ts +1 -1
- package/dist/search/orama-backend.d.ts +1 -1
- package/dist/search/port.d.ts +1 -1
- package/dist/search/remote-backend.d.ts +1 -1
- package/dist/{semantic-consolidation-B8LiYtvx.d.ts → semantic-consolidation-Cl15ksZy.d.ts} +1 -1
- package/dist/semantic-consolidation.d.ts +2 -2
- package/dist/semantic-consolidation.js +8 -8
- package/dist/semantic-rule-promotion.js +7 -7
- package/dist/semantic-rule-verifier.d.ts +1 -1
- package/dist/semantic-rule-verifier.js +7 -7
- package/dist/session-observer-bands.d.ts +1 -1
- package/dist/session-observer-state.d.ts +1 -1
- package/dist/shared-context/manager.d.ts +1 -1
- package/dist/signal.d.ts +1 -1
- package/dist/storage.d.ts +1 -1
- package/dist/storage.js +6 -6
- package/dist/summarizer.d.ts +1 -1
- package/dist/summary-snapshot.d.ts +1 -1
- package/dist/temporal-supersession.d.ts +1 -1
- package/dist/temporal-validity.d.ts +1 -1
- package/dist/threading.d.ts +1 -1
- package/dist/tier-migration.d.ts +1 -1
- package/dist/tier-routing.d.ts +1 -1
- package/dist/topics.d.ts +1 -1
- package/dist/transcript.d.ts +1 -1
- package/dist/transfer/backup.js +2 -2
- package/dist/transfer/capsule-export.js +2 -2
- package/dist/transfer/capsule-import.js +2 -2
- package/dist/trust-score-stage.d.ts +1 -1
- package/dist/trust-score.d.ts +1 -1
- package/dist/{types-DmTaqob2.d.ts → types-ChQb9D6s.d.ts} +8 -0
- package/dist/types.d.ts +1 -1
- package/dist/types.js +1 -1
- package/dist/utility-runtime.d.ts +1 -1
- package/dist/verified-recall.js +7 -7
- package/package.json +26 -2
- package/src/access-http.ts +68 -1
- package/src/access-service.ts +2 -1
- package/src/admin/admin-surfaces.test.ts +289 -0
- package/src/admin/admin-surfaces.ts +138 -7
- package/src/bitemporal-dedup-backfill.test.ts +294 -0
- package/src/capabilities.test.ts +155 -0
- package/src/capabilities.ts +91 -0
- package/src/chat/chat-engine.test.ts +104 -1
- package/src/chat/chat-http.test.ts +119 -1
- package/src/chat/chat-http.ts +252 -97
- package/src/chat/chat-session.ts +166 -7
- package/src/cli.ts +3 -2
- package/src/compat/codex-cli-fallback.ts +23 -0
- package/src/compat/codex-subpath-aliases.test.ts +93 -0
- package/src/compat/codex-thread-key.ts +23 -0
- package/src/embedding-fallback.ts +2 -1
- package/src/extraction-faithfulness.test.ts +240 -0
- package/src/extraction-faithfulness.ts +58 -6
- package/src/extraction.ts +7 -3
- package/src/faithfulness-config.ts +16 -1
- package/src/local-model-endpoint.ts +24 -3
- package/src/maintenance/first-start-migration.ts +3 -1
- package/src/orchestrator.ts +238 -34
- package/src/provenance-extraction.test.ts +266 -0
- package/src/provenance.ts +66 -16
- package/src/search/embed-helper.ts +3 -2
- package/src/types.ts +8 -0
- package/dist/chunk-2NBP25BG.js.map +0 -1
- package/dist/chunk-6OIDDKAA.js.map +0 -1
- package/dist/chunk-7AG54AF3.js.map +0 -1
- package/dist/chunk-C4X4QGIO.js.map +0 -1
- package/dist/chunk-DCKNMLEQ.js.map +0 -1
- package/dist/chunk-EHISUJFN.js.map +0 -1
- package/dist/chunk-MDBZTH7N.js.map +0 -1
- package/dist/chunk-OAHOL3ZC.js.map +0 -1
- package/dist/chunk-PLG6JHQS.js.map +0 -1
- package/dist/chunk-Q6D75LVH.js.map +0 -1
- package/dist/chunk-XGTBNVIU.js.map +0 -1
- package/dist/chunk-ZA2S2VLL.js.map +0 -1
- package/dist/first-start-migration-PG5HBC3K.js.map +0 -1
- /package/dist/{capsule-crypto-GWVG7LGC.js.map → capsule-crypto-7FJQINUR.js.map} +0 -0
- /package/dist/{chunk-OWHERGF2.js.map → chunk-2NLLXCJG.js.map} +0 -0
- /package/dist/{chunk-BIA7ELIB.js.map → chunk-36SEDFAU.js.map} +0 -0
- /package/dist/{chunk-UQSVUEN2.js.map → chunk-37436GML.js.map} +0 -0
- /package/dist/{chunk-RRHHQYJS.js.map → chunk-3TSTGI6F.js.map} +0 -0
- /package/dist/{chunk-5OOA7SGX.js.map → chunk-4ESAR77N.js.map} +0 -0
- /package/dist/{chunk-FO3HO52S.js.map → chunk-4H4F3BFL.js.map} +0 -0
- /package/dist/{chunk-BEUCACTV.js.map → chunk-4NOI577P.js.map} +0 -0
- /package/dist/{chunk-BBIE7W52.js.map → chunk-4NR2PDZ5.js.map} +0 -0
- /package/dist/{chunk-WGTWGDR5.js.map → chunk-76NHR6ZL.js.map} +0 -0
- /package/dist/{chunk-PKMQK2WY.js.map → chunk-773DPI6T.js.map} +0 -0
- /package/dist/{chunk-MQVKHFIU.js.map → chunk-7RLCCNHU.js.map} +0 -0
- /package/dist/{chunk-EZNDNMRH.js.map → chunk-AQLOP5F4.js.map} +0 -0
- /package/dist/{chunk-UNNDFYUX.js.map → chunk-ARV3AUOM.js.map} +0 -0
- /package/dist/{chunk-NPL7VDM6.js.map → chunk-ASKXIAHD.js.map} +0 -0
- /package/dist/{chunk-K4ZNARDO.js.map → chunk-CBWI22JN.js.map} +0 -0
- /package/dist/{chunk-ZOHMPFOB.js.map → chunk-CKFLLAIS.js.map} +0 -0
- /package/dist/{chunk-JPUT5NGH.js.map → chunk-DMQWV6SQ.js.map} +0 -0
- /package/dist/{chunk-F3XZN24Z.js.map → chunk-DRUDC2UX.js.map} +0 -0
- /package/dist/{chunk-KJR66XOM.js.map → chunk-EY6MDMHK.js.map} +0 -0
- /package/dist/{chunk-3TUT4PWB.js.map → chunk-FVZ2ISIX.js.map} +0 -0
- /package/dist/{chunk-6SQQOJ4U.js.map → chunk-GKRLPHPU.js.map} +0 -0
- /package/dist/{chunk-5NWYQUQC.js.map → chunk-HOHVIVDR.js.map} +0 -0
- /package/dist/{chunk-6WAJQKBS.js.map → chunk-JYLFZWUX.js.map} +0 -0
- /package/dist/{chunk-5W7CTZ2E.js.map → chunk-KQAFEZQX.js.map} +0 -0
- /package/dist/{chunk-UKTDXTWX.js.map → chunk-M4AYVEWS.js.map} +0 -0
- /package/dist/{chunk-YTZ3VGC7.js.map → chunk-M4V2JVSZ.js.map} +0 -0
- /package/dist/{chunk-TA3Y6GEX.js.map → chunk-MLKWJOAJ.js.map} +0 -0
- /package/dist/{chunk-DQXPJ6HD.js.map → chunk-O6YH5F47.js.map} +0 -0
- /package/dist/{chunk-53EEQYE5.js.map → chunk-UTHQPSRC.js.map} +0 -0
- /package/dist/{chunk-BB57OZWC.js.map → chunk-UVPL565C.js.map} +0 -0
- /package/dist/{chunk-6AFVIOVZ.js.map → chunk-V4GNSDFH.js.map} +0 -0
- /package/dist/{chunk-UVYI6VIX.js.map → chunk-X7Y7WX73.js.map} +0 -0
- /package/dist/{chunk-V7CMKMHM.js.map → chunk-Y4YD5TYW.js.map} +0 -0
- /package/dist/{chunk-7NS7AWMZ.js.map → chunk-YGIQORJ3.js.map} +0 -0
- /package/dist/{chunk-3MS3QVX6.js.map → chunk-YOLZS7NY.js.map} +0 -0
|
@@ -17,7 +17,7 @@ import { test } from "node:test";
|
|
|
17
17
|
import type { EngramAccessService } from "../access-service.js";
|
|
18
18
|
import { parseConfig } from "../config.js";
|
|
19
19
|
import { handleChatMessage, handleChatEventsSSE } from "./chat-http.js";
|
|
20
|
-
import { createChatSession, appendTranscriptEntry, chatSessionFile } from "./chat-session.js";
|
|
20
|
+
import { createChatSession, loadChatSession, appendTranscriptEntry, chatSessionFile } from "./chat-session.js";
|
|
21
21
|
import { DEFAULT_CHAT_CONFIG } from "./chat-config.js";
|
|
22
22
|
|
|
23
23
|
// Minimal mock res capturing writes. All tracking fields live on one
|
|
@@ -195,3 +195,121 @@ test("HTTP chat/events SSE: 403 for a session owned by another principal", async
|
|
|
195
195
|
assert.equal(m.status, 403);
|
|
196
196
|
});
|
|
197
197
|
});
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
test("HTTP chat/events SSE: pushes new transcript entries live (issue #1687)", async () => {
|
|
201
|
+
await withTempDir(async (memoryDir) => {
|
|
202
|
+
const session = await createChatSession(memoryDir, { principal: "alice" });
|
|
203
|
+
await appendTranscriptEntry(memoryDir, session.id, { role: "user", content: "first" });
|
|
204
|
+
|
|
205
|
+
const service = makeService({
|
|
206
|
+
memoryDir,
|
|
207
|
+
configRef: parseConfig({ memoryDir, chat: { ...DEFAULT_CHAT_CONFIG, enabled: true } }),
|
|
208
|
+
});
|
|
209
|
+
const m = mockRes();
|
|
210
|
+
const req = mockReq();
|
|
211
|
+
await handleChatEventsSSE(req as never, m as never, session.id, { service, config: service.configRef!.chat, memoryDir }, "alice");
|
|
212
|
+
assert.equal(m.status, 200);
|
|
213
|
+
assert.equal(m.headers["content-type"], "text/event-stream");
|
|
214
|
+
// The stream must advertise a retry directive so reconnects are safe.
|
|
215
|
+
assert.ok(m.body.includes("retry: 5000"), "SSE must emit a retry: directive");
|
|
216
|
+
|
|
217
|
+
const initialFrames = m.body.split("\n\n").filter((f) => f.startsWith("data:"));
|
|
218
|
+
|
|
219
|
+
// Append a NEW entry AFTER the client connected — it must be pushed live
|
|
220
|
+
// to the open SSE connection via the per-session pub/sub.
|
|
221
|
+
await appendTranscriptEntry(memoryDir, session.id, { role: "assistant", content: "pushed-live" });
|
|
222
|
+
|
|
223
|
+
const allFrames = m.body.split("\n\n").filter((f) => f.startsWith("data:"));
|
|
224
|
+
assert.ok(allFrames.length > initialFrames.length, "expected a pushed frame after append");
|
|
225
|
+
assert.ok(m.body.includes("pushed-live"), "the appended entry must reach the live SSE stream");
|
|
226
|
+
// The pushed frame must be well-formed SSE (data: <json>\n\n).
|
|
227
|
+
assert.ok(m.body.includes("data: {"), "frames must be data: <json> framed");
|
|
228
|
+
req.emit("close");
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("HTTP chat/message: concurrent identical requests are deduplicated (issue #1687)", async () => {
|
|
233
|
+
await withTempDir(async (memoryDir) => {
|
|
234
|
+
const session = await createChatSession(memoryDir, { principal: "alice" });
|
|
235
|
+
let llmCalls = 0;
|
|
236
|
+
const service = makeService({
|
|
237
|
+
memoryDir,
|
|
238
|
+
configRef: parseConfig({ memoryDir, chat: { ...DEFAULT_CHAT_CONFIG, enabled: true } }),
|
|
239
|
+
fallbackLlmRef: ({
|
|
240
|
+
chatCompletion: async () => {
|
|
241
|
+
llmCalls++;
|
|
242
|
+
// Simulate enough work that the second request lands while the
|
|
243
|
+
// first is still in-flight (the dedup window).
|
|
244
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
245
|
+
return { content: "shared-reply", modelUsed: "test-model" };
|
|
246
|
+
},
|
|
247
|
+
}) as never,
|
|
248
|
+
});
|
|
249
|
+
const opts = { service, config: service.configRef!.chat, memoryDir };
|
|
250
|
+
const m1 = mockRes();
|
|
251
|
+
const m2 = mockRes();
|
|
252
|
+
await Promise.all([
|
|
253
|
+
handleChatMessage(mockReq() as never, m1 as never, { message: "duplicate", chatSessionId: session.id }, opts, "alice"),
|
|
254
|
+
handleChatMessage(mockReq() as never, m2 as never, { message: "duplicate", chatSessionId: session.id }, opts, "alice"),
|
|
255
|
+
]);
|
|
256
|
+
// Exactly ONE LLM call despite two concurrent identical requests.
|
|
257
|
+
assert.equal(llmCalls, 1, "a concurrent duplicate must not re-process");
|
|
258
|
+
// Both callers receive the (same) reply.
|
|
259
|
+
assert.equal(m1.status, 200);
|
|
260
|
+
assert.equal(m2.status, 200);
|
|
261
|
+
assert.ok(m1.body.includes("shared-reply"));
|
|
262
|
+
assert.ok(m2.body.includes("shared-reply"));
|
|
263
|
+
// The transcript must contain exactly one user "duplicate" line — the
|
|
264
|
+
// duplicate did not double-append.
|
|
265
|
+
const loaded = await loadChatSession(memoryDir, session.id);
|
|
266
|
+
const userDups = loaded!.transcript.filter((e) => e.role === "user" && e.content === "duplicate");
|
|
267
|
+
assert.equal(userDups.length, 1, "duplicate request must not append a second user transcript line");
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("HTTP chat/message: distinct messages are NOT deduplicated (issue #1687)", async () => {
|
|
272
|
+
await withTempDir(async (memoryDir) => {
|
|
273
|
+
const session = await createChatSession(memoryDir, { principal: "alice" });
|
|
274
|
+
let llmCalls = 0;
|
|
275
|
+
const service = makeService({
|
|
276
|
+
memoryDir,
|
|
277
|
+
configRef: parseConfig({ memoryDir, chat: { ...DEFAULT_CHAT_CONFIG, enabled: true } }),
|
|
278
|
+
fallbackLlmRef: ({
|
|
279
|
+
chatCompletion: async () => {
|
|
280
|
+
llmCalls++;
|
|
281
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
282
|
+
return { content: "reply", modelUsed: "test-model" };
|
|
283
|
+
},
|
|
284
|
+
}) as never,
|
|
285
|
+
});
|
|
286
|
+
const opts = { service, config: service.configRef!.chat, memoryDir };
|
|
287
|
+
await Promise.all([
|
|
288
|
+
handleChatMessage(mockReq() as never, mockRes() as never, { message: "alpha", chatSessionId: session.id }, opts, "alice"),
|
|
289
|
+
handleChatMessage(mockReq() as never, mockRes() as never, { message: "beta", chatSessionId: session.id }, opts, "alice"),
|
|
290
|
+
]);
|
|
291
|
+
// Distinct messages both process independently.
|
|
292
|
+
assert.equal(llmCalls, 2, "distinct messages must not be coalesced");
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("HTTP chat/events SSE: early disconnect during load bails cleanly — no write to ended response, no leak (issue #1687 review)", async () => {
|
|
297
|
+
await withTempDir(async (memoryDir) => {
|
|
298
|
+
const session = await createChatSession(memoryDir, { principal: "alice" });
|
|
299
|
+
const service = makeService({
|
|
300
|
+
memoryDir,
|
|
301
|
+
configRef: parseConfig({ memoryDir, chat: { ...DEFAULT_CHAT_CONFIG, enabled: true } }),
|
|
302
|
+
});
|
|
303
|
+
const m = mockRes();
|
|
304
|
+
const req = mockReq();
|
|
305
|
+
// Emit close as a microtask so it fires while the handler is awaiting
|
|
306
|
+
// loadChatSession (before writeHead). The handler must bail out without
|
|
307
|
+
// writing to the ended response and without setting up a heartbeat.
|
|
308
|
+
queueMicrotask(() => req.emit("close"));
|
|
309
|
+
// Resolving without throwing is the core contract.
|
|
310
|
+
await handleChatEventsSSE(req as never, m as never, session.id, { service, config: service.configRef!.chat, memoryDir }, "alice");
|
|
311
|
+
// writeHead was skipped (status stays 0) because the handler returned at
|
|
312
|
+
// the `if (closed) return` guard before any SSE framing.
|
|
313
|
+
assert.equal(m.status, 0, "handler must not writeHead after an early close");
|
|
314
|
+
});
|
|
315
|
+
});
|
package/src/chat/chat-http.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Chat HTTP handler (issue #1583 PR 3).
|
|
2
|
+
* Chat HTTP handler (issue #1583 PR 3; deferred items #1685 / #1687).
|
|
3
3
|
*
|
|
4
4
|
* Implements the HTTP backend for the admin console Chat pane:
|
|
5
5
|
* - POST /engram/v1/chat/message — send a message, get a reply
|
|
@@ -9,41 +9,133 @@
|
|
|
9
9
|
* Per-session isolation: every request verifies the session's principal
|
|
10
10
|
* matches the caller (rule 42/47).
|
|
11
11
|
*
|
|
12
|
+
* Deferred items wired here (issues #1685 / #1687):
|
|
13
|
+
* - The HTTP message handler delegates to the shared `processChatMessage`
|
|
14
|
+
* (Thread 18) so session/engine/transcript construction is identical to
|
|
15
|
+
* the MCP surface, instead of re-implementing it.
|
|
16
|
+
* - Concurrent identical POSTs are coalesced by an in-flight dedup keyed on
|
|
17
|
+
* `memoryDir + chatSessionId + principal + message` so a double-submit
|
|
18
|
+
* processes exactly once (issue #1687 Thread 18 / #1685). The memoryDir
|
|
19
|
+
* scopes the key per store (AGENTS.md State Scoping); an absent principal
|
|
20
|
+
* is encoded distinctly so it can never alias a real principal string.
|
|
21
|
+
* - The SSE stream subscribes to live transcript appends and pushes new
|
|
22
|
+
* entries to connected clients (issue #1685 item 2 / #1687 Thread 8).
|
|
23
|
+
* Subscription is established before the transcript snapshot is read so no
|
|
24
|
+
* concurrent append is missed (cursor Bugbot race fix); the burst drains
|
|
25
|
+
* the buffer only after switching to live mode so no buffered entry is
|
|
26
|
+
* dropped. The disconnect cleanup is registered immediately and guarded
|
|
27
|
+
* by a `closed` flag so an early close cannot leak the heartbeat or write
|
|
28
|
+
* to an ended response.
|
|
29
|
+
*
|
|
12
30
|
* This module is imported by access-http.ts with thin route registration —
|
|
13
31
|
* the god-file ratchet (#1520) tracks access-http.ts LOC.
|
|
14
32
|
*/
|
|
15
33
|
|
|
16
34
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
35
|
+
import { createHash } from "node:crypto";
|
|
17
36
|
|
|
18
37
|
import type { EngramAccessService } from "../access-service.js";
|
|
19
|
-
import type { ChatConfig } from "./chat-types.js";
|
|
20
|
-
import
|
|
21
|
-
import { ChatEngine } from "./chat-engine.js";
|
|
22
|
-
import { isConfirmationMessage } from "./chat-engine.js";
|
|
23
|
-
import { createProductionChatLlmAdapter } from "./chat-llm.js";
|
|
24
|
-
import { createChatExecutor } from "./chat-executor.js";
|
|
38
|
+
import type { ChatConfig, ChatTranscriptEntry, ChatTurnResult } from "./chat-types.js";
|
|
39
|
+
import { processChatMessage } from "./chat-factory.js";
|
|
25
40
|
import {
|
|
26
|
-
createChatSession,
|
|
27
41
|
loadChatSession,
|
|
28
|
-
appendTranscriptEntry,
|
|
29
42
|
sessionBelongsToPrincipal,
|
|
30
|
-
|
|
31
|
-
markPlanResolved,
|
|
32
|
-
markPendingPromotion,
|
|
33
|
-
markPromotionResolved,
|
|
43
|
+
subscribeChatTranscript,
|
|
34
44
|
} from "./chat-session.js";
|
|
35
45
|
|
|
36
46
|
export interface ChatHttpHandlerOptions {
|
|
37
47
|
service: EngramAccessService;
|
|
38
48
|
config: ChatConfig | undefined;
|
|
39
49
|
memoryDir: string;
|
|
50
|
+
/**
|
|
51
|
+
* Optional hook the host server supplies so the SSE handler can register
|
|
52
|
+
* its disconnect cleanup with the server's stop() set (cursor Medium
|
|
53
|
+
* review). Returns an unregister function the handler calls on normal
|
|
54
|
+
* client disconnect. When omitted, cleanup runs only on req 'close'.
|
|
55
|
+
*/
|
|
56
|
+
registerSseCleanup?: (cleanup: () => void) => () => void;
|
|
40
57
|
}
|
|
41
58
|
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// In-flight request dedup (issue #1687 Thread 18 / #1685)
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Pending chat-message promises keyed by
|
|
65
|
+
* `memoryDir:chatSessionId:principal:messageHash`. A concurrent identical
|
|
66
|
+
* POST joins the in-flight promise instead of re-processing (no second LLM
|
|
67
|
+
* call, no second transcript append). Entries are always removed via
|
|
68
|
+
* `finally` so the map cannot leak across requests.
|
|
69
|
+
*
|
|
70
|
+
* The memoryDir is part of the key so two Remnic stores in one process
|
|
71
|
+
* cannot share a promise (AGENTS.md State Scoping). Only requests targeting
|
|
72
|
+
* an existing `chatSessionId` are coalesced: when no session id is supplied
|
|
73
|
+
* each request legitimately mints its own session, so identical message
|
|
74
|
+
* bodies are genuinely distinct turns.
|
|
75
|
+
*/
|
|
76
|
+
const chatInFlight = new Map<string, Promise<ChatTurnResult>>();
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Encode the principal into a fingerprint component where an absent principal
|
|
80
|
+
* occupies a distinct namespace from any real principal string (control-byte
|
|
81
|
+
* prefix), so a no-principal request can never coalesce with a request whose
|
|
82
|
+
* principal is literally `"_"` (or any other string) and reuse its authorized
|
|
83
|
+
* promise (codex P2 review thread).
|
|
84
|
+
*/
|
|
85
|
+
function principalFingerprintComponent(principal: string | undefined): string {
|
|
86
|
+
return principal === undefined ? "\x00absent" : `\x01${principal}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function chatDedupFingerprint(memoryDir: string, chatSessionId: string, principal: string | undefined, message: string): string {
|
|
90
|
+
// sha256 keeps the key bounded for long messages; the principal component
|
|
91
|
+
// is distinct per principal (incl. absent) so two callers targeting the
|
|
92
|
+
// same session id can never share a result — the wrong principal still
|
|
93
|
+
// independently hits access_denied inside processChatMessage.
|
|
94
|
+
const messageHash = createHash("sha256").update(message, "utf8").digest("hex").slice(0, 32);
|
|
95
|
+
return `${memoryDir}:${chatSessionId}:${principalFingerprintComponent(principal)}:${messageHash}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Run `processChatMessage` with concurrent-identical-request coalescing.
|
|
100
|
+
* Distinct messages (or distinct sessions / principals / stores) never collide.
|
|
101
|
+
*/
|
|
102
|
+
function processChatMessageDedup(opts: {
|
|
103
|
+
service: EngramAccessService;
|
|
104
|
+
config: ChatConfig | undefined;
|
|
105
|
+
memoryDir: string;
|
|
106
|
+
message: string;
|
|
107
|
+
chatSessionId?: string;
|
|
108
|
+
principal?: string;
|
|
109
|
+
}): Promise<ChatTurnResult> {
|
|
110
|
+
// No session id → no coalescing (each request creates its own session).
|
|
111
|
+
if (!opts.chatSessionId) {
|
|
112
|
+
return processChatMessage(opts);
|
|
113
|
+
}
|
|
114
|
+
const fp = chatDedupFingerprint(opts.memoryDir, opts.chatSessionId, opts.principal, opts.message);
|
|
115
|
+
const existing = chatInFlight.get(fp);
|
|
116
|
+
if (existing) return existing;
|
|
117
|
+
const pending = processChatMessage(opts).finally(() => {
|
|
118
|
+
chatInFlight.delete(fp);
|
|
119
|
+
});
|
|
120
|
+
chatInFlight.set(fp, pending);
|
|
121
|
+
return pending;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// POST /engram/v1/chat/message
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
|
|
42
128
|
/**
|
|
43
129
|
* Handle POST /engram/v1/chat/message.
|
|
44
130
|
*
|
|
45
131
|
* Body: `{ message: string, chatSessionId?: string }`
|
|
46
132
|
* Response: `{ reply: string, chatSessionId: string, pendingPlan?: {...} }`
|
|
133
|
+
*
|
|
134
|
+
* Boundary checks (chat-disabled / missing message / no-LLM) run before
|
|
135
|
+
* delegation so each maps to a precise HTTP status without spinning up a
|
|
136
|
+
* session. Session/engine work is delegated to `processChatMessage`
|
|
137
|
+
* (Thread 18), wrapped in {@link processChatMessageDedup} for double-submit
|
|
138
|
+
* protection.
|
|
47
139
|
*/
|
|
48
140
|
export async function handleChatMessage(
|
|
49
141
|
req: IncomingMessage,
|
|
@@ -63,8 +155,7 @@ export async function handleChatMessage(
|
|
|
63
155
|
return;
|
|
64
156
|
}
|
|
65
157
|
|
|
66
|
-
|
|
67
|
-
if (!llm) {
|
|
158
|
+
if (!opts.service.fallbackLlmRef && !opts.service.localLlmRef) {
|
|
68
159
|
respondJson(res, 503, {
|
|
69
160
|
error: "No LLM model is available. Configure a local or cloud model.",
|
|
70
161
|
code: "no_llm",
|
|
@@ -72,74 +163,42 @@ export async function handleChatMessage(
|
|
|
72
163
|
return;
|
|
73
164
|
}
|
|
74
165
|
|
|
75
|
-
const
|
|
76
|
-
chatCompletion(
|
|
77
|
-
messages: Array<{ role: string; content: string }>,
|
|
78
|
-
options?: { model?: string; signal?: AbortSignal },
|
|
79
|
-
): Promise<{ content: string } | null>;
|
|
80
|
-
});
|
|
166
|
+
const chatSessionId = typeof body.chatSessionId === "string" ? body.chatSessionId : undefined;
|
|
81
167
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
168
|
+
let result: ChatTurnResult;
|
|
169
|
+
try {
|
|
170
|
+
result = await processChatMessageDedup({
|
|
171
|
+
service: opts.service,
|
|
172
|
+
config: opts.config,
|
|
173
|
+
memoryDir: opts.memoryDir,
|
|
174
|
+
message,
|
|
175
|
+
...(chatSessionId ? { chatSessionId } : {}),
|
|
176
|
+
...(principal ? { principal } : {}),
|
|
177
|
+
});
|
|
178
|
+
} catch (err) {
|
|
179
|
+
// processChatMessage throws tagged errors for the session/LLM/engine
|
|
180
|
+
// paths; map each back to its HTTP status. Dedup coalescing means a
|
|
181
|
+
// concurrent duplicate shares the same rejection. A 'chat_session_expired'
|
|
182
|
+
// means the session was swept by the TTL cleanup mid-turn (codex P2) —
|
|
183
|
+
// tell the client to start a fresh session (410 Gone).
|
|
184
|
+
const code = err instanceof Error ? err.message : String(err);
|
|
185
|
+
if (code === "chat_session_not_found") {
|
|
88
186
|
respondJson(res, 404, { error: "chat_session_not_found", code: "chat_session_not_found" });
|
|
89
|
-
|
|
90
|
-
}
|
|
91
|
-
if (!sessionBelongsToPrincipal(session, principal)) {
|
|
187
|
+
} else if (code === "access_denied") {
|
|
92
188
|
respondJson(res, 403, { error: "access_denied", code: "access_denied" });
|
|
93
|
-
|
|
189
|
+
} else if (code === "chat_session_expired") {
|
|
190
|
+
respondJson(res, 410, { error: "chat_session_expired", code: "chat_session_expired" });
|
|
191
|
+
} else if (code === "chat_disabled") {
|
|
192
|
+
respondJson(res, 404, { error: "chat_disabled", code: "chat_disabled" });
|
|
193
|
+
} else if (code === "no_llm_available" || code === "engine_unavailable") {
|
|
194
|
+
respondJson(res, 503, {
|
|
195
|
+
error: "No LLM model is available. Configure a local or cloud model.",
|
|
196
|
+
code: "no_llm",
|
|
197
|
+
});
|
|
198
|
+
} else {
|
|
199
|
+
respondJson(res, 500, { error: "internal_error", code: "internal_error" });
|
|
94
200
|
}
|
|
95
|
-
|
|
96
|
-
session = await createChatSession(opts.memoryDir, { principal });
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
const executor = createChatExecutor({
|
|
100
|
-
service: opts.service,
|
|
101
|
-
principal,
|
|
102
|
-
...(session.namespace ? { namespace: session.namespace } : {}),
|
|
103
|
-
...(session.sessionKey ? { sessionKey: session.sessionKey } : {}),
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
const engine = new ChatEngine({
|
|
107
|
-
llm: adapter,
|
|
108
|
-
executor,
|
|
109
|
-
maxToolCallsPerTurn: opts.config.maxToolCallsPerTurn,
|
|
110
|
-
...(opts.config.model ? { model: opts.config.model } : {}),
|
|
111
|
-
correctionAvailable: false,
|
|
112
|
-
scopeInspectAvailable: false,
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
await appendTranscriptEntry(opts.memoryDir, session.id, {
|
|
116
|
-
role: "user",
|
|
117
|
-
content: message,
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
// Snapshot pending state BEFORE the turn so we only persist a marker when
|
|
121
|
-
// state actually changed (Thread 15 — a normal turn must not clear an
|
|
122
|
-
// active pending plan/promotion from an earlier turn).
|
|
123
|
-
const priorPendingPlanId = session.pendingPlanId;
|
|
124
|
-
const priorPendingPromotionId = session.pendingPromotionId;
|
|
125
|
-
|
|
126
|
-
const result: ChatTurnResult = await engine.processMessage(message, session);
|
|
127
|
-
|
|
128
|
-
await appendTranscriptEntry(opts.memoryDir, session.id, {
|
|
129
|
-
role: "assistant",
|
|
130
|
-
content: result.reply,
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
// Persist pending-plan/promotion state (append-only markers). Only append
|
|
134
|
-
// when state actually changed.
|
|
135
|
-
if (result.pendingPlan?.planId) {
|
|
136
|
-
await markPendingPlan(opts.memoryDir, session.id, result.pendingPlan.planId);
|
|
137
|
-
} else if (session.pendingPromotionId && !priorPendingPromotionId) {
|
|
138
|
-
await markPendingPromotion(opts.memoryDir, session.id, session.pendingPromotionId);
|
|
139
|
-
} else if (priorPendingPlanId && !session.pendingPlanId && !result.error) {
|
|
140
|
-
await markPlanResolved(opts.memoryDir, session.id, priorPendingPlanId);
|
|
141
|
-
} else if (priorPendingPromotionId && !session.pendingPromotionId && !result.error) {
|
|
142
|
-
await markPromotionResolved(opts.memoryDir, session.id, priorPendingPromotionId);
|
|
201
|
+
return;
|
|
143
202
|
}
|
|
144
203
|
|
|
145
204
|
// Strip internal error details from the wire response (CodeQL — no stack
|
|
@@ -150,12 +209,30 @@ export async function handleChatMessage(
|
|
|
150
209
|
respondJson(res, 200, wireResult);
|
|
151
210
|
}
|
|
152
211
|
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
// GET /engram/v1/chat/events/:chatSessionId (SSE)
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
|
|
153
216
|
/**
|
|
154
217
|
* Handle GET /engram/v1/chat/events/:chatSessionId.
|
|
155
218
|
*
|
|
156
|
-
* SSE stream. Sends
|
|
157
|
-
*
|
|
158
|
-
*
|
|
219
|
+
* SSE stream. Sends the existing transcript as an initial burst, then keeps
|
|
220
|
+
* the connection open and **pushes new transcript entries live** as they are
|
|
221
|
+
* appended (issue #1685 item 2 / #1687 Thread 8) via the per-session pub/sub
|
|
222
|
+
* in `chat-session.ts`. A heartbeat is emitted every 25 s so proxies do not
|
|
223
|
+
* time out. A `retry:` directive asks reconnecting clients to wait 5 s, and
|
|
224
|
+
* because the initial burst always replays the full transcript (entries carry
|
|
225
|
+
* a strictly-monotonic `seq`), clients dedupe on reconnect — the stream is
|
|
226
|
+
* reconnect-safe.
|
|
227
|
+
*
|
|
228
|
+
* Race-free ordering (cursor Bugbot): the subscription is established BEFORE
|
|
229
|
+
* the transcript snapshot is read; entries appended concurrently during the
|
|
230
|
+
* snapshot read are buffered, then the buffer is drained AFTER switching to
|
|
231
|
+
* live mode (so an append landing between the drain and the mode switch is
|
|
232
|
+
* never dropped — cursor review). Every write goes through a `closed`-guarded
|
|
233
|
+
* helper so a disconnect during the burst cannot write to an ended response
|
|
234
|
+
* (cursor/kilo review). The disconnect cleanup is registered immediately and
|
|
235
|
+
* guarded by `closed` so an early close cannot leak the heartbeat.
|
|
159
236
|
*/
|
|
160
237
|
export async function handleChatEventsSSE(
|
|
161
238
|
req: IncomingMessage,
|
|
@@ -169,12 +246,74 @@ export async function handleChatEventsSSE(
|
|
|
169
246
|
return;
|
|
170
247
|
}
|
|
171
248
|
|
|
249
|
+
// Liveness flag + handle, both closed over by the disconnect cleanup. The
|
|
250
|
+
// cleanup is idempotent (guarded by `closed`) so a repeat close is harmless.
|
|
251
|
+
let heartbeat: NodeJS.Timeout | undefined;
|
|
252
|
+
let closed = false;
|
|
253
|
+
// Every SSE write goes through this helper so a closed connection is never
|
|
254
|
+
// written to (cursor Low review). Returns false when the write was skipped.
|
|
255
|
+
const writeSse = (chunk: string): boolean => {
|
|
256
|
+
if (closed) return false;
|
|
257
|
+
try {
|
|
258
|
+
res.write(chunk);
|
|
259
|
+
return true;
|
|
260
|
+
} catch {
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
// Subscribe BEFORE reading the transcript so a concurrent append between
|
|
266
|
+
// the snapshot read and subscription is never missed (cursor Bugbot race).
|
|
267
|
+
// During the initial burst entries are buffered; after the burst the
|
|
268
|
+
// listener switches to writing straight to the response. A `closed` check
|
|
269
|
+
// short-circuits pushes to a connection that is already gone.
|
|
270
|
+
const pending: ChatTranscriptEntry[] = [];
|
|
271
|
+
let bursting = true;
|
|
272
|
+
const unsubscribe = subscribeChatTranscript(opts.memoryDir, chatSessionId, (entry) => {
|
|
273
|
+
if (closed) return;
|
|
274
|
+
if (bursting) {
|
|
275
|
+
pending.push(entry);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
writeSse(`data: ${JSON.stringify(entry)}\n\n`);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// Register disconnect cleanup IMMEDIATELY so a close during loadChatSession
|
|
282
|
+
// or the burst still releases the subscription (cursor Bugbot). The flag
|
|
283
|
+
// gates every later write so the handler never touches an ended response
|
|
284
|
+
// (kilo review thread) and never sets up a heartbeat for a dead socket
|
|
285
|
+
// (cursor review thread).
|
|
286
|
+
const doCleanup = (): void => {
|
|
287
|
+
closed = true;
|
|
288
|
+
clearInterval(heartbeat);
|
|
289
|
+
unsubscribe();
|
|
290
|
+
try { res.end(); } catch { /* already ended */ }
|
|
291
|
+
};
|
|
292
|
+
// Register with the host server's stop() cleanup set (cursor Medium) so an
|
|
293
|
+
// HTTP shutdown forcibly releases the subscription + heartbeat even when a
|
|
294
|
+
// lingering client never emits 'close'. Unregistered again on normal close.
|
|
295
|
+
const unregisterServerCleanup = opts.registerSseCleanup?.(doCleanup) ?? ((): void => {});
|
|
296
|
+
req.on("close", () => {
|
|
297
|
+
doCleanup();
|
|
298
|
+
unregisterServerCleanup();
|
|
299
|
+
});
|
|
300
|
+
|
|
172
301
|
const session = await loadChatSession(opts.memoryDir, chatSessionId);
|
|
302
|
+
// If the client disconnected during the load, cleanup already ran — bail
|
|
303
|
+
// out before touching the response (prevents "headers after end").
|
|
304
|
+
if (closed) return;
|
|
173
305
|
if (!session) {
|
|
306
|
+
// No SSE stream is established — release the server stop() registration
|
|
307
|
+
// alongside the subscription so sseCleanupFns does not retain a stale
|
|
308
|
+
// callback for this JSON error response (cursor Medium: error-path leak).
|
|
309
|
+
unsubscribe();
|
|
310
|
+
unregisterServerCleanup();
|
|
174
311
|
respondJson(res, 404, { error: "chat_session_not_found", code: "chat_session_not_found" });
|
|
175
312
|
return;
|
|
176
313
|
}
|
|
177
314
|
if (!sessionBelongsToPrincipal(session, principal)) {
|
|
315
|
+
unsubscribe();
|
|
316
|
+
unregisterServerCleanup();
|
|
178
317
|
respondJson(res, 403, { error: "access_denied", code: "access_denied" });
|
|
179
318
|
return;
|
|
180
319
|
}
|
|
@@ -186,26 +325,42 @@ export async function handleChatEventsSSE(
|
|
|
186
325
|
connection: "keep-alive",
|
|
187
326
|
});
|
|
188
327
|
|
|
189
|
-
//
|
|
328
|
+
// Ask reconnecting clients to wait 5 s before retrying (reconnect-safe:
|
|
329
|
+
// the initial burst below replays the full transcript on rejoin).
|
|
330
|
+
writeSse("retry: 5000\n\n");
|
|
331
|
+
|
|
332
|
+
// Send the transcript snapshot as the initial burst, recording delivered
|
|
333
|
+
// seqs so buffered concurrent appends are not double-delivered.
|
|
334
|
+
const deliveredSeqs = new Set<number>();
|
|
190
335
|
for (const entry of session.transcript) {
|
|
191
|
-
|
|
336
|
+
writeSse(`data: ${JSON.stringify(entry)}\n\n`);
|
|
337
|
+
deliveredSeqs.add(entry.seq);
|
|
192
338
|
}
|
|
193
339
|
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
340
|
+
// Switch to live push FIRST, THEN drain buffered appends. Ordering matters:
|
|
341
|
+
// if an append lands between the drain and the mode switch it would be
|
|
342
|
+
// buffered (bursting still true) and never re-drained (cursor Medium). With
|
|
343
|
+
// live mode on, appends during the drain write straight through and the
|
|
344
|
+
// buffer is emptied once.
|
|
345
|
+
bursting = false;
|
|
346
|
+
for (const entry of pending) {
|
|
347
|
+
if (!deliveredSeqs.has(entry.seq)) {
|
|
348
|
+
writeSse(`data: ${JSON.stringify(entry)}\n\n`);
|
|
201
349
|
}
|
|
202
|
-
}
|
|
203
|
-
heartbeat.unref();
|
|
350
|
+
}
|
|
204
351
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
352
|
+
// Heartbeat. Unref'd so a lingering connection never blocks process
|
|
353
|
+
// exit (rule 47 — no shared mutable objects keep the loop alive). Cleared
|
|
354
|
+
// by the close handler above when the client disconnects. Guard against
|
|
355
|
+
// arming after a disconnect during the burst/drain: the close handler
|
|
356
|
+
// already ran doCleanup (clearing any prior handle and unregistering the
|
|
357
|
+
// server callback), so an interval armed here would be untracked and never
|
|
358
|
+
// cleared — bail out instead (codex P2).
|
|
359
|
+
if (closed) return;
|
|
360
|
+
heartbeat = setInterval(() => {
|
|
361
|
+
writeSse(`data: ${JSON.stringify({ type: "heartbeat" })}\n\n`);
|
|
362
|
+
}, 25_000);
|
|
363
|
+
heartbeat.unref?.();
|
|
209
364
|
}
|
|
210
365
|
|
|
211
366
|
// ---------------------------------------------------------------------------
|