@remnic/core 9.69.4 → 9.69.5

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.
Files changed (196) hide show
  1. package/dist/access-admin-ops-surface.js +20 -20
  2. package/dist/access-authorization-probe.js +21 -21
  3. package/dist/access-boundary.js +20 -20
  4. package/dist/access-cli.js +68 -68
  5. package/dist/access-extraction-force-flush.js +20 -20
  6. package/dist/access-http-query.js +22 -22
  7. package/dist/access-http.js +36 -36
  8. package/dist/access-identity-continuity-surface.js +20 -20
  9. package/dist/access-lcm-surface.js +20 -20
  10. package/dist/access-mcp.js +29 -29
  11. package/dist/access-namespace-preflight.js +20 -20
  12. package/dist/access-observe-write-surface.js +20 -20
  13. package/dist/access-operations-batch.js +23 -23
  14. package/dist/access-operations.js +28 -28
  15. package/dist/access-recall-concurrency.js +20 -20
  16. package/dist/access-recall-response.js +20 -20
  17. package/dist/access-recall-surface.js +20 -20
  18. package/dist/access-schema.js +4 -4
  19. package/dist/access-service.js +20 -20
  20. package/dist/access-surface-catalog.js +22 -22
  21. package/dist/briefing.js +8 -8
  22. package/dist/{capsule-crypto-7FJQINUR.js → capsule-crypto-YO5QJ6L3.js} +2 -2
  23. package/dist/causal-consolidation.js +9 -9
  24. package/dist/{chunk-N3VPKMU3.js → chunk-3X7Z467X.js} +2 -2
  25. package/dist/{chunk-EB3V7PJC.js → chunk-4OH6FGAO.js} +13 -13
  26. package/dist/{chunk-EA2C5Z7V.js → chunk-5C4LXMSK.js} +26 -26
  27. package/dist/{chunk-YSD4MK5Q.js → chunk-5HP6LVWU.js} +2 -2
  28. package/dist/{chunk-OM4VYQPH.js → chunk-6EXRODDF.js} +2 -2
  29. package/dist/{chunk-2L5FSNPC.js → chunk-6F5WBXE2.js} +4 -4
  30. package/dist/{chunk-DZPX5WSG.js → chunk-6JYTP3JP.js} +3 -3
  31. package/dist/{chunk-RFYKLES2.js → chunk-6KQLDPKA.js} +2 -2
  32. package/dist/{chunk-J2R275FN.js → chunk-7DXW5V2W.js} +2 -2
  33. package/dist/chunk-7O5EW7KX.js +27 -0
  34. package/dist/chunk-7O5EW7KX.js.map +1 -0
  35. package/dist/{chunk-227UFSEQ.js → chunk-7RZJWZ46.js} +5 -5
  36. package/dist/{chunk-WOA7DS7O.js → chunk-7TCISOAJ.js} +210 -47
  37. package/dist/chunk-7TCISOAJ.js.map +1 -0
  38. package/dist/{chunk-3KURGLO7.js → chunk-7VA7PARC.js} +2 -2
  39. package/dist/{chunk-TOUZM2RQ.js → chunk-AJFKQJQK.js} +2 -2
  40. package/dist/{chunk-2NLLXCJG.js → chunk-BXLOS5AJ.js} +2 -2
  41. package/dist/{chunk-WZBDOI2C.js → chunk-C5FRCM4B.js} +2 -2
  42. package/dist/{chunk-PRCJDCDD.js → chunk-CGFDT752.js} +4 -4
  43. package/dist/{chunk-V63BBOFS.js → chunk-DD2BKHSD.js} +2 -2
  44. package/dist/{chunk-X7Y7WX73.js → chunk-DQEMWVMT.js} +1 -1
  45. package/dist/{chunk-WJTB4SF5.js → chunk-EAG7YMIS.js} +2 -2
  46. package/dist/{chunk-DA2XDNS5.js → chunk-EJF6AV2U.js} +7 -7
  47. package/dist/{chunk-3HEYRDNF.js → chunk-EJJ57PTY.js} +2 -2
  48. package/dist/{chunk-YHSZTMM3.js → chunk-EVLHZ6F3.js} +2 -2
  49. package/dist/{chunk-35J5ABJC.js → chunk-FGV7V2N4.js} +2 -2
  50. package/dist/{chunk-6AYPXHOF.js → chunk-GM35IHF7.js} +2 -2
  51. package/dist/{chunk-MU4FAKGV.js → chunk-HDEQRI2O.js} +2 -2
  52. package/dist/{chunk-PBSZQBFM.js → chunk-IFHT77WK.js} +2 -2
  53. package/dist/{chunk-OZFHE6FY.js → chunk-IHFN3JTR.js} +3 -3
  54. package/dist/{chunk-LXMYIJOO.js → chunk-ISDSX7GW.js} +1 -1
  55. package/dist/{chunk-EZPBQVGZ.js → chunk-JJAOVJ3E.js} +5 -5
  56. package/dist/{chunk-76X3F4WV.js → chunk-LGVIKA7O.js} +6 -6
  57. package/dist/{chunk-7DQQYJKU.js → chunk-MDQXW235.js} +2 -2
  58. package/dist/{chunk-ASDK4GEV.js → chunk-MOUCCUET.js} +2 -2
  59. package/dist/{chunk-DT7LKLET.js → chunk-MVH7YAWY.js} +2 -2
  60. package/dist/{chunk-GH477IGR.js → chunk-NAB4MKTJ.js} +3 -3
  61. package/dist/{chunk-FGORNFTZ.js → chunk-NM5EOKN7.js} +2 -2
  62. package/dist/{chunk-LXBVTRBG.js → chunk-OUAK37ID.js} +2 -2
  63. package/dist/{chunk-HCERYDRI.js → chunk-PGD3DXZP.js} +2 -2
  64. package/dist/{chunk-6CT4HSVJ.js → chunk-PY5E3UOD.js} +2 -2
  65. package/dist/{chunk-HBOQO4GB.js → chunk-QDBG45NK.js} +7 -7
  66. package/dist/{chunk-EJCKJS4X.js → chunk-R3YVNAJB.js} +2 -2
  67. package/dist/{chunk-XVI2TR26.js → chunk-R4FPBSFD.js} +2 -2
  68. package/dist/{chunk-VVE5SLO5.js → chunk-SKVSBTFQ.js} +2 -2
  69. package/dist/{chunk-S7363OIV.js → chunk-SZ3SRRYZ.js} +2 -2
  70. package/dist/{chunk-WRPRUMUI.js → chunk-SZZGVXKC.js} +2 -2
  71. package/dist/{chunk-TFN3PPYO.js → chunk-TGDFKC7X.js} +2 -2
  72. package/dist/{chunk-7SL6EJLT.js → chunk-TP3N5364.js} +2 -2
  73. package/dist/{chunk-IPLYGWQF.js → chunk-VDX2J7OX.js} +6 -6
  74. package/dist/{chunk-VJERIMIX.js → chunk-W6V2XVUO.js} +3 -3
  75. package/dist/{chunk-5FBA6UXN.js → chunk-WTWHORVW.js} +7 -7
  76. package/dist/{chunk-UFY4G6EC.js → chunk-XOGYSWQZ.js} +10 -10
  77. package/dist/{chunk-J6SWAE74.js → chunk-YERBUSU2.js} +2 -2
  78. package/dist/{chunk-D65XGENZ.js → chunk-YPNTBYEV.js} +3 -3
  79. package/dist/{chunk-FNNWK7G7.js → chunk-ZET6A3OF.js} +2 -2
  80. package/dist/cli.js +51 -51
  81. package/dist/coding/export-okf-codegraph.js +8 -8
  82. package/dist/compounding/engine.js +8 -8
  83. package/dist/connectors/codex-materialize-runner.js +8 -8
  84. package/dist/connectors/index.js +8 -8
  85. package/dist/convergence-refresh.js +10 -10
  86. package/dist/conversation-index/backend.js +2 -2
  87. package/dist/corpus-watermark.js +10 -10
  88. package/dist/entity-retrieval.js +8 -8
  89. package/dist/external-wiki-access.js +21 -21
  90. package/dist/extraction.js +8 -8
  91. package/dist/harmonic-retrieval.js +1 -1
  92. package/dist/index.d.ts +77 -1
  93. package/dist/index.js +101 -91
  94. package/dist/lcm/engine.js +2 -2
  95. package/dist/lcm/index.js +5 -5
  96. package/dist/location/index.js +10 -10
  97. package/dist/maintenance/memory-governance.js +8 -8
  98. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +8 -8
  99. package/dist/maintenance/rebuild-memory-projection.js +9 -9
  100. package/dist/namespaces/migrate.js +9 -9
  101. package/dist/namespaces/search.js +6 -6
  102. package/dist/namespaces/storage.js +8 -8
  103. package/dist/operator-doctor-corpus.js +11 -11
  104. package/dist/operator-toolkit.js +16 -16
  105. package/dist/orchestration/maintenance.js +16 -16
  106. package/dist/orchestrator.js +68 -68
  107. package/dist/recall-navigate-link.d.ts +9 -3
  108. package/dist/recall-navigate-link.js +3 -15
  109. package/dist/recall-navigate-link.js.map +1 -1
  110. package/dist/recall-navigate-traverse.d.ts +21 -0
  111. package/dist/recall-navigate-traverse.js +44 -0
  112. package/dist/recall-navigate-traverse.js.map +1 -0
  113. package/dist/search/factory.js +5 -5
  114. package/dist/search/index.js +9 -9
  115. package/dist/semantic-consolidation.js +9 -9
  116. package/dist/semantic-rule-promotion.js +8 -8
  117. package/dist/semantic-rule-verifier.js +8 -8
  118. package/dist/source-agent-qualifier.js +8 -8
  119. package/dist/standup/build.js +8 -8
  120. package/dist/storage.js +7 -7
  121. package/dist/support-passport/index.js +23 -23
  122. package/dist/transfer/autodetect.js +1 -1
  123. package/dist/transfer/backup.js +2 -2
  124. package/dist/transfer/capsule-export.js +3 -3
  125. package/dist/transfer/capsule-import.js +2 -2
  126. package/dist/transfer/export-okf.js +9 -9
  127. package/dist/verified-recall.js +8 -8
  128. package/dist/who-knows-cli.js +9 -9
  129. package/dist/who-knows.js +8 -8
  130. package/package.json +2 -2
  131. package/src/activity/index.ts +2 -0
  132. package/src/activity/privacy-gate-resolve.test.ts +175 -0
  133. package/src/activity/privacy-gate-resolve.ts +84 -0
  134. package/src/activity/timeline/index.ts +1 -0
  135. package/src/activity/timeline/recap-fallback.test.ts +184 -0
  136. package/src/activity/timeline/recap-fallback.ts +106 -0
  137. package/src/activity/vault-dry-run.test.ts +114 -0
  138. package/src/activity/vault-dry-run.ts +76 -0
  139. package/src/recall-navigate-link.test.ts +28 -6
  140. package/src/recall-navigate-link.ts +16 -2
  141. package/src/recall-navigate-traverse.test.ts +247 -0
  142. package/src/recall-navigate-traverse.ts +73 -0
  143. package/dist/chunk-WOA7DS7O.js.map +0 -1
  144. /package/dist/{capsule-crypto-7FJQINUR.js.map → capsule-crypto-YO5QJ6L3.js.map} +0 -0
  145. /package/dist/{chunk-N3VPKMU3.js.map → chunk-3X7Z467X.js.map} +0 -0
  146. /package/dist/{chunk-EB3V7PJC.js.map → chunk-4OH6FGAO.js.map} +0 -0
  147. /package/dist/{chunk-EA2C5Z7V.js.map → chunk-5C4LXMSK.js.map} +0 -0
  148. /package/dist/{chunk-YSD4MK5Q.js.map → chunk-5HP6LVWU.js.map} +0 -0
  149. /package/dist/{chunk-OM4VYQPH.js.map → chunk-6EXRODDF.js.map} +0 -0
  150. /package/dist/{chunk-2L5FSNPC.js.map → chunk-6F5WBXE2.js.map} +0 -0
  151. /package/dist/{chunk-DZPX5WSG.js.map → chunk-6JYTP3JP.js.map} +0 -0
  152. /package/dist/{chunk-RFYKLES2.js.map → chunk-6KQLDPKA.js.map} +0 -0
  153. /package/dist/{chunk-J2R275FN.js.map → chunk-7DXW5V2W.js.map} +0 -0
  154. /package/dist/{chunk-227UFSEQ.js.map → chunk-7RZJWZ46.js.map} +0 -0
  155. /package/dist/{chunk-3KURGLO7.js.map → chunk-7VA7PARC.js.map} +0 -0
  156. /package/dist/{chunk-TOUZM2RQ.js.map → chunk-AJFKQJQK.js.map} +0 -0
  157. /package/dist/{chunk-2NLLXCJG.js.map → chunk-BXLOS5AJ.js.map} +0 -0
  158. /package/dist/{chunk-WZBDOI2C.js.map → chunk-C5FRCM4B.js.map} +0 -0
  159. /package/dist/{chunk-PRCJDCDD.js.map → chunk-CGFDT752.js.map} +0 -0
  160. /package/dist/{chunk-V63BBOFS.js.map → chunk-DD2BKHSD.js.map} +0 -0
  161. /package/dist/{chunk-X7Y7WX73.js.map → chunk-DQEMWVMT.js.map} +0 -0
  162. /package/dist/{chunk-WJTB4SF5.js.map → chunk-EAG7YMIS.js.map} +0 -0
  163. /package/dist/{chunk-DA2XDNS5.js.map → chunk-EJF6AV2U.js.map} +0 -0
  164. /package/dist/{chunk-3HEYRDNF.js.map → chunk-EJJ57PTY.js.map} +0 -0
  165. /package/dist/{chunk-YHSZTMM3.js.map → chunk-EVLHZ6F3.js.map} +0 -0
  166. /package/dist/{chunk-35J5ABJC.js.map → chunk-FGV7V2N4.js.map} +0 -0
  167. /package/dist/{chunk-6AYPXHOF.js.map → chunk-GM35IHF7.js.map} +0 -0
  168. /package/dist/{chunk-MU4FAKGV.js.map → chunk-HDEQRI2O.js.map} +0 -0
  169. /package/dist/{chunk-PBSZQBFM.js.map → chunk-IFHT77WK.js.map} +0 -0
  170. /package/dist/{chunk-OZFHE6FY.js.map → chunk-IHFN3JTR.js.map} +0 -0
  171. /package/dist/{chunk-LXMYIJOO.js.map → chunk-ISDSX7GW.js.map} +0 -0
  172. /package/dist/{chunk-EZPBQVGZ.js.map → chunk-JJAOVJ3E.js.map} +0 -0
  173. /package/dist/{chunk-76X3F4WV.js.map → chunk-LGVIKA7O.js.map} +0 -0
  174. /package/dist/{chunk-7DQQYJKU.js.map → chunk-MDQXW235.js.map} +0 -0
  175. /package/dist/{chunk-ASDK4GEV.js.map → chunk-MOUCCUET.js.map} +0 -0
  176. /package/dist/{chunk-DT7LKLET.js.map → chunk-MVH7YAWY.js.map} +0 -0
  177. /package/dist/{chunk-GH477IGR.js.map → chunk-NAB4MKTJ.js.map} +0 -0
  178. /package/dist/{chunk-FGORNFTZ.js.map → chunk-NM5EOKN7.js.map} +0 -0
  179. /package/dist/{chunk-LXBVTRBG.js.map → chunk-OUAK37ID.js.map} +0 -0
  180. /package/dist/{chunk-HCERYDRI.js.map → chunk-PGD3DXZP.js.map} +0 -0
  181. /package/dist/{chunk-6CT4HSVJ.js.map → chunk-PY5E3UOD.js.map} +0 -0
  182. /package/dist/{chunk-HBOQO4GB.js.map → chunk-QDBG45NK.js.map} +0 -0
  183. /package/dist/{chunk-EJCKJS4X.js.map → chunk-R3YVNAJB.js.map} +0 -0
  184. /package/dist/{chunk-XVI2TR26.js.map → chunk-R4FPBSFD.js.map} +0 -0
  185. /package/dist/{chunk-VVE5SLO5.js.map → chunk-SKVSBTFQ.js.map} +0 -0
  186. /package/dist/{chunk-S7363OIV.js.map → chunk-SZ3SRRYZ.js.map} +0 -0
  187. /package/dist/{chunk-WRPRUMUI.js.map → chunk-SZZGVXKC.js.map} +0 -0
  188. /package/dist/{chunk-TFN3PPYO.js.map → chunk-TGDFKC7X.js.map} +0 -0
  189. /package/dist/{chunk-7SL6EJLT.js.map → chunk-TP3N5364.js.map} +0 -0
  190. /package/dist/{chunk-IPLYGWQF.js.map → chunk-VDX2J7OX.js.map} +0 -0
  191. /package/dist/{chunk-VJERIMIX.js.map → chunk-W6V2XVUO.js.map} +0 -0
  192. /package/dist/{chunk-5FBA6UXN.js.map → chunk-WTWHORVW.js.map} +0 -0
  193. /package/dist/{chunk-UFY4G6EC.js.map → chunk-XOGYSWQZ.js.map} +0 -0
  194. /package/dist/{chunk-J6SWAE74.js.map → chunk-YERBUSU2.js.map} +0 -0
  195. /package/dist/{chunk-D65XGENZ.js.map → chunk-YPNTBYEV.js.map} +0 -0
  196. /package/dist/{chunk-FNNWK7G7.js.map → chunk-ZET6A3OF.js.map} +0 -0
@@ -0,0 +1,114 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ import { planVaultDryRun } from "./vault-dry-run.js";
5
+ import type { VaultDryRunInput } from "./vault-dry-run.js";
6
+ import { VAULT_PUBLISH_OUTCOMES } from "./vault-status.js";
7
+
8
+ test("planVaultDryRun predicts a mixed batch with correct per-file outcomes and counts", () => {
9
+ const status = planVaultDryRun([
10
+ { path: "notes/b.md", currentText: "same", nextText: "same" },
11
+ { path: "notes/a.md", currentText: null, nextText: "new body" },
12
+ { path: "notes/c.md", currentText: "old", nextText: "new" },
13
+ { path: "notes/d.md", currentText: "text", nextText: null, skipReason: "no_marker" },
14
+ ]);
15
+ assert.deepEqual(status.counts, { updated: 1, unchanged: 1, skipped: 2, error: 0 });
16
+ assert.equal(status.hasError, false);
17
+ assert.deepEqual(status.results, [
18
+ { path: "notes/a.md", outcome: "skipped", reason: "missing_file" },
19
+ { path: "notes/b.md", outcome: "unchanged" },
20
+ { path: "notes/c.md", outcome: "updated" },
21
+ { path: "notes/d.md", outcome: "skipped", reason: "no_marker" },
22
+ ]);
23
+ });
24
+
25
+ // The real publisher refuses to create missing notes (missing_file), so a
26
+ // dry run must predict that skip rather than promise an update.
27
+ test("a missing note predicts the publisher's skip, not a create", () => {
28
+ const status = planVaultDryRun([{ path: "new.md", currentText: null, nextText: "body" }]);
29
+ assert.deepEqual(status.counts, { updated: 0, unchanged: 0, skipped: 1, error: 0 });
30
+ assert.deepEqual(status.results, [{ path: "new.md", outcome: "skipped", reason: "missing_file" }]);
31
+ });
32
+
33
+ test("byte-identical text is unchanged", () => {
34
+ const status = planVaultDryRun([{ path: "same.md", currentText: "line\nline\n", nextText: "line\nline\n" }]);
35
+ assert.deepEqual(status.counts, { updated: 0, unchanged: 1, skipped: 0, error: 0 });
36
+ assert.deepEqual(status.results, [{ path: "same.md", outcome: "unchanged" }]);
37
+ });
38
+
39
+ test("a whitespace-only difference is updated, not unchanged", () => {
40
+ const status = planVaultDryRun([
41
+ { path: "trailing.md", currentText: "body", nextText: "body " },
42
+ { path: "newline.md", currentText: "body\n", nextText: "body" },
43
+ ]);
44
+ assert.deepEqual(status.counts, { updated: 2, unchanged: 0, skipped: 0, error: 0 });
45
+ });
46
+
47
+ test("nextText null with a reason is skipped carrying that exact reason", () => {
48
+ const status = planVaultDryRun([
49
+ { path: "skip.md", currentText: "text", nextText: null, skipReason: "no_marker" },
50
+ ]);
51
+ assert.deepEqual(status.results, [{ path: "skip.md", outcome: "skipped", reason: "no_marker" }]);
52
+ assert.equal(status.counts.skipped, 1);
53
+ });
54
+
55
+ test("nextText null without a reason throws TypeError naming skipReason", () => {
56
+ assert.throws(
57
+ () => planVaultDryRun([{ path: "skip.md", currentText: "text", nextText: null }]),
58
+ (err: unknown) => err instanceof TypeError && /skipReason/.test(err.message),
59
+ );
60
+ assert.throws(
61
+ () => planVaultDryRun([{ path: "skip.md", currentText: "text", nextText: null, skipReason: " " }]),
62
+ (err: unknown) => err instanceof TypeError && /skipReason/.test(err.message),
63
+ );
64
+ });
65
+
66
+ test("skipReason with a non-null nextText throws TypeError", () => {
67
+ assert.throws(
68
+ () => planVaultDryRun([{ path: "conflict.md", currentText: "a", nextText: "b", skipReason: "no_marker" }]),
69
+ (err: unknown) => err instanceof TypeError && /skipReason/.test(err.message),
70
+ );
71
+ });
72
+
73
+ test("blank path throws RangeError naming path", () => {
74
+ assert.throws(
75
+ () => planVaultDryRun([{ path: "", currentText: "a", nextText: "a" }]),
76
+ (err: unknown) => err instanceof RangeError && /path/.test(err.message),
77
+ );
78
+ assert.throws(
79
+ () => planVaultDryRun([{ path: " ", currentText: "a", nextText: "a" }]),
80
+ (err: unknown) => err instanceof RangeError && /path/.test(err.message),
81
+ );
82
+ });
83
+
84
+ test("unused outcome keys are present at zero", () => {
85
+ const status = planVaultDryRun([{ path: "only.md", currentText: "a", nextText: "a" }]);
86
+ for (const outcome of VAULT_PUBLISH_OUTCOMES) {
87
+ assert.ok(outcome in status.counts, `count key ${outcome} must exist`);
88
+ assert.equal(status.counts[outcome], outcome === "unchanged" ? 1 : 0);
89
+ }
90
+ });
91
+
92
+ test("input is not mutated", () => {
93
+ const inputs: VaultDryRunInput[] = [
94
+ { path: "a.md", currentText: "old", nextText: "new" },
95
+ { path: "b.md", currentText: "x", nextText: null, skipReason: "no_marker" },
96
+ ];
97
+ const snapshot = JSON.parse(JSON.stringify(inputs));
98
+ planVaultDryRun(inputs);
99
+ assert.deepEqual(inputs, snapshot);
100
+ });
101
+
102
+ // Round 3: undefined and non-string text fields are malformed input, never a
103
+ // prediction. Two undefined fields used to compare equal and report unchanged.
104
+ test("undefined and non-string text fields throw rather than predict", () => {
105
+ for (const bad of [
106
+ { path: "a.md", currentText: undefined as unknown as string, nextText: "x" },
107
+ { path: "a.md", currentText: "x", nextText: undefined as unknown as string },
108
+ { path: "a.md", currentText: 5 as unknown as string, nextText: "x" },
109
+ { path: "a.md", currentText: "x", nextText: {} as unknown as string },
110
+ { path: "a.md", currentText: undefined as unknown as string, nextText: undefined as unknown as string },
111
+ ]) {
112
+ assert.throws(() => planVaultDryRun([bad]), /must be a string or null/);
113
+ }
114
+ });
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Pure vault publish dry-run planner (issue #1985).
3
+ *
4
+ * `planVaultDryRun` predicts per-note publish outcomes from text the caller
5
+ * has already read. It takes strings, not paths, and returns only a report:
6
+ * zero writes is a property of the signature, not a promise. Reporting a
7
+ * note as `skipped` with reason `missing_file` when `currentText` is null,
8
+ * matching the real publisher, which refuses to create a missing note.
9
+ *
10
+ * Internal helper: nothing calls it yet. CLI/HTTP wiring for the `--dry-run`
11
+ * flag is a later slice of #1985.
12
+ */
13
+ import { summarizeVaultPublish } from "./vault-status.js";
14
+ import type { VaultPublishOutcome, VaultPublishResult, VaultPublishStatus } from "./vault-status.js";
15
+
16
+ export interface VaultDryRunInput {
17
+ /** Vault-relative note path, for reporting only. */
18
+ path: string;
19
+ /** Current note text, or null when the note does not exist. */
20
+ currentText: string | null;
21
+ /** The text a real publish would produce, or null when it would skip. */
22
+ nextText: string | null;
23
+ /** Required when nextText is null: why the publish would skip. */
24
+ skipReason?: string;
25
+ }
26
+
27
+ export function planVaultDryRun(inputs: readonly VaultDryRunInput[]): VaultPublishStatus {
28
+ const results: VaultPublishResult[] = [];
29
+ for (const input of inputs) {
30
+ if (typeof input !== "object" || input === null) {
31
+ throw new TypeError(`vault dry-run inputs must be objects (got ${typeof input})`);
32
+ }
33
+ if (typeof input.path !== "string" || input.path.trim() === "") {
34
+ throw new RangeError(`vault dry-run input requires a non-blank path (got ${JSON.stringify(input.path)})`);
35
+ }
36
+ // Validate the exact values before classification: a non-string or
37
+ // undefined text field is malformed input, and letting it reach the
38
+ // comparison would silently report a prediction for data the contract
39
+ // does not permit (two undefined fields would read as "unchanged").
40
+ for (const field of ["currentText", "nextText"] as const) {
41
+ const value = input[field];
42
+ if (value !== null && typeof value !== "string") {
43
+ throw new TypeError(
44
+ `vault dry-run input ${field} must be a string or null (got ${typeof value})`,
45
+ );
46
+ }
47
+ }
48
+ if (input.nextText === null) {
49
+ if (typeof input.skipReason !== "string" || input.skipReason.trim() === "") {
50
+ throw new TypeError(
51
+ `vault dry-run skip (nextText null) requires a non-blank skipReason (got ${JSON.stringify(input.skipReason)})`,
52
+ );
53
+ }
54
+ results.push({ path: input.path, outcome: "skipped", reason: input.skipReason });
55
+ continue;
56
+ }
57
+ if (input.skipReason !== undefined) {
58
+ throw new TypeError(
59
+ `vault dry-run skipReason is only valid when nextText is null (path ${JSON.stringify(input.path)})`,
60
+ );
61
+ }
62
+ // A missing note is NOT a create: the real publisher (vault-publish.ts
63
+ // publishVaultRegion) refuses to create missing files and returns
64
+ // missing_file, so the dry run must predict that skip rather than promise
65
+ // an update the real run will not perform.
66
+ if (input.currentText === null) {
67
+ results.push({ path: input.path, outcome: "skipped", reason: "missing_file" });
68
+ continue;
69
+ }
70
+ // Byte-exact compare: a publish that rewrites only trailing whitespace is
71
+ // still a write.
72
+ const outcome: VaultPublishOutcome = input.currentText === input.nextText ? "unchanged" : "updated";
73
+ results.push({ path: input.path, outcome });
74
+ }
75
+ return summarizeVaultPublish(results);
76
+ }
@@ -2,18 +2,40 @@ import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
 
4
4
  import { parseNavigateLinkType } from "./recall-navigate-link.js";
5
+ import { selectTraverseNeighbors } from "./recall-navigate-traverse.js";
5
6
 
6
- test("parses each allowed link type", () => {
7
- for (const type of ["supports", "contradicts", "elaborates", "causes", "caused_by"] as const) {
7
+ test("every navigation relation parses", () => {
8
+ for (const type of ["supports", "contradicts", "elaborates", "causes", "caused_by"]) {
8
9
  assert.deepEqual(parseNavigateLinkType(type), { ok: true, type });
9
10
  }
10
11
  });
11
12
 
12
- test("unknown link is unknown_link", () => {
13
- assert.deepEqual(parseNavigateLinkType("supersedes"), { ok: false, error: "unknown_link" });
14
- assert.deepEqual(parseNavigateLinkType("related"), { ok: false, error: "unknown_link" });
13
+ // Persisted MemoryLinkType values must parse too: real frontmatter carries
14
+ // them, and a traversal over stored links loses those neighbors otherwise.
15
+ test("persisted MemoryLinkType values parse", () => {
16
+ for (const type of ["follows", "references", "related"]) {
17
+ assert.deepEqual(parseNavigateLinkType(type), { ok: true, type });
18
+ }
15
19
  });
16
20
 
17
- test("empty link is unknown_link", () => {
21
+ test("unknown or empty link types are refused", () => {
22
+ assert.deepEqual(parseNavigateLinkType("supersedesX"), { ok: false, error: "unknown_link" });
18
23
  assert.deepEqual(parseNavigateLinkType(""), { ok: false, error: "unknown_link" });
24
+ assert.deepEqual(parseNavigateLinkType("Supports"), { ok: false, error: "unknown_link" });
25
+ });
26
+
27
+
28
+ // Round 3: the stepper's navigation contract accepts supersedes, so the
29
+ // shared selector vocabulary must too.
30
+ test("supersedes parses and filters", () => {
31
+ assert.deepEqual(parseNavigateLinkType("supersedes"), { ok: true, type: "supersedes" });
32
+ const result = selectTraverseNeighbors({
33
+ links: [
34
+ { targetId: "m-1", linkType: "supersedes" },
35
+ { targetId: "m-2", linkType: "supports" },
36
+ ],
37
+ relation: "supersedes",
38
+ });
39
+ assert.ok(result.ok);
40
+ assert.deepEqual(result.neighbors, [{ targetId: "m-1", linkType: "supersedes" }]);
19
41
  });
@@ -1,7 +1,13 @@
1
1
  /**
2
- * Recall navigation link-type parse (issue #1956 leftover).
2
+ * Recall navigation link-type parse (issue #1956).
3
3
  *
4
- * Pure. Surfaces wait. Unknown or empty is unknown_link.
4
+ * One allow-list covering BOTH vocabularies a traversal can meet:
5
+ * the navigation set this module originally defined and the persisted
6
+ * `MemoryLinkType` values (`follows | references | related`) that real
7
+ * memory frontmatter carries. Review found the narrower list silently
8
+ * dropped every persisted `follows`/`references`/`related` edge and
9
+ * rejected `relation: "related"` as unknown — a traversal over actual
10
+ * stored links would lose valid neighbors. Surfaces wait.
5
11
  */
6
12
 
7
13
  const NAVIGATE_LINK_TYPES = [
@@ -10,6 +16,14 @@ const NAVIGATE_LINK_TYPES = [
10
16
  "elaborates",
11
17
  "causes",
12
18
  "caused_by",
19
+ // Persisted MemoryLinkType values (types.ts) that real frontmatter carries.
20
+ "follows",
21
+ "references",
22
+ "related",
23
+ // The stepper's own navigation contract (recall-navigate.ts) accepts
24
+ // supersedes; omitting it here would reject a relation that surface is
25
+ // prepared to handle.
26
+ "supersedes",
13
27
  ] as const;
14
28
 
15
29
  export type NavigateLinkType = (typeof NAVIGATE_LINK_TYPES)[number];
@@ -0,0 +1,247 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ import { DEFAULT_TRAVERSE_LIMIT } from "./recall-navigate-traverse.js";
5
+ import { selectTraverseNeighbors } from "./recall-navigate-traverse.js";
6
+
7
+ test("no relation returns all known-type links sorted", () => {
8
+ const result = selectTraverseNeighbors({
9
+ links: [
10
+ { targetId: "m-3", linkType: "supports" },
11
+ { targetId: "m-1", linkType: "elaborates" },
12
+ { targetId: "m-2", linkType: "causes" },
13
+ ],
14
+ });
15
+ assert.ok(result.ok);
16
+ assert.equal(result.truncated, false);
17
+ assert.deepEqual(result.neighbors, [
18
+ { targetId: "m-2", linkType: "causes" },
19
+ { targetId: "m-1", linkType: "elaborates" },
20
+ { targetId: "m-3", linkType: "supports" },
21
+ ]);
22
+ });
23
+
24
+ test("relation filter returns only that relation", () => {
25
+ const result = selectTraverseNeighbors({
26
+ links: [
27
+ { targetId: "m-1", linkType: "supports" },
28
+ { targetId: "m-2", linkType: "contradicts" },
29
+ { targetId: "m-3", linkType: "supports" },
30
+ ],
31
+ relation: "supports",
32
+ });
33
+ assert.ok(result.ok);
34
+ assert.deepEqual(result.neighbors, [
35
+ { targetId: "m-1", linkType: "supports" },
36
+ { targetId: "m-3", linkType: "supports" },
37
+ ]);
38
+ });
39
+
40
+ test("unknown relation is refused with unknown_relation", () => {
41
+ const result = selectTraverseNeighbors({
42
+ links: [{ targetId: "m-1", linkType: "supports" }],
43
+ relation: "destroys",
44
+ });
45
+ assert.deepEqual(result, { ok: false, error: "unknown_relation" });
46
+ });
47
+
48
+ test("unknown linkType link is skipped while siblings survive", () => {
49
+ const result = selectTraverseNeighbors({
50
+ links: [
51
+ { targetId: "m-1", linkType: "hyperlinks" },
52
+ { targetId: "m-2", linkType: "supports" },
53
+ ],
54
+ });
55
+ assert.ok(result.ok);
56
+ assert.deepEqual(result.neighbors, [{ targetId: "m-2", linkType: "supports" }]);
57
+ });
58
+
59
+ test("duplicate targetIds collapse to the first occurrence", () => {
60
+ const result = selectTraverseNeighbors({
61
+ links: [
62
+ { targetId: "m-1", linkType: "causes" },
63
+ { targetId: "m-1", linkType: "supports" },
64
+ { targetId: "m-2", linkType: "elaborates" },
65
+ ],
66
+ });
67
+ assert.ok(result.ok);
68
+ assert.deepEqual(result.neighbors, [
69
+ { targetId: "m-1", linkType: "causes" },
70
+ { targetId: "m-2", linkType: "elaborates" },
71
+ ]);
72
+ });
73
+
74
+ test("blank targetId is skipped", () => {
75
+ const result = selectTraverseNeighbors({
76
+ links: [
77
+ { targetId: "", linkType: "supports" },
78
+ { targetId: " ", linkType: "supports" },
79
+ { targetId: "m-1", linkType: "supports" },
80
+ ],
81
+ });
82
+ assert.ok(result.ok);
83
+ assert.deepEqual(result.neighbors, [{ targetId: "m-1", linkType: "supports" }]);
84
+ });
85
+
86
+ test("limit truncates after sorting and sets truncated", () => {
87
+ const result = selectTraverseNeighbors({
88
+ links: [
89
+ { targetId: "m-9", linkType: "supports" },
90
+ { targetId: "m-2", linkType: "causes" },
91
+ { targetId: "m-5", linkType: "elaborates" },
92
+ ],
93
+ limit: 2,
94
+ });
95
+ assert.ok(result.ok);
96
+ assert.equal(result.truncated, true);
97
+ assert.deepEqual(result.neighbors, [
98
+ { targetId: "m-2", linkType: "causes" },
99
+ { targetId: "m-5", linkType: "elaborates" },
100
+ ]);
101
+ });
102
+
103
+ test("default limit caps at DEFAULT_TRAVERSE_LIMIT", () => {
104
+ const links = Array.from({ length: DEFAULT_TRAVERSE_LIMIT + 3 }, (_, i) => ({
105
+ targetId: `m-${i}`,
106
+ linkType: "supports",
107
+ }));
108
+ const result = selectTraverseNeighbors({ links });
109
+ assert.ok(result.ok);
110
+ assert.equal(result.truncated, true);
111
+ assert.equal(result.neighbors.length, DEFAULT_TRAVERSE_LIMIT);
112
+ });
113
+
114
+ test("limit 0 is invalid_limit", () => {
115
+ assert.deepEqual(
116
+ selectTraverseNeighbors({ links: [], limit: 0 }),
117
+ { ok: false, error: "invalid_limit" },
118
+ );
119
+ });
120
+
121
+ test("limit -1 is invalid_limit", () => {
122
+ assert.deepEqual(
123
+ selectTraverseNeighbors({ links: [], limit: -1 }),
124
+ { ok: false, error: "invalid_limit" },
125
+ );
126
+ });
127
+
128
+ test("limit 1.5 is invalid_limit", () => {
129
+ assert.deepEqual(
130
+ selectTraverseNeighbors({ links: [], limit: 1.5 }),
131
+ { ok: false, error: "invalid_limit" },
132
+ );
133
+ });
134
+
135
+ test("limit NaN is invalid_limit", () => {
136
+ assert.deepEqual(
137
+ selectTraverseNeighbors({ links: [], limit: Number.NaN }),
138
+ { ok: false, error: "invalid_limit" },
139
+ );
140
+ });
141
+
142
+ test("shuffled input is deep-equal to sorted-input result", () => {
143
+ const sorted = [
144
+ { targetId: "m-2", linkType: "caused_by" },
145
+ { targetId: "m-1", linkType: "causes" },
146
+ { targetId: "m-3", linkType: "elaborates" },
147
+ { targetId: "m-4", linkType: "supports" },
148
+ ];
149
+ const result = selectTraverseNeighbors({
150
+ links: [...sorted].reverse(),
151
+ });
152
+ assert.ok(result.ok);
153
+ assert.deepEqual(result.neighbors, sorted);
154
+ assert.deepEqual(selectTraverseNeighbors({ links: sorted }), result);
155
+ });
156
+
157
+ test("inputs are not mutated", () => {
158
+ const links = [
159
+ { targetId: "m-2", linkType: "supports" },
160
+ { targetId: "m-1", linkType: "causes" },
161
+ ];
162
+ selectTraverseNeighbors({ links, relation: "causes", limit: 1 });
163
+ assert.deepEqual(links, [
164
+ { targetId: "m-2", linkType: "supports" },
165
+ { targetId: "m-1", linkType: "causes" },
166
+ ]);
167
+ });
168
+
169
+ // Review: the contract said bad stored data is skipped, but a nullish
170
+ // targetId threw before the skip checks could run.
171
+ test("a nullish or non-string targetId is skipped, never thrown", () => {
172
+ const result = selectTraverseNeighbors({
173
+ links: [
174
+ { targetId: undefined as unknown as string, linkType: "supports" },
175
+ { targetId: null as unknown as string, linkType: "supports" },
176
+ { targetId: 42 as unknown as string, linkType: "supports" },
177
+ { targetId: "m-real", linkType: "supports" },
178
+ ],
179
+ });
180
+ assert.ok(result.ok);
181
+ assert.deepEqual(result.neighbors, [{ targetId: "m-real", linkType: "supports" }]);
182
+ });
183
+
184
+ // A padded id is malformed, not a variant of the trimmed id: trimming would
185
+ // guess at an identity, so the row is skipped.
186
+ test("a whitespace-padded targetId is skipped rather than trimmed", () => {
187
+ const result = selectTraverseNeighbors({
188
+ links: [
189
+ { targetId: " m-1", linkType: "supports" },
190
+ { targetId: "m-2", linkType: "supports" },
191
+ ],
192
+ });
193
+ assert.ok(result.ok);
194
+ assert.deepEqual(result.neighbors, [{ targetId: "m-2", linkType: "supports" }]);
195
+ });
196
+
197
+ // Review: dedup ran before sorting, so input order decided which relation
198
+ // survived for a doubly-linked target. Sorting first fixes the survivor.
199
+ test("a target under two relations keeps the smallest linkType regardless of input order", () => {
200
+ const a = { targetId: "m-1", linkType: "supports" };
201
+ const b = { targetId: "m-1", linkType: "contradicts" };
202
+ const forward = selectTraverseNeighbors({ links: [a, b] });
203
+ const reversed = selectTraverseNeighbors({ links: [b, a] });
204
+ assert.ok(forward.ok && reversed.ok);
205
+ assert.deepEqual(forward.neighbors, reversed.neighbors);
206
+ assert.deepEqual(forward.neighbors, [{ targetId: "m-1", linkType: "contradicts" }]);
207
+ });
208
+
209
+ test("sort-then-dedup also decides which target survives a tight limit", () => {
210
+ const links = [
211
+ { targetId: "m-z", linkType: "supports" },
212
+ { targetId: "m-a", linkType: "supports" },
213
+ { targetId: "m-a", linkType: "elaborates" },
214
+ ];
215
+ const shuffled = selectTraverseNeighbors({ links: [...links].reverse(), limit: 1 });
216
+ const straight = selectTraverseNeighbors({ links, limit: 1 });
217
+ assert.ok(shuffled.ok && straight.ok);
218
+ assert.deepEqual(shuffled.neighbors, straight.neighbors);
219
+ assert.deepEqual(straight.neighbors, [{ targetId: "m-a", linkType: "elaborates" }]);
220
+ });
221
+
222
+
223
+ // Round 2: the persisted MemoryLinkType values are real neighbors, not
224
+ // unknown rows to drop. The selector gets them from the widened parser.
225
+ test("persisted link types survive an unfiltered traversal", () => {
226
+ const result = selectTraverseNeighbors({
227
+ links: [
228
+ { targetId: "m-1", linkType: "follows" },
229
+ { targetId: "m-2", linkType: "references" },
230
+ { targetId: "m-3", linkType: "related" },
231
+ ],
232
+ });
233
+ assert.ok(result.ok);
234
+ assert.equal(result.neighbors.length, 3);
235
+ });
236
+
237
+ test("relation: related is a valid filter, not unknown", () => {
238
+ const result = selectTraverseNeighbors({
239
+ links: [
240
+ { targetId: "m-1", linkType: "related" },
241
+ { targetId: "m-2", linkType: "supports" },
242
+ ],
243
+ relation: "related",
244
+ });
245
+ assert.ok(result.ok);
246
+ assert.deepEqual(result.neighbors, [{ targetId: "m-1", linkType: "related" }]);
247
+ });
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Recall navigation traverse selection (issue #1956).
3
+ *
4
+ * Pure selection step for `traverseLinks(id, relation?, limit)`: given a
5
+ * recalled memory's links, pick which neighbors a traverse would visit.
6
+ * Internal; surfaces wire it in a later slice.
7
+ */
8
+ import { parseNavigateLinkType } from "./recall-navigate-link.js";
9
+
10
+ export const DEFAULT_TRAVERSE_LIMIT = 10;
11
+
12
+ export interface TraverseNeighbor {
13
+ targetId: string;
14
+ linkType: string;
15
+ }
16
+
17
+ export type TraverseSelectionResult =
18
+ | { ok: true; neighbors: TraverseNeighbor[]; truncated: boolean }
19
+ | { ok: false; error: "unknown_relation" | "invalid_limit" };
20
+
21
+ export function selectTraverseNeighbors(input: {
22
+ links: readonly TraverseNeighbor[];
23
+ /** Optional relation filter; omitted means every known relation. */
24
+ relation?: string;
25
+ limit?: number;
26
+ }): TraverseSelectionResult {
27
+ const limit = input.limit ?? DEFAULT_TRAVERSE_LIMIT;
28
+ if (!Number.isFinite(limit) || !Number.isInteger(limit) || limit < 1) {
29
+ return { ok: false, error: "invalid_limit" };
30
+ }
31
+ if (input.relation !== undefined && !parseNavigateLinkType(input.relation).ok) {
32
+ return { ok: false, error: "unknown_relation" };
33
+ }
34
+
35
+ const kept: TraverseNeighbor[] = [];
36
+ // Asymmetry is intentional: a bad request (unknown relation, invalid
37
+ // limit) is refused, but bad stored data is skipped — stored data may
38
+ // predate a type or carry a malformed id, and one bad row must not fail
39
+ // the whole traverse. A targetId that is not a non-blank string, including
40
+ // undefined/null and whitespace-only, is malformed and skipped. Padded ids
41
+ // are NOT trimmed into validity: an id is an exact identity, and guessing
42
+ // the intended target is worse than skipping the row.
43
+ for (const link of input.links) {
44
+ if (!parseNavigateLinkType(link.linkType).ok) continue;
45
+ if (input.relation !== undefined && link.linkType !== input.relation) continue;
46
+ if (typeof link.targetId !== "string" || link.targetId.trim() === "" || link.targetId.trim() !== link.targetId) {
47
+ continue;
48
+ }
49
+ kept.push({ targetId: link.targetId, linkType: link.linkType });
50
+ }
51
+
52
+ // Sort BEFORE deduplicating: first-occurrence dedup on an unsorted list
53
+ // lets input order decide which relation survives for a target that is
54
+ // linked under two types, and after the cap is applied, which target is
55
+ // returned at all. Sorting first makes the survivor deterministic
56
+ // (smallest linkType wins the tie).
57
+ kept.sort((a, b) => {
58
+ if (a.linkType !== b.linkType) return a.linkType < b.linkType ? -1 : 1;
59
+ if (a.targetId !== b.targetId) return a.targetId < b.targetId ? -1 : 1;
60
+ return 0;
61
+ });
62
+
63
+ const seen = new Set<string>();
64
+ const deduped: TraverseNeighbor[] = [];
65
+ for (const link of kept) {
66
+ if (seen.has(link.targetId)) continue;
67
+ seen.add(link.targetId);
68
+ deduped.push(link);
69
+ }
70
+
71
+ const truncated = deduped.length > limit;
72
+ return { ok: true, neighbors: truncated ? deduped.slice(0, limit) : deduped, truncated };
73
+ }