@remnic/core 9.69.3 → 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 (233) hide show
  1. package/dist/access-admin-ops-surface.d.ts +1 -1
  2. package/dist/access-admin-ops-surface.js +20 -20
  3. package/dist/access-authorization-probe.d.ts +1 -1
  4. package/dist/access-authorization-probe.js +21 -21
  5. package/dist/access-boundary.d.ts +1 -1
  6. package/dist/access-boundary.js +20 -20
  7. package/dist/access-cli.js +68 -68
  8. package/dist/access-extraction-force-flush.d.ts +1 -1
  9. package/dist/access-extraction-force-flush.js +20 -20
  10. package/dist/access-http-lcm-compaction.d.ts +1 -1
  11. package/dist/access-http-lifecycle-flush.d.ts +1 -1
  12. package/dist/access-http-offline-stream.d.ts +1 -1
  13. package/dist/access-http-query.js +22 -22
  14. package/dist/access-http.d.ts +2 -2
  15. package/dist/access-http.js +36 -36
  16. package/dist/access-identity-continuity-surface.js +20 -20
  17. package/dist/access-lcm-surface.d.ts +1 -1
  18. package/dist/access-lcm-surface.js +20 -20
  19. package/dist/access-mcp.d.ts +1 -1
  20. package/dist/access-mcp.js +29 -29
  21. package/dist/access-namespace-preflight.js +20 -20
  22. package/dist/access-observe-write-surface.d.ts +1 -1
  23. package/dist/access-observe-write-surface.js +20 -20
  24. package/dist/access-operations-batch.js +23 -23
  25. package/dist/access-operations.d.ts +6 -6
  26. package/dist/access-operations.js +28 -28
  27. package/dist/access-recall-concurrency.d.ts +1 -1
  28. package/dist/access-recall-concurrency.js +20 -20
  29. package/dist/access-recall-response.d.ts +1 -1
  30. package/dist/access-recall-response.js +20 -20
  31. package/dist/access-recall-surface.d.ts +1 -1
  32. package/dist/access-recall-surface.js +20 -20
  33. package/dist/access-schema.d.ts +89 -89
  34. package/dist/access-schema.js +4 -4
  35. package/dist/access-service-helpers.d.ts +1 -1
  36. package/dist/access-service.d.ts +1 -1
  37. package/dist/access-service.js +20 -20
  38. package/dist/access-surface-catalog.d.ts +1 -1
  39. package/dist/access-surface-catalog.js +22 -22
  40. package/dist/briefing.js +8 -8
  41. package/dist/{capsule-crypto-7FJQINUR.js → capsule-crypto-YO5QJ6L3.js} +2 -2
  42. package/dist/causal-consolidation.js +9 -9
  43. package/dist/{chunk-N3VPKMU3.js → chunk-3X7Z467X.js} +2 -2
  44. package/dist/{chunk-EB3V7PJC.js → chunk-4OH6FGAO.js} +13 -13
  45. package/dist/{chunk-EA2C5Z7V.js → chunk-5C4LXMSK.js} +26 -26
  46. package/dist/{chunk-YSD4MK5Q.js → chunk-5HP6LVWU.js} +2 -2
  47. package/dist/{chunk-OM4VYQPH.js → chunk-6EXRODDF.js} +2 -2
  48. package/dist/{chunk-2L5FSNPC.js → chunk-6F5WBXE2.js} +4 -4
  49. package/dist/{chunk-DZPX5WSG.js → chunk-6JYTP3JP.js} +3 -3
  50. package/dist/{chunk-RFYKLES2.js → chunk-6KQLDPKA.js} +2 -2
  51. package/dist/{chunk-J2R275FN.js → chunk-7DXW5V2W.js} +2 -2
  52. package/dist/chunk-7O5EW7KX.js +27 -0
  53. package/dist/chunk-7O5EW7KX.js.map +1 -0
  54. package/dist/{chunk-227UFSEQ.js → chunk-7RZJWZ46.js} +5 -5
  55. package/dist/{chunk-ATS6H6AC.js → chunk-7TCISOAJ.js} +210 -47
  56. package/dist/chunk-7TCISOAJ.js.map +1 -0
  57. package/dist/{chunk-3KURGLO7.js → chunk-7VA7PARC.js} +2 -2
  58. package/dist/{chunk-TOUZM2RQ.js → chunk-AJFKQJQK.js} +2 -2
  59. package/dist/{chunk-2NLLXCJG.js → chunk-BXLOS5AJ.js} +2 -2
  60. package/dist/{chunk-WZBDOI2C.js → chunk-C5FRCM4B.js} +2 -2
  61. package/dist/{chunk-PRCJDCDD.js → chunk-CGFDT752.js} +4 -4
  62. package/dist/{chunk-V63BBOFS.js → chunk-DD2BKHSD.js} +2 -2
  63. package/dist/{chunk-X7Y7WX73.js → chunk-DQEMWVMT.js} +1 -1
  64. package/dist/{chunk-WJTB4SF5.js → chunk-EAG7YMIS.js} +2 -2
  65. package/dist/{chunk-DA2XDNS5.js → chunk-EJF6AV2U.js} +7 -7
  66. package/dist/{chunk-3HEYRDNF.js → chunk-EJJ57PTY.js} +2 -2
  67. package/dist/{chunk-YHSZTMM3.js → chunk-EVLHZ6F3.js} +2 -2
  68. package/dist/{chunk-35J5ABJC.js → chunk-FGV7V2N4.js} +2 -2
  69. package/dist/{chunk-6AYPXHOF.js → chunk-GM35IHF7.js} +2 -2
  70. package/dist/{chunk-MU4FAKGV.js → chunk-HDEQRI2O.js} +2 -2
  71. package/dist/{chunk-PBSZQBFM.js → chunk-IFHT77WK.js} +2 -2
  72. package/dist/{chunk-OZFHE6FY.js → chunk-IHFN3JTR.js} +3 -3
  73. package/dist/{chunk-LXMYIJOO.js → chunk-ISDSX7GW.js} +1 -1
  74. package/dist/{chunk-EZPBQVGZ.js → chunk-JJAOVJ3E.js} +5 -5
  75. package/dist/{chunk-76X3F4WV.js → chunk-LGVIKA7O.js} +6 -6
  76. package/dist/{chunk-7DQQYJKU.js → chunk-MDQXW235.js} +2 -2
  77. package/dist/{chunk-ASDK4GEV.js → chunk-MOUCCUET.js} +2 -2
  78. package/dist/{chunk-DT7LKLET.js → chunk-MVH7YAWY.js} +2 -2
  79. package/dist/{chunk-GH477IGR.js → chunk-NAB4MKTJ.js} +3 -3
  80. package/dist/{chunk-FGORNFTZ.js → chunk-NM5EOKN7.js} +2 -2
  81. package/dist/{chunk-LXBVTRBG.js → chunk-OUAK37ID.js} +2 -2
  82. package/dist/{chunk-HCERYDRI.js → chunk-PGD3DXZP.js} +2 -2
  83. package/dist/{chunk-6CT4HSVJ.js → chunk-PY5E3UOD.js} +2 -2
  84. package/dist/{chunk-HBOQO4GB.js → chunk-QDBG45NK.js} +7 -7
  85. package/dist/{chunk-EJCKJS4X.js → chunk-R3YVNAJB.js} +2 -2
  86. package/dist/{chunk-XVI2TR26.js → chunk-R4FPBSFD.js} +2 -2
  87. package/dist/{chunk-VVE5SLO5.js → chunk-SKVSBTFQ.js} +2 -2
  88. package/dist/{chunk-S7363OIV.js → chunk-SZ3SRRYZ.js} +2 -2
  89. package/dist/{chunk-WRPRUMUI.js → chunk-SZZGVXKC.js} +2 -2
  90. package/dist/{chunk-TFN3PPYO.js → chunk-TGDFKC7X.js} +2 -2
  91. package/dist/{chunk-7SL6EJLT.js → chunk-TP3N5364.js} +2 -2
  92. package/dist/{chunk-IPLYGWQF.js → chunk-VDX2J7OX.js} +6 -6
  93. package/dist/{chunk-VJERIMIX.js → chunk-W6V2XVUO.js} +3 -3
  94. package/dist/{chunk-5FBA6UXN.js → chunk-WTWHORVW.js} +7 -7
  95. package/dist/{chunk-UFY4G6EC.js → chunk-XOGYSWQZ.js} +10 -10
  96. package/dist/{chunk-J6SWAE74.js → chunk-YERBUSU2.js} +2 -2
  97. package/dist/{chunk-D65XGENZ.js → chunk-YPNTBYEV.js} +3 -3
  98. package/dist/{chunk-FNNWK7G7.js → chunk-ZET6A3OF.js} +2 -2
  99. package/dist/{cli-CFpMhqz5.d.ts → cli-Bvy4jdBu.d.ts} +1 -1
  100. package/dist/cli.d.ts +2 -2
  101. package/dist/cli.js +51 -51
  102. package/dist/coding/export-okf-codegraph.js +8 -8
  103. package/dist/compounding/engine.js +8 -8
  104. package/dist/connectors/codex-materialize-runner.js +8 -8
  105. package/dist/connectors/index.js +8 -8
  106. package/dist/convergence-refresh.js +10 -10
  107. package/dist/conversation-index/backend.js +2 -2
  108. package/dist/corpus-watermark.js +10 -10
  109. package/dist/entity-retrieval.js +8 -8
  110. package/dist/external-wiki-access.d.ts +1 -1
  111. package/dist/external-wiki-access.js +21 -21
  112. package/dist/external-wiki-mcp-tools.d.ts +1 -1
  113. package/dist/extraction.js +8 -8
  114. package/dist/harmonic-retrieval.js +1 -1
  115. package/dist/index.d.ts +757 -681
  116. package/dist/index.js +101 -91
  117. package/dist/lcm/engine.js +2 -2
  118. package/dist/lcm/index.js +5 -5
  119. package/dist/location/index.js +10 -10
  120. package/dist/maintenance/memory-governance.js +8 -8
  121. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +8 -8
  122. package/dist/maintenance/rebuild-memory-projection.js +9 -9
  123. package/dist/mcp-memory-inspector-app.d.ts +1 -1
  124. package/dist/memory-subject-cli.d.ts +1 -1
  125. package/dist/memory-subject.d.ts +1 -1
  126. package/dist/namespaces/migrate.js +9 -9
  127. package/dist/namespaces/search.js +6 -6
  128. package/dist/namespaces/storage.js +8 -8
  129. package/dist/operator-doctor-corpus.js +11 -11
  130. package/dist/operator-toolkit.js +16 -16
  131. package/dist/orchestration/maintenance.js +16 -16
  132. package/dist/orchestrator.js +68 -68
  133. package/dist/{public-http-C9-CHc40.d.ts → public-http-2MyshWxl.d.ts} +1 -1
  134. package/dist/recall-navigate-link.d.ts +9 -3
  135. package/dist/recall-navigate-link.js +3 -15
  136. package/dist/recall-navigate-link.js.map +1 -1
  137. package/dist/recall-navigate-traverse.d.ts +21 -0
  138. package/dist/recall-navigate-traverse.js +44 -0
  139. package/dist/recall-navigate-traverse.js.map +1 -0
  140. package/dist/schemas.d.ts +176 -176
  141. package/dist/search/factory.js +5 -5
  142. package/dist/search/index.js +9 -9
  143. package/dist/semantic-consolidation.js +9 -9
  144. package/dist/semantic-rule-promotion.js +8 -8
  145. package/dist/semantic-rule-verifier.js +8 -8
  146. package/dist/shared-context/manager.d.ts +6 -6
  147. package/dist/source-agent-qualifier.js +8 -8
  148. package/dist/standup/build.js +8 -8
  149. package/dist/storage.js +7 -7
  150. package/dist/support-passport/index.d.ts +4 -4
  151. package/dist/support-passport/index.js +23 -23
  152. package/dist/transfer/autodetect.js +1 -1
  153. package/dist/transfer/backup.js +2 -2
  154. package/dist/transfer/capsule-export.js +3 -3
  155. package/dist/transfer/capsule-import.js +2 -2
  156. package/dist/transfer/export-okf.js +9 -9
  157. package/dist/transfer/types.d.ts +78 -78
  158. package/dist/verified-recall.js +8 -8
  159. package/dist/who-knows-cli.d.ts +1 -1
  160. package/dist/who-knows-cli.js +9 -9
  161. package/dist/who-knows.d.ts +1 -1
  162. package/dist/who-knows.js +8 -8
  163. package/package.json +2 -2
  164. package/src/activity/index.ts +2 -0
  165. package/src/activity/privacy-gate-resolve.test.ts +175 -0
  166. package/src/activity/privacy-gate-resolve.ts +84 -0
  167. package/src/activity/timeline/index.ts +1 -0
  168. package/src/activity/timeline/recap-fallback.test.ts +184 -0
  169. package/src/activity/timeline/recap-fallback.ts +106 -0
  170. package/src/activity/vault-dry-run.test.ts +114 -0
  171. package/src/activity/vault-dry-run.ts +76 -0
  172. package/src/dedup/index.ts +6 -0
  173. package/src/dedup/merge-content.test.ts +116 -0
  174. package/src/dedup/merge-content.ts +40 -0
  175. package/src/recall-navigate-link.test.ts +28 -6
  176. package/src/recall-navigate-link.ts +16 -2
  177. package/src/recall-navigate-traverse.test.ts +247 -0
  178. package/src/recall-navigate-traverse.ts +73 -0
  179. package/dist/chunk-ATS6H6AC.js.map +0 -1
  180. package/dist/{access-service-DcfM0Vf0.d.ts → access-service-LbwWl05g.d.ts} +64 -64
  181. /package/dist/{capsule-crypto-7FJQINUR.js.map → capsule-crypto-YO5QJ6L3.js.map} +0 -0
  182. /package/dist/{chunk-N3VPKMU3.js.map → chunk-3X7Z467X.js.map} +0 -0
  183. /package/dist/{chunk-EB3V7PJC.js.map → chunk-4OH6FGAO.js.map} +0 -0
  184. /package/dist/{chunk-EA2C5Z7V.js.map → chunk-5C4LXMSK.js.map} +0 -0
  185. /package/dist/{chunk-YSD4MK5Q.js.map → chunk-5HP6LVWU.js.map} +0 -0
  186. /package/dist/{chunk-OM4VYQPH.js.map → chunk-6EXRODDF.js.map} +0 -0
  187. /package/dist/{chunk-2L5FSNPC.js.map → chunk-6F5WBXE2.js.map} +0 -0
  188. /package/dist/{chunk-DZPX5WSG.js.map → chunk-6JYTP3JP.js.map} +0 -0
  189. /package/dist/{chunk-RFYKLES2.js.map → chunk-6KQLDPKA.js.map} +0 -0
  190. /package/dist/{chunk-J2R275FN.js.map → chunk-7DXW5V2W.js.map} +0 -0
  191. /package/dist/{chunk-227UFSEQ.js.map → chunk-7RZJWZ46.js.map} +0 -0
  192. /package/dist/{chunk-3KURGLO7.js.map → chunk-7VA7PARC.js.map} +0 -0
  193. /package/dist/{chunk-TOUZM2RQ.js.map → chunk-AJFKQJQK.js.map} +0 -0
  194. /package/dist/{chunk-2NLLXCJG.js.map → chunk-BXLOS5AJ.js.map} +0 -0
  195. /package/dist/{chunk-WZBDOI2C.js.map → chunk-C5FRCM4B.js.map} +0 -0
  196. /package/dist/{chunk-PRCJDCDD.js.map → chunk-CGFDT752.js.map} +0 -0
  197. /package/dist/{chunk-V63BBOFS.js.map → chunk-DD2BKHSD.js.map} +0 -0
  198. /package/dist/{chunk-X7Y7WX73.js.map → chunk-DQEMWVMT.js.map} +0 -0
  199. /package/dist/{chunk-WJTB4SF5.js.map → chunk-EAG7YMIS.js.map} +0 -0
  200. /package/dist/{chunk-DA2XDNS5.js.map → chunk-EJF6AV2U.js.map} +0 -0
  201. /package/dist/{chunk-3HEYRDNF.js.map → chunk-EJJ57PTY.js.map} +0 -0
  202. /package/dist/{chunk-YHSZTMM3.js.map → chunk-EVLHZ6F3.js.map} +0 -0
  203. /package/dist/{chunk-35J5ABJC.js.map → chunk-FGV7V2N4.js.map} +0 -0
  204. /package/dist/{chunk-6AYPXHOF.js.map → chunk-GM35IHF7.js.map} +0 -0
  205. /package/dist/{chunk-MU4FAKGV.js.map → chunk-HDEQRI2O.js.map} +0 -0
  206. /package/dist/{chunk-PBSZQBFM.js.map → chunk-IFHT77WK.js.map} +0 -0
  207. /package/dist/{chunk-OZFHE6FY.js.map → chunk-IHFN3JTR.js.map} +0 -0
  208. /package/dist/{chunk-LXMYIJOO.js.map → chunk-ISDSX7GW.js.map} +0 -0
  209. /package/dist/{chunk-EZPBQVGZ.js.map → chunk-JJAOVJ3E.js.map} +0 -0
  210. /package/dist/{chunk-76X3F4WV.js.map → chunk-LGVIKA7O.js.map} +0 -0
  211. /package/dist/{chunk-7DQQYJKU.js.map → chunk-MDQXW235.js.map} +0 -0
  212. /package/dist/{chunk-ASDK4GEV.js.map → chunk-MOUCCUET.js.map} +0 -0
  213. /package/dist/{chunk-DT7LKLET.js.map → chunk-MVH7YAWY.js.map} +0 -0
  214. /package/dist/{chunk-GH477IGR.js.map → chunk-NAB4MKTJ.js.map} +0 -0
  215. /package/dist/{chunk-FGORNFTZ.js.map → chunk-NM5EOKN7.js.map} +0 -0
  216. /package/dist/{chunk-LXBVTRBG.js.map → chunk-OUAK37ID.js.map} +0 -0
  217. /package/dist/{chunk-HCERYDRI.js.map → chunk-PGD3DXZP.js.map} +0 -0
  218. /package/dist/{chunk-6CT4HSVJ.js.map → chunk-PY5E3UOD.js.map} +0 -0
  219. /package/dist/{chunk-HBOQO4GB.js.map → chunk-QDBG45NK.js.map} +0 -0
  220. /package/dist/{chunk-EJCKJS4X.js.map → chunk-R3YVNAJB.js.map} +0 -0
  221. /package/dist/{chunk-XVI2TR26.js.map → chunk-R4FPBSFD.js.map} +0 -0
  222. /package/dist/{chunk-VVE5SLO5.js.map → chunk-SKVSBTFQ.js.map} +0 -0
  223. /package/dist/{chunk-S7363OIV.js.map → chunk-SZ3SRRYZ.js.map} +0 -0
  224. /package/dist/{chunk-WRPRUMUI.js.map → chunk-SZZGVXKC.js.map} +0 -0
  225. /package/dist/{chunk-TFN3PPYO.js.map → chunk-TGDFKC7X.js.map} +0 -0
  226. /package/dist/{chunk-7SL6EJLT.js.map → chunk-TP3N5364.js.map} +0 -0
  227. /package/dist/{chunk-IPLYGWQF.js.map → chunk-VDX2J7OX.js.map} +0 -0
  228. /package/dist/{chunk-VJERIMIX.js.map → chunk-W6V2XVUO.js.map} +0 -0
  229. /package/dist/{chunk-5FBA6UXN.js.map → chunk-WTWHORVW.js.map} +0 -0
  230. /package/dist/{chunk-UFY4G6EC.js.map → chunk-XOGYSWQZ.js.map} +0 -0
  231. /package/dist/{chunk-J6SWAE74.js.map → chunk-YERBUSU2.js.map} +0 -0
  232. /package/dist/{chunk-D65XGENZ.js.map → chunk-YPNTBYEV.js.map} +0 -0
  233. /package/dist/{chunk-FNNWK7G7.js.map → chunk-ZET6A3OF.js.map} +0 -0
@@ -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
+ }
@@ -481,3 +481,9 @@ export {
481
481
  type MergeFrontmatterUpdate,
482
482
  type MergeProvenanceSource,
483
483
  } from "./merge-provenance.js";
484
+
485
+ export {
486
+ MERGE_CONTENT_LENGTH_FACTOR,
487
+ checkMergedContent,
488
+ type MergeContentCheck,
489
+ } from "./merge-content.js";
@@ -0,0 +1,116 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ import {
5
+ MERGE_CONTENT_LENGTH_FACTOR,
6
+ checkMergedContent,
7
+ } from "./merge-content.js";
8
+
9
+ const INCOMING = "alpha";
10
+ const TARGET = "beta";
11
+
12
+ test("normal merge passes and returns the content unchanged", () => {
13
+ const merged = "alpha and beta combined";
14
+ const result = checkMergedContent({
15
+ mergedContent: merged,
16
+ incomingContent: INCOMING,
17
+ targetContent: TARGET,
18
+ });
19
+ assert.deepEqual(result, { ok: true, content: merged });
20
+ });
21
+
22
+ test("merged content at exactly the limit is accepted", () => {
23
+ const limit = MERGE_CONTENT_LENGTH_FACTOR * (INCOMING.length + TARGET.length);
24
+ const merged = "x".repeat(limit);
25
+ const result = checkMergedContent({
26
+ mergedContent: merged,
27
+ incomingContent: INCOMING,
28
+ targetContent: TARGET,
29
+ });
30
+ assert.deepEqual(result, { ok: true, content: merged });
31
+ });
32
+
33
+ test("merged content one character over the limit is oversized with the reported limit", () => {
34
+ const limit = MERGE_CONTENT_LENGTH_FACTOR * (INCOMING.length + TARGET.length);
35
+ const result = checkMergedContent({
36
+ mergedContent: "x".repeat(limit + 1),
37
+ incomingContent: INCOMING,
38
+ targetContent: TARGET,
39
+ });
40
+ assert.deepEqual(result, { ok: false, reason: "oversized", limit });
41
+ });
42
+
43
+ test("empty, whitespace-only, null, undefined, object, and bigint merged content are all empty refusals", () => {
44
+ const cases: unknown[] = ["", " ", "\t\n", null, undefined, { body: 1 }, 42n];
45
+ for (const mergedContent of cases) {
46
+ const result = checkMergedContent({
47
+ mergedContent,
48
+ incomingContent: INCOMING,
49
+ targetContent: TARGET,
50
+ });
51
+ assert.deepEqual(result, {
52
+ ok: false,
53
+ reason: "empty",
54
+ limit: MERGE_CONTENT_LENGTH_FACTOR * (INCOMING.length + TARGET.length),
55
+ });
56
+ }
57
+ });
58
+
59
+ test("two empty inputs make the limit zero, so any content is oversized", () => {
60
+ const result = checkMergedContent({
61
+ mergedContent: "x",
62
+ incomingContent: "",
63
+ targetContent: "",
64
+ });
65
+ assert.deepEqual(result, { ok: false, reason: "oversized", limit: 0 });
66
+ });
67
+
68
+ test("non-string incomingContent throws RangeError naming the field", () => {
69
+ assert.throws(
70
+ () =>
71
+ checkMergedContent({
72
+ mergedContent: "ok",
73
+ incomingContent: 7 as unknown as string,
74
+ targetContent: TARGET,
75
+ }),
76
+ (error: unknown) =>
77
+ error instanceof RangeError && /incomingContent/.test(error.message),
78
+ );
79
+ });
80
+
81
+ test("non-string targetContent throws RangeError naming the field", () => {
82
+ assert.throws(
83
+ () =>
84
+ checkMergedContent({
85
+ mergedContent: "ok",
86
+ incomingContent: INCOMING,
87
+ targetContent: null as unknown as string,
88
+ }),
89
+ (error: unknown) =>
90
+ error instanceof RangeError && /targetContent/.test(error.message),
91
+ );
92
+ });
93
+
94
+ test("the rejected merged content never appears in an error message or refusal result", () => {
95
+ const sentinel = "UNIQUE-LEAK-SENTINEL";
96
+ const leakyObject = { body: sentinel };
97
+
98
+ const refusal = checkMergedContent({
99
+ mergedContent: leakyObject,
100
+ incomingContent: INCOMING,
101
+ targetContent: TARGET,
102
+ });
103
+ assert.ok(!refusal.ok);
104
+ assert.ok(!JSON.stringify(refusal).includes(sentinel));
105
+
106
+ assert.throws(
107
+ () =>
108
+ checkMergedContent({
109
+ mergedContent: sentinel,
110
+ incomingContent: undefined as unknown as string,
111
+ targetContent: TARGET,
112
+ }),
113
+ (error: unknown) =>
114
+ error instanceof RangeError && !error.message.includes(sentinel),
115
+ );
116
+ });
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Merged-content acceptance check (issue #2330).
3
+ *
4
+ * A judge-merged body that is blank or a runaway continuation is refused so
5
+ * the write path can fall back to create. `mergedContent` arrives from a
6
+ * model and gets a typed refusal; non-string `incomingContent`/
7
+ * `targetContent` is a caller bug and throws. Internal helper; wiring into
8
+ * the write path is a later slice.
9
+ */
10
+ export const MERGE_CONTENT_LENGTH_FACTOR = 4;
11
+
12
+ export type MergeContentCheck =
13
+ | { ok: true; content: string }
14
+ | { ok: false; reason: "empty" | "oversized"; limit: number };
15
+
16
+ function requireString(value: unknown, field: string): string {
17
+ if (typeof value !== "string") {
18
+ throw new RangeError(`merge ${field} must be a string`);
19
+ }
20
+ return value;
21
+ }
22
+
23
+ export function checkMergedContent(input: {
24
+ mergedContent: unknown;
25
+ incomingContent: string;
26
+ targetContent: string;
27
+ }): MergeContentCheck {
28
+ const incoming = requireString(input.incomingContent, "incomingContent");
29
+ const target = requireString(input.targetContent, "targetContent");
30
+ const limit = MERGE_CONTENT_LENGTH_FACTOR * (incoming.length + target.length);
31
+
32
+ const merged = input.mergedContent;
33
+ if (typeof merged !== "string" || merged.trim() === "") {
34
+ return { ok: false, reason: "empty", limit };
35
+ }
36
+ if (merged.length > limit) {
37
+ return { ok: false, reason: "oversized", limit };
38
+ }
39
+ return { ok: true, content: merged };
40
+ }
@@ -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
+ }