@remnic/core 9.66.12 → 9.68.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.js +26 -26
- package/dist/access-audit.js +2 -2
- package/dist/access-authorization-probe.js +27 -27
- package/dist/access-boundary.js +26 -26
- package/dist/access-cli.js +87 -87
- package/dist/access-extraction-force-flush.js +26 -26
- package/dist/access-http-query.js +28 -28
- package/dist/access-http.js +44 -44
- package/dist/access-identity-continuity-surface.js +26 -26
- package/dist/access-lcm-surface.js +26 -26
- package/dist/access-mcp.js +34 -34
- package/dist/access-namespace-preflight.js +26 -26
- package/dist/access-observe-write-surface.js +26 -26
- package/dist/access-operations-batch.js +28 -28
- package/dist/access-operations.js +33 -33
- package/dist/access-recall-concurrency.js +26 -26
- package/dist/access-recall-response.js +26 -26
- package/dist/access-recall-surface.js +26 -26
- package/dist/access-schema.js +3 -3
- package/dist/access-service.js +26 -26
- package/dist/access-surface-catalog.js +28 -28
- package/dist/adapters/index.js +7 -7
- package/dist/adapters/registry.js +3 -3
- package/dist/briefing.js +11 -11
- package/dist/{capsule-crypto-GWVG7LGC.js → capsule-crypto-7FJQINUR.js} +2 -2
- package/dist/causal-consolidation.js +12 -12
- package/dist/{chunk-LEPOP46W.js → chunk-227UFSEQ.js} +4 -4
- package/dist/{chunk-FJP6MVRK.js → chunk-25P3LW6U.js} +2 -2
- package/dist/{chunk-JKXPFIPC.js → chunk-2MHMHF76.js} +2 -2
- package/dist/{chunk-OWHERGF2.js → chunk-2NLLXCJG.js} +2 -2
- package/dist/{chunk-QMIKXIAM.js → chunk-327JMA2N.js} +2 -2
- package/dist/{chunk-H6VYVOVP.js → chunk-363VZTVH.js} +2 -2
- package/dist/{chunk-PKRYQ222.js → chunk-3IRH5MYM.js} +6 -6
- package/dist/{chunk-GEHEBLVY.js → chunk-4PEZMGZD.js} +2 -2
- package/dist/{chunk-EU5PDHU2.js → chunk-4PMPOZK3.js} +2 -2
- package/dist/{chunk-6SRUPUTY.js → chunk-52GQNSLC.js} +5 -5
- package/dist/{chunk-SV3MDTJI.js → chunk-5UI7WQA6.js} +2 -2
- package/dist/{chunk-JGXMA6HL.js → chunk-6FUMA6AA.js} +2 -2
- package/dist/{chunk-TVVEYCNW.js → chunk-7K5Q6COX.js} +4 -4
- package/dist/{chunk-WGVAEVT7.js → chunk-7KG5V4VV.js} +2 -2
- package/dist/{chunk-A6HVSRGM.js → chunk-A7NMS5II.js} +3 -3
- package/dist/{chunk-M3BFW3VX.js → chunk-ABPCF4MW.js} +1 -1
- package/dist/{chunk-NNBZS3DY.js → chunk-AG46P24C.js} +4 -4
- package/dist/{chunk-I7FVQMAA.js → chunk-BGD3T743.js} +2 -2
- package/dist/{chunk-QRHKJT7F.js → chunk-BHH736UQ.js} +4 -4
- package/dist/{chunk-4L7OKRGP.js → chunk-BS5BD3DF.js} +2 -2
- package/dist/{chunk-BT7AQZQX.js → chunk-BTRY6UH3.js} +2 -2
- package/dist/{chunk-BE4Y6GK6.js → chunk-D35XLYQ7.js} +11 -11
- package/dist/{chunk-CJTZO5MW.js → chunk-D6Q5EPOV.js} +2 -2
- package/dist/{chunk-6KGBI54C.js → chunk-DJ7TZLGU.js} +2 -2
- package/dist/{chunk-IDPFBRPJ.js → chunk-ERKFG4RV.js} +38 -38
- package/dist/{chunk-K7VTD5AZ.js → chunk-F25CL3IQ.js} +2 -2
- package/dist/{chunk-QZPSIQRB.js → chunk-FOT5GP2U.js} +2 -2
- package/dist/{chunk-OMA2B7GY.js → chunk-HACVBEAP.js} +2 -2
- package/dist/{chunk-IEZ6CGTW.js → chunk-HCM7ZYJD.js} +2 -2
- package/dist/{chunk-46XRYYOJ.js → chunk-HDQQRRCZ.js} +3 -3
- package/dist/{chunk-QXDICB4J.js → chunk-I6T4SYFW.js} +3 -3
- package/dist/{chunk-HQJM3BKH.js → chunk-J2R275FN.js} +2 -2
- package/dist/{chunk-STUPJFG7.js → chunk-KAY2C6VX.js} +2 -2
- package/dist/{chunk-I3X5NWDU.js → chunk-KJUH3QIQ.js} +2 -2
- package/dist/{chunk-BUKK5SWA.js → chunk-KQAFEZQX.js} +2 -2
- package/dist/{chunk-KZAN4JXY.js → chunk-KRNFJKMH.js} +2 -2
- package/dist/{chunk-SBUQ4KIE.js → chunk-L7MJJAG2.js} +2 -2
- package/dist/{chunk-IVKIODHO.js → chunk-MB6GLPRY.js} +4 -4
- package/dist/{chunk-NROLO2HY.js → chunk-ND6NL2YR.js} +2 -2
- package/dist/{chunk-O2HDDAUM.js → chunk-NFITGYMQ.js} +3 -3
- package/dist/{chunk-GRZYF6KK.js → chunk-NYQHD7H4.js} +12 -12
- package/dist/{chunk-REJCULWA.js → chunk-P342D5BP.js} +2 -2
- package/dist/{chunk-HGRRJDUH.js → chunk-PIBQIYF2.js} +2 -2
- package/dist/{chunk-UUGU26P2.js → chunk-QOE3EM5Z.js} +2 -2
- package/dist/{chunk-N37X5RU7.js → chunk-RACZB5HT.js} +4 -4
- package/dist/{chunk-AU7Q3LSC.js → chunk-RKNJBZ55.js} +7 -7
- package/dist/{chunk-F3H7VNM4.js → chunk-USQAH6PN.js} +2 -2
- package/dist/{chunk-ZFE7NXB7.js → chunk-VLSUKXCJ.js} +4 -4
- package/dist/{chunk-GHSJBVVQ.js → chunk-WFFGUFFN.js} +265 -49
- package/dist/chunk-WFFGUFFN.js.map +1 -0
- package/dist/{chunk-YWD5UIWY.js → chunk-WQCET3N3.js} +2 -2
- package/dist/{chunk-MUYJQ273.js → chunk-WSYACIKD.js} +2 -2
- package/dist/{chunk-UVYI6VIX.js → chunk-X7Y7WX73.js} +1 -1
- package/dist/{chunk-AWMS3IFB.js → chunk-XPDWNWH6.js} +3 -3
- package/dist/{chunk-K5JFIA44.js → chunk-XW3G4BJI.js} +7 -7
- package/dist/{chunk-GIC6BASP.js → chunk-XZBXINXZ.js} +4 -4
- package/dist/{chunk-KDCPIXIY.js → chunk-YFK64FYK.js} +2 -2
- package/dist/{chunk-DDAHG5ZD.js → chunk-YYAWJRGL.js} +2 -2
- package/dist/{chunk-IYFL3534.js → chunk-Z2FXSE5P.js} +2 -2
- package/dist/{chunk-CR73YTWC.js → chunk-ZDJMCXC7.js} +11 -11
- package/dist/{chunk-OQT34EVH.js → chunk-ZVW724ZK.js} +2 -2
- package/dist/cli.js +63 -63
- package/dist/coding/export-okf-codegraph.js +11 -11
- package/dist/compounding/engine.js +11 -11
- package/dist/connectors/codex-materialize-runner.js +11 -11
- package/dist/connectors/index.js +11 -11
- package/dist/convergence-refresh.js +13 -13
- package/dist/conversation-index/backend.js +2 -2
- package/dist/corpus-watermark.js +13 -13
- package/dist/entity-retrieval.js +11 -11
- package/dist/external-wiki-access.js +27 -27
- package/dist/extraction-span-gate.d.ts +31 -0
- package/dist/extraction-span-gate.js +37 -0
- package/dist/extraction-span-gate.js.map +1 -0
- package/dist/extraction.js +14 -14
- package/dist/harmonic-retrieval.js +1 -1
- package/dist/index.d.ts +61 -6
- package/dist/index.js +106 -100
- package/dist/lcm/index.js +3 -3
- package/dist/local-llm.js +2 -2
- package/dist/location/index.js +13 -13
- package/dist/maintenance/memory-governance.js +11 -11
- package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +11 -11
- package/dist/maintenance/rebuild-memory-projection.js +12 -12
- package/dist/namespaces/migrate.js +12 -12
- package/dist/namespaces/search.js +9 -9
- package/dist/namespaces/storage.js +11 -11
- package/dist/operator-doctor-corpus.js +14 -14
- package/dist/operator-toolkit.js +19 -19
- package/dist/orchestration/maintenance.js +19 -19
- package/dist/orchestrator.js +87 -87
- package/dist/recall-navigate-window.d.ts +26 -0
- package/dist/recall-navigate-window.js +32 -0
- package/dist/recall-navigate-window.js.map +1 -0
- package/dist/search/factory.js +8 -8
- package/dist/search/index.js +13 -13
- package/dist/search/lancedb-backend.js +2 -2
- package/dist/search/meilisearch-backend.js +2 -2
- package/dist/search/orama-backend.js +2 -2
- package/dist/semantic-consolidation.js +12 -12
- package/dist/semantic-rule-promotion.js +11 -11
- package/dist/semantic-rule-verifier.js +11 -11
- package/dist/source-agent-qualifier.js +11 -11
- package/dist/standup/build.js +11 -11
- package/dist/storage.js +10 -10
- package/dist/summarizer.js +3 -3
- package/dist/support-passport/index.js +29 -29
- 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/export-okf.js +12 -12
- package/dist/verified-recall.js +11 -11
- package/dist/who-knows-cli.js +12 -12
- package/dist/who-knows.js +11 -11
- package/package.json +2 -2
- package/src/activity/index.ts +2 -0
- package/src/activity/journal-section.ts +1 -1
- package/src/activity/journal-strip.test.ts +126 -0
- package/src/activity/journal-strip.ts +115 -0
- package/src/activity/privacy-delete-plan.test.ts +215 -0
- package/src/activity/privacy-delete-plan.ts +116 -0
- package/src/activity/timeline/index.ts +1 -0
- package/src/activity/timeline/recap-merge-edits.test.ts +181 -0
- package/src/activity/timeline/recap-merge-edits.ts +70 -0
- package/src/extraction-span-gate.test.ts +202 -0
- package/src/extraction-span-gate.ts +58 -0
- package/src/recall-navigate-window.test.ts +104 -0
- package/src/recall-navigate-window.ts +48 -0
- package/src/shared-context/supersession-chain.test.ts +158 -0
- package/src/shared-context/supersession-chain.ts +95 -0
- package/dist/chunk-GHSJBVVQ.js.map +0 -1
- /package/dist/{capsule-crypto-GWVG7LGC.js.map → capsule-crypto-7FJQINUR.js.map} +0 -0
- /package/dist/{chunk-LEPOP46W.js.map → chunk-227UFSEQ.js.map} +0 -0
- /package/dist/{chunk-FJP6MVRK.js.map → chunk-25P3LW6U.js.map} +0 -0
- /package/dist/{chunk-JKXPFIPC.js.map → chunk-2MHMHF76.js.map} +0 -0
- /package/dist/{chunk-OWHERGF2.js.map → chunk-2NLLXCJG.js.map} +0 -0
- /package/dist/{chunk-QMIKXIAM.js.map → chunk-327JMA2N.js.map} +0 -0
- /package/dist/{chunk-H6VYVOVP.js.map → chunk-363VZTVH.js.map} +0 -0
- /package/dist/{chunk-PKRYQ222.js.map → chunk-3IRH5MYM.js.map} +0 -0
- /package/dist/{chunk-GEHEBLVY.js.map → chunk-4PEZMGZD.js.map} +0 -0
- /package/dist/{chunk-EU5PDHU2.js.map → chunk-4PMPOZK3.js.map} +0 -0
- /package/dist/{chunk-6SRUPUTY.js.map → chunk-52GQNSLC.js.map} +0 -0
- /package/dist/{chunk-SV3MDTJI.js.map → chunk-5UI7WQA6.js.map} +0 -0
- /package/dist/{chunk-JGXMA6HL.js.map → chunk-6FUMA6AA.js.map} +0 -0
- /package/dist/{chunk-TVVEYCNW.js.map → chunk-7K5Q6COX.js.map} +0 -0
- /package/dist/{chunk-WGVAEVT7.js.map → chunk-7KG5V4VV.js.map} +0 -0
- /package/dist/{chunk-A6HVSRGM.js.map → chunk-A7NMS5II.js.map} +0 -0
- /package/dist/{chunk-M3BFW3VX.js.map → chunk-ABPCF4MW.js.map} +0 -0
- /package/dist/{chunk-NNBZS3DY.js.map → chunk-AG46P24C.js.map} +0 -0
- /package/dist/{chunk-I7FVQMAA.js.map → chunk-BGD3T743.js.map} +0 -0
- /package/dist/{chunk-QRHKJT7F.js.map → chunk-BHH736UQ.js.map} +0 -0
- /package/dist/{chunk-4L7OKRGP.js.map → chunk-BS5BD3DF.js.map} +0 -0
- /package/dist/{chunk-BT7AQZQX.js.map → chunk-BTRY6UH3.js.map} +0 -0
- /package/dist/{chunk-BE4Y6GK6.js.map → chunk-D35XLYQ7.js.map} +0 -0
- /package/dist/{chunk-CJTZO5MW.js.map → chunk-D6Q5EPOV.js.map} +0 -0
- /package/dist/{chunk-6KGBI54C.js.map → chunk-DJ7TZLGU.js.map} +0 -0
- /package/dist/{chunk-IDPFBRPJ.js.map → chunk-ERKFG4RV.js.map} +0 -0
- /package/dist/{chunk-K7VTD5AZ.js.map → chunk-F25CL3IQ.js.map} +0 -0
- /package/dist/{chunk-QZPSIQRB.js.map → chunk-FOT5GP2U.js.map} +0 -0
- /package/dist/{chunk-OMA2B7GY.js.map → chunk-HACVBEAP.js.map} +0 -0
- /package/dist/{chunk-IEZ6CGTW.js.map → chunk-HCM7ZYJD.js.map} +0 -0
- /package/dist/{chunk-46XRYYOJ.js.map → chunk-HDQQRRCZ.js.map} +0 -0
- /package/dist/{chunk-QXDICB4J.js.map → chunk-I6T4SYFW.js.map} +0 -0
- /package/dist/{chunk-HQJM3BKH.js.map → chunk-J2R275FN.js.map} +0 -0
- /package/dist/{chunk-STUPJFG7.js.map → chunk-KAY2C6VX.js.map} +0 -0
- /package/dist/{chunk-I3X5NWDU.js.map → chunk-KJUH3QIQ.js.map} +0 -0
- /package/dist/{chunk-BUKK5SWA.js.map → chunk-KQAFEZQX.js.map} +0 -0
- /package/dist/{chunk-KZAN4JXY.js.map → chunk-KRNFJKMH.js.map} +0 -0
- /package/dist/{chunk-SBUQ4KIE.js.map → chunk-L7MJJAG2.js.map} +0 -0
- /package/dist/{chunk-IVKIODHO.js.map → chunk-MB6GLPRY.js.map} +0 -0
- /package/dist/{chunk-NROLO2HY.js.map → chunk-ND6NL2YR.js.map} +0 -0
- /package/dist/{chunk-O2HDDAUM.js.map → chunk-NFITGYMQ.js.map} +0 -0
- /package/dist/{chunk-GRZYF6KK.js.map → chunk-NYQHD7H4.js.map} +0 -0
- /package/dist/{chunk-REJCULWA.js.map → chunk-P342D5BP.js.map} +0 -0
- /package/dist/{chunk-HGRRJDUH.js.map → chunk-PIBQIYF2.js.map} +0 -0
- /package/dist/{chunk-UUGU26P2.js.map → chunk-QOE3EM5Z.js.map} +0 -0
- /package/dist/{chunk-N37X5RU7.js.map → chunk-RACZB5HT.js.map} +0 -0
- /package/dist/{chunk-AU7Q3LSC.js.map → chunk-RKNJBZ55.js.map} +0 -0
- /package/dist/{chunk-F3H7VNM4.js.map → chunk-USQAH6PN.js.map} +0 -0
- /package/dist/{chunk-ZFE7NXB7.js.map → chunk-VLSUKXCJ.js.map} +0 -0
- /package/dist/{chunk-YWD5UIWY.js.map → chunk-WQCET3N3.js.map} +0 -0
- /package/dist/{chunk-MUYJQ273.js.map → chunk-WSYACIKD.js.map} +0 -0
- /package/dist/{chunk-UVYI6VIX.js.map → chunk-X7Y7WX73.js.map} +0 -0
- /package/dist/{chunk-AWMS3IFB.js.map → chunk-XPDWNWH6.js.map} +0 -0
- /package/dist/{chunk-K5JFIA44.js.map → chunk-XW3G4BJI.js.map} +0 -0
- /package/dist/{chunk-GIC6BASP.js.map → chunk-XZBXINXZ.js.map} +0 -0
- /package/dist/{chunk-KDCPIXIY.js.map → chunk-YFK64FYK.js.map} +0 -0
- /package/dist/{chunk-DDAHG5ZD.js.map → chunk-YYAWJRGL.js.map} +0 -0
- /package/dist/{chunk-IYFL3534.js.map → chunk-Z2FXSE5P.js.map} +0 -0
- /package/dist/{chunk-CR73YTWC.js.map → chunk-ZDJMCXC7.js.map} +0 -0
- /package/dist/{chunk-OQT34EVH.js.map → chunk-ZVW724ZK.js.map} +0 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
mergeRecapUserEdits,
|
|
6
|
+
type RecapSection,
|
|
7
|
+
} from "./recap-merge-edits.js";
|
|
8
|
+
|
|
9
|
+
test("edited section survives regeneration", () => {
|
|
10
|
+
const result = mergeRecapUserEdits({
|
|
11
|
+
generated: [{ key: "highlights", body: "new body" }],
|
|
12
|
+
edited: [{ key: "highlights", body: "hand edit" }],
|
|
13
|
+
});
|
|
14
|
+
assert.deepEqual(result.sections, [{ key: "highlights", body: "hand edit" }]);
|
|
15
|
+
assert.deepEqual(result.preserved, ["highlights"]);
|
|
16
|
+
assert.deepEqual(result.regenerated, []);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("reset discards every edit", () => {
|
|
20
|
+
const result = mergeRecapUserEdits({
|
|
21
|
+
generated: [{ key: "highlights", body: "new body" }],
|
|
22
|
+
edited: [{ key: "highlights", body: "hand edit" }],
|
|
23
|
+
reset: true,
|
|
24
|
+
});
|
|
25
|
+
assert.deepEqual(result.sections, [{ key: "highlights", body: "new body" }]);
|
|
26
|
+
assert.deepEqual(result.preserved, []);
|
|
27
|
+
assert.deepEqual(result.regenerated, ["highlights"]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("edited-only section is kept and counted preserved", () => {
|
|
31
|
+
const result = mergeRecapUserEdits({
|
|
32
|
+
generated: [{ key: "highlights", body: "new body" }],
|
|
33
|
+
edited: [
|
|
34
|
+
{ key: "highlights", body: "hand edit" },
|
|
35
|
+
{ key: "zeta", body: "user section" },
|
|
36
|
+
{ key: "alpha", body: "another user section" },
|
|
37
|
+
],
|
|
38
|
+
});
|
|
39
|
+
assert.deepEqual(
|
|
40
|
+
result.sections.map((s) => s.key),
|
|
41
|
+
["highlights", "alpha", "zeta"],
|
|
42
|
+
);
|
|
43
|
+
assert.deepEqual(result.preserved, ["highlights", "alpha", "zeta"]);
|
|
44
|
+
assert.deepEqual(result.regenerated, []);
|
|
45
|
+
const alpha = result.sections.find((s) => s.key === "alpha");
|
|
46
|
+
assert.equal(alpha?.body, "another user section");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("edited-only section is dropped on reset", () => {
|
|
50
|
+
const result = mergeRecapUserEdits({
|
|
51
|
+
generated: [{ key: "highlights", body: "new body" }],
|
|
52
|
+
edited: [{ key: "zeta", body: "user section" }],
|
|
53
|
+
reset: true,
|
|
54
|
+
});
|
|
55
|
+
assert.deepEqual(
|
|
56
|
+
result.sections.map((s) => s.key),
|
|
57
|
+
["highlights"],
|
|
58
|
+
);
|
|
59
|
+
assert.deepEqual(result.preserved, []);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("generated-only section is included and counted regenerated", () => {
|
|
63
|
+
const result = mergeRecapUserEdits({
|
|
64
|
+
generated: [
|
|
65
|
+
{ key: "highlights", body: "new body" },
|
|
66
|
+
{ key: "notes", body: "generated notes" },
|
|
67
|
+
],
|
|
68
|
+
edited: [{ key: "highlights", body: "hand edit" }],
|
|
69
|
+
});
|
|
70
|
+
const notes = result.sections.find((s) => s.key === "notes");
|
|
71
|
+
assert.equal(notes?.body, "generated notes");
|
|
72
|
+
assert.deepEqual(result.regenerated, ["notes"]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("whitespace-only edit falls back to generated body", () => {
|
|
76
|
+
const result = mergeRecapUserEdits({
|
|
77
|
+
generated: [{ key: "highlights", body: "new body" }],
|
|
78
|
+
edited: [{ key: "highlights", body: " \n\t " }],
|
|
79
|
+
});
|
|
80
|
+
assert.deepEqual(result.sections, [{ key: "highlights", body: "new body" }]);
|
|
81
|
+
assert.deepEqual(result.preserved, []);
|
|
82
|
+
assert.deepEqual(result.regenerated, ["highlights"]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("whitespace-only edited-only key is dropped entirely", () => {
|
|
86
|
+
const result = mergeRecapUserEdits({
|
|
87
|
+
generated: [{ key: "highlights", body: "new body" }],
|
|
88
|
+
edited: [{ key: "zeta", body: " " }],
|
|
89
|
+
});
|
|
90
|
+
assert.deepEqual(
|
|
91
|
+
result.sections.map((s) => s.key),
|
|
92
|
+
["highlights"],
|
|
93
|
+
);
|
|
94
|
+
assert.deepEqual(result.preserved, []);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("duplicate keys keep the first occurrence", () => {
|
|
98
|
+
const result = mergeRecapUserEdits({
|
|
99
|
+
generated: [
|
|
100
|
+
{ key: "highlights", body: "first generated" },
|
|
101
|
+
{ key: "highlights", body: "second generated" },
|
|
102
|
+
],
|
|
103
|
+
edited: [
|
|
104
|
+
{ key: "highlights", body: "first edit" },
|
|
105
|
+
{ key: "highlights", body: "second edit" },
|
|
106
|
+
],
|
|
107
|
+
});
|
|
108
|
+
assert.deepEqual(result.sections, [
|
|
109
|
+
{ key: "highlights", body: "first edit" },
|
|
110
|
+
]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("duplicate edited-only keys keep the first occurrence", () => {
|
|
114
|
+
const result = mergeRecapUserEdits({
|
|
115
|
+
generated: [{ key: "highlights", body: "gen" }],
|
|
116
|
+
edited: [
|
|
117
|
+
{ key: "zeta", body: "first user section" },
|
|
118
|
+
{ key: "zeta", body: "second user section" },
|
|
119
|
+
],
|
|
120
|
+
});
|
|
121
|
+
const zeta = result.sections.find((s) => s.key === "zeta");
|
|
122
|
+
assert.equal(zeta?.body, "first user section");
|
|
123
|
+
assert.equal(result.sections.filter((s) => s.key === "zeta").length, 1);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("output is deterministic across two calls on the same input", () => {
|
|
127
|
+
const input = {
|
|
128
|
+
generated: [
|
|
129
|
+
{ key: "notes", body: "generated notes" },
|
|
130
|
+
{ key: "highlights", body: "new body" },
|
|
131
|
+
],
|
|
132
|
+
edited: [
|
|
133
|
+
{ key: "zeta", body: "user section" },
|
|
134
|
+
{ key: "highlights", body: "hand edit" },
|
|
135
|
+
{ key: "alpha", body: "another user section" },
|
|
136
|
+
],
|
|
137
|
+
};
|
|
138
|
+
const first = mergeRecapUserEdits(input);
|
|
139
|
+
const second = mergeRecapUserEdits(input);
|
|
140
|
+
assert.deepEqual(first, second);
|
|
141
|
+
assert.deepEqual(
|
|
142
|
+
first.sections.map((s) => s.key),
|
|
143
|
+
["notes", "highlights", "alpha", "zeta"],
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("preserved and regenerated are disjoint and cover every output key", () => {
|
|
148
|
+
const result = mergeRecapUserEdits({
|
|
149
|
+
generated: [
|
|
150
|
+
{ key: "highlights", body: "new body" },
|
|
151
|
+
{ key: "notes", body: "generated notes" },
|
|
152
|
+
],
|
|
153
|
+
edited: [
|
|
154
|
+
{ key: "highlights", body: "hand edit" },
|
|
155
|
+
{ key: "zeta", body: "user section" },
|
|
156
|
+
],
|
|
157
|
+
});
|
|
158
|
+
const outputKeys = result.sections.map((s) => s.key).sort();
|
|
159
|
+
const accounted = [...result.preserved, ...result.regenerated].sort();
|
|
160
|
+
assert.deepEqual(accounted, outputKeys);
|
|
161
|
+
for (const key of result.preserved) {
|
|
162
|
+
assert.equal(result.regenerated.includes(key), false);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("inputs are not mutated", () => {
|
|
167
|
+
const generated: RecapSection[] = [
|
|
168
|
+
{ key: "highlights", body: "new body" },
|
|
169
|
+
{ key: "notes", body: "generated notes" },
|
|
170
|
+
];
|
|
171
|
+
const edited: RecapSection[] = [
|
|
172
|
+
{ key: "highlights", body: "hand edit" },
|
|
173
|
+
{ key: "zeta", body: "user section" },
|
|
174
|
+
];
|
|
175
|
+
const generatedSnapshot = structuredClone(generated);
|
|
176
|
+
const editedSnapshot = structuredClone(edited);
|
|
177
|
+
mergeRecapUserEdits({ generated, edited, reset: false });
|
|
178
|
+
mergeRecapUserEdits({ generated, edited, reset: true });
|
|
179
|
+
assert.deepEqual(generated, generatedSnapshot);
|
|
180
|
+
assert.deepEqual(edited, editedSnapshot);
|
|
181
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Merge user hand-edits into a regenerated journal recap (issue #2051).
|
|
3
|
+
*
|
|
4
|
+
* Rule: user edits survive regeneration; an explicit reset is the only
|
|
5
|
+
* way to discard them. Pure — no I/O, no clock, no randomness.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface RecapSection {
|
|
9
|
+
/** Stable section key, e.g. "highlights". */
|
|
10
|
+
key: string;
|
|
11
|
+
body: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type MergeRecapEditsResult = {
|
|
15
|
+
sections: RecapSection[];
|
|
16
|
+
/** Section keys whose user edit was kept over the regenerated body. */
|
|
17
|
+
preserved: string[];
|
|
18
|
+
/** Section keys taken from the regenerated recap. */
|
|
19
|
+
regenerated: string[];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function dedupeByFirst(list: readonly RecapSection[]): RecapSection[] {
|
|
23
|
+
const seen = new Set<string>();
|
|
24
|
+
const out: RecapSection[] = [];
|
|
25
|
+
for (const section of list) {
|
|
26
|
+
if (seen.has(section.key)) continue;
|
|
27
|
+
seen.add(section.key);
|
|
28
|
+
out.push(section);
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function mergeRecapUserEdits(input: {
|
|
34
|
+
generated: readonly RecapSection[];
|
|
35
|
+
edited?: readonly RecapSection[];
|
|
36
|
+
reset?: boolean;
|
|
37
|
+
}): MergeRecapEditsResult {
|
|
38
|
+
const generated = dedupeByFirst(input.generated);
|
|
39
|
+
const edited = dedupeByFirst(input.edited ?? []);
|
|
40
|
+
const editedByKey = new Map(edited.map((s) => [s.key, s]));
|
|
41
|
+
|
|
42
|
+
const sections: RecapSection[] = [];
|
|
43
|
+
const preserved: string[] = [];
|
|
44
|
+
const regenerated: string[] = [];
|
|
45
|
+
|
|
46
|
+
for (const gen of generated) {
|
|
47
|
+
const edit = editedByKey.get(gen.key);
|
|
48
|
+
if (!input.reset && edit !== undefined && edit.body.trim() !== "") {
|
|
49
|
+
sections.push({ key: gen.key, body: edit.body });
|
|
50
|
+
preserved.push(gen.key);
|
|
51
|
+
} else {
|
|
52
|
+
sections.push({ key: gen.key, body: gen.body });
|
|
53
|
+
regenerated.push(gen.key);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!input.reset) {
|
|
58
|
+
const generatedKeys = new Set(generated.map((s) => s.key));
|
|
59
|
+
const editedOnly = edited
|
|
60
|
+
.filter((s) => !generatedKeys.has(s.key))
|
|
61
|
+
.filter((s) => s.body.trim() !== "")
|
|
62
|
+
.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
|
|
63
|
+
for (const edit of editedOnly) {
|
|
64
|
+
sections.push({ key: edit.key, body: edit.body });
|
|
65
|
+
preserved.push(edit.key);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return { sections, preserved, regenerated };
|
|
70
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
evaluateSpanPhaseGate,
|
|
6
|
+
SPAN_GATE_MAX_FALLBACK_RATE_PCT,
|
|
7
|
+
SPAN_GATE_MAX_JUDGE_DROP_POINTS,
|
|
8
|
+
SPAN_GATE_MIN_WALLCLOCK_REDUCTION_PCT,
|
|
9
|
+
} from "./extraction-span-gate.js";
|
|
10
|
+
|
|
11
|
+
function assertRangeError(fn: () => unknown, field: string): void {
|
|
12
|
+
assert.throws(fn, (err: unknown) => {
|
|
13
|
+
assert.ok(err instanceof RangeError, `expected RangeError, got ${String(err)}`);
|
|
14
|
+
assert.match((err as RangeError).message, new RegExp(field));
|
|
15
|
+
return true;
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("evaluateSpanPhaseGate", () => {
|
|
20
|
+
it("passes the Phase A bench numbers from the issue", () => {
|
|
21
|
+
const verdict = evaluateSpanPhaseGate({
|
|
22
|
+
wallClockReductionPct: 45,
|
|
23
|
+
judgeScoreDropPoints: 0.3,
|
|
24
|
+
fallbackRatePct: 4,
|
|
25
|
+
});
|
|
26
|
+
assert.deepEqual(verdict, {
|
|
27
|
+
pass: true,
|
|
28
|
+
conditions: { wallClockReduction: true, judgeQuality: true, fallbackRate: true },
|
|
29
|
+
failed: [],
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("treats the wall-clock boundary 20.0 as a pass (>= 20)", () => {
|
|
34
|
+
const verdict = evaluateSpanPhaseGate({
|
|
35
|
+
wallClockReductionPct: SPAN_GATE_MIN_WALLCLOCK_REDUCTION_PCT,
|
|
36
|
+
judgeScoreDropPoints: 0,
|
|
37
|
+
fallbackRatePct: 0,
|
|
38
|
+
});
|
|
39
|
+
assert.equal(verdict.conditions.wallClockReduction, true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("treats a judge drop of exactly 2.0 as a failure (< 2)", () => {
|
|
43
|
+
const verdict = evaluateSpanPhaseGate({
|
|
44
|
+
wallClockReductionPct: 45,
|
|
45
|
+
judgeScoreDropPoints: SPAN_GATE_MAX_JUDGE_DROP_POINTS,
|
|
46
|
+
fallbackRatePct: 4,
|
|
47
|
+
});
|
|
48
|
+
assert.equal(verdict.pass, false);
|
|
49
|
+
assert.deepEqual(verdict.failed, ["judgeQuality"]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("treats a fallback rate of exactly 15.0 as a failure (< 15)", () => {
|
|
53
|
+
const verdict = evaluateSpanPhaseGate({
|
|
54
|
+
wallClockReductionPct: 45,
|
|
55
|
+
judgeScoreDropPoints: 0.3,
|
|
56
|
+
fallbackRatePct: SPAN_GATE_MAX_FALLBACK_RATE_PCT,
|
|
57
|
+
});
|
|
58
|
+
assert.equal(verdict.pass, false);
|
|
59
|
+
assert.deepEqual(verdict.failed, ["fallbackRate"]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("fails on wall-clock reduction alone", () => {
|
|
63
|
+
const verdict = evaluateSpanPhaseGate({
|
|
64
|
+
wallClockReductionPct: 19.9,
|
|
65
|
+
judgeScoreDropPoints: 0.3,
|
|
66
|
+
fallbackRatePct: 4,
|
|
67
|
+
});
|
|
68
|
+
assert.equal(verdict.pass, false);
|
|
69
|
+
assert.deepEqual(verdict.failed, ["wallClockReduction"]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("fails on judge quality alone", () => {
|
|
73
|
+
const verdict = evaluateSpanPhaseGate({
|
|
74
|
+
wallClockReductionPct: 45,
|
|
75
|
+
judgeScoreDropPoints: 2.5,
|
|
76
|
+
fallbackRatePct: 4,
|
|
77
|
+
});
|
|
78
|
+
assert.equal(verdict.pass, false);
|
|
79
|
+
assert.deepEqual(verdict.failed, ["judgeQuality"]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("fails on fallback rate alone", () => {
|
|
83
|
+
const verdict = evaluateSpanPhaseGate({
|
|
84
|
+
wallClockReductionPct: 45,
|
|
85
|
+
judgeScoreDropPoints: 0.3,
|
|
86
|
+
fallbackRatePct: 15.1,
|
|
87
|
+
});
|
|
88
|
+
assert.equal(verdict.pass, false);
|
|
89
|
+
assert.deepEqual(verdict.failed, ["fallbackRate"]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("lists all three failures in declaration order", () => {
|
|
93
|
+
const verdict = evaluateSpanPhaseGate({
|
|
94
|
+
wallClockReductionPct: 10,
|
|
95
|
+
judgeScoreDropPoints: 3,
|
|
96
|
+
fallbackRatePct: 20,
|
|
97
|
+
});
|
|
98
|
+
assert.equal(verdict.pass, false);
|
|
99
|
+
assert.deepEqual(verdict.conditions, {
|
|
100
|
+
wallClockReduction: false,
|
|
101
|
+
judgeQuality: false,
|
|
102
|
+
fallbackRate: false,
|
|
103
|
+
});
|
|
104
|
+
assert.deepEqual(verdict.failed, ["wallClockReduction", "judgeQuality", "fallbackRate"]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("fails a negative wall-clock reduction without throwing", () => {
|
|
108
|
+
const verdict = evaluateSpanPhaseGate({
|
|
109
|
+
wallClockReductionPct: -5,
|
|
110
|
+
judgeScoreDropPoints: 0.3,
|
|
111
|
+
fallbackRatePct: 4,
|
|
112
|
+
});
|
|
113
|
+
assert.equal(verdict.pass, false);
|
|
114
|
+
assert.deepEqual(verdict.failed, ["wallClockReduction"]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("passes a negative judge drop (quality improved)", () => {
|
|
118
|
+
const verdict = evaluateSpanPhaseGate({
|
|
119
|
+
wallClockReductionPct: 45,
|
|
120
|
+
judgeScoreDropPoints: -0.5,
|
|
121
|
+
fallbackRatePct: 4,
|
|
122
|
+
});
|
|
123
|
+
assert.equal(verdict.conditions.judgeQuality, true);
|
|
124
|
+
assert.equal(verdict.pass, true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("throws RangeError naming the field on non-finite wall-clock reduction", () => {
|
|
128
|
+
assertRangeError(
|
|
129
|
+
() =>
|
|
130
|
+
evaluateSpanPhaseGate({
|
|
131
|
+
wallClockReductionPct: Number.NaN,
|
|
132
|
+
judgeScoreDropPoints: 0.3,
|
|
133
|
+
fallbackRatePct: 4,
|
|
134
|
+
}),
|
|
135
|
+
"wallClockReductionPct",
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("throws RangeError naming the field on non-finite judge drop", () => {
|
|
140
|
+
assertRangeError(
|
|
141
|
+
() =>
|
|
142
|
+
evaluateSpanPhaseGate({
|
|
143
|
+
wallClockReductionPct: 45,
|
|
144
|
+
judgeScoreDropPoints: Number.POSITIVE_INFINITY,
|
|
145
|
+
fallbackRatePct: 4,
|
|
146
|
+
}),
|
|
147
|
+
"judgeScoreDropPoints",
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("throws RangeError naming the field on non-finite fallback rate", () => {
|
|
152
|
+
assertRangeError(
|
|
153
|
+
() =>
|
|
154
|
+
evaluateSpanPhaseGate({
|
|
155
|
+
wallClockReductionPct: 45,
|
|
156
|
+
judgeScoreDropPoints: 0.3,
|
|
157
|
+
fallbackRatePct: Number.NEGATIVE_INFINITY,
|
|
158
|
+
}),
|
|
159
|
+
"fallbackRatePct",
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("throws RangeError on a fallback rate of 101", () => {
|
|
164
|
+
assertRangeError(
|
|
165
|
+
() =>
|
|
166
|
+
evaluateSpanPhaseGate({
|
|
167
|
+
wallClockReductionPct: 45,
|
|
168
|
+
judgeScoreDropPoints: 0.3,
|
|
169
|
+
fallbackRatePct: 101,
|
|
170
|
+
}),
|
|
171
|
+
"fallbackRatePct",
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("throws RangeError on a fallback rate of -1", () => {
|
|
176
|
+
assertRangeError(
|
|
177
|
+
() =>
|
|
178
|
+
evaluateSpanPhaseGate({
|
|
179
|
+
wallClockReductionPct: 45,
|
|
180
|
+
judgeScoreDropPoints: 0.3,
|
|
181
|
+
fallbackRatePct: -1,
|
|
182
|
+
}),
|
|
183
|
+
"fallbackRatePct",
|
|
184
|
+
);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("accepts the fallback-rate endpoints 0 and 100 as measurements", () => {
|
|
188
|
+
assert.doesNotThrow(() =>
|
|
189
|
+
evaluateSpanPhaseGate({
|
|
190
|
+
wallClockReductionPct: 45,
|
|
191
|
+
judgeScoreDropPoints: 0.3,
|
|
192
|
+
fallbackRatePct: 0,
|
|
193
|
+
}),
|
|
194
|
+
);
|
|
195
|
+
const verdict = evaluateSpanPhaseGate({
|
|
196
|
+
wallClockReductionPct: 45,
|
|
197
|
+
judgeScoreDropPoints: 0.3,
|
|
198
|
+
fallbackRatePct: 100,
|
|
199
|
+
});
|
|
200
|
+
assert.deepEqual(verdict.failed, ["fallbackRate"]);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Span-mode Phase B gate (issue #2333).
|
|
3
|
+
*
|
|
4
|
+
* Phase A must earn >= 20% extraction wall-clock reduction, keep the LLM-judge
|
|
5
|
+
* score drop under 2 points, and keep the span fallback rate under 15% before
|
|
6
|
+
* Phase B wiring starts. A failed gate is reported, never rounded into a pass.
|
|
7
|
+
*
|
|
8
|
+
* Pure. No I/O, no clock, no randomness.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const SPAN_GATE_MIN_WALLCLOCK_REDUCTION_PCT = 20;
|
|
12
|
+
export const SPAN_GATE_MAX_JUDGE_DROP_POINTS = 2;
|
|
13
|
+
export const SPAN_GATE_MAX_FALLBACK_RATE_PCT = 15;
|
|
14
|
+
|
|
15
|
+
export interface SpanGateConditions {
|
|
16
|
+
wallClockReduction: boolean;
|
|
17
|
+
judgeQuality: boolean;
|
|
18
|
+
fallbackRate: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface SpanGateVerdict {
|
|
22
|
+
/** True only when every condition passes. */
|
|
23
|
+
pass: boolean;
|
|
24
|
+
conditions: SpanGateConditions;
|
|
25
|
+
/** Condition names that failed, in declaration order. Empty when pass. */
|
|
26
|
+
failed: string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function requireFinite(value: number, field: string): void {
|
|
30
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
31
|
+
throw new RangeError(`span gate input ${field} must be a finite number, got ${value}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function evaluateSpanPhaseGate(input: {
|
|
36
|
+
wallClockReductionPct: number;
|
|
37
|
+
judgeScoreDropPoints: number;
|
|
38
|
+
fallbackRatePct: number;
|
|
39
|
+
}): SpanGateVerdict {
|
|
40
|
+
requireFinite(input.wallClockReductionPct, "wallClockReductionPct");
|
|
41
|
+
requireFinite(input.judgeScoreDropPoints, "judgeScoreDropPoints");
|
|
42
|
+
requireFinite(input.fallbackRatePct, "fallbackRatePct");
|
|
43
|
+
if (input.fallbackRatePct < 0 || input.fallbackRatePct > 100) {
|
|
44
|
+
throw new RangeError(
|
|
45
|
+
`span gate input fallbackRatePct must be within [0, 100], got ${input.fallbackRatePct}`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const conditions: SpanGateConditions = {
|
|
50
|
+
wallClockReduction: input.wallClockReductionPct >= SPAN_GATE_MIN_WALLCLOCK_REDUCTION_PCT,
|
|
51
|
+
judgeQuality: input.judgeScoreDropPoints < SPAN_GATE_MAX_JUDGE_DROP_POINTS,
|
|
52
|
+
fallbackRate: input.fallbackRatePct < SPAN_GATE_MAX_FALLBACK_RATE_PCT,
|
|
53
|
+
};
|
|
54
|
+
const failed = (Object.keys(conditions) as (keyof SpanGateConditions)[]).filter(
|
|
55
|
+
(name) => !conditions[name],
|
|
56
|
+
);
|
|
57
|
+
return { pass: failed.length === 0, conditions, failed };
|
|
58
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_NAVIGATION_WINDOW_SNAPSHOTS,
|
|
6
|
+
assertIdInNavigationWindow,
|
|
7
|
+
type NavigationSnapshot,
|
|
8
|
+
} from "./recall-navigate-window.js";
|
|
9
|
+
|
|
10
|
+
const snap = (...servedIds: string[]): NavigationSnapshot => ({ servedIds });
|
|
11
|
+
const malformed = (value: unknown): NavigationSnapshot => value as NavigationSnapshot;
|
|
12
|
+
|
|
13
|
+
test("default window is 3 snapshots", () => {
|
|
14
|
+
assert.equal(DEFAULT_NAVIGATION_WINDOW_SNAPSHOTS, 3);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("id in the newest snapshot is ok", () => {
|
|
18
|
+
assert.deepEqual(
|
|
19
|
+
assertIdInNavigationWindow({ snapshots: [snap("fact-1"), snap("fact-2")], memoryId: "fact-1" }),
|
|
20
|
+
{ ok: true, memoryId: "fact-1" },
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("id in the 3rd snapshot is ok at the default window", () => {
|
|
25
|
+
const snapshots = [snap("fact-a"), snap("fact-b"), snap("fact-c"), snap("fact-d")];
|
|
26
|
+
assert.deepEqual(assertIdInNavigationWindow({ snapshots, memoryId: "fact-c" }), {
|
|
27
|
+
ok: true,
|
|
28
|
+
memoryId: "fact-c",
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("id only in a 4th snapshot is not_served", () => {
|
|
33
|
+
const snapshots = [snap("fact-a"), snap("fact-b"), snap("fact-c"), snap("fact-d")];
|
|
34
|
+
assert.deepEqual(assertIdInNavigationWindow({ snapshots, memoryId: "fact-d" }), {
|
|
35
|
+
ok: false,
|
|
36
|
+
error: "not_served",
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("windowSnapshots 1 rejects an id from the 2nd snapshot", () => {
|
|
41
|
+
const snapshots = [snap("fact-a"), snap("fact-b")];
|
|
42
|
+
assert.deepEqual(
|
|
43
|
+
assertIdInNavigationWindow({ snapshots, memoryId: "fact-b", windowSnapshots: 1 }),
|
|
44
|
+
{ ok: false, error: "not_served" },
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("windowSnapshots 0, negative, fractional, NaN, Infinity throw", () => {
|
|
49
|
+
const snapshots = [snap("fact-a")];
|
|
50
|
+
for (const windowSnapshots of [0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY]) {
|
|
51
|
+
assert.throws(
|
|
52
|
+
() => assertIdInNavigationWindow({ snapshots, memoryId: "fact-a", windowSnapshots }),
|
|
53
|
+
{ name: "RangeError", message: /windowSnapshots/ },
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("non-string and whitespace ids are empty_id, never not_served", () => {
|
|
59
|
+
const snapshots = [snap("fact-a")];
|
|
60
|
+
for (const memoryId of [null, undefined, 42, {}, "", " ", "\t\n"]) {
|
|
61
|
+
assert.deepEqual(assertIdInNavigationWindow({ snapshots, memoryId }), {
|
|
62
|
+
ok: false,
|
|
63
|
+
error: "empty_id",
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("padded id matches after trim", () => {
|
|
69
|
+
const snapshots = [snap("fact-1")];
|
|
70
|
+
assert.deepEqual(assertIdInNavigationWindow({ snapshots, memoryId: " fact-1 \t" }), {
|
|
71
|
+
ok: true,
|
|
72
|
+
memoryId: "fact-1",
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("case-different id is not_served", () => {
|
|
77
|
+
const snapshots = [snap("Fact-1")];
|
|
78
|
+
assert.deepEqual(assertIdInNavigationWindow({ snapshots, memoryId: "fact-1" }), {
|
|
79
|
+
ok: false,
|
|
80
|
+
error: "not_served",
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("empty snapshots list is not_served", () => {
|
|
85
|
+
assert.deepEqual(assertIdInNavigationWindow({ snapshots: [], memoryId: "fact-1" }), {
|
|
86
|
+
ok: false,
|
|
87
|
+
error: "not_served",
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("malformed snapshot consumes its window slot without throwing", () => {
|
|
92
|
+
// Missing servedIds on the newest snapshot: it still burns the only
|
|
93
|
+
// window slot, so the id served by the 2nd snapshot stays not_served.
|
|
94
|
+
assert.doesNotThrow(() => {
|
|
95
|
+
assert.deepEqual(
|
|
96
|
+
assertIdInNavigationWindow({
|
|
97
|
+
snapshots: [malformed({}), malformed({ servedIds: "fact-1" })],
|
|
98
|
+
memoryId: "fact-1",
|
|
99
|
+
windowSnapshots: 1,
|
|
100
|
+
}),
|
|
101
|
+
{ ok: false, error: "not_served" },
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recall navigation window authority (issue #1956).
|
|
3
|
+
*
|
|
4
|
+
* Pure. An expandable id must have been served by one of the session's
|
|
5
|
+
* last `windowSnapshots` recall snapshots, ordered newest first. Unknown
|
|
6
|
+
* or foreign ids are rejected, not reinterpreted (pattern 39).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_NAVIGATION_WINDOW_SNAPSHOTS = 3;
|
|
10
|
+
|
|
11
|
+
/** Ids served by one recall snapshot, newest snapshot first when passed in. */
|
|
12
|
+
export interface NavigationSnapshot {
|
|
13
|
+
servedIds: readonly string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type NavigationAuthorityResult =
|
|
17
|
+
| { ok: true; memoryId: string }
|
|
18
|
+
| { ok: false; error: "empty_id" | "not_served" };
|
|
19
|
+
|
|
20
|
+
export function assertIdInNavigationWindow(input: {
|
|
21
|
+
snapshots: readonly NavigationSnapshot[];
|
|
22
|
+
memoryId: unknown;
|
|
23
|
+
windowSnapshots?: number;
|
|
24
|
+
}): NavigationAuthorityResult {
|
|
25
|
+
const windowSnapshots = input.windowSnapshots ?? DEFAULT_NAVIGATION_WINDOW_SNAPSHOTS;
|
|
26
|
+
if (!Number.isInteger(windowSnapshots) || windowSnapshots < 1) {
|
|
27
|
+
throw new RangeError(
|
|
28
|
+
`windowSnapshots must be a positive integer; got ${JSON.stringify(windowSnapshots)}`,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
if (typeof input.memoryId !== "string" || input.memoryId.trim() === "") {
|
|
32
|
+
return { ok: false, error: "empty_id" };
|
|
33
|
+
}
|
|
34
|
+
const wanted = input.memoryId.trim();
|
|
35
|
+
for (const snapshot of input.snapshots.slice(0, windowSnapshots)) {
|
|
36
|
+
// A malformed entry (missing or non-array servedIds) was still a real
|
|
37
|
+
// recall turn: it consumes a window slot but contributes no ids.
|
|
38
|
+
if (!snapshot || !Array.isArray(snapshot.servedIds)) {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
for (const served of snapshot.servedIds) {
|
|
42
|
+
if (typeof served === "string" && served.trim() === wanted) {
|
|
43
|
+
return { ok: true, memoryId: wanted };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return { ok: false, error: "not_served" };
|
|
48
|
+
}
|