@remnic/core 9.3.726 → 9.3.727
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 +20 -20
- package/dist/access-cli.js +42 -42
- package/dist/access-http.d.ts +20 -5
- package/dist/access-http.js +25 -25
- package/dist/access-mcp.d.ts +5 -5
- package/dist/access-mcp.js +24 -24
- package/dist/access-operations-batch.js +21 -21
- package/dist/access-operations.d.ts +5 -5
- package/dist/access-operations.js +23 -23
- 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 +19 -19
- 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 +1 -1
- 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-Q6D75LVH.js → chunk-27QQC7ZX.js} +75 -14
- package/dist/{chunk-Q6D75LVH.js.map → chunk-27QQC7ZX.js.map} +1 -1
- package/dist/{chunk-53EEQYE5.js → chunk-2KAYTPPT.js} +7 -7
- 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-RRHHQYJS.js → chunk-3TSTGI6F.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-TQQH2FTX.js → chunk-6PY5YJAV.js} +101 -15
- package/dist/{chunk-TQQH2FTX.js.map → chunk-6PY5YJAV.js.map} +1 -1
- 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-K4ZNARDO.js → chunk-CBWI22JN.js} +2 -2
- package/dist/{chunk-ZOHMPFOB.js → chunk-CKFLLAIS.js} +3 -3
- package/dist/{chunk-DCKNMLEQ.js → chunk-CSRXQ6NB.js} +2 -2
- package/dist/{chunk-6AFVIOVZ.js → chunk-CTOQEZSN.js} +2 -2
- package/dist/{chunk-7NS7AWMZ.js → chunk-DKJCRGWM.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-5NWYQUQC.js → chunk-HOHVIVDR.js} +2 -2
- package/dist/{chunk-6WAJQKBS.js → chunk-JYLFZWUX.js} +2 -2
- package/dist/{chunk-5W7CTZ2E.js → chunk-KQAFEZQX.js} +4 -4
- package/dist/{chunk-YTZ3VGC7.js → chunk-M4V2JVSZ.js} +2 -2
- package/dist/{chunk-PLG6JHQS.js → chunk-MJIHJ7OG.js} +38 -38
- package/dist/{chunk-TA3Y6GEX.js → chunk-MLKWJOAJ.js} +2 -2
- package/dist/{chunk-2NBP25BG.js → chunk-N3VNUM4Z.js} +171 -79
- package/dist/chunk-N3VNUM4Z.js.map +1 -0
- package/dist/{chunk-UKTDXTWX.js → chunk-NSA5ZRUL.js} +6 -6
- package/dist/{chunk-DQXPJ6HD.js → chunk-OEEW4KJA.js} +4 -4
- package/dist/{chunk-7AG54AF3.js → chunk-PUR2CKX2.js} +1 -1
- package/dist/chunk-PUR2CKX2.js.map +1 -0
- package/dist/{chunk-5OOA7SGX.js → chunk-R7IYU5DZ.js} +2 -2
- package/dist/{chunk-WGTWGDR5.js → chunk-SNKPUJHR.js} +5 -5
- package/dist/{chunk-BB57OZWC.js → chunk-UVPL565C.js} +2 -2
- package/dist/{chunk-UVYI6VIX.js → chunk-X7Y7WX73.js} +1 -1
- package/dist/{chunk-OAHOL3ZC.js → chunk-XPHVAOBT.js} +111 -30
- package/dist/chunk-XPHVAOBT.js.map +1 -0
- package/dist/{chunk-V7CMKMHM.js → chunk-Y4YD5TYW.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 +47 -47
- 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/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 +2 -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/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 +61 -61
- 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 +12 -12
- package/dist/namespaces/principal.d.ts +1 -1
- package/dist/namespaces/search.d.ts +1 -1
- package/dist/namespaces/search.js +4 -4
- 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 +18 -18
- 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 +30 -30
- 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/schemas.d.ts +22 -22
- package/dist/search/embed-helper.d.ts +1 -1
- package/dist/search/factory.d.ts +1 -1
- package/dist/search/factory.js +3 -3
- package/dist/search/index.d.ts +1 -1
- package/dist/search/index.js +7 -7
- 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/transfer/types.d.ts +12 -12
- 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/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/chat/chat-engine.test.ts +66 -1
- package/src/chat/chat-http.test.ts +119 -1
- package/src/chat/chat-http.ts +252 -97
- package/src/chat/chat-session.ts +143 -7
- 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/extraction-faithfulness.test.ts +240 -0
- package/src/extraction-faithfulness.ts +58 -6
- package/src/faithfulness-config.ts +16 -1
- package/src/local-model-endpoint.ts +24 -3
- package/src/orchestrator.ts +200 -8
- package/src/provenance-extraction.test.ts +266 -0
- package/src/provenance.ts +66 -16
- 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-MDBZTH7N.js.map +0 -1
- package/dist/chunk-OAHOL3ZC.js.map +0 -1
- package/dist/chunk-XGTBNVIU.js.map +0 -1
- /package/dist/{capsule-crypto-GWVG7LGC.js.map → capsule-crypto-7FJQINUR.js.map} +0 -0
- /package/dist/{chunk-53EEQYE5.js.map → chunk-2KAYTPPT.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-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-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-DCKNMLEQ.js.map → chunk-CSRXQ6NB.js.map} +0 -0
- /package/dist/{chunk-6AFVIOVZ.js.map → chunk-CTOQEZSN.js.map} +0 -0
- /package/dist/{chunk-7NS7AWMZ.js.map → chunk-DKJCRGWM.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-YTZ3VGC7.js.map → chunk-M4V2JVSZ.js.map} +0 -0
- /package/dist/{chunk-PLG6JHQS.js.map → chunk-MJIHJ7OG.js.map} +0 -0
- /package/dist/{chunk-TA3Y6GEX.js.map → chunk-MLKWJOAJ.js.map} +0 -0
- /package/dist/{chunk-UKTDXTWX.js.map → chunk-NSA5ZRUL.js.map} +0 -0
- /package/dist/{chunk-DQXPJ6HD.js.map → chunk-OEEW4KJA.js.map} +0 -0
- /package/dist/{chunk-5OOA7SGX.js.map → chunk-R7IYU5DZ.js.map} +0 -0
- /package/dist/{chunk-WGTWGDR5.js.map → chunk-SNKPUJHR.js.map} +0 -0
- /package/dist/{chunk-BB57OZWC.js.map → chunk-UVPL565C.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-3MS3QVX6.js.map → chunk-YOLZS7NY.js.map} +0 -0
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
|
// ---------------------------------------------------------------------------
|
package/src/chat/chat-session.ts
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* *about* memories does not create memories of the conversation itself.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { mkdir, readFile, appendFile, readdir, stat, unlink } from "node:fs/promises";
|
|
10
|
+
import { mkdir, readFile, appendFile, readdir, stat, unlink, open } from "node:fs/promises";
|
|
11
|
+
import { constants as fsConstants } from "node:fs";
|
|
11
12
|
import { join } from "node:path";
|
|
12
13
|
import { randomUUID } from "node:crypto";
|
|
13
14
|
|
|
@@ -40,6 +41,26 @@ export function chatSessionFile(memoryDir: string, chatSessionId: string): strin
|
|
|
40
41
|
return join(chatSessionDir(memoryDir), `${chatSessionId}.jsonl`);
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Monotonic transcript seq (issue #1687 review — strictly increasing)
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Last emitted transcript `seq`. Seeded from `Date.now()` on first use and
|
|
50
|
+
* incremented when two appends land in the same millisecond, so `seq` is
|
|
51
|
+
* strictly increasing within a process (review thread: `Date.now()` alone is
|
|
52
|
+
* not monotonic — same-ms entries would share a seq and break the SSE
|
|
53
|
+
* reconnect dedup-by-seq contract). Across process restarts `Date.now()` is
|
|
54
|
+
* already forward-moving, so no collision occurs in practice.
|
|
55
|
+
*/
|
|
56
|
+
let lastTranscriptSeq = 0;
|
|
57
|
+
|
|
58
|
+
function nextTranscriptSeq(): number {
|
|
59
|
+
const now = Date.now();
|
|
60
|
+
lastTranscriptSeq = now > lastTranscriptSeq ? now : lastTranscriptSeq + 1;
|
|
61
|
+
return lastTranscriptSeq;
|
|
62
|
+
}
|
|
63
|
+
|
|
43
64
|
/**
|
|
44
65
|
* Create a new chat session. The session binds the caller's principal and
|
|
45
66
|
* sessionKey at creation (rule 42); every tool call flows through
|
|
@@ -169,25 +190,130 @@ export async function loadChatSession(
|
|
|
169
190
|
|
|
170
191
|
/**
|
|
171
192
|
* Append a transcript entry to the session file (atomic append, rule 54).
|
|
193
|
+
*
|
|
194
|
+
* After the append resolves, in-process subscribers (open SSE connections —
|
|
195
|
+
* issue #1687) are notified synchronously so live clients receive the new
|
|
196
|
+
* entry without polling. Listener errors are swallowed so a misbehaving
|
|
197
|
+
* subscriber can never break a transcript append (rule 54 — atomic + safe).
|
|
172
198
|
*/
|
|
173
199
|
export async function appendTranscriptEntry(
|
|
174
200
|
memoryDir: string,
|
|
175
201
|
chatSessionId: string,
|
|
176
202
|
entry: Omit<ChatTranscriptEntry, "seq" | "ts">,
|
|
177
203
|
): Promise<ChatTranscriptEntry> {
|
|
204
|
+
const filePath = chatSessionFile(memoryDir, chatSessionId);
|
|
178
205
|
const full: ChatTranscriptEntry = {
|
|
179
206
|
...entry,
|
|
180
|
-
seq:
|
|
207
|
+
seq: nextTranscriptSeq(),
|
|
181
208
|
ts: new Date().toISOString(),
|
|
182
209
|
};
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
210
|
+
// Atomic resurrection guard (codex P2): open with O_APPEND but WITHOUT
|
|
211
|
+
// O_CREAT so a session already unlinked by a concurrent TTL sweep fails at
|
|
212
|
+
// open-time (ENOENT) — there is no stat->appendFile TOCTOU. A headerless
|
|
213
|
+
// recreation would load with no principal binding and be treated as public
|
|
214
|
+
// by sessionBelongsToPrincipal. A concurrent unlink during the append is
|
|
215
|
+
// caught by the post-append re-stat below so the turn fails visibly rather
|
|
216
|
+
// than writing to an orphaned inode and returning 200.
|
|
217
|
+
let fh;
|
|
218
|
+
try {
|
|
219
|
+
fh = await open(filePath, fsConstants.O_APPEND | fsConstants.O_WRONLY);
|
|
220
|
+
} catch (err) {
|
|
221
|
+
// Only ENOENT (session unlinked by a concurrent TTL sweep) maps to
|
|
222
|
+
// chat_session_expired; real fs errors (EACCES, EMFILE, EISDIR, ...)
|
|
223
|
+
// are rethrown so the caller surfaces the actual failure (codex P2).
|
|
224
|
+
if (err instanceof Error && (err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
225
|
+
throw new Error("chat_session_expired");
|
|
226
|
+
}
|
|
227
|
+
throw err;
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
await fh.appendFile(JSON.stringify(full) + "\n", "utf8");
|
|
231
|
+
} finally {
|
|
232
|
+
await fh.close();
|
|
233
|
+
}
|
|
234
|
+
// Post-append re-check (codex P2): if a concurrent TTL sweep unlinked the
|
|
235
|
+
// session between open() and here, the append landed on the orphaned inode
|
|
236
|
+
// and the directory no longer references it. Re-stat the PATH (not the fd)
|
|
237
|
+
// so the turn fails with chat_session_expired instead of returning 200 with
|
|
238
|
+
// a reply the client cannot resume. A later sweep unlinking an over-TTL
|
|
239
|
+
// session is a legitimate TTL action, not a silent loss.
|
|
240
|
+
try {
|
|
241
|
+
await stat(filePath);
|
|
242
|
+
} catch (err) {
|
|
243
|
+
// Only ENOENT (concurrent TTL unlink) is session expiry; other fs
|
|
244
|
+
// errors (EACCES, EMFILE, ENOTDIR, ...) are rethrown (codex/kilo P2).
|
|
245
|
+
if (err instanceof Error && (err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
246
|
+
throw new Error("chat_session_expired");
|
|
247
|
+
}
|
|
248
|
+
throw err;
|
|
249
|
+
}
|
|
250
|
+
notifyTranscriptListeners(memoryDir, chatSessionId, full);
|
|
188
251
|
return full;
|
|
189
252
|
}
|
|
190
253
|
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
// In-process transcript pub/sub (issue #1687 SSE push)
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Per-session listener sets, keyed by `memoryDir + chatSessionId` so two
|
|
260
|
+
* memory stores sharing a legacy/imported session id cannot leak transcript
|
|
261
|
+
* entries to each other (AGENTS.md State Scoping — module-level singletons
|
|
262
|
+
* are scoped per store). Kept module-level so appendTranscriptEntry can fan
|
|
263
|
+
* out new entries to open SSE connections regardless of which caller
|
|
264
|
+
* appended (HTTP, MCP, or CLI). Empty sets are pruned so the map never
|
|
265
|
+
* grows unbounded (rule 47 — no leak).
|
|
266
|
+
*/
|
|
267
|
+
const transcriptListeners = new Map<string, Set<(entry: ChatTranscriptEntry) => void>>();
|
|
268
|
+
|
|
269
|
+
function transcriptListenerKey(memoryDir: string, chatSessionId: string): string {
|
|
270
|
+
return `${memoryDir}\x00${chatSessionId}`;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function notifyTranscriptListeners(memoryDir: string, chatSessionId: string, entry: ChatTranscriptEntry): void {
|
|
274
|
+
const set = transcriptListeners.get(transcriptListenerKey(memoryDir, chatSessionId));
|
|
275
|
+
if (!set) return;
|
|
276
|
+
for (const fn of set) {
|
|
277
|
+
try {
|
|
278
|
+
fn(entry);
|
|
279
|
+
} catch {
|
|
280
|
+
// A listener must never break the append path (rule 54).
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Subscribe to live transcript entries for a chat session in a given memory
|
|
287
|
+
* store. Returns an unsubscribe function that removes the listener and
|
|
288
|
+
* prunes the session's set when it becomes empty. Used by the SSE handler
|
|
289
|
+
* to push new user/assistant entries to connected clients (issue #1687).
|
|
290
|
+
*
|
|
291
|
+
* The `memoryDir` scopes the subscription to its store so a process hosting
|
|
292
|
+
* two Remnic services cannot cross-deliver transcript entries (AGENTS.md
|
|
293
|
+
* State Scoping).
|
|
294
|
+
*/
|
|
295
|
+
export function subscribeChatTranscript(
|
|
296
|
+
memoryDir: string,
|
|
297
|
+
chatSessionId: string,
|
|
298
|
+
listener: (entry: ChatTranscriptEntry) => void,
|
|
299
|
+
): () => void {
|
|
300
|
+
const key = transcriptListenerKey(memoryDir, chatSessionId);
|
|
301
|
+
let set = transcriptListeners.get(key);
|
|
302
|
+
if (!set) {
|
|
303
|
+
set = new Set();
|
|
304
|
+
transcriptListeners.set(key, set);
|
|
305
|
+
}
|
|
306
|
+
set.add(listener);
|
|
307
|
+
return () => {
|
|
308
|
+
const current = transcriptListeners.get(key);
|
|
309
|
+
if (!current) return;
|
|
310
|
+
current.delete(listener);
|
|
311
|
+
if (current.size === 0) {
|
|
312
|
+
transcriptListeners.delete(key);
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
191
317
|
/**
|
|
192
318
|
* Append a pending-plan marker so a later turn can confirm the plan
|
|
193
319
|
* (append-only state — loadChatSession scans for the latest unresolved one).
|
|
@@ -271,6 +397,16 @@ export async function cleanupExpiredChatSessions(
|
|
|
271
397
|
// boundary is inclusive; sub-millisecond APFS mtime skew is negligible
|
|
272
398
|
// against an hour-scale threshold (rule 54 — deterministic cleanup).
|
|
273
399
|
if (ttlHours <= 0 || ageHours >= ttlHours) {
|
|
400
|
+
// Re-check mtime immediately before unlinking so a turn appended
|
|
401
|
+
// mid-sweep (which refreshes mtime) is not deleted by the stale age
|
|
402
|
+
// decision captured above (codex P2). ttlHours <= 0 (expire-all)
|
|
403
|
+
// skips the re-check. The residual microsecond window is a
|
|
404
|
+
// legitimate sweep of an over-TTL file.
|
|
405
|
+
if (ttlHours > 0) {
|
|
406
|
+
const fresh = await stat(filePath);
|
|
407
|
+
const freshAge = (Date.now() - fresh.mtimeMs) / (1000 * 60 * 60);
|
|
408
|
+
if (freshAge < ttlHours) continue;
|
|
409
|
+
}
|
|
274
410
|
await unlink(filePath);
|
|
275
411
|
removed++;
|
|
276
412
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deprecated alias subpath `@remnic/core/codex-cli-fallback`.
|
|
3
|
+
*
|
|
4
|
+
* PR #1638 renamed `src/codex-cli-fallback.ts` -> `src/cli-fallback.ts` to
|
|
5
|
+
* graduate the host-prefixed name per CLAUDE.md rule 31 (generic @remnic/core
|
|
6
|
+
* modules must not carry a host prefix). This thin re-export keeps the old
|
|
7
|
+
* subpath resolving during the compat window so external consumers importing
|
|
8
|
+
* `@remnic/core/codex-cli-fallback` do not hit ERR_PACKAGE_PATH_NOT_EXPORTED.
|
|
9
|
+
* It forwards every export to the canonical module -- no logic is duplicated.
|
|
10
|
+
*
|
|
11
|
+
* Removal timeline: v10.0.0 (see issue #1643).
|
|
12
|
+
* Import from `@remnic/core/cli-fallback` instead.
|
|
13
|
+
*/
|
|
14
|
+
export * from "../cli-fallback.js";
|
|
15
|
+
|
|
16
|
+
process.emitWarning(
|
|
17
|
+
"@remnic/core/codex-cli-fallback is a deprecated alias; import from @remnic/core/cli-fallback instead. Removed in v10.0.0 (issue #1643).",
|
|
18
|
+
{
|
|
19
|
+
type: "DeprecationWarning",
|
|
20
|
+
code: "REMNIC_DEP_CORE_SUBPATH_CODEX_CLI_FALLBACK",
|
|
21
|
+
detail: "PR #1638 renamed codex-cli-fallback.ts to cli-fallback.ts.",
|
|
22
|
+
},
|
|
23
|
+
);
|