@remnic/core 9.67.0 → 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.d.ts +1 -1
- package/dist/access-admin-ops-surface.js +26 -26
- package/dist/access-audit.js +2 -2
- package/dist/access-authorization-probe.d.ts +1 -1
- package/dist/access-authorization-probe.js +27 -27
- package/dist/access-boundary.d.ts +1 -1
- package/dist/access-boundary.js +26 -26
- package/dist/access-cli.js +87 -87
- package/dist/access-extraction-force-flush.d.ts +1 -1
- package/dist/access-extraction-force-flush.js +26 -26
- package/dist/access-http-lcm-compaction.d.ts +1 -1
- package/dist/access-http-lifecycle-flush.d.ts +1 -1
- package/dist/access-http-offline-stream.d.ts +1 -1
- package/dist/access-http-query.js +28 -28
- package/dist/access-http.d.ts +2 -2
- package/dist/access-http.js +44 -44
- package/dist/access-identity-continuity-surface.js +26 -26
- package/dist/access-lcm-surface.d.ts +1 -1
- package/dist/access-lcm-surface.js +26 -26
- package/dist/access-mcp.d.ts +1 -1
- package/dist/access-mcp.js +34 -34
- package/dist/access-namespace-preflight.js +26 -26
- package/dist/access-observe-write-surface.d.ts +1 -1
- package/dist/access-observe-write-surface.js +26 -26
- package/dist/access-operations-batch.js +28 -28
- package/dist/access-operations.d.ts +6 -6
- package/dist/access-operations.js +33 -33
- package/dist/access-recall-concurrency.d.ts +1 -1
- package/dist/access-recall-concurrency.js +26 -26
- package/dist/access-recall-response.d.ts +1 -1
- package/dist/access-recall-response.js +26 -26
- package/dist/access-recall-surface.d.ts +1 -1
- package/dist/access-recall-surface.js +26 -26
- package/dist/access-schema.d.ts +89 -89
- package/dist/access-schema.js +3 -3
- package/dist/access-service-helpers.d.ts +1 -1
- package/dist/access-service.d.ts +1 -1
- package/dist/access-service.js +26 -26
- package/dist/access-surface-catalog.d.ts +1 -1
- 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-QJLTAH5X.js → chunk-WFFGUFFN.js} +152 -43
- 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-CFpMhqz5.d.ts → cli-Bvy4jdBu.d.ts} +1 -1
- package/dist/cli.d.ts +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.d.ts +1 -1
- package/dist/external-wiki-access.js +27 -27
- package/dist/external-wiki-mcp-tools.d.ts +1 -1
- 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 +727 -681
- 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/mcp-memory-inspector-app.d.ts +1 -1
- package/dist/memory-subject-cli.d.ts +1 -1
- package/dist/memory-subject.d.ts +1 -1
- 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/{public-http-C9-CHc40.d.ts → public-http-2MyshWxl.d.ts} +1 -1
- 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/schemas.d.ts +176 -176
- 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/shared-context/manager.d.ts +6 -6
- 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.d.ts +4 -4
- 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/transfer/types.d.ts +78 -78
- package/dist/verified-recall.js +11 -11
- package/dist/who-knows-cli.d.ts +1 -1
- package/dist/who-knows-cli.js +12 -12
- package/dist/who-knows.d.ts +1 -1
- package/dist/who-knows.js +11 -11
- package/package.json +2 -2
- package/src/activity/index.ts +1 -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-QJLTAH5X.js.map +0 -1
- package/dist/{access-service-DcfM0Vf0.d.ts → access-service-LbwWl05g.d.ts} +64 -64
- /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,215 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
|
|
4
|
+
import { MS_PER_DAY } from "./privacy.js";
|
|
5
|
+
import {
|
|
6
|
+
ACTIVITY_DELETE_SCOPES,
|
|
7
|
+
planActivityDeletion,
|
|
8
|
+
type ActivityDeleteCandidate,
|
|
9
|
+
} from "./privacy-delete-plan.js";
|
|
10
|
+
|
|
11
|
+
const NOW = 100 * MS_PER_DAY;
|
|
12
|
+
|
|
13
|
+
function cand(
|
|
14
|
+
scope: string,
|
|
15
|
+
relPath: string,
|
|
16
|
+
capturedAtMs: number,
|
|
17
|
+
): ActivityDeleteCandidate {
|
|
18
|
+
return { scope, relPath, capturedAtMs };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function plan(
|
|
22
|
+
candidates: readonly ActivityDeleteCandidate[],
|
|
23
|
+
overrides: Partial<Parameters<typeof planActivityDeletion>[0]> = {},
|
|
24
|
+
) {
|
|
25
|
+
return planActivityDeletion({
|
|
26
|
+
candidates,
|
|
27
|
+
scopes: ACTIVITY_DELETE_SCOPES,
|
|
28
|
+
retentionDays: 30,
|
|
29
|
+
nowMs: NOW,
|
|
30
|
+
...overrides,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
test("expired in-scope activity paths are planned across every owned root", () => {
|
|
35
|
+
const expired = NOW - 40 * MS_PER_DAY;
|
|
36
|
+
const result = plan([
|
|
37
|
+
cand("observations", "activity/2026-01-01.md", expired),
|
|
38
|
+
cand("cards", "wearables/limitless/2026-01-01.md", expired),
|
|
39
|
+
cand("journal", "meetings/2026-01-01/mtg-x.md", expired),
|
|
40
|
+
]);
|
|
41
|
+
assert.deepEqual(result.deletePaths, [
|
|
42
|
+
"activity/2026-01-01.md",
|
|
43
|
+
"meetings/2026-01-01/mtg-x.md",
|
|
44
|
+
"wearables/limitless/2026-01-01.md",
|
|
45
|
+
]);
|
|
46
|
+
assert.equal(result.keptCount, 0);
|
|
47
|
+
assert.deepEqual(result.refused, []);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("fresh in-scope candidates are kept, not deleted", () => {
|
|
51
|
+
const result = plan([cand("observations", "activity/2026-08-19.md", NOW - MS_PER_DAY)]);
|
|
52
|
+
assert.deepEqual(result.deletePaths, []);
|
|
53
|
+
assert.equal(result.keptCount, 1);
|
|
54
|
+
assert.deepEqual(result.refused, []);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("retentionDays 0 deletes nothing but counts kept", () => {
|
|
58
|
+
const ancient = NOW - 4000 * MS_PER_DAY;
|
|
59
|
+
const result = plan([cand("weekly", "activity/2015-01-01.md", ancient)], {
|
|
60
|
+
retentionDays: 0,
|
|
61
|
+
});
|
|
62
|
+
assert.deepEqual(result.deletePaths, []);
|
|
63
|
+
assert.equal(result.keptCount, 1);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("facts paths are refused even when expired and in scope", () => {
|
|
67
|
+
const expired = NOW - 40 * MS_PER_DAY;
|
|
68
|
+
const result = plan([cand("observations", "facts/2026-01-01/fact-1.md", expired)]);
|
|
69
|
+
assert.deepEqual(result.deletePaths, []);
|
|
70
|
+
assert.deepEqual(result.refused, ["facts/2026-01-01/fact-1.md"]);
|
|
71
|
+
assert.equal(result.keptCount, 0);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("profile.md, entities, and state files are refused", () => {
|
|
75
|
+
const expired = NOW - 40 * MS_PER_DAY;
|
|
76
|
+
const result = plan([
|
|
77
|
+
cand("journal", "profile.md", expired),
|
|
78
|
+
cand("journal", "entities/person-a.md", expired),
|
|
79
|
+
cand("providerCache", "state/buffer.json", expired),
|
|
80
|
+
]);
|
|
81
|
+
assert.deepEqual(result.deletePaths, []);
|
|
82
|
+
assert.deepEqual(result.refused, ["entities/person-a.md", "profile.md", "state/buffer.json"]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("absolute, traversal, and blank paths are refused", () => {
|
|
86
|
+
const expired = NOW - 40 * MS_PER_DAY;
|
|
87
|
+
const result = plan([
|
|
88
|
+
cand("observations", "/etc/passwd", expired),
|
|
89
|
+
cand("observations", "activity/../../facts/fact-1.md", expired),
|
|
90
|
+
cand("observations", " ", expired),
|
|
91
|
+
]);
|
|
92
|
+
assert.deepEqual(result.deletePaths, []);
|
|
93
|
+
assert.deepEqual(result.refused, [
|
|
94
|
+
" ",
|
|
95
|
+
"/etc/passwd",
|
|
96
|
+
"activity/../../facts/fact-1.md",
|
|
97
|
+
]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("unknown candidate scope is refused", () => {
|
|
101
|
+
const expired = NOW - 40 * MS_PER_DAY;
|
|
102
|
+
const result = plan([cand("bogus", "activity/2026-01-01.md", expired)]);
|
|
103
|
+
assert.deepEqual(result.deletePaths, []);
|
|
104
|
+
assert.deepEqual(result.refused, ["activity/2026-01-01.md"]);
|
|
105
|
+
assert.equal(result.keptCount, 0);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("scope absent from scopes is skipped entirely", () => {
|
|
109
|
+
const expired = NOW - 40 * MS_PER_DAY;
|
|
110
|
+
const result = plan([cand("cards", "activity/2026-01-01.md", expired)], {
|
|
111
|
+
scopes: ["observations"],
|
|
112
|
+
});
|
|
113
|
+
assert.deepEqual(result.deletePaths, []);
|
|
114
|
+
assert.deepEqual(result.refused, []);
|
|
115
|
+
assert.equal(result.keptCount, 0);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("unknown value in scopes throws RangeError", () => {
|
|
119
|
+
assert.throws(
|
|
120
|
+
() => plan([], { scopes: ["observations", "everything"] }),
|
|
121
|
+
(error: unknown) => error instanceof RangeError && /scope/.test(error.message),
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("negative or float retentionDays throws RangeError", () => {
|
|
126
|
+
assert.throws(
|
|
127
|
+
() => plan([], { retentionDays: -1 }),
|
|
128
|
+
(error: unknown) => error instanceof RangeError && /retentionDays/.test(error.message),
|
|
129
|
+
);
|
|
130
|
+
assert.throws(
|
|
131
|
+
() => plan([], { retentionDays: 2.5 }),
|
|
132
|
+
(error: unknown) => error instanceof RangeError && /retentionDays/.test(error.message),
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("non-finite nowMs throws RangeError", () => {
|
|
137
|
+
assert.throws(
|
|
138
|
+
() => plan([], { nowMs: Number.POSITIVE_INFINITY }),
|
|
139
|
+
(error: unknown) => error instanceof RangeError && /nowMs/.test(error.message),
|
|
140
|
+
);
|
|
141
|
+
assert.throws(
|
|
142
|
+
() => plan([], { nowMs: Number.NaN }),
|
|
143
|
+
(error: unknown) => error instanceof RangeError && /nowMs/.test(error.message),
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("deletePaths and refused are sorted and deduplicated", () => {
|
|
148
|
+
const expired = NOW - 40 * MS_PER_DAY;
|
|
149
|
+
const result = plan([
|
|
150
|
+
cand("observations", "activity/b.md", expired),
|
|
151
|
+
cand("cards", "activity/a.md", expired),
|
|
152
|
+
cand("journal", "activity/b.md", expired),
|
|
153
|
+
cand("observations", "facts/z.md", expired),
|
|
154
|
+
cand("cards", "facts/z.md", expired),
|
|
155
|
+
]);
|
|
156
|
+
assert.deepEqual(result.deletePaths, ["activity/a.md", "activity/b.md"]);
|
|
157
|
+
assert.deepEqual(result.refused, ["facts/z.md"]);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("half-open boundary: age exactly retentionDays * MS_PER_DAY is deleted", () => {
|
|
161
|
+
const boundary = NOW - 30 * MS_PER_DAY;
|
|
162
|
+
const result = plan([
|
|
163
|
+
cand("observations", "activity/boundary.md", boundary),
|
|
164
|
+
cand("observations", "activity/inside.md", boundary + 1),
|
|
165
|
+
]);
|
|
166
|
+
assert.deepEqual(result.deletePaths, ["activity/boundary.md"]);
|
|
167
|
+
assert.equal(result.keptCount, 1);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("input candidates are not mutated", () => {
|
|
171
|
+
const candidates = [
|
|
172
|
+
cand("observations", "activity/2026-01-01.md", NOW - 40 * MS_PER_DAY),
|
|
173
|
+
cand("observations", "facts/2026-01-01/fact-1.md", NOW - 40 * MS_PER_DAY),
|
|
174
|
+
];
|
|
175
|
+
const snapshot = structuredClone(candidates);
|
|
176
|
+
plan(candidates);
|
|
177
|
+
assert.deepEqual(candidates, snapshot);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// Review round 1: the planner trimmed relPath before validating it, so
|
|
181
|
+
// " activity/x.md" validated as owned and the plan then targeted
|
|
182
|
+
// "activity/x.md" — a path the caller never supplied.
|
|
183
|
+
test("a path with surrounding whitespace is refused, not normalized into a delete", () => {
|
|
184
|
+
const expired = 0;
|
|
185
|
+
for (const relPath of [" activity/day.md", "activity/day.md ", "\tactivity/day.md"]) {
|
|
186
|
+
const plan = planActivityDeletion({
|
|
187
|
+
candidates: [{ scope: "cards", relPath, capturedAtMs: expired }],
|
|
188
|
+
scopes: ["cards"],
|
|
189
|
+
retentionDays: 1,
|
|
190
|
+
nowMs: 10 * 86_400_000,
|
|
191
|
+
});
|
|
192
|
+
assert.deepEqual(plan.deletePaths, [], `${JSON.stringify(relPath)} must not be planned`);
|
|
193
|
+
assert.deepEqual(plan.refused, [relPath], "the exact candidate path is reported as refused");
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// Review round 1: every comparison against NaN is false, so shouldRetain
|
|
198
|
+
// reported "not retained" and a corrupt timestamp resolved toward deletion.
|
|
199
|
+
test("a non-finite capturedAtMs is refused instead of deleted", () => {
|
|
200
|
+
for (const capturedAtMs of [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]) {
|
|
201
|
+
const plan = planActivityDeletion({
|
|
202
|
+
candidates: [{ scope: "cards", relPath: "activity/corrupt.md", capturedAtMs }],
|
|
203
|
+
scopes: ["cards"],
|
|
204
|
+
retentionDays: 1,
|
|
205
|
+
nowMs: 10 * 86_400_000,
|
|
206
|
+
});
|
|
207
|
+
assert.deepEqual(
|
|
208
|
+
plan.deletePaths,
|
|
209
|
+
[],
|
|
210
|
+
`capturedAtMs ${String(capturedAtMs)} must never resolve toward deletion`,
|
|
211
|
+
);
|
|
212
|
+
assert.deepEqual(plan.refused, ["activity/corrupt.md"]);
|
|
213
|
+
assert.equal(plan.keptCount, 0);
|
|
214
|
+
}
|
|
215
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Activity retention deletion planner (issue #2053).
|
|
3
|
+
*
|
|
4
|
+
* Pure: decides what a retention pass may delete. Never performs I/O.
|
|
5
|
+
* Safety rule: a path outside the activity-owned roots is refused, never
|
|
6
|
+
* planned for deletion, so unrelated Remnic memories survive.
|
|
7
|
+
*/
|
|
8
|
+
import { shouldRetain } from "./privacy.js";
|
|
9
|
+
|
|
10
|
+
export const ACTIVITY_DELETE_SCOPES = [
|
|
11
|
+
"observations",
|
|
12
|
+
"cards",
|
|
13
|
+
"journal",
|
|
14
|
+
"weekly",
|
|
15
|
+
"providerCache",
|
|
16
|
+
] as const;
|
|
17
|
+
export type ActivityDeleteScope = (typeof ACTIVITY_DELETE_SCOPES)[number];
|
|
18
|
+
|
|
19
|
+
const KNOWN_SCOPES: readonly string[] = ACTIVITY_DELETE_SCOPES;
|
|
20
|
+
|
|
21
|
+
/** Deletion only ever touches these memory-dir-relative roots. */
|
|
22
|
+
const OWNED_ROOTS: readonly string[] = ["activity", "wearables", "meetings"];
|
|
23
|
+
|
|
24
|
+
export interface ActivityDeleteCandidate {
|
|
25
|
+
/** Which activity artifact family this item belongs to. */
|
|
26
|
+
scope: string;
|
|
27
|
+
/** Memory-dir-relative path, POSIX separators. */
|
|
28
|
+
relPath: string;
|
|
29
|
+
capturedAtMs: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ActivityDeletePlan {
|
|
33
|
+
deletePaths: string[];
|
|
34
|
+
keptCount: number;
|
|
35
|
+
/** Candidates refused because they are not activity-owned paths. */
|
|
36
|
+
refused: string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isRefusablePath(relPath: unknown): boolean {
|
|
40
|
+
if (typeof relPath !== "string") return true;
|
|
41
|
+
if (relPath.trim() === "") return true;
|
|
42
|
+
// Validate the EXACT candidate path. Trimming first would let
|
|
43
|
+
// " activity/x.md" validate as owned and then plan a delete for
|
|
44
|
+
// "activity/x.md" — a path the caller never supplied. Surrounding
|
|
45
|
+
// whitespace means this is not a clean owned path: refuse it.
|
|
46
|
+
if (relPath !== relPath.trim()) return true;
|
|
47
|
+
if (relPath.startsWith("/")) return true;
|
|
48
|
+
const segments = relPath.split("/");
|
|
49
|
+
if (segments.includes("..")) return true;
|
|
50
|
+
const [root = "", ...rest] = segments;
|
|
51
|
+
return (
|
|
52
|
+
rest.length === 0 ||
|
|
53
|
+
!OWNED_ROOTS.includes(root) ||
|
|
54
|
+
rest.some((segment) => segment.length === 0)
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function sortedUnique(paths: Iterable<string>): string[] {
|
|
59
|
+
return [...new Set(paths)].sort();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function planActivityDeletion(input: {
|
|
63
|
+
candidates: readonly ActivityDeleteCandidate[];
|
|
64
|
+
scopes: readonly string[];
|
|
65
|
+
retentionDays: number;
|
|
66
|
+
nowMs: number;
|
|
67
|
+
}): ActivityDeletePlan {
|
|
68
|
+
const { candidates, scopes, retentionDays, nowMs } = input;
|
|
69
|
+
|
|
70
|
+
for (const scope of scopes) {
|
|
71
|
+
if (!KNOWN_SCOPES.includes(scope)) {
|
|
72
|
+
throw new RangeError(`unknown activity delete scope: ${scope}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (!Number.isInteger(retentionDays) || retentionDays < 0) {
|
|
76
|
+
throw new RangeError("retentionDays must be a non-negative integer");
|
|
77
|
+
}
|
|
78
|
+
if (!Number.isFinite(nowMs)) {
|
|
79
|
+
throw new RangeError("nowMs must be a finite number");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const selectedScopes = new Set(scopes);
|
|
83
|
+
const deletePaths: string[] = [];
|
|
84
|
+
const refused: string[] = [];
|
|
85
|
+
let keptCount = 0;
|
|
86
|
+
|
|
87
|
+
for (const candidate of candidates) {
|
|
88
|
+
if (!KNOWN_SCOPES.includes(candidate.scope)) {
|
|
89
|
+
refused.push(String(candidate.relPath));
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (!selectedScopes.has(candidate.scope)) continue;
|
|
93
|
+
if (isRefusablePath(candidate.relPath)) {
|
|
94
|
+
refused.push(String(candidate.relPath));
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
// A corrupt timestamp must never resolve toward deletion: every
|
|
98
|
+
// comparison against NaN is false, which shouldRetain reads as
|
|
99
|
+
// "not retained". Refuse the candidate instead.
|
|
100
|
+
if (typeof candidate.capturedAtMs !== "number" || !Number.isFinite(candidate.capturedAtMs)) {
|
|
101
|
+
refused.push(candidate.relPath);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (shouldRetain(candidate.capturedAtMs, nowMs, retentionDays, true)) {
|
|
105
|
+
keptCount += 1;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
deletePaths.push(candidate.relPath);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
deletePaths: sortedUnique(deletePaths),
|
|
113
|
+
keptCount,
|
|
114
|
+
refused: sortedUnique(refused),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -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
|
+
}
|