@remnic/core 9.69.0 → 9.69.1
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 +19 -19
- package/dist/access-authorization-probe.d.ts +1 -1
- package/dist/access-authorization-probe.js +20 -20
- package/dist/access-boundary.d.ts +1 -1
- package/dist/access-boundary.js +19 -19
- package/dist/access-cli.js +65 -65
- package/dist/access-extraction-force-flush.d.ts +1 -1
- package/dist/access-extraction-force-flush.js +19 -19
- 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 +21 -21
- package/dist/access-http.d.ts +2 -2
- package/dist/access-http.js +33 -33
- package/dist/access-identity-continuity-surface.js +19 -19
- package/dist/access-lcm-surface.d.ts +1 -1
- package/dist/access-lcm-surface.js +19 -19
- package/dist/access-mcp.d.ts +1 -1
- package/dist/access-mcp.js +26 -26
- package/dist/access-namespace-preflight.js +19 -19
- package/dist/access-observe-write-surface.d.ts +1 -1
- package/dist/access-observe-write-surface.js +19 -19
- package/dist/access-operations-batch.js +20 -20
- package/dist/access-operations.d.ts +2 -2
- package/dist/access-operations.js +25 -25
- package/dist/access-recall-concurrency.d.ts +1 -1
- package/dist/access-recall-concurrency.js +19 -19
- package/dist/access-recall-response.d.ts +1 -1
- package/dist/access-recall-response.js +19 -19
- package/dist/access-recall-surface.d.ts +1 -1
- package/dist/access-recall-surface.js +19 -19
- package/dist/access-service-helpers.d.ts +1 -1
- package/dist/access-service.d.ts +1 -1
- package/dist/access-service.js +19 -19
- package/dist/access-surface-catalog.d.ts +1 -1
- package/dist/access-surface-catalog.js +21 -21
- package/dist/briefing.js +10 -10
- package/dist/causal-consolidation.js +11 -11
- package/dist/{chunk-KAY2C6VX.js → chunk-2D7S3FZW.js} +2 -2
- package/dist/{chunk-BZ26443Y.js → chunk-2FEBD4LN.js} +89 -41
- package/dist/chunk-2FEBD4LN.js.map +1 -0
- package/dist/{chunk-HDQQRRCZ.js → chunk-5DSPAWXQ.js} +3 -3
- package/dist/{chunk-VLSUKXCJ.js → chunk-5JUSOMNO.js} +3 -3
- package/dist/{chunk-P342D5BP.js → chunk-626ILV6J.js} +2 -2
- package/dist/{chunk-I6T4SYFW.js → chunk-63Z7D2YJ.js} +3 -3
- package/dist/{chunk-XW3G4BJI.js → chunk-6DPAZEGK.js} +7 -7
- package/dist/{chunk-XPDWNWH6.js → chunk-6LIILOP3.js} +3 -3
- package/dist/{chunk-3IRH5MYM.js → chunk-6OX6SNRC.js} +5 -5
- package/dist/{chunk-FOT5GP2U.js → chunk-73CA45TG.js} +2 -2
- package/dist/{chunk-2MHMHF76.js → chunk-7GFLU63V.js} +2 -2
- package/dist/{chunk-A7NMS5II.js → chunk-7HAZ6GZE.js} +3 -3
- package/dist/{chunk-X2NFFBTO.js → chunk-A7GQBJP5.js} +4 -4
- package/dist/{chunk-7KG5V4VV.js → chunk-BZFHCRO6.js} +2 -2
- package/dist/{chunk-4PEZMGZD.js → chunk-CUQI6DR3.js} +2 -2
- package/dist/{chunk-NFITGYMQ.js → chunk-CX6J53S5.js} +3 -3
- package/dist/{chunk-DJ7TZLGU.js → chunk-DHXPZZ5U.js} +2 -2
- package/dist/{chunk-XZBXINXZ.js → chunk-GIC6BASP.js} +4 -4
- package/dist/{chunk-ABPCF4MW.js → chunk-HB3GEHGF.js} +1 -1
- package/dist/{chunk-6FUMA6AA.js → chunk-JFPUQ3E2.js} +2 -2
- package/dist/{chunk-BTRY6UH3.js → chunk-JMMG5K7H.js} +2 -2
- package/dist/{chunk-ZDJMCXC7.js → chunk-JRLDQ6WS.js} +12 -12
- package/dist/{chunk-BGD3T743.js → chunk-JRYINMLF.js} +2 -2
- package/dist/{chunk-F25CL3IQ.js → chunk-KSTLZCR5.js} +2 -2
- package/dist/{chunk-363VZTVH.js → chunk-L5QVIATR.js} +2 -2
- package/dist/{chunk-BS5BD3DF.js → chunk-LFGSIMFS.js} +2 -2
- package/dist/{chunk-YYAWJRGL.js → chunk-MYMME2VL.js} +2 -2
- package/dist/{chunk-RACZB5HT.js → chunk-N37X5RU7.js} +4 -4
- package/dist/{chunk-Z2FXSE5P.js → chunk-NFZUZL44.js} +2 -2
- package/dist/{chunk-AG46P24C.js → chunk-NNBZS3DY.js} +4 -4
- package/dist/{chunk-L7MJJAG2.js → chunk-NRDLOFAI.js} +2 -2
- package/dist/{chunk-PIBQIYF2.js → chunk-NX2ECBRG.js} +2 -2
- package/dist/{chunk-QOE3EM5Z.js → chunk-PVN6DVWU.js} +8 -8
- package/dist/{chunk-327JMA2N.js → chunk-PZY4DLLO.js} +2 -2
- package/dist/{chunk-ZVW724ZK.js → chunk-Q4OITLPA.js} +2 -2
- package/dist/{chunk-ND6NL2YR.js → chunk-QBZMUF54.js} +2 -2
- package/dist/{chunk-25P3LW6U.js → chunk-QLT74C2M.js} +2 -2
- package/dist/{chunk-KJUH3QIQ.js → chunk-TITZPONM.js} +2 -2
- package/dist/{chunk-YFK64FYK.js → chunk-TPIO36PJ.js} +2 -2
- package/dist/{chunk-5UI7WQA6.js → chunk-TX55UTJT.js} +2 -2
- package/dist/{chunk-D35XLYQ7.js → chunk-UF65P4GF.js} +6 -6
- package/dist/{chunk-BHH736UQ.js → chunk-UJYNT3R2.js} +3 -3
- package/dist/{chunk-4PMPOZK3.js → chunk-V5I36Y2F.js} +2 -2
- package/dist/{chunk-WQCET3N3.js → chunk-VK7YWP5Q.js} +2 -2
- package/dist/{chunk-USQAH6PN.js → chunk-VPYAQYD5.js} +2 -2
- package/dist/{chunk-KRNFJKMH.js → chunk-VTEO5T6Z.js} +2 -2
- package/dist/{chunk-WSYACIKD.js → chunk-W6PZQY4L.js} +2 -2
- package/dist/{chunk-52GQNSLC.js → chunk-WM2N5DRB.js} +7 -7
- package/dist/{chunk-ERKFG4RV.js → chunk-XRT7ZG5B.js} +33 -33
- package/dist/{chunk-NYQHD7H4.js → chunk-YTWEK6CU.js} +17 -17
- package/dist/{cli-CFpMhqz5.d.ts → cli-DDHGtWI1.d.ts} +1 -1
- package/dist/cli.d.ts +2 -2
- package/dist/cli.js +48 -48
- package/dist/coding/export-okf-codegraph.js +10 -10
- package/dist/compounding/engine.js +10 -10
- package/dist/connectors/codex-materialize-runner.js +10 -10
- package/dist/connectors/index.js +10 -10
- package/dist/convergence-refresh.js +12 -12
- package/dist/corpus-watermark.js +12 -12
- package/dist/entity-retrieval.js +10 -10
- package/dist/external-wiki-access.d.ts +1 -1
- package/dist/external-wiki-access.js +20 -20
- package/dist/external-wiki-mcp-tools.d.ts +1 -1
- package/dist/extraction.js +10 -10
- package/dist/{first-start-migration-KHIMCBIC.js → first-start-migration-UG32BSG2.js} +4 -4
- package/dist/harmonic-retrieval.js +1 -1
- package/dist/index.d.ts +28 -7
- package/dist/index.js +78 -72
- package/dist/lcm/engine.js +2 -2
- package/dist/lcm/index.js +7 -7
- package/dist/location/index.js +12 -12
- package/dist/maintenance/memory-governance.js +10 -10
- package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +10 -10
- package/dist/maintenance/rebuild-memory-projection.js +11 -11
- 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 +11 -11
- package/dist/namespaces/search.js +7 -7
- package/dist/namespaces/storage.js +10 -10
- package/dist/operator-doctor-corpus.js +13 -13
- package/dist/operator-toolkit.js +18 -18
- package/dist/orchestration/maintenance.js +18 -18
- package/dist/orchestrator.js +65 -65
- package/dist/{public-http-C9-CHc40.d.ts → public-http-Bab7S0ED.d.ts} +1 -1
- package/dist/recall-navigate-disclosure.d.ts +44 -0
- package/dist/recall-navigate-disclosure.js +30 -0
- package/dist/recall-navigate-disclosure.js.map +1 -0
- package/dist/recall-state-view-packet.d.ts +30 -0
- package/dist/recall-state-view-packet.js +69 -0
- package/dist/recall-state-view-packet.js.map +1 -0
- package/dist/schemas.d.ts +42 -42
- package/dist/search/factory.js +6 -6
- package/dist/search/index.js +10 -10
- 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 +11 -11
- package/dist/semantic-rule-promotion.js +10 -10
- package/dist/semantic-rule-verifier.js +10 -10
- package/dist/source-agent-qualifier.js +10 -10
- package/dist/standup/build.js +10 -10
- package/dist/storage.js +9 -9
- package/dist/support-passport/index.d.ts +4 -4
- package/dist/support-passport/index.js +22 -22
- package/dist/transfer/export-okf.js +11 -11
- package/dist/transfer/types.d.ts +12 -12
- package/dist/verified-recall.js +10 -10
- package/dist/who-knows-cli.d.ts +1 -1
- package/dist/who-knows-cli.js +11 -11
- package/dist/who-knows.d.ts +1 -1
- package/dist/who-knows.js +10 -10
- package/package.json +2 -2
- package/src/activity/timeline/index.ts +1 -0
- package/src/activity/timeline/week-recurring.test.ts +177 -0
- package/src/activity/timeline/week-recurring.ts +88 -0
- package/src/dedup/index.ts +7 -0
- package/src/dedup/merge-provenance.test.ts +185 -0
- package/src/dedup/merge-provenance.ts +151 -0
- package/src/recall-navigate-disclosure.test.ts +128 -0
- package/src/recall-navigate-disclosure.ts +45 -0
- package/src/recall-state-view-packet.test.ts +158 -0
- package/src/recall-state-view-packet.ts +128 -0
- package/dist/chunk-BZ26443Y.js.map +0 -1
- package/dist/{access-service-DcfM0Vf0.d.ts → access-service-CRZHXClX.d.ts} +6 -6
- /package/dist/{chunk-KAY2C6VX.js.map → chunk-2D7S3FZW.js.map} +0 -0
- /package/dist/{chunk-HDQQRRCZ.js.map → chunk-5DSPAWXQ.js.map} +0 -0
- /package/dist/{chunk-VLSUKXCJ.js.map → chunk-5JUSOMNO.js.map} +0 -0
- /package/dist/{chunk-P342D5BP.js.map → chunk-626ILV6J.js.map} +0 -0
- /package/dist/{chunk-I6T4SYFW.js.map → chunk-63Z7D2YJ.js.map} +0 -0
- /package/dist/{chunk-XW3G4BJI.js.map → chunk-6DPAZEGK.js.map} +0 -0
- /package/dist/{chunk-XPDWNWH6.js.map → chunk-6LIILOP3.js.map} +0 -0
- /package/dist/{chunk-3IRH5MYM.js.map → chunk-6OX6SNRC.js.map} +0 -0
- /package/dist/{chunk-FOT5GP2U.js.map → chunk-73CA45TG.js.map} +0 -0
- /package/dist/{chunk-2MHMHF76.js.map → chunk-7GFLU63V.js.map} +0 -0
- /package/dist/{chunk-A7NMS5II.js.map → chunk-7HAZ6GZE.js.map} +0 -0
- /package/dist/{chunk-X2NFFBTO.js.map → chunk-A7GQBJP5.js.map} +0 -0
- /package/dist/{chunk-7KG5V4VV.js.map → chunk-BZFHCRO6.js.map} +0 -0
- /package/dist/{chunk-4PEZMGZD.js.map → chunk-CUQI6DR3.js.map} +0 -0
- /package/dist/{chunk-NFITGYMQ.js.map → chunk-CX6J53S5.js.map} +0 -0
- /package/dist/{chunk-DJ7TZLGU.js.map → chunk-DHXPZZ5U.js.map} +0 -0
- /package/dist/{chunk-XZBXINXZ.js.map → chunk-GIC6BASP.js.map} +0 -0
- /package/dist/{chunk-ABPCF4MW.js.map → chunk-HB3GEHGF.js.map} +0 -0
- /package/dist/{chunk-6FUMA6AA.js.map → chunk-JFPUQ3E2.js.map} +0 -0
- /package/dist/{chunk-BTRY6UH3.js.map → chunk-JMMG5K7H.js.map} +0 -0
- /package/dist/{chunk-ZDJMCXC7.js.map → chunk-JRLDQ6WS.js.map} +0 -0
- /package/dist/{chunk-BGD3T743.js.map → chunk-JRYINMLF.js.map} +0 -0
- /package/dist/{chunk-F25CL3IQ.js.map → chunk-KSTLZCR5.js.map} +0 -0
- /package/dist/{chunk-363VZTVH.js.map → chunk-L5QVIATR.js.map} +0 -0
- /package/dist/{chunk-BS5BD3DF.js.map → chunk-LFGSIMFS.js.map} +0 -0
- /package/dist/{chunk-YYAWJRGL.js.map → chunk-MYMME2VL.js.map} +0 -0
- /package/dist/{chunk-RACZB5HT.js.map → chunk-N37X5RU7.js.map} +0 -0
- /package/dist/{chunk-Z2FXSE5P.js.map → chunk-NFZUZL44.js.map} +0 -0
- /package/dist/{chunk-AG46P24C.js.map → chunk-NNBZS3DY.js.map} +0 -0
- /package/dist/{chunk-L7MJJAG2.js.map → chunk-NRDLOFAI.js.map} +0 -0
- /package/dist/{chunk-PIBQIYF2.js.map → chunk-NX2ECBRG.js.map} +0 -0
- /package/dist/{chunk-QOE3EM5Z.js.map → chunk-PVN6DVWU.js.map} +0 -0
- /package/dist/{chunk-327JMA2N.js.map → chunk-PZY4DLLO.js.map} +0 -0
- /package/dist/{chunk-ZVW724ZK.js.map → chunk-Q4OITLPA.js.map} +0 -0
- /package/dist/{chunk-ND6NL2YR.js.map → chunk-QBZMUF54.js.map} +0 -0
- /package/dist/{chunk-25P3LW6U.js.map → chunk-QLT74C2M.js.map} +0 -0
- /package/dist/{chunk-KJUH3QIQ.js.map → chunk-TITZPONM.js.map} +0 -0
- /package/dist/{chunk-YFK64FYK.js.map → chunk-TPIO36PJ.js.map} +0 -0
- /package/dist/{chunk-5UI7WQA6.js.map → chunk-TX55UTJT.js.map} +0 -0
- /package/dist/{chunk-D35XLYQ7.js.map → chunk-UF65P4GF.js.map} +0 -0
- /package/dist/{chunk-BHH736UQ.js.map → chunk-UJYNT3R2.js.map} +0 -0
- /package/dist/{chunk-4PMPOZK3.js.map → chunk-V5I36Y2F.js.map} +0 -0
- /package/dist/{chunk-WQCET3N3.js.map → chunk-VK7YWP5Q.js.map} +0 -0
- /package/dist/{chunk-USQAH6PN.js.map → chunk-VPYAQYD5.js.map} +0 -0
- /package/dist/{chunk-KRNFJKMH.js.map → chunk-VTEO5T6Z.js.map} +0 -0
- /package/dist/{chunk-WSYACIKD.js.map → chunk-W6PZQY4L.js.map} +0 -0
- /package/dist/{chunk-52GQNSLC.js.map → chunk-WM2N5DRB.js.map} +0 -0
- /package/dist/{chunk-ERKFG4RV.js.map → chunk-XRT7ZG5B.js.map} +0 -0
- /package/dist/{chunk-NYQHD7H4.js.map → chunk-YTWEK6CU.js.map} +0 -0
- /package/dist/{first-start-migration-KHIMCBIC.js.map → first-start-migration-UG32BSG2.js.map} +0 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_RECURRENCE_MIN_DAYS,
|
|
6
|
+
MIN_RECURRENCE_MIN_DAYS,
|
|
7
|
+
findRecurringPatterns,
|
|
8
|
+
type WeekDayOccurrence,
|
|
9
|
+
} from "./week-recurring.js";
|
|
10
|
+
|
|
11
|
+
function rangeErrorMatching(pattern: RegExp): (err: unknown) => boolean {
|
|
12
|
+
return (err: unknown) => err instanceof RangeError && pattern.test((err as Error).message);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
test("default minDays is 3; a key on exactly minDays distinct dates qualifies", () => {
|
|
16
|
+
assert.equal(DEFAULT_RECURRENCE_MIN_DAYS, 3);
|
|
17
|
+
const result = findRecurringPatterns({
|
|
18
|
+
occurrences: [
|
|
19
|
+
{ date: "2026-08-10", key: "code", durationMs: 100 },
|
|
20
|
+
{ date: "2026-08-11", key: "code", durationMs: 100 },
|
|
21
|
+
{ date: "2026-08-12", key: "code", durationMs: 100 },
|
|
22
|
+
],
|
|
23
|
+
});
|
|
24
|
+
assert.deepEqual(result, [{ key: "code", dayCount: 3, totalDurationMs: 300 }]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("a key on minDays - 1 distinct dates does not qualify", () => {
|
|
28
|
+
const result = findRecurringPatterns({
|
|
29
|
+
occurrences: [
|
|
30
|
+
{ date: "2026-08-10", key: "code", durationMs: 999_999 },
|
|
31
|
+
{ date: "2026-08-11", key: "code", durationMs: 999_999 },
|
|
32
|
+
],
|
|
33
|
+
});
|
|
34
|
+
assert.deepEqual(result, []);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("same-date occurrences count as one day but all durations sum", () => {
|
|
38
|
+
const result = findRecurringPatterns({
|
|
39
|
+
occurrences: [
|
|
40
|
+
{ date: "2026-08-10", key: "code", durationMs: 100 },
|
|
41
|
+
{ date: "2026-08-10", key: "code", durationMs: 40 },
|
|
42
|
+
{ date: "2026-08-11", key: "code", durationMs: 100 },
|
|
43
|
+
{ date: "2026-08-12", key: "code", durationMs: 100 },
|
|
44
|
+
],
|
|
45
|
+
});
|
|
46
|
+
assert.deepEqual(result, [{ key: "code", dayCount: 3, totalDurationMs: 340 }]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("blank keys are ignored", () => {
|
|
50
|
+
const result = findRecurringPatterns({
|
|
51
|
+
occurrences: [
|
|
52
|
+
{ date: "2026-08-10", key: "", durationMs: 100 },
|
|
53
|
+
{ date: "2026-08-11", key: " ", durationMs: 100 },
|
|
54
|
+
{ date: "2026-08-12", key: "\t", durationMs: 100 },
|
|
55
|
+
],
|
|
56
|
+
});
|
|
57
|
+
assert.deepEqual(result, []);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("keys are compared trimmed and case-sensitively", () => {
|
|
61
|
+
const result = findRecurringPatterns({
|
|
62
|
+
occurrences: [
|
|
63
|
+
{ date: "2026-08-10", key: "code", durationMs: 100 },
|
|
64
|
+
{ date: "2026-08-11", key: " code ", durationMs: 100 },
|
|
65
|
+
{ date: "2026-08-12", key: "code", durationMs: 100 },
|
|
66
|
+
{ date: "2026-08-10", key: "Code", durationMs: 100 },
|
|
67
|
+
{ date: "2026-08-11", key: "Code", durationMs: 100 },
|
|
68
|
+
],
|
|
69
|
+
});
|
|
70
|
+
// " code " folds into "code" (3 distinct days); "Code" stays separate at 2 days.
|
|
71
|
+
assert.deepEqual(result, [{ key: "code", dayCount: 3, totalDurationMs: 300 }]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("minDays must be an integer >= MIN_RECURRENCE_MIN_DAYS", () => {
|
|
75
|
+
for (const bad of [0, 1, -3, 1.5]) {
|
|
76
|
+
assert.throws(
|
|
77
|
+
() => findRecurringPatterns({ occurrences: [], minDays: bad }),
|
|
78
|
+
rangeErrorMatching(/minDays/),
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
assert.doesNotThrow(() =>
|
|
82
|
+
findRecurringPatterns({
|
|
83
|
+
occurrences: [{ date: "2026-08-10", key: "code", durationMs: 1 }],
|
|
84
|
+
minDays: MIN_RECURRENCE_MIN_DAYS,
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// minDays 1 would make a single-day key "recurring", which is the invariant
|
|
90
|
+
// this helper exists to enforce — configuration must not be able to disable it.
|
|
91
|
+
test("minDays 1 is refused rather than reporting one-off keys", () => {
|
|
92
|
+
assert.throws(
|
|
93
|
+
() =>
|
|
94
|
+
findRecurringPatterns({
|
|
95
|
+
occurrences: [{ date: "2026-08-10", key: "code", durationMs: 5 }],
|
|
96
|
+
minDays: 1,
|
|
97
|
+
}),
|
|
98
|
+
rangeErrorMatching(/minDays/),
|
|
99
|
+
);
|
|
100
|
+
assert.equal(MIN_RECURRENCE_MIN_DAYS, 2);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("durationMs must be a finite number >= 0", () => {
|
|
104
|
+
for (const bad of [Number.NaN, Number.POSITIVE_INFINITY, -1]) {
|
|
105
|
+
assert.throws(
|
|
106
|
+
() =>
|
|
107
|
+
findRecurringPatterns({
|
|
108
|
+
occurrences: [{ date: "2026-08-10", key: "code", durationMs: bad }],
|
|
109
|
+
}),
|
|
110
|
+
rangeErrorMatching(/durationMs/),
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("date must be a valid YYYY-MM-DD calendar date", () => {
|
|
116
|
+
for (const bad of ["", "not-a-date", "2026-8-10", "2026-02-30"]) {
|
|
117
|
+
assert.throws(
|
|
118
|
+
() =>
|
|
119
|
+
findRecurringPatterns({
|
|
120
|
+
occurrences: [{ date: bad, key: "code", durationMs: 0 }],
|
|
121
|
+
}),
|
|
122
|
+
rangeErrorMatching(/date/),
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("ordering: dayCount desc, then totalDurationMs desc, then key asc", () => {
|
|
128
|
+
const result = findRecurringPatterns({
|
|
129
|
+
minDays: 2,
|
|
130
|
+
occurrences: [
|
|
131
|
+
// alpha: 2 days, biggest total — dayCount still ranks it last.
|
|
132
|
+
{ date: "2026-08-10", key: "alpha", durationMs: 999_999 },
|
|
133
|
+
{ date: "2026-08-11", key: "alpha", durationMs: 999_999 },
|
|
134
|
+
// beta: 3 days, 10 total — wins the dayCount tie on duration.
|
|
135
|
+
{ date: "2026-08-10", key: "beta", durationMs: 4 },
|
|
136
|
+
{ date: "2026-08-11", key: "beta", durationMs: 4 },
|
|
137
|
+
{ date: "2026-08-12", key: "beta", durationMs: 2 },
|
|
138
|
+
// gamma: 3 days, 8 total — ties delta on both, loses on key ascending.
|
|
139
|
+
{ date: "2026-08-10", key: "gamma", durationMs: 8 },
|
|
140
|
+
{ date: "2026-08-11", key: "gamma", durationMs: 0 },
|
|
141
|
+
{ date: "2026-08-12", key: "gamma", durationMs: 0 },
|
|
142
|
+
// delta: 3 days, 8 total.
|
|
143
|
+
{ date: "2026-08-10", key: "delta", durationMs: 8 },
|
|
144
|
+
{ date: "2026-08-11", key: "delta", durationMs: 0 },
|
|
145
|
+
{ date: "2026-08-12", key: "delta", durationMs: 0 },
|
|
146
|
+
],
|
|
147
|
+
});
|
|
148
|
+
assert.deepEqual(result.map((p) => p.key), ["beta", "delta", "gamma", "alpha"]);
|
|
149
|
+
assert.deepEqual(result[0], { key: "beta", dayCount: 3, totalDurationMs: 10 });
|
|
150
|
+
assert.deepEqual(result[3], { key: "alpha", dayCount: 2, totalDurationMs: 1_999_998 });
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("empty input and no-qualifier input return []", () => {
|
|
154
|
+
assert.deepEqual(findRecurringPatterns({ occurrences: [] }), []);
|
|
155
|
+
assert.deepEqual(
|
|
156
|
+
findRecurringPatterns({
|
|
157
|
+
occurrences: [{ date: "2026-08-10", key: "code", durationMs: 5000 }],
|
|
158
|
+
}),
|
|
159
|
+
[],
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("pure: two calls deep-equal and the input is not mutated", () => {
|
|
164
|
+
const occurrences: WeekDayOccurrence[] = [
|
|
165
|
+
{ date: "2026-08-10", key: "code", durationMs: 100 },
|
|
166
|
+
{ date: "2026-08-10", key: "code", durationMs: 50 },
|
|
167
|
+
{ date: "2026-08-11", key: "mail", durationMs: 10 },
|
|
168
|
+
{ date: "2026-08-11", key: "code", durationMs: 100 },
|
|
169
|
+
{ date: "2026-08-12", key: "code", durationMs: 100 },
|
|
170
|
+
];
|
|
171
|
+
const snapshot = structuredClone(occurrences);
|
|
172
|
+
const first = findRecurringPatterns({ occurrences });
|
|
173
|
+
const second = findRecurringPatterns({ occurrences });
|
|
174
|
+
assert.deepEqual(first, second);
|
|
175
|
+
assert.deepEqual(first, [{ key: "code", dayCount: 3, totalDurationMs: 350 }]);
|
|
176
|
+
assert.deepEqual(occurrences, snapshot);
|
|
177
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recurring weekly pattern gate (issue #2052).
|
|
3
|
+
*
|
|
4
|
+
* A category/application key counts as recurring only when it appeared on at
|
|
5
|
+
* least `minDays` DISTINCT days of the analyzed week, so a one-off blip is
|
|
6
|
+
* never reported as a pattern. Pure: no I/O, no clock, no input mutation.
|
|
7
|
+
* Validation happens on the exact occurrence values before any key trimming
|
|
8
|
+
* (AGENTS.md: validate before normalizing; reject non-finite numbers
|
|
9
|
+
* explicitly instead of letting NaN fall through a comparison).
|
|
10
|
+
*/
|
|
11
|
+
import { isValidActivityDate } from "../digest.js";
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_RECURRENCE_MIN_DAYS = 3;
|
|
14
|
+
|
|
15
|
+
/** Lowest threshold that still means "recurring": two distinct days. */
|
|
16
|
+
export const MIN_RECURRENCE_MIN_DAYS = 2;
|
|
17
|
+
|
|
18
|
+
export interface WeekDayOccurrence {
|
|
19
|
+
/** YYYY-MM-DD inside the analyzed week. */
|
|
20
|
+
date: string;
|
|
21
|
+
/** Category id or application/domain label. */
|
|
22
|
+
key: string;
|
|
23
|
+
durationMs: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface RecurringPattern {
|
|
27
|
+
key: string;
|
|
28
|
+
/** Count of DISTINCT days the key appeared on. */
|
|
29
|
+
dayCount: number;
|
|
30
|
+
totalDurationMs: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function findRecurringPatterns(input: {
|
|
34
|
+
occurrences: readonly WeekDayOccurrence[];
|
|
35
|
+
minDays?: number;
|
|
36
|
+
}): RecurringPattern[] {
|
|
37
|
+
const minDays = input.minDays ?? DEFAULT_RECURRENCE_MIN_DAYS;
|
|
38
|
+
// A floor of 2 is the module's defining invariant, not a preference: at
|
|
39
|
+
// minDays 1 every key that appeared once is "recurring", which is exactly
|
|
40
|
+
// the one-off blip this helper exists to exclude. Configuration must not be
|
|
41
|
+
// able to switch the invariant off.
|
|
42
|
+
if (!Number.isInteger(minDays) || minDays < MIN_RECURRENCE_MIN_DAYS) {
|
|
43
|
+
throw new RangeError(
|
|
44
|
+
`Invalid minDays ${minDays}; expected an integer >= ${MIN_RECURRENCE_MIN_DAYS}.`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const daysByKey = new Map<string, Set<string>>();
|
|
49
|
+
const totalsByKey = new Map<string, number>();
|
|
50
|
+
|
|
51
|
+
for (const occurrence of input.occurrences) {
|
|
52
|
+
if (!isValidActivityDate(occurrence.date)) {
|
|
53
|
+
throw new RangeError(`Invalid date "${occurrence.date}"; expected YYYY-MM-DD.`);
|
|
54
|
+
}
|
|
55
|
+
if (
|
|
56
|
+
typeof occurrence.durationMs !== "number" ||
|
|
57
|
+
!Number.isFinite(occurrence.durationMs) ||
|
|
58
|
+
occurrence.durationMs < 0
|
|
59
|
+
) {
|
|
60
|
+
throw new RangeError(`Invalid durationMs ${occurrence.durationMs}; expected a finite number >= 0.`);
|
|
61
|
+
}
|
|
62
|
+
const key = occurrence.key.trim();
|
|
63
|
+
if (key === "") continue;
|
|
64
|
+
let days = daysByKey.get(key);
|
|
65
|
+
if (!days) {
|
|
66
|
+
days = new Set<string>();
|
|
67
|
+
daysByKey.set(key, days);
|
|
68
|
+
totalsByKey.set(key, 0);
|
|
69
|
+
}
|
|
70
|
+
days.add(occurrence.date);
|
|
71
|
+
totalsByKey.set(key, totalsByKey.get(key)! + occurrence.durationMs);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const patterns: RecurringPattern[] = [];
|
|
75
|
+
for (const [key, days] of daysByKey) {
|
|
76
|
+
if (days.size < minDays) continue;
|
|
77
|
+
patterns.push({ key, dayCount: days.size, totalDurationMs: totalsByKey.get(key)! });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
patterns.sort((a, b) => {
|
|
81
|
+
if (a.dayCount !== b.dayCount) return b.dayCount - a.dayCount;
|
|
82
|
+
if (a.totalDurationMs !== b.totalDurationMs) return b.totalDurationMs - a.totalDurationMs;
|
|
83
|
+
if (a.key < b.key) return -1;
|
|
84
|
+
if (a.key > b.key) return 1;
|
|
85
|
+
return 0;
|
|
86
|
+
});
|
|
87
|
+
return patterns;
|
|
88
|
+
}
|
package/src/dedup/index.ts
CHANGED
|
@@ -474,3 +474,10 @@ export {
|
|
|
474
474
|
type MergeJudgeVerdictName,
|
|
475
475
|
type ParseMergeVerdictResult,
|
|
476
476
|
} from "./merge-verdict.js";
|
|
477
|
+
|
|
478
|
+
export {
|
|
479
|
+
MERGE_DERIVED_VIA,
|
|
480
|
+
buildMergeFrontmatterUpdate,
|
|
481
|
+
type MergeFrontmatterUpdate,
|
|
482
|
+
type MergeProvenanceSource,
|
|
483
|
+
} from "./merge-provenance.js";
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
buildMergeFrontmatterUpdate,
|
|
6
|
+
MERGE_DERIVED_VIA,
|
|
7
|
+
type MergeProvenanceSource,
|
|
8
|
+
} from "./merge-provenance.js";
|
|
9
|
+
|
|
10
|
+
const NOW = "2026-08-19T12:00:00.000Z";
|
|
11
|
+
|
|
12
|
+
function source(overrides: Partial<MergeProvenanceSource> = {}): MergeProvenanceSource {
|
|
13
|
+
return {
|
|
14
|
+
sessionKey: "project/demo/1",
|
|
15
|
+
observedAt: "2026-08-18T09:00:00.000Z",
|
|
16
|
+
quote: "the API limit is 100/min",
|
|
17
|
+
...overrides,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
test("appends incoming sources after target sources in order", () => {
|
|
22
|
+
const a = source({ quote: "first" });
|
|
23
|
+
const b = source({ quote: "second" });
|
|
24
|
+
const update = buildMergeFrontmatterUpdate({
|
|
25
|
+
targetSources: [a],
|
|
26
|
+
incomingSources: [b],
|
|
27
|
+
nowIso: NOW,
|
|
28
|
+
});
|
|
29
|
+
assert.deepEqual(
|
|
30
|
+
update.sources.map((s) => s.quote),
|
|
31
|
+
["first", "second"],
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("uses the serializer's allow-listed operator, not an invented one", () => {
|
|
36
|
+
const update = buildMergeFrontmatterUpdate({ incomingSources: [source()], nowIso: NOW });
|
|
37
|
+
assert.equal(update.derived_via, "merge");
|
|
38
|
+
assert.equal(MERGE_DERIVED_VIA, "merge");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("preserves every canonical optional field on an accepted source", () => {
|
|
42
|
+
const full = source({ turnId: "turn-7", charStart: 4, charEnd: 12 });
|
|
43
|
+
const update = buildMergeFrontmatterUpdate({ incomingSources: [full], nowIso: NOW });
|
|
44
|
+
assert.deepEqual(update.sources, [
|
|
45
|
+
{
|
|
46
|
+
sessionKey: full.sessionKey,
|
|
47
|
+
observedAt: full.observedAt,
|
|
48
|
+
quote: full.quote,
|
|
49
|
+
turnId: "turn-7",
|
|
50
|
+
charStart: 4,
|
|
51
|
+
charEnd: 12,
|
|
52
|
+
},
|
|
53
|
+
]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("accepts a source with no turnId, which the canonical shape allows", () => {
|
|
57
|
+
const update = buildMergeFrontmatterUpdate({ incomingSources: [source()], nowIso: NOW });
|
|
58
|
+
assert.equal(update.sources.length, 1);
|
|
59
|
+
assert.equal("turnId" in update.sources[0]!, false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("deduplicates on the full field tuple and keeps the first", () => {
|
|
63
|
+
const first = source({ turnId: "turn-1" });
|
|
64
|
+
const update = buildMergeFrontmatterUpdate({
|
|
65
|
+
targetSources: [first],
|
|
66
|
+
incomingSources: [{ ...first }, source({ turnId: "turn-2" })],
|
|
67
|
+
nowIso: NOW,
|
|
68
|
+
});
|
|
69
|
+
assert.deepEqual(
|
|
70
|
+
update.sources.map((s) => s.turnId),
|
|
71
|
+
["turn-1", "turn-2"],
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("sources differing only in offsets are both kept", () => {
|
|
76
|
+
const update = buildMergeFrontmatterUpdate({
|
|
77
|
+
incomingSources: [source({ charStart: 0, charEnd: 5 }), source({ charStart: 6, charEnd: 9 })],
|
|
78
|
+
nowIso: NOW,
|
|
79
|
+
});
|
|
80
|
+
assert.equal(update.sources.length, 2);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("a blank sessionKey or quote is rejected", () => {
|
|
84
|
+
for (const bad of [source({ sessionKey: " " }), source({ quote: "" })]) {
|
|
85
|
+
assert.throws(() => buildMergeFrontmatterUpdate({ incomingSources: [bad], nowIso: NOW }), /non-blank/);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("a missing or malformed observedAt is rejected", () => {
|
|
90
|
+
for (const bad of ["", "2026-08-18", "not-a-date", undefined as unknown as string]) {
|
|
91
|
+
assert.throws(
|
|
92
|
+
() => buildMergeFrontmatterUpdate({ incomingSources: [source({ observedAt: bad })], nowIso: NOW }),
|
|
93
|
+
/observedAt/,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("a blank turnId is rejected while an absent one is fine", () => {
|
|
99
|
+
assert.throws(
|
|
100
|
+
() => buildMergeFrontmatterUpdate({ incomingSources: [source({ turnId: " " })], nowIso: NOW }),
|
|
101
|
+
/turnId/,
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("negative, non-integer, and inverted offsets are rejected", () => {
|
|
106
|
+
assert.throws(
|
|
107
|
+
() => buildMergeFrontmatterUpdate({ incomingSources: [source({ charStart: -1 })], nowIso: NOW }),
|
|
108
|
+
/charStart/,
|
|
109
|
+
);
|
|
110
|
+
assert.throws(
|
|
111
|
+
() => buildMergeFrontmatterUpdate({ incomingSources: [source({ charEnd: 1.5 })], nowIso: NOW }),
|
|
112
|
+
/charEnd/,
|
|
113
|
+
);
|
|
114
|
+
assert.throws(
|
|
115
|
+
() =>
|
|
116
|
+
buildMergeFrontmatterUpdate({
|
|
117
|
+
incomingSources: [source({ charStart: 9, charEnd: 2 })],
|
|
118
|
+
nowIso: NOW,
|
|
119
|
+
}),
|
|
120
|
+
/charEnd must be >= charStart/,
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("reinforcement_count starts at 1 and increments from the target", () => {
|
|
125
|
+
assert.equal(
|
|
126
|
+
buildMergeFrontmatterUpdate({ incomingSources: [source()], nowIso: NOW }).reinforcement_count,
|
|
127
|
+
1,
|
|
128
|
+
);
|
|
129
|
+
assert.equal(
|
|
130
|
+
buildMergeFrontmatterUpdate({
|
|
131
|
+
incomingSources: [source()],
|
|
132
|
+
targetReinforcementCount: 2,
|
|
133
|
+
nowIso: NOW,
|
|
134
|
+
}).reinforcement_count,
|
|
135
|
+
3,
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("an invalid target reinforcement_count is rejected", () => {
|
|
140
|
+
for (const bad of [-1, 1.5, Number.NaN, "2" as unknown as number]) {
|
|
141
|
+
assert.throws(
|
|
142
|
+
() =>
|
|
143
|
+
buildMergeFrontmatterUpdate({
|
|
144
|
+
incomingSources: [source()],
|
|
145
|
+
targetReinforcementCount: bad,
|
|
146
|
+
nowIso: NOW,
|
|
147
|
+
}),
|
|
148
|
+
/reinforcement_count/,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// Date.parse alone accepts "123" and rolls 2026-02-30 into March; the value is
|
|
154
|
+
// echoed verbatim into frontmatter, so the full ISO shape is required.
|
|
155
|
+
test("nowIso must be a full ISO instant with a real calendar date", () => {
|
|
156
|
+
for (const bad of ["", " ", "123", "2026-01-01", "2026-02-30T00:00:00Z", "nope"]) {
|
|
157
|
+
assert.throws(
|
|
158
|
+
() => buildMergeFrontmatterUpdate({ incomingSources: [source()], nowIso: bad }),
|
|
159
|
+
/nowIso/,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
assert.equal(
|
|
163
|
+
buildMergeFrontmatterUpdate({ incomingSources: [source()], nowIso: "2026-08-19T12:00:00+02:00" }).updated,
|
|
164
|
+
"2026-08-19T12:00:00+02:00",
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("empty incomingSources still bumps the counter and preserves target sources", () => {
|
|
169
|
+
const update = buildMergeFrontmatterUpdate({
|
|
170
|
+
targetSources: [source()],
|
|
171
|
+
incomingSources: [],
|
|
172
|
+
targetReinforcementCount: 1,
|
|
173
|
+
nowIso: NOW,
|
|
174
|
+
});
|
|
175
|
+
assert.equal(update.sources.length, 1);
|
|
176
|
+
assert.equal(update.reinforcement_count, 2);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("inputs are not mutated", () => {
|
|
180
|
+
const target = [source({ turnId: "t" })];
|
|
181
|
+
const incoming = [source({ quote: "other" })];
|
|
182
|
+
const snapshot = JSON.stringify({ target, incoming });
|
|
183
|
+
buildMergeFrontmatterUpdate({ targetSources: target, incomingSources: incoming, nowIso: NOW });
|
|
184
|
+
assert.equal(JSON.stringify({ target, incoming }), snapshot);
|
|
185
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Merge frontmatter builder (issue #2330).
|
|
3
|
+
*
|
|
4
|
+
* On a create-or-update merge the target keeps its own provenance and gains
|
|
5
|
+
* the incoming fact's, so a merge can never erase where a claim came from.
|
|
6
|
+
*
|
|
7
|
+
* Fields here match the canonical schema in `types.ts` deliberately: the
|
|
8
|
+
* serializer rejects a `derived_via` outside its allow-list and drops keys
|
|
9
|
+
* `MemoryFrontmatter` does not define, so an invented operator name or
|
|
10
|
+
* counter would vanish on write. Pure — no I/O, no clock.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** The serializer's allow-listed operator for a create-or-update merge. */
|
|
14
|
+
export const MERGE_DERIVED_VIA = "merge" as const;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Structural mirror of `ProvenanceSource` (types.ts). `sessionKey`,
|
|
18
|
+
* `observedAt`, and `quote` are required; `turnId` and the character
|
|
19
|
+
* offsets are optional and MUST survive a merge untouched.
|
|
20
|
+
*/
|
|
21
|
+
export interface MergeProvenanceSource {
|
|
22
|
+
sessionKey: string;
|
|
23
|
+
observedAt: string;
|
|
24
|
+
quote: string;
|
|
25
|
+
turnId?: string;
|
|
26
|
+
charStart?: number;
|
|
27
|
+
charEnd?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface MergeFrontmatterUpdate {
|
|
31
|
+
updated: string;
|
|
32
|
+
derived_via: typeof MERGE_DERIVED_VIA;
|
|
33
|
+
/** Follows the `reinforcement_count` convention; the serializer requires > 0. */
|
|
34
|
+
reinforcement_count: number;
|
|
35
|
+
sources: MergeProvenanceSource[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const ISO_TIMESTAMP =
|
|
39
|
+
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Require a full ISO 8601 instant whose calendar components round-trip.
|
|
43
|
+
* `Date.parse` alone accepts `123` and rolls `2026-02-30` over into March,
|
|
44
|
+
* and this value is echoed verbatim into frontmatter.
|
|
45
|
+
*/
|
|
46
|
+
function assertIsoInstant(value: unknown, field: string): string {
|
|
47
|
+
if (typeof value !== "string" || !ISO_TIMESTAMP.test(value)) {
|
|
48
|
+
throw new RangeError(`${field} must be a full ISO 8601 instant, got ${JSON.stringify(value)}`);
|
|
49
|
+
}
|
|
50
|
+
const ms = Date.parse(value);
|
|
51
|
+
if (Number.isNaN(ms)) {
|
|
52
|
+
throw new RangeError(`${field} must be a full ISO 8601 instant, got ${JSON.stringify(value)}`);
|
|
53
|
+
}
|
|
54
|
+
// Reject a rolled-over calendar date (2026-02-30 -> 2026-03-02).
|
|
55
|
+
if (!new Date(ms).toISOString().startsWith(value.slice(0, 10))) {
|
|
56
|
+
throw new RangeError(`${field} is not a real calendar date: ${JSON.stringify(value)}`);
|
|
57
|
+
}
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function assertOffset(value: unknown, field: string): void {
|
|
62
|
+
if (value === undefined) return;
|
|
63
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
|
|
64
|
+
throw new RangeError(`${field} must be a non-negative integer when present, got ${String(value)}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function normalizeSource(source: MergeProvenanceSource, index: number): MergeProvenanceSource {
|
|
69
|
+
const label = `sources[${index}]`;
|
|
70
|
+
for (const field of ["sessionKey", "quote"] as const) {
|
|
71
|
+
const value = source[field];
|
|
72
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
73
|
+
throw new RangeError(`${label}.${field} must be a non-blank string`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
assertIsoInstant(source.observedAt, `${label}.observedAt`);
|
|
77
|
+
if (source.turnId !== undefined && (typeof source.turnId !== "string" || source.turnId.trim() === "")) {
|
|
78
|
+
throw new RangeError(`${label}.turnId must be a non-blank string when present`);
|
|
79
|
+
}
|
|
80
|
+
assertOffset(source.charStart, `${label}.charStart`);
|
|
81
|
+
assertOffset(source.charEnd, `${label}.charEnd`);
|
|
82
|
+
if (source.charStart !== undefined && source.charEnd !== undefined && source.charEnd < source.charStart) {
|
|
83
|
+
throw new RangeError(`${label}.charEnd must be >= charStart`);
|
|
84
|
+
}
|
|
85
|
+
// Copy every canonical field, including the optional ones: dropping
|
|
86
|
+
// observedAt or the offsets here would make the serializer discard the
|
|
87
|
+
// clone, turning "append provenance" into "erase provenance".
|
|
88
|
+
const copy: MergeProvenanceSource = {
|
|
89
|
+
sessionKey: source.sessionKey,
|
|
90
|
+
observedAt: source.observedAt,
|
|
91
|
+
quote: source.quote,
|
|
92
|
+
};
|
|
93
|
+
if (source.turnId !== undefined) copy.turnId = source.turnId;
|
|
94
|
+
if (source.charStart !== undefined) copy.charStart = source.charStart;
|
|
95
|
+
if (source.charEnd !== undefined) copy.charEnd = source.charEnd;
|
|
96
|
+
return copy;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function sourceKey(source: MergeProvenanceSource): string {
|
|
100
|
+
return JSON.stringify([
|
|
101
|
+
source.sessionKey,
|
|
102
|
+
source.turnId ?? null,
|
|
103
|
+
source.observedAt,
|
|
104
|
+
source.quote,
|
|
105
|
+
source.charStart ?? null,
|
|
106
|
+
source.charEnd ?? null,
|
|
107
|
+
]);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function buildMergeFrontmatterUpdate(input: {
|
|
111
|
+
/** Existing sources already on the merge target. */
|
|
112
|
+
targetSources?: readonly MergeProvenanceSource[];
|
|
113
|
+
/** Sources from the incoming fact being merged in. */
|
|
114
|
+
incomingSources: readonly MergeProvenanceSource[];
|
|
115
|
+
/** Existing reinforcement_count on the target, if any. */
|
|
116
|
+
targetReinforcementCount?: number;
|
|
117
|
+
nowIso: string;
|
|
118
|
+
}): MergeFrontmatterUpdate {
|
|
119
|
+
const updated = assertIsoInstant(input.nowIso, "nowIso");
|
|
120
|
+
|
|
121
|
+
const previous = input.targetReinforcementCount;
|
|
122
|
+
if (
|
|
123
|
+
previous !== undefined &&
|
|
124
|
+
(typeof previous !== "number" || !Number.isInteger(previous) || previous < 0)
|
|
125
|
+
) {
|
|
126
|
+
throw new RangeError(
|
|
127
|
+
`reinforcement_count must be a non-negative integer when supplied, got ${String(previous)}`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const seen = new Set<string>();
|
|
132
|
+
const sources: MergeProvenanceSource[] = [];
|
|
133
|
+
const append = (source: MergeProvenanceSource, index: number): void => {
|
|
134
|
+
const copy = normalizeSource(source, index);
|
|
135
|
+
const key = sourceKey(copy);
|
|
136
|
+
if (seen.has(key)) return;
|
|
137
|
+
seen.add(key);
|
|
138
|
+
sources.push(copy);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const target = input.targetSources ?? [];
|
|
142
|
+
target.forEach(append);
|
|
143
|
+
input.incomingSources.forEach((source, i) => append(source, target.length + i));
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
updated,
|
|
147
|
+
derived_via: MERGE_DERIVED_VIA,
|
|
148
|
+
reinforcement_count: (previous ?? 0) + 1,
|
|
149
|
+
sources,
|
|
150
|
+
};
|
|
151
|
+
}
|