@remnic/core 9.5.0 → 9.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/access-admin-ops-surface.d.ts +6 -6
- package/dist/access-admin-ops-surface.js +12 -12
- package/dist/access-boundary.d.ts +6 -6
- package/dist/access-boundary.js +13 -13
- package/dist/access-cli.js +36 -36
- package/dist/access-http.d.ts +6 -6
- package/dist/access-http.js +17 -17
- package/dist/access-identity-continuity-surface.d.ts +5 -5
- package/dist/access-identity-continuity-surface.js +12 -12
- package/dist/access-lcm-surface.d.ts +6 -6
- package/dist/access-lcm-surface.js +12 -12
- package/dist/access-mcp.d.ts +6 -6
- package/dist/access-mcp.js +16 -16
- package/dist/access-observe-write-surface.d.ts +6 -6
- package/dist/access-observe-write-surface.js +12 -12
- package/dist/access-operations-batch.js +14 -14
- package/dist/access-operations.d.ts +7 -7
- package/dist/access-operations.js +15 -15
- package/dist/access-recall-surface.d.ts +6 -6
- package/dist/access-recall-surface.js +12 -12
- package/dist/access-schema.d.ts +4 -4
- package/dist/{access-service-DZX0sYAv.d.ts → access-service-CXJaql3A.d.ts} +4 -4
- package/dist/access-service.d.ts +6 -6
- package/dist/access-service.js +12 -12
- package/dist/access-surface-catalog.d.ts +6 -6
- 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 +1 -1
- package/dist/{auto-sync-G6IU7L6C.js → auto-sync-4A5YBWYO.js} +3 -3
- package/dist/behavior-learner.d.ts +1 -1
- package/dist/behavior-signals.d.ts +1 -1
- package/dist/bootstrap.d.ts +5 -5
- package/dist/briefing.d.ts +2 -2
- package/dist/briefing.js +4 -4
- package/dist/buffer-surprise-report.d.ts +1 -1
- package/dist/buffer.d.ts +2 -2
- package/dist/calibration.d.ts +1 -1
- package/dist/capabilities.d.ts +1 -1
- package/dist/{catalog-DoH2b0OR.d.ts → catalog-C9cu67Ig.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 +5 -5
- package/dist/causal-trajectory-graph.d.ts +1 -1
- package/dist/{chunk-H5PFR5ZT.js → chunk-23JFRB73.js} +2 -2
- package/dist/{chunk-BGMSRN6I.js → chunk-3XIY7MDQ.js} +3 -3
- package/dist/{chunk-L2QJQSHL.js → chunk-4GNEDXDI.js} +2 -2
- package/dist/{chunk-MFDJ63U5.js → chunk-4LV23CHK.js} +2 -2
- package/dist/{chunk-AER6MT24.js → chunk-6SXVCD7W.js} +2 -2
- package/dist/{chunk-2SFEFR5I.js → chunk-72WG5QKN.js} +2 -2
- package/dist/{chunk-7BAJAXGB.js → chunk-7MW3CVLD.js} +2 -2
- package/dist/{chunk-SNNIUDRS.js → chunk-AYZJID4S.js} +2 -2
- package/dist/{chunk-M7XQSUBB.js → chunk-B3ABVQ36.js} +116 -9
- package/dist/chunk-B3ABVQ36.js.map +1 -0
- package/dist/{chunk-LNHJ32NK.js → chunk-BEM4D2QG.js} +3 -3
- package/dist/{chunk-AOYJVCFN.js → chunk-CYWA2CXR.js} +2 -2
- package/dist/{chunk-2JFDHI2K.js → chunk-DEG5ULFJ.js} +2 -2
- package/dist/{chunk-L6V3WJ55.js → chunk-DS7MM2ES.js} +3 -3
- package/dist/{chunk-CXEL3Y22.js → chunk-FKCRGLRD.js} +184 -29
- package/dist/{chunk-CXEL3Y22.js.map → chunk-FKCRGLRD.js.map} +1 -1
- package/dist/{chunk-TI7IHDBQ.js → chunk-H2DXDQCQ.js} +5 -5
- package/dist/{chunk-KMF67QDQ.js → chunk-HLN7RROI.js} +4 -4
- package/dist/{chunk-D2XYYU5Q.js → chunk-J3XHPFEU.js} +2 -2
- package/dist/{chunk-ODSYJXDU.js → chunk-JSNVWNGX.js} +2 -2
- package/dist/{chunk-7Z2DZNWN.js → chunk-KL4UAXUY.js} +43 -2
- package/dist/{chunk-7Z2DZNWN.js.map → chunk-KL4UAXUY.js.map} +1 -1
- package/dist/{chunk-4WNLSSHL.js → chunk-LADFWYHY.js} +4 -4
- package/dist/{chunk-UBGAOYBP.js → chunk-LR5DZ56F.js} +2 -2
- package/dist/{chunk-5K5VEDFY.js → chunk-N3JDIRBW.js} +2 -2
- package/dist/{chunk-2B7DP7AP.js → chunk-P454KXD6.js} +93 -15
- package/dist/chunk-P454KXD6.js.map +1 -0
- package/dist/{chunk-VWEWK2BM.js → chunk-PCLIWBGD.js} +3 -3
- package/dist/{chunk-TV7C3HCB.js → chunk-PNLFN2PZ.js} +2 -2
- package/dist/{chunk-STCCFQU5.js → chunk-PVUJ6WVV.js} +6 -6
- package/dist/{chunk-ITWTKT2R.js → chunk-RGK27ELN.js} +2 -2
- package/dist/{chunk-II6WHCCW.js → chunk-SIRPLF54.js} +2 -2
- package/dist/{chunk-UDCDGP6A.js → chunk-SVOHGUMI.js} +1188 -112
- package/dist/chunk-SVOHGUMI.js.map +1 -0
- package/dist/{chunk-NFTABV56.js → chunk-TLUDFPZL.js} +2 -2
- package/dist/{chunk-I74ZEBMA.js → chunk-UFUKHGZD.js} +4 -4
- package/dist/{chunk-L4UZBJNM.js → chunk-UTRJFDP2.js} +2 -2
- package/dist/{chunk-5GPPACXK.js → chunk-VSMRDBFT.js} +2 -1
- package/dist/{chunk-SNBEVJBX.js → chunk-XCL57M6Q.js} +3 -3
- package/dist/{chunk-DZSAXE3T.js → chunk-Y7UNNAZD.js} +2 -2
- package/dist/{chunk-XN3TUUX2.js → chunk-YQB32INI.js} +2 -2
- package/dist/{cli-1dghPCO2.d.ts → cli-CqdPA6z9.d.ts} +3 -3
- package/dist/cli.d.ts +7 -7
- package/dist/cli.js +29 -29
- package/dist/compounding/engine.d.ts +2 -2
- package/dist/compounding/engine.js +4 -4
- 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 +1 -1
- package/dist/connectors/codex-materialize-runner.d.ts +1 -1
- package/dist/connectors/codex-materialize-runner.js +4 -4
- package/dist/connectors/codex-materialize.d.ts +1 -1
- package/dist/connectors/index.d.ts +1 -1
- package/dist/connectors/index.js +4 -4
- package/dist/consolidation-provenance-check.d.ts +2 -2
- package/dist/consolidation-undo.d.ts +2 -2
- package/dist/contradiction/index.d.ts +2 -2
- 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 +2 -2
- package/dist/entity-retrieval.js +4 -4
- package/dist/entity-schema.d.ts +1 -1
- package/dist/explicit-capture.d.ts +5 -5
- package/dist/extraction-faithfulness.d.ts +1 -1
- 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/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 +268 -17
- package/dist/index.js +80 -38
- package/dist/intent.d.ts +1 -1
- package/dist/lcm/engine.d.ts +1 -1
- package/dist/lcm/index.d.ts +1 -1
- 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 +1 -1
- package/dist/maintenance/memory-governance.d.ts +1 -1
- package/dist/maintenance/memory-governance.js +4 -4
- package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +4 -4
- package/dist/maintenance/rebuild-memory-projection.js +5 -5
- package/dist/mcp-memory-inspector-app.d.ts +6 -6
- 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 +2 -2
- package/dist/models-json.d.ts +1 -1
- package/dist/namespaces/migrate.d.ts +3 -3
- package/dist/namespaces/migrate.js +11 -11
- package/dist/namespaces/principal.d.ts +1 -1
- package/dist/namespaces/search.d.ts +1 -1
- package/dist/namespaces/search.js +7 -7
- package/dist/namespaces/storage.d.ts +3 -3
- package/dist/namespaces/storage.js +4 -4
- package/dist/native-knowledge.d.ts +1 -1
- package/dist/operator-toolkit.d.ts +2 -2
- package/dist/operator-toolkit.js +15 -15
- package/dist/orchestration/compression-guideline-coordinator.d.ts +2 -2
- package/dist/orchestration/maintenance.d.ts +2 -2
- package/dist/orchestration/maintenance.js +6 -6
- package/dist/{orchestrator-Dh3WSW_O.d.ts → orchestrator-CWFrT8AF.d.ts} +91 -60
- package/dist/orchestrator.d.ts +5 -5
- package/dist/orchestrator.js +36 -36
- 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/{qmd-DoUVxqND.d.ts → qmd--fnPMK60.d.ts} +1 -1
- package/dist/qmd-preflight.d.ts +2 -2
- package/dist/qmd-recall-cache.d.ts +1 -1
- package/dist/qmd.d.ts +2 -2
- package/dist/recall-disclosure-escalation.d.ts +1 -1
- package/dist/recall-explain-renderer.d.ts +1 -1
- 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-timings.d.ts +1 -1
- package/dist/recall-xray-cli.d.ts +1 -1
- package/dist/recall-xray-renderer.d.ts +1 -1
- package/dist/recall-xray.d.ts +1 -1
- package/dist/resolve-auth-token.d.ts +1 -1
- package/dist/resume-bundles.js +2 -2
- package/dist/retrieval-agents.d.ts +2 -2
- 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/document-scanner.js +2 -2
- package/dist/search/embed-helper.d.ts +1 -1
- package/dist/search/factory.d.ts +1 -1
- package/dist/search/factory.js +6 -6
- package/dist/search/index.d.ts +1 -1
- package/dist/search/index.js +6 -6
- package/dist/search/lancedb-backend.d.ts +1 -1
- package/dist/search/lancedb-backend.js +3 -3
- package/dist/search/meilisearch-backend.d.ts +1 -1
- package/dist/search/meilisearch-backend.js +3 -3
- package/dist/search/noop-backend.d.ts +1 -1
- package/dist/search/orama-backend.d.ts +1 -1
- package/dist/search/orama-backend.js +3 -3
- package/dist/search/port.d.ts +1 -1
- package/dist/search/remote-backend.d.ts +1 -1
- package/dist/{semantic-consolidation-Dof1865o.d.ts → semantic-consolidation-O5lMZ4LG.d.ts} +1 -1
- package/dist/semantic-consolidation.d.ts +2 -2
- package/dist/semantic-consolidation.js +5 -5
- package/dist/semantic-rule-promotion.js +4 -4
- package/dist/semantic-rule-verifier.d.ts +1 -1
- package/dist/semantic-rule-verifier.js +4 -4
- 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-C1maCNbc.d.ts +1591 -0
- package/dist/storage.d.ts +5 -1239
- package/dist/storage.js +3 -3
- package/dist/summarizer.d.ts +1 -1
- package/dist/summary-snapshot.d.ts +1 -1
- package/dist/temporal-supersession.d.ts +2 -2
- package/dist/temporal-validity.d.ts +1 -1
- package/dist/threading.d.ts +1 -1
- package/dist/tier-migration.d.ts +2 -2
- 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/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-C7HUgOuA.d.ts → types-CGgcNWCG.d.ts} +31 -1
- package/dist/types.d.ts +1 -1
- package/dist/utility-runtime.d.ts +1 -1
- package/dist/verified-recall.js +4 -4
- package/package.json +2 -2
- package/src/cli-wearables-fuse.test.ts +197 -0
- package/src/cli.ts +25 -0
- package/src/storage.ts +16 -0
- package/src/wearables/cli.test.ts +102 -0
- package/src/wearables/cli.ts +65 -0
- package/src/wearables/config.test.ts +32 -0
- package/src/wearables/config.ts +52 -0
- package/src/wearables/day-store.test.ts +246 -0
- package/src/wearables/day-store.ts +272 -3
- package/src/wearables/fusion/cluster.ts +230 -0
- package/src/wearables/fusion/fuse.ts +218 -0
- package/src/wearables/fusion/fusion.test.ts +2575 -0
- package/src/wearables/fusion/index.ts +50 -0
- package/src/wearables/fusion/reconcile.ts +831 -0
- package/src/wearables/fusion/reconstruct.ts +251 -0
- package/src/wearables/fusion/store.ts +480 -0
- package/src/wearables/fusion/types.ts +228 -0
- package/src/wearables/index.ts +41 -0
- package/src/wearables/service.test.ts +1030 -2
- package/src/wearables/service.ts +258 -16
- package/src/wearables/speakers.test.ts +128 -0
- package/src/wearables/speakers.ts +58 -4
- package/src/wearables/storage-io.test.ts +146 -0
- package/src/wearables/types.ts +31 -0
- package/dist/chunk-2B7DP7AP.js.map +0 -1
- package/dist/chunk-M7XQSUBB.js.map +0 -1
- package/dist/chunk-UDCDGP6A.js.map +0 -1
- /package/dist/{auto-sync-G6IU7L6C.js.map → auto-sync-4A5YBWYO.js.map} +0 -0
- /package/dist/{chunk-H5PFR5ZT.js.map → chunk-23JFRB73.js.map} +0 -0
- /package/dist/{chunk-BGMSRN6I.js.map → chunk-3XIY7MDQ.js.map} +0 -0
- /package/dist/{chunk-L2QJQSHL.js.map → chunk-4GNEDXDI.js.map} +0 -0
- /package/dist/{chunk-MFDJ63U5.js.map → chunk-4LV23CHK.js.map} +0 -0
- /package/dist/{chunk-AER6MT24.js.map → chunk-6SXVCD7W.js.map} +0 -0
- /package/dist/{chunk-2SFEFR5I.js.map → chunk-72WG5QKN.js.map} +0 -0
- /package/dist/{chunk-7BAJAXGB.js.map → chunk-7MW3CVLD.js.map} +0 -0
- /package/dist/{chunk-SNNIUDRS.js.map → chunk-AYZJID4S.js.map} +0 -0
- /package/dist/{chunk-LNHJ32NK.js.map → chunk-BEM4D2QG.js.map} +0 -0
- /package/dist/{chunk-AOYJVCFN.js.map → chunk-CYWA2CXR.js.map} +0 -0
- /package/dist/{chunk-2JFDHI2K.js.map → chunk-DEG5ULFJ.js.map} +0 -0
- /package/dist/{chunk-L6V3WJ55.js.map → chunk-DS7MM2ES.js.map} +0 -0
- /package/dist/{chunk-TI7IHDBQ.js.map → chunk-H2DXDQCQ.js.map} +0 -0
- /package/dist/{chunk-KMF67QDQ.js.map → chunk-HLN7RROI.js.map} +0 -0
- /package/dist/{chunk-D2XYYU5Q.js.map → chunk-J3XHPFEU.js.map} +0 -0
- /package/dist/{chunk-ODSYJXDU.js.map → chunk-JSNVWNGX.js.map} +0 -0
- /package/dist/{chunk-4WNLSSHL.js.map → chunk-LADFWYHY.js.map} +0 -0
- /package/dist/{chunk-UBGAOYBP.js.map → chunk-LR5DZ56F.js.map} +0 -0
- /package/dist/{chunk-5K5VEDFY.js.map → chunk-N3JDIRBW.js.map} +0 -0
- /package/dist/{chunk-VWEWK2BM.js.map → chunk-PCLIWBGD.js.map} +0 -0
- /package/dist/{chunk-TV7C3HCB.js.map → chunk-PNLFN2PZ.js.map} +0 -0
- /package/dist/{chunk-STCCFQU5.js.map → chunk-PVUJ6WVV.js.map} +0 -0
- /package/dist/{chunk-ITWTKT2R.js.map → chunk-RGK27ELN.js.map} +0 -0
- /package/dist/{chunk-II6WHCCW.js.map → chunk-SIRPLF54.js.map} +0 -0
- /package/dist/{chunk-NFTABV56.js.map → chunk-TLUDFPZL.js.map} +0 -0
- /package/dist/{chunk-I74ZEBMA.js.map → chunk-UFUKHGZD.js.map} +0 -0
- /package/dist/{chunk-L4UZBJNM.js.map → chunk-UTRJFDP2.js.map} +0 -0
- /package/dist/{chunk-5GPPACXK.js.map → chunk-VSMRDBFT.js.map} +0 -0
- /package/dist/{chunk-SNBEVJBX.js.map → chunk-XCL57M6Q.js.map} +0 -0
- /package/dist/{chunk-DZSAXE3T.js.map → chunk-Y7UNNAZD.js.map} +0 -0
- /package/dist/{chunk-XN3TUUX2.js.map → chunk-YQB32INI.js.map} +0 -0
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wearable cross-source fusion — derived-day file serialization.
|
|
3
|
+
*
|
|
4
|
+
* One derived file per fused day, stored ALONGSIDE raw source transcripts
|
|
5
|
+
* (under the wearables tree) but never overwriting them. Format mirrors
|
|
6
|
+
* the day-transcript convention: YAML frontmatter (`kind:
|
|
7
|
+
* wearable-fusion`) + a JSON body carrying the structured
|
|
8
|
+
* `FusedWearableConversation[]`. The frontmatter `contentHash` is the
|
|
9
|
+
* idempotency key derived from inputs plus the effective fusion config,
|
|
10
|
+
* so an unchanged re-run (same inputs AND config) produces a
|
|
11
|
+
* byte-identical file.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
import * as path from "node:path";
|
|
16
|
+
import { pathIsInside } from "../../utils/path-containment.js";
|
|
17
|
+
import { isValidTranscriptDate } from "../day-store.js";
|
|
18
|
+
import type {
|
|
19
|
+
FusedDayFile,
|
|
20
|
+
FusedDayMeta,
|
|
21
|
+
FusedWearableConversation,
|
|
22
|
+
} from "./types.js";
|
|
23
|
+
|
|
24
|
+
export const FUSION_KIND = "wearable-fusion" as const;
|
|
25
|
+
|
|
26
|
+
/** Hash the canonical JSON body for idempotent skip-unchanged checks. */
|
|
27
|
+
export function hashFusionBody(
|
|
28
|
+
conversations: readonly FusedWearableConversation[],
|
|
29
|
+
): string {
|
|
30
|
+
return createHash("sha256")
|
|
31
|
+
.update(JSON.stringify(conversations), "utf-8")
|
|
32
|
+
.digest("hex");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function composeFusionDayMeta(
|
|
36
|
+
date: string,
|
|
37
|
+
conversations: readonly FusedWearableConversation[],
|
|
38
|
+
sources: readonly string[],
|
|
39
|
+
contentHash: string,
|
|
40
|
+
fusedAt: string,
|
|
41
|
+
): FusedDayMeta {
|
|
42
|
+
return {
|
|
43
|
+
kind: FUSION_KIND,
|
|
44
|
+
date,
|
|
45
|
+
sourceCount: sources.length,
|
|
46
|
+
conversationCount: conversations.length,
|
|
47
|
+
contentHash,
|
|
48
|
+
bodyHash: hashFusionBody(conversations),
|
|
49
|
+
fusedAt,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Serialize meta + conversations into the persisted file format. */
|
|
54
|
+
export function serializeFusionDay(
|
|
55
|
+
meta: FusedDayMeta,
|
|
56
|
+
conversations: readonly FusedWearableConversation[],
|
|
57
|
+
): string {
|
|
58
|
+
const lines: string[] = ["---"];
|
|
59
|
+
lines.push(`kind: ${meta.kind}`);
|
|
60
|
+
lines.push(`date: ${JSON.stringify(meta.date)}`);
|
|
61
|
+
lines.push(`sourceCount: ${meta.sourceCount}`);
|
|
62
|
+
lines.push(`conversationCount: ${meta.conversationCount}`);
|
|
63
|
+
lines.push(`contentHash: ${JSON.stringify(meta.contentHash)}`);
|
|
64
|
+
lines.push(`bodyHash: ${JSON.stringify(meta.bodyHash)}`);
|
|
65
|
+
lines.push(`fusedAt: ${JSON.stringify(meta.fusedAt)}`);
|
|
66
|
+
lines.push("---");
|
|
67
|
+
lines.push("");
|
|
68
|
+
return `${lines.join("\n")}\n${JSON.stringify(conversations, null, 2)}\n`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function parseScalar(value: string): string {
|
|
72
|
+
const trimmed = value.trim();
|
|
73
|
+
if (trimmed.startsWith('"') && trimmed.endsWith('"') && trimmed.length >= 2) {
|
|
74
|
+
try {
|
|
75
|
+
const parsed = JSON.parse(trimmed);
|
|
76
|
+
if (typeof parsed === "string") return parsed;
|
|
77
|
+
} catch {
|
|
78
|
+
// Fall through to the raw value below.
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return trimmed;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function parseNonNegativeInt(value: string | undefined): number {
|
|
85
|
+
if (value === undefined) return 0;
|
|
86
|
+
const parsed = Number(value);
|
|
87
|
+
if (!Number.isFinite(parsed) || parsed < 0) return 0;
|
|
88
|
+
return Math.floor(parsed);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const FUSED_SEGMENT_PICK_REASONS: Record<string, true> = {
|
|
92
|
+
"only-source": true,
|
|
93
|
+
"higher-trust": true,
|
|
94
|
+
"more-complete": true,
|
|
95
|
+
"tie-break": true,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const FUSED_DISAGREEMENT_KINDS: Record<string, true> = {
|
|
99
|
+
"asr-text": true,
|
|
100
|
+
speaker: true,
|
|
101
|
+
timestamp: true,
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
105
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isStringArray(value: unknown): value is string[] {
|
|
109
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Structural guard for a parsed segment-provenance record. */
|
|
113
|
+
function isFusedSegmentProvenance(value: unknown): boolean {
|
|
114
|
+
if (!isPlainObject(value)) return false;
|
|
115
|
+
if (typeof value.source !== "string") return false;
|
|
116
|
+
if (typeof value.conversationId !== "string") return false;
|
|
117
|
+
if (typeof value.sourceTrust !== "number") return false;
|
|
118
|
+
if (typeof value.reason !== "string") return false;
|
|
119
|
+
if (!(value.reason in FUSED_SEGMENT_PICK_REASONS)) return false;
|
|
120
|
+
return (
|
|
121
|
+
Array.isArray(value.alternatives) &&
|
|
122
|
+
value.alternatives.every(
|
|
123
|
+
(alt) =>
|
|
124
|
+
isPlainObject(alt) && typeof alt.source === "string" && typeof alt.text === "string",
|
|
125
|
+
)
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Structural guard for a parsed fused segment. */
|
|
130
|
+
function isFusedSegment(value: unknown): boolean {
|
|
131
|
+
if (!isPlainObject(value)) return false;
|
|
132
|
+
if (typeof value.speaker !== "string") return false;
|
|
133
|
+
if (typeof value.isSelf !== "boolean") return false;
|
|
134
|
+
if (typeof value.text !== "string") return false;
|
|
135
|
+
if (typeof value.confidence !== "number") return false;
|
|
136
|
+
return isFusedSegmentProvenance(value.provenance);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Structural guard for a parsed fused speaker. */
|
|
140
|
+
function isFusedSpeaker(value: unknown): boolean {
|
|
141
|
+
if (!isPlainObject(value)) return false;
|
|
142
|
+
if (typeof value.label !== "string") return false;
|
|
143
|
+
if (typeof value.isSelf !== "boolean") return false;
|
|
144
|
+
if (typeof value.confidence !== "number") return false;
|
|
145
|
+
return isStringArray(value.sources);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Structural guard for a parsed fused disagreement. */
|
|
149
|
+
function isFusedDisagreement(value: unknown): boolean {
|
|
150
|
+
if (!isPlainObject(value)) return false;
|
|
151
|
+
if (typeof value.kind !== "string") return false;
|
|
152
|
+
if (!(value.kind in FUSED_DISAGREEMENT_KINDS)) return false;
|
|
153
|
+
if (typeof value.subject !== "string") return false;
|
|
154
|
+
return (
|
|
155
|
+
Array.isArray(value.candidates) &&
|
|
156
|
+
value.candidates.every(
|
|
157
|
+
(cand) =>
|
|
158
|
+
isPlainObject(cand) && typeof cand.source === "string" && typeof cand.value === "string",
|
|
159
|
+
)
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Structural guard for a parsed fused-conversation contribution record. */
|
|
164
|
+
function isFusedContribution(value: unknown): boolean {
|
|
165
|
+
if (!isPlainObject(value)) return false;
|
|
166
|
+
if (typeof value.source !== "string") return false;
|
|
167
|
+
if (typeof value.conversationId !== "string") return false;
|
|
168
|
+
if (typeof value.startIso !== "string") return false;
|
|
169
|
+
return typeof value.segmentCount === "number";
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Structural guard for a parsed fused-conversation provenance record. */
|
|
173
|
+
function isFusedConversationProvenance(value: unknown): boolean {
|
|
174
|
+
if (!isPlainObject(value)) return false;
|
|
175
|
+
if (!Array.isArray(value.contributions) || !value.contributions.every(isFusedContribution)) {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
if (typeof value.proximityGapMs !== "number") return false;
|
|
179
|
+
if (typeof value.windowToleranceMs !== "number") return false;
|
|
180
|
+
return value.method === "time-proximity";
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Structural guard for a parsed fused conversation. Every required field of
|
|
185
|
+
* `FusedWearableConversation` (and its nested segments / speakers /
|
|
186
|
+
* disagreements / provenance) must be present with the right shape, so a
|
|
187
|
+
* null, wrong-typed, or partial element (`[{}]`, `[null]`, `[{id:1}]`) is
|
|
188
|
+
* rejected and the caller treats the body as corrupt (parseOk:false →
|
|
189
|
+
* self-repair rewrite) rather than trusting it.
|
|
190
|
+
*/
|
|
191
|
+
function isFusedWearableConversation(value: unknown): value is FusedWearableConversation {
|
|
192
|
+
if (!isPlainObject(value)) return false;
|
|
193
|
+
if (typeof value.id !== "string") return false;
|
|
194
|
+
if (typeof value.date !== "string") return false;
|
|
195
|
+
if (typeof value.startIso !== "string") return false;
|
|
196
|
+
if (!isStringArray(value.sources)) return false;
|
|
197
|
+
if (!Array.isArray(value.speakers) || !value.speakers.every(isFusedSpeaker)) return false;
|
|
198
|
+
if (!Array.isArray(value.segments) || !value.segments.every(isFusedSegment)) return false;
|
|
199
|
+
if (!Array.isArray(value.disagreements) || !value.disagreements.every(isFusedDisagreement)) {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
return isFusedConversationProvenance(value.provenance);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Parse a persisted fused-day file. Returns null when the content does
|
|
207
|
+
* not look like a fusion artifact (wrong kind / missing frontmatter) so
|
|
208
|
+
* callers can distinguish "not fused" from a malformed file. A non-null
|
|
209
|
+
* result carries `parseOk`: false when the JSON body failed to parse, was
|
|
210
|
+
* not an array, or held a malformed element (conversations is empty in
|
|
211
|
+
* that case), true for a well-formed (incl. legitimately-empty `[]`)
|
|
212
|
+
* body — so the skip-unchanged path can force a self-repair rewrite
|
|
213
|
+
* regardless of the recomputed conversation count or body hash.
|
|
214
|
+
*/
|
|
215
|
+
export function parseFusionDay(raw: string): FusedDayFile | null {
|
|
216
|
+
if (!raw.startsWith("---\n")) return null;
|
|
217
|
+
const closeIndex = raw.indexOf("\n---\n", 4);
|
|
218
|
+
if (closeIndex === -1) return null;
|
|
219
|
+
const header = raw.slice(4, closeIndex);
|
|
220
|
+
const body = raw.slice(closeIndex + 5).replace(/^\n/, "");
|
|
221
|
+
|
|
222
|
+
const scalars = new Map<string, string>();
|
|
223
|
+
for (const line of header.split("\n")) {
|
|
224
|
+
const match = line.match(/^([A-Za-z][A-Za-z0-9]*): (.*)$/);
|
|
225
|
+
if (match) scalars.set(match[1], parseScalar(match[2]));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (scalars.get("kind") !== FUSION_KIND) return null;
|
|
229
|
+
const date = scalars.get("date");
|
|
230
|
+
if (date === undefined) return null;
|
|
231
|
+
|
|
232
|
+
let conversations: FusedWearableConversation[] = [];
|
|
233
|
+
let parseOk = true;
|
|
234
|
+
const bodyTrimmed = body.trim();
|
|
235
|
+
if (bodyTrimmed.length === 0) {
|
|
236
|
+
// An existing artifact whose body is blank or whitespace-only (no
|
|
237
|
+
// JSON at all) is corrupt — it is NOT a valid empty `[]`. The
|
|
238
|
+
// serializer always writes `JSON.stringify(conversations, null, 2)`
|
|
239
|
+
// which produces `[]` for zero conversations, so a blank body means
|
|
240
|
+
// the file was truncated, partially written, or manually emptied.
|
|
241
|
+
// Flag parseOk:false so fusedConversations() surfaces the corruption
|
|
242
|
+
// and fuseDay() repairs it (issue #1849).
|
|
243
|
+
parseOk = false;
|
|
244
|
+
} else {
|
|
245
|
+
try {
|
|
246
|
+
const parsed: unknown = JSON.parse(bodyTrimmed);
|
|
247
|
+
if (Array.isArray(parsed) && parsed.every(isFusedWearableConversation)) {
|
|
248
|
+
conversations = parsed;
|
|
249
|
+
} else {
|
|
250
|
+
// Valid JSON but not a well-formed FusedWearableConversation[]
|
|
251
|
+
// (non-array, or an array carrying a null / wrong-typed /
|
|
252
|
+
// missing-required-field element) — flag corrupt so callers
|
|
253
|
+
// recompute fusion rather than trusting a partial read. A
|
|
254
|
+
// legitimately-empty [] is accepted with parseOk left true.
|
|
255
|
+
parseOk = false;
|
|
256
|
+
}
|
|
257
|
+
} catch {
|
|
258
|
+
// A truncated/corrupt body fails to parse; flag parseOk:false so the
|
|
259
|
+
// skip-unchanged path forces a self-repair rewrite (even when the
|
|
260
|
+
// frontmatter hash matches and the recomputed count is also zero).
|
|
261
|
+
parseOk = false;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const meta: FusedDayMeta = {
|
|
266
|
+
kind: FUSION_KIND,
|
|
267
|
+
date,
|
|
268
|
+
sourceCount: parseNonNegativeInt(scalars.get("sourceCount")),
|
|
269
|
+
conversationCount: parseNonNegativeInt(scalars.get("conversationCount")),
|
|
270
|
+
contentHash: scalars.get("contentHash") ?? "",
|
|
271
|
+
bodyHash: scalars.get("bodyHash") ?? "",
|
|
272
|
+
fusedAt: scalars.get("fusedAt") ?? "",
|
|
273
|
+
};
|
|
274
|
+
return { meta, conversations, parseOk };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Reserved underscore-prefixed subdir for derived fusion artifacts. */
|
|
278
|
+
export const FUSION_DIR_NAME = "_fusion";
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Encrypted-at-rest + atomic file IO the fusion artifact store needs.
|
|
282
|
+
* Satisfied by StorageManager (which owns the secure-store key + atomic
|
|
283
|
+
* write path) and injected so this module performs NO direct fs — the
|
|
284
|
+
* derived files inherit the same encrypted-at-rest + atomic-write
|
|
285
|
+
* semantics as raw day transcripts without this module knowing the key.
|
|
286
|
+
*/
|
|
287
|
+
export interface FusionFileIo {
|
|
288
|
+
writeFile(filePath: string, content: string): Promise<void>;
|
|
289
|
+
/** Read a file; rejects with ENOENT when it is absent. */
|
|
290
|
+
readFile(filePath: string): Promise<string>;
|
|
291
|
+
/** Directory entries; rejects with ENOENT when the directory is absent. */
|
|
292
|
+
readDir(dirPath: string): Promise<string[]>;
|
|
293
|
+
/** Remove a file; rejects with ENOENT when it is absent. */
|
|
294
|
+
deleteFile(filePath: string): Promise<void>;
|
|
295
|
+
/**
|
|
296
|
+
* Resolve a path through symlinks (node:fs/promises realpath); rejects
|
|
297
|
+
* with ENOENT when the path is absent. Used by the symlink-containment
|
|
298
|
+
* guard so a symlinked `_fusion` dir (or a symlinked wearables root)
|
|
299
|
+
* that resolves outside the memory dir is rejected before any
|
|
300
|
+
* read/write/delete (AGENTS.md #3).
|
|
301
|
+
*/
|
|
302
|
+
realpath(filePath: string): Promise<string>;
|
|
303
|
+
/**
|
|
304
|
+
* Stat a path WITHOUT following a trailing symlink
|
|
305
|
+
* (node:fs/promises lstat); rejects with ENOENT when the path is
|
|
306
|
+
* absent. Used to detect a pre-existing symlink at the fusion
|
|
307
|
+
* artifact directory itself, which is rejected even when its
|
|
308
|
+
* target resolves inside the memory dir (issue #1849).
|
|
309
|
+
*/
|
|
310
|
+
lstat(filePath: string): Promise<{ isSymbolicLink: boolean }>;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Owns the derived fused-day artifact files under
|
|
315
|
+
* `<wearablesDir>/_fusion/<YYYY-MM-DD>.md`. Path construction, date
|
|
316
|
+
* validation, and newest-first listing live here (relocated out of the
|
|
317
|
+
* root storage module — issue #1810 ratchet); the bytes flow through the
|
|
318
|
+
* injected secure IO.
|
|
319
|
+
*/
|
|
320
|
+
export class FusionArtifactStore {
|
|
321
|
+
constructor(
|
|
322
|
+
private readonly wearablesDir: string,
|
|
323
|
+
private readonly memoryDir: string,
|
|
324
|
+
private readonly io: FusionFileIo,
|
|
325
|
+
) {}
|
|
326
|
+
|
|
327
|
+
fusedDayPath(date: string): string {
|
|
328
|
+
if (!isValidTranscriptDate(date)) {
|
|
329
|
+
throw new Error(
|
|
330
|
+
`invalid wearable fusion date '${String(date)}' — expected YYYY-MM-DD`,
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
return path.join(this.wearablesDir, FUSION_DIR_NAME, `${date}.md`);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async writeFusedDay(date: string, serialized: string): Promise<void> {
|
|
337
|
+
const filePath = this.fusedDayPath(date);
|
|
338
|
+
await this.assertPathContained(filePath);
|
|
339
|
+
await this.io.writeFile(filePath, serialized);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Read a stored fused-day artifact; null when absent. */
|
|
343
|
+
async readFusedDay(date: string): Promise<string | null> {
|
|
344
|
+
const filePath = this.fusedDayPath(date);
|
|
345
|
+
await this.assertPathContained(filePath);
|
|
346
|
+
try {
|
|
347
|
+
return await this.io.readFile(filePath);
|
|
348
|
+
} catch (err) {
|
|
349
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
350
|
+
throw err;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Remove a stored fused-day artifact. Idempotent: a no-op when the
|
|
356
|
+
* artifact is already absent. Used to clear a now-stale derived file
|
|
357
|
+
* before a fusion run that deliberately refuses to fuse (e.g. a day
|
|
358
|
+
* whose sources later conflict on timezone) so fusedConversations()
|
|
359
|
+
* does not keep serving a stale view (issue #1849).
|
|
360
|
+
*/
|
|
361
|
+
async deleteFusedDay(date: string): Promise<void> {
|
|
362
|
+
const filePath = this.fusedDayPath(date);
|
|
363
|
+
await this.assertPathContained(filePath);
|
|
364
|
+
try {
|
|
365
|
+
await this.io.deleteFile(filePath);
|
|
366
|
+
} catch (err) {
|
|
367
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return;
|
|
368
|
+
throw err;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** List dates with stored fused artifacts, newest first. */
|
|
373
|
+
async listFusedDays(): Promise<string[]> {
|
|
374
|
+
const fusionDir = path.join(this.wearablesDir, FUSION_DIR_NAME);
|
|
375
|
+
await this.assertPathContained(fusionDir);
|
|
376
|
+
let entries: string[];
|
|
377
|
+
try {
|
|
378
|
+
entries = await this.io.readDir(fusionDir);
|
|
379
|
+
} catch (err) {
|
|
380
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
381
|
+
throw err;
|
|
382
|
+
}
|
|
383
|
+
const days: string[] = [];
|
|
384
|
+
for (const entry of entries) {
|
|
385
|
+
if (!entry.endsWith(".md")) continue;
|
|
386
|
+
const date = entry.slice(0, -3);
|
|
387
|
+
if (!isValidTranscriptDate(date)) continue;
|
|
388
|
+
days.push(date);
|
|
389
|
+
}
|
|
390
|
+
days.sort((a, b) => (a > b ? -1 : a < b ? 1 : 0));
|
|
391
|
+
return days;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Reject a symlinked/traversing fusion artifact path before any IO. The
|
|
396
|
+
* fusion dir (`<wearablesDir>/_fusion`) and its parents are followed
|
|
397
|
+
* directly by the secure IO; a symlink placed there (attacker-writable
|
|
398
|
+
* or a malformed memory dir) would otherwise be followed and
|
|
399
|
+
* read/write/delete outside the allowed root (AGENTS.md pattern #3).
|
|
400
|
+
*
|
|
401
|
+
* Containment is anchored to the real MEMORY-DIR root, not the
|
|
402
|
+
* wearables dir, AND the wearables root is checked explicitly: if
|
|
403
|
+
* `wearablesDir` itself is a symlink that resolves outside memoryDir, a
|
|
404
|
+
* leaf candidate like `<wearablesDir>/_fusion/<date>.md` may not yet
|
|
405
|
+
* exist (so its realpath ENOENT-skips) and the write would silently
|
|
406
|
+
* create the file inside the symlinked escape target. Checking
|
|
407
|
+
* `wearablesDir` itself — which resolves through the symlink when it
|
|
408
|
+
* exists — closes that gap, while the per-leaf checks catch a nested
|
|
409
|
+
* `_fusion` or per-file symlink. Absent paths are allowed (a fresh
|
|
410
|
+
* write creates them lexically under the root; only a path that
|
|
411
|
+
* resolves to an existing target can redirect IO). Mirrors
|
|
412
|
+
* `assertPathInsideRoot` in the storage walkers.
|
|
413
|
+
*/
|
|
414
|
+
private async assertPathContained(targetPath: string): Promise<void> {
|
|
415
|
+
let rootReal: string;
|
|
416
|
+
try {
|
|
417
|
+
rootReal = await this.io.realpath(this.memoryDir);
|
|
418
|
+
} catch {
|
|
419
|
+
// Fresh memory dir not yet on disk — no symlink can resolve through
|
|
420
|
+
// a missing root, so fall back to a lexical resolve.
|
|
421
|
+
rootReal = path.resolve(this.memoryDir);
|
|
422
|
+
}
|
|
423
|
+
// The wearables root first (catches a symlinked wearables dir even
|
|
424
|
+
// when the leaf artifact does not yet exist), then the fusion dir and
|
|
425
|
+
// the target file itself.
|
|
426
|
+
await this.assertNoEscape(rootReal, this.wearablesDir);
|
|
427
|
+
await this.assertNoEscape(rootReal, path.dirname(targetPath));
|
|
428
|
+
await this.assertNoEscape(rootReal, targetPath);
|
|
429
|
+
// Reject a symlinked `_fusion` dir outright — even when its
|
|
430
|
+
// target resolves INSIDE the memory dir. The managed fusion root
|
|
431
|
+
// is always a real directory created by the secure write; a
|
|
432
|
+
// pre-existing link there is tampering/aliasing and is refused
|
|
433
|
+
// before a single byte is written or read (issue #1849).
|
|
434
|
+
await this.assertFusionDirNotSymlinked();
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Refuse a pre-existing symbolic link at the `<wearablesDir>/_fusion`
|
|
439
|
+
* directory. Unlike `assertNoEscape` (which follows the link and only
|
|
440
|
+
* rejects when the target lands outside the memory dir), this catches a
|
|
441
|
+
* fusion root that aliases an in-bounds path — e.g. a `_fusion` link
|
|
442
|
+
* pointed at a raw source transcript — so fusion IO never writes
|
|
443
|
+
* through it and overwrites the aliased file. Absent dirs are allowed
|
|
444
|
+
* (a fresh write creates a real directory lexically under the root).
|
|
445
|
+
*/
|
|
446
|
+
private async assertFusionDirNotSymlinked(): Promise<void> {
|
|
447
|
+
const fusionDir = path.join(this.wearablesDir, FUSION_DIR_NAME);
|
|
448
|
+
let stat: { isSymbolicLink: boolean };
|
|
449
|
+
try {
|
|
450
|
+
stat = await this.io.lstat(fusionDir);
|
|
451
|
+
} catch (err) {
|
|
452
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return;
|
|
453
|
+
throw err;
|
|
454
|
+
}
|
|
455
|
+
if (stat.isSymbolicLink) {
|
|
456
|
+
throw new Error(
|
|
457
|
+
"wearable fusion dir '_fusion' is a symbolic link — refusing to follow it even when it resolves inside the memory dir (AGENTS.md pattern #3)",
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Refuse a candidate whose realpath escapes the memory root. Absent
|
|
464
|
+
* candidates are allowed: a missing path cannot yet redirect IO, and
|
|
465
|
+
* the secure write creates it lexically under the root.
|
|
466
|
+
*/
|
|
467
|
+
private async assertNoEscape(rootReal: string, candidate: string): Promise<void> {
|
|
468
|
+
let candidateReal: string;
|
|
469
|
+
try {
|
|
470
|
+
candidateReal = await this.io.realpath(candidate);
|
|
471
|
+
} catch {
|
|
472
|
+
return; // absent — nothing to follow
|
|
473
|
+
}
|
|
474
|
+
if (!pathIsInside(rootReal, candidateReal)) {
|
|
475
|
+
throw new Error(
|
|
476
|
+
"wearable fusion artifact path resolves outside the memory dir — refusing to follow a symlink/traversal (AGENTS.md pattern #3)",
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|