@remnic/core 9.23.2 → 9.23.4

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 (211) hide show
  1. package/dist/access-admin-ops-surface.d.ts +6 -5
  2. package/dist/access-admin-ops-surface.js +36 -33
  3. package/dist/access-boundary.d.ts +6 -5
  4. package/dist/access-boundary.js +36 -33
  5. package/dist/access-cli.js +92 -90
  6. package/dist/access-cli.js.map +1 -1
  7. package/dist/access-http.d.ts +7 -6
  8. package/dist/access-http.js +46 -43
  9. package/dist/access-identity-continuity-surface.d.ts +4 -4
  10. package/dist/access-identity-continuity-surface.js +36 -33
  11. package/dist/access-lcm-surface.d.ts +6 -5
  12. package/dist/access-lcm-surface.js +36 -33
  13. package/dist/access-mcp.d.ts +6 -5
  14. package/dist/access-mcp.js +41 -38
  15. package/dist/access-namespace-preflight.js +36 -33
  16. package/dist/access-observe-write-surface.d.ts +6 -5
  17. package/dist/access-observe-write-surface.js +36 -33
  18. package/dist/access-operations-batch.js +38 -35
  19. package/dist/access-operations.d.ts +6 -5
  20. package/dist/access-operations.js +40 -37
  21. package/dist/access-recall-concurrency.d.ts +6 -5
  22. package/dist/access-recall-concurrency.js +36 -33
  23. package/dist/access-recall-response.d.ts +6 -5
  24. package/dist/access-recall-response.js +36 -33
  25. package/dist/access-recall-surface.d.ts +6 -5
  26. package/dist/access-recall-surface.js +36 -33
  27. package/dist/access-schema.js +4 -4
  28. package/dist/{access-service-CKEROeFP.d.ts → access-service-Pl-h7x4p.d.ts} +6 -3
  29. package/dist/access-service.d.ts +8 -7
  30. package/dist/access-service.js +36 -33
  31. package/dist/access-surface-catalog.d.ts +6 -5
  32. package/dist/access-wearables-meetings-surface.d.ts +2 -2
  33. package/dist/active-recall.js +1 -1
  34. package/dist/adapters/index.js +7 -7
  35. package/dist/adapters/registry.js +3 -3
  36. package/dist/bootstrap.d.ts +4 -4
  37. package/dist/briefing.d.ts +1 -1
  38. package/dist/briefing.js +7 -7
  39. package/dist/buffer.d.ts +1 -1
  40. package/dist/{capsule-crypto-7FJQINUR.js → capsule-crypto-YO5QJ6L3.js} +2 -2
  41. package/dist/causal-behavior.js +2 -2
  42. package/dist/causal-chain.js +2 -2
  43. package/dist/causal-consolidation.js +10 -10
  44. package/dist/causal-retrieval.js +2 -2
  45. package/dist/causal-trajectory-graph.js +2 -2
  46. package/dist/causal-trajectory.js +1 -1
  47. package/dist/{chunk-L2DPF4DF.js → chunk-3SHB4YAW.js} +4 -4
  48. package/dist/{chunk-ATEAL5EI.js → chunk-3UD5ZAUA.js} +2 -2
  49. package/dist/{chunk-ES5IHQ6M.js → chunk-4E3DWNTJ.js} +2 -2
  50. package/dist/{chunk-QCKQEBIY.js → chunk-5XMJ2P62.js} +5 -5
  51. package/dist/{chunk-WZVIB5BK.js → chunk-6MFMTNY4.js} +38 -29
  52. package/dist/chunk-6MFMTNY4.js.map +1 -0
  53. package/dist/{chunk-7CPOZH6Y.js → chunk-AJ7QCSIS.js} +2 -2
  54. package/dist/{chunk-M6LJXJ3J.js → chunk-AT7XL2JJ.js} +3 -3
  55. package/dist/{chunk-2NLLXCJG.js → chunk-BXLOS5AJ.js} +2 -2
  56. package/dist/{chunk-NRV4Y3PM.js → chunk-CAGPP5NB.js} +1 -1
  57. package/dist/{chunk-XGEFGNL5.js → chunk-CNUZ2PN2.js} +13 -9
  58. package/dist/chunk-CNUZ2PN2.js.map +1 -0
  59. package/dist/chunk-D5ILARSD.js +60 -0
  60. package/dist/chunk-D5ILARSD.js.map +1 -0
  61. package/dist/{chunk-X7Y7WX73.js → chunk-DQEMWVMT.js} +1 -1
  62. package/dist/{chunk-P2BLHUL2.js → chunk-EHOTE26K.js} +5 -5
  63. package/dist/{chunk-DLKRXYRL.js → chunk-EXH5AK46.js} +6 -6
  64. package/dist/{chunk-JNIJBXVK.js → chunk-FPJWQZ35.js} +2 -2
  65. package/dist/{chunk-XWPFZX6S.js → chunk-GCQIMJFH.js} +97 -26
  66. package/dist/chunk-GCQIMJFH.js.map +1 -0
  67. package/dist/{chunk-GD3QOBMB.js → chunk-H2M4YA6K.js} +2 -2
  68. package/dist/{chunk-3ZW6ZQO6.js → chunk-HVYOZAQ7.js} +6 -6
  69. package/dist/{chunk-3UOSVTFR.js → chunk-JRQBPN63.js} +8 -8
  70. package/dist/{chunk-3L5O7SMT.js → chunk-JVOA5VDZ.js} +3 -3
  71. package/dist/{chunk-WWZ35GWW.js → chunk-K3C6CJOF.js} +2 -2
  72. package/dist/{chunk-4PQ4KLLK.js → chunk-KYP5FSPG.js} +6 -6
  73. package/dist/{chunk-227UFSEQ.js → chunk-LEPOP46W.js} +4 -4
  74. package/dist/{chunk-V2XVMCP6.js → chunk-M2PG7OTI.js} +2 -2
  75. package/dist/{chunk-HSSIWAZP.js → chunk-NSRTLSBX.js} +114 -98
  76. package/dist/{chunk-HSSIWAZP.js.map → chunk-NSRTLSBX.js.map} +1 -1
  77. package/dist/{chunk-MU7ZHSZE.js → chunk-PO5LSKQO.js} +2 -2
  78. package/dist/{chunk-6TWXQ4E2.js → chunk-POHSSW2T.js} +2 -2
  79. package/dist/{chunk-AU7Q3LSC.js → chunk-RKNJBZ55.js} +7 -7
  80. package/dist/{chunk-QE63OHLB.js → chunk-RPOSRICW.js} +5 -5
  81. package/dist/{chunk-ZN4UZWMT.js → chunk-RSLXMGFP.js} +2 -2
  82. package/dist/{chunk-7I7AYIUQ.js → chunk-SPWOLGXT.js} +5 -5
  83. package/dist/{chunk-E3ZMFFQZ.js → chunk-TMX4MBSK.js} +2 -2
  84. package/dist/{chunk-SVZGA2MK.js → chunk-UZ7HGRHK.js} +2 -2
  85. package/dist/{chunk-JYIFGGEM.js → chunk-UZGJFIIG.js} +2 -2
  86. package/dist/{chunk-IPLYGWQF.js → chunk-VDX2J7OX.js} +6 -6
  87. package/dist/{chunk-CUUY5CC4.js → chunk-W3BKLKG6.js} +2 -2
  88. package/dist/{chunk-HNZKVLAN.js → chunk-W6ROK2KU.js} +2 -2
  89. package/dist/chunk-XJTYLU6J.js +261 -0
  90. package/dist/chunk-XJTYLU6J.js.map +1 -0
  91. package/dist/{chunk-5FZQ74K5.js → chunk-XPLTZEJU.js} +2 -2
  92. package/dist/{chunk-MAXVH44E.js → chunk-Z54EM5YZ.js} +7 -7
  93. package/dist/{chunk-KXGE7L62.js → chunk-ZLDMAGCB.js} +59 -59
  94. package/dist/{cli-BrwT9YMD.d.ts → cli-DuMB83Mw.d.ts} +3 -3
  95. package/dist/cli.d.ts +7 -6
  96. package/dist/cli.js +66 -64
  97. package/dist/compounding/engine.d.ts +1 -1
  98. package/dist/compounding/engine.js +7 -7
  99. package/dist/config.js +1 -1
  100. package/dist/connectors/codex-materialize-runner.js +7 -7
  101. package/dist/connectors/index.js +7 -7
  102. package/dist/consolidation-provenance-check.d.ts +1 -1
  103. package/dist/consolidation-undo.d.ts +1 -1
  104. package/dist/contradiction/index.d.ts +1 -1
  105. package/dist/conversation-index/backend.js +2 -2
  106. package/dist/corpus-watermark.d.ts +227 -0
  107. package/dist/corpus-watermark.js +106 -0
  108. package/dist/entity-retrieval.d.ts +1 -1
  109. package/dist/entity-retrieval.js +7 -7
  110. package/dist/explicit-capture.d.ts +4 -4
  111. package/dist/extraction.js +1 -1
  112. package/dist/{graph-edge-decay-Z6ENALU2.js → graph-edge-decay-GF3ZSGSP.js} +4 -4
  113. package/dist/graph-edge-decay-GF3ZSGSP.js.map +1 -0
  114. package/dist/graph-snapshot.js +3 -3
  115. package/dist/graph.js +2 -2
  116. package/dist/index.d.ts +12 -11
  117. package/dist/index.js +136 -134
  118. package/dist/lcm/engine.js +3 -3
  119. package/dist/lcm/index.js +6 -6
  120. package/dist/maintenance/memory-governance.js +7 -7
  121. package/dist/maintenance/rebuild-memory-lifecycle-ledger.d.ts +1 -1
  122. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +7 -7
  123. package/dist/maintenance/rebuild-memory-projection.d.ts +1 -1
  124. package/dist/maintenance/rebuild-memory-projection.js +8 -8
  125. package/dist/{maintenance-C98njYb8.d.ts → maintenance-DGvDB1xF.d.ts} +1 -1
  126. package/dist/mcp-memory-inspector-app.d.ts +6 -5
  127. package/dist/memory-worth-outcomes.d.ts +1 -1
  128. package/dist/namespaces/migrate.d.ts +1 -1
  129. package/dist/namespaces/migrate.js +14 -14
  130. package/dist/namespaces/search.js +6 -6
  131. package/dist/namespaces/storage.d.ts +1 -1
  132. package/dist/namespaces/storage.js +7 -7
  133. package/dist/operator-doctor-corpus.d.ts +44 -0
  134. package/dist/operator-doctor-corpus.js +91 -0
  135. package/dist/operator-doctor-corpus.js.map +1 -0
  136. package/dist/operator-toolkit.d.ts +1 -1
  137. package/dist/operator-toolkit.js +22 -20
  138. package/dist/orchestration/compression-guideline-coordinator.d.ts +1 -1
  139. package/dist/orchestration/maintenance.d.ts +2 -2
  140. package/dist/orchestration/maintenance.js +12 -12
  141. package/dist/{orchestrator-C9ZVppNh.d.ts → orchestrator-RXHefhAy.d.ts} +3 -3
  142. package/dist/orchestrator.d.ts +4 -4
  143. package/dist/orchestrator.js +91 -89
  144. package/dist/resume-bundles.js +1 -1
  145. package/dist/search/factory.js +5 -5
  146. package/dist/search/index.js +9 -9
  147. package/dist/semantic-consolidation.js +8 -8
  148. package/dist/semantic-rule-promotion.js +7 -7
  149. package/dist/semantic-rule-verifier.js +7 -7
  150. package/dist/{service-DgFWlZ-r.d.ts → service-BrWqlsZ4.d.ts} +1 -1
  151. package/dist/{storage-XGawwqXE.d.ts → storage-DgNVexXm.d.ts} +24 -1
  152. package/dist/storage.d.ts +1 -1
  153. package/dist/storage.js +6 -6
  154. package/dist/temporal-supersession.d.ts +1 -1
  155. package/dist/tier-migration.d.ts +1 -1
  156. package/dist/transfer/autodetect.js +1 -1
  157. package/dist/transfer/backup.js +2 -2
  158. package/dist/transfer/capsule-export.js +3 -3
  159. package/dist/transfer/capsule-import.js +2 -2
  160. package/dist/verified-recall.js +7 -7
  161. package/package.json +12 -2
  162. package/src/access-http.test.ts +4 -2
  163. package/src/access-service.ts +5 -0
  164. package/src/corpus-watermark.test.ts +840 -0
  165. package/src/corpus-watermark.ts +531 -0
  166. package/src/operator-doctor-corpus.test.ts +171 -0
  167. package/src/operator-doctor-corpus.ts +95 -0
  168. package/src/operator-toolkit.ts +3 -3
  169. package/src/recall/archive-scoring.ts +18 -0
  170. package/src/storage/memory-read-store.ts +91 -22
  171. package/src/storage.ts +27 -2
  172. package/dist/chunk-WZVIB5BK.js.map +0 -1
  173. package/dist/chunk-XGEFGNL5.js.map +0 -1
  174. package/dist/chunk-XWPFZX6S.js.map +0 -1
  175. /package/dist/{capsule-crypto-7FJQINUR.js.map → capsule-crypto-YO5QJ6L3.js.map} +0 -0
  176. /package/dist/{chunk-L2DPF4DF.js.map → chunk-3SHB4YAW.js.map} +0 -0
  177. /package/dist/{chunk-ATEAL5EI.js.map → chunk-3UD5ZAUA.js.map} +0 -0
  178. /package/dist/{chunk-ES5IHQ6M.js.map → chunk-4E3DWNTJ.js.map} +0 -0
  179. /package/dist/{chunk-QCKQEBIY.js.map → chunk-5XMJ2P62.js.map} +0 -0
  180. /package/dist/{chunk-7CPOZH6Y.js.map → chunk-AJ7QCSIS.js.map} +0 -0
  181. /package/dist/{chunk-M6LJXJ3J.js.map → chunk-AT7XL2JJ.js.map} +0 -0
  182. /package/dist/{chunk-2NLLXCJG.js.map → chunk-BXLOS5AJ.js.map} +0 -0
  183. /package/dist/{chunk-NRV4Y3PM.js.map → chunk-CAGPP5NB.js.map} +0 -0
  184. /package/dist/{chunk-X7Y7WX73.js.map → chunk-DQEMWVMT.js.map} +0 -0
  185. /package/dist/{chunk-P2BLHUL2.js.map → chunk-EHOTE26K.js.map} +0 -0
  186. /package/dist/{chunk-DLKRXYRL.js.map → chunk-EXH5AK46.js.map} +0 -0
  187. /package/dist/{chunk-JNIJBXVK.js.map → chunk-FPJWQZ35.js.map} +0 -0
  188. /package/dist/{chunk-GD3QOBMB.js.map → chunk-H2M4YA6K.js.map} +0 -0
  189. /package/dist/{chunk-3ZW6ZQO6.js.map → chunk-HVYOZAQ7.js.map} +0 -0
  190. /package/dist/{chunk-3UOSVTFR.js.map → chunk-JRQBPN63.js.map} +0 -0
  191. /package/dist/{chunk-3L5O7SMT.js.map → chunk-JVOA5VDZ.js.map} +0 -0
  192. /package/dist/{chunk-WWZ35GWW.js.map → chunk-K3C6CJOF.js.map} +0 -0
  193. /package/dist/{chunk-4PQ4KLLK.js.map → chunk-KYP5FSPG.js.map} +0 -0
  194. /package/dist/{chunk-227UFSEQ.js.map → chunk-LEPOP46W.js.map} +0 -0
  195. /package/dist/{chunk-V2XVMCP6.js.map → chunk-M2PG7OTI.js.map} +0 -0
  196. /package/dist/{chunk-MU7ZHSZE.js.map → chunk-PO5LSKQO.js.map} +0 -0
  197. /package/dist/{chunk-6TWXQ4E2.js.map → chunk-POHSSW2T.js.map} +0 -0
  198. /package/dist/{chunk-AU7Q3LSC.js.map → chunk-RKNJBZ55.js.map} +0 -0
  199. /package/dist/{chunk-QE63OHLB.js.map → chunk-RPOSRICW.js.map} +0 -0
  200. /package/dist/{chunk-ZN4UZWMT.js.map → chunk-RSLXMGFP.js.map} +0 -0
  201. /package/dist/{chunk-7I7AYIUQ.js.map → chunk-SPWOLGXT.js.map} +0 -0
  202. /package/dist/{chunk-E3ZMFFQZ.js.map → chunk-TMX4MBSK.js.map} +0 -0
  203. /package/dist/{chunk-SVZGA2MK.js.map → chunk-UZ7HGRHK.js.map} +0 -0
  204. /package/dist/{chunk-JYIFGGEM.js.map → chunk-UZGJFIIG.js.map} +0 -0
  205. /package/dist/{chunk-IPLYGWQF.js.map → chunk-VDX2J7OX.js.map} +0 -0
  206. /package/dist/{chunk-CUUY5CC4.js.map → chunk-W3BKLKG6.js.map} +0 -0
  207. /package/dist/{chunk-HNZKVLAN.js.map → chunk-W6ROK2KU.js.map} +0 -0
  208. /package/dist/{chunk-5FZQ74K5.js.map → chunk-XPLTZEJU.js.map} +0 -0
  209. /package/dist/{chunk-MAXVH44E.js.map → chunk-Z54EM5YZ.js.map} +0 -0
  210. /package/dist/{chunk-KXGE7L62.js.map → chunk-ZLDMAGCB.js.map} +0 -0
  211. /package/dist/{graph-edge-decay-Z6ENALU2.js.map → corpus-watermark.js.map} +0 -0
@@ -0,0 +1,171 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ /**
6
+ * Corpus-watermark doctor check (issue #2149).
7
+ *
8
+ * Verifies `summarizeCorpusWatermark` reports per-namespace counts + a short
9
+ * digest prefix and always resolves `ok` (this PR has no peer to compare
10
+ * against, so it never warns), and that `runOperatorDoctor` wires it in.
11
+ */
12
+ import test from "node:test";
13
+ import { parseConfig } from "./config.js";
14
+ import type { CorpusWatermark } from "./corpus-watermark.js";
15
+ import { summarizeCorpusWatermark } from "./operator-doctor-corpus.js";
16
+ import { type OperatorToolkitOrchestrator, runOperatorDoctor } from "./operator-toolkit.js";
17
+ import type { OperatorDoctorCheck } from "./operator-toolkit.js";
18
+ import { StorageManager } from "./storage.js";
19
+ import type { PluginConfig } from "./types.js";
20
+
21
+ const storageFactory = (dir: string) => new StorageManager(dir);
22
+
23
+ // The doctor stores the watermark array under `details.corpus`; `details` is
24
+ // typed `unknown`, so narrow it once here rather than at each read.
25
+ function corpusOf(check: OperatorDoctorCheck | undefined): CorpusWatermark[] {
26
+ const details = check?.details;
27
+ if (!details || typeof details !== "object" || !("corpus" in details)) return [];
28
+ // `details.corpus` is `unknown` after the `in` guard; the doctor always
29
+ // stores the watermark array here.
30
+ const corpus = details.corpus as CorpusWatermark[];
31
+ return corpus;
32
+ }
33
+
34
+ async function writeMemory(memoryDir: string, rel: string): Promise<void> {
35
+ const full = path.join(memoryDir, rel);
36
+ await mkdir(path.dirname(full), { recursive: true });
37
+ await writeFile(full, `---\nid: ${path.basename(rel, ".md")}\n---\n\nbody\n`, "utf-8");
38
+ }
39
+
40
+ async function makeFixture(): Promise<{
41
+ root: string;
42
+ memoryDir: string;
43
+ config: PluginConfig;
44
+ configPath: string;
45
+ orchestrator: OperatorToolkitOrchestrator;
46
+ }> {
47
+ const root = await mkdtemp(path.join(os.tmpdir(), "engram-corpus-doctor-"));
48
+ const memoryDir = path.join(root, "memory");
49
+ const workspaceDir = path.join(root, "workspace");
50
+ await mkdir(memoryDir, { recursive: true });
51
+ await mkdir(workspaceDir, { recursive: true });
52
+ const rawConfig = {
53
+ openaiApiKey: "sk-test",
54
+ memoryDir,
55
+ workspaceDir,
56
+ qmdEnabled: false,
57
+ transcriptEnabled: false,
58
+ hourlySummariesEnabled: false,
59
+ identityEnabled: false,
60
+ identityContinuityEnabled: false,
61
+ sharedContextEnabled: false,
62
+ captureMode: "implicit",
63
+ namespacesEnabled: false,
64
+ defaultNamespace: "global",
65
+ };
66
+ const config = parseConfig(rawConfig);
67
+ const configPath = path.join(root, "openclaw.json");
68
+ await writeFile(
69
+ configPath,
70
+ JSON.stringify({ plugins: { entries: { "openclaw-remnic": { config: rawConfig } } } }, null, 2),
71
+ "utf-8"
72
+ );
73
+ const orchestrator: OperatorToolkitOrchestrator = {
74
+ config,
75
+ storage: new StorageManager(memoryDir),
76
+ qmd: {
77
+ async probe() {
78
+ return false;
79
+ },
80
+ isAvailable() {
81
+ return false;
82
+ },
83
+ async ensureCollection() {
84
+ return "skipped";
85
+ },
86
+ debugStatus() {
87
+ return "disabled";
88
+ },
89
+ },
90
+ conversationIndexCoordinator: {
91
+ async getHealth() {
92
+ return {
93
+ enabled: false,
94
+ backend: "qmd" as const,
95
+ status: "disabled" as const,
96
+ chunkDocCount: 0,
97
+ lastUpdateAt: null,
98
+ };
99
+ },
100
+ async rebuild() {
101
+ return { chunks: 0, skipped: true, reason: "disabled", embedded: false, rebuilt: false };
102
+ },
103
+ },
104
+ };
105
+ return { root, memoryDir, config, configPath, orchestrator };
106
+ }
107
+
108
+ test("summarizeCorpusWatermark: reports an ok check with per-namespace count and a short digest prefix", async () => {
109
+ const { root, memoryDir, config } = await makeFixture();
110
+ try {
111
+ await writeMemory(memoryDir, "facts/2026-03-08/a.md");
112
+ await writeMemory(memoryDir, "facts/2026-03-08/b.md");
113
+ await writeMemory(memoryDir, "procedures/2026-03-09/c.md");
114
+
115
+ const check = await summarizeCorpusWatermark(config, storageFactory);
116
+ assert.equal(check.key, "corpus_watermark");
117
+ assert.equal(check.status, "ok");
118
+ const corpus = corpusOf(check);
119
+ assert.equal(corpus.length, 1);
120
+ assert.equal(corpus[0]?.namespace, "global");
121
+ assert.equal(corpus[0]?.memoryFileCount, 3);
122
+ assert.match(check.summary, /global: 3 files/);
123
+ assert.match(check.summary, /digest=[0-9a-f]{12}/);
124
+ } finally {
125
+ await rm(root, { recursive: true, force: true });
126
+ }
127
+ });
128
+
129
+ test("summarizeCorpusWatermark: an empty corpus still resolves ok with a zero count", async () => {
130
+ const { root, config } = await makeFixture();
131
+ try {
132
+ const check = await summarizeCorpusWatermark(config, storageFactory);
133
+ assert.equal(check.status, "ok");
134
+ const corpus = corpusOf(check);
135
+ assert.equal(corpus.length, 1);
136
+ assert.equal(corpus[0]?.memoryFileCount, 0);
137
+ } finally {
138
+ await rm(root, { recursive: true, force: true });
139
+ }
140
+ });
141
+
142
+ test("runOperatorDoctor: includes the corpus_watermark check", async () => {
143
+ const { root, memoryDir, configPath, orchestrator } = await makeFixture();
144
+ try {
145
+ await writeMemory(memoryDir, "facts/2026-03-08/a.md");
146
+ const report = await runOperatorDoctor({ configPath, orchestrator });
147
+ const check = report.checks.find((c) => c.key === "corpus_watermark");
148
+ assert.ok(check, "expected a corpus_watermark check");
149
+ assert.equal(check?.status, "ok");
150
+ assert.equal(corpusOf(check)[0]?.memoryFileCount, 1);
151
+ } finally {
152
+ await rm(root, { recursive: true, force: true });
153
+ }
154
+ });
155
+
156
+ test("summarizeCorpusWatermark: warns (not ok) when a namespace scan fails", async () => {
157
+ const { root, config } = await makeFixture();
158
+ try {
159
+ // A storage factory that fails every scan (e.g. an EACCES permission
160
+ // regression). The namespace is omitted, so the check must WARN — not
161
+ // certify ok, and not conflate the failure with an empty deployment.
162
+ const throwingFactory = (_dir: string): never => {
163
+ throw new Error("simulated backend read failure");
164
+ };
165
+ const check = await summarizeCorpusWatermark(config, throwingFactory);
166
+ assert.equal(check.status, "warn", "a scan failure must not be reported as ok");
167
+ assert.match(check.summary, /unavailable|unreadable|churning/i);
168
+ } finally {
169
+ await rm(root, { recursive: true, force: true });
170
+ }
171
+ });
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Corpus-watermark doctor check (issue #2149).
3
+ *
4
+ * Lives beside operator-toolkit.ts (which is at its structural-ratchet ceiling)
5
+ * so the god file only gains the wiring. Reports each namespace's memory-file
6
+ * count, newest write, and a short census-digest prefix, and WARNS when a
7
+ * namespace could not be scanned (unreadable/churning) or enumeration failed —
8
+ * a scan failure omits a namespace, so an unconditional "ok" would certify a
9
+ * partial fleet or conflate an unreadable corpus with an empty deployment
10
+ * (issue #2156 round-7). Peer comparison (count/age divergence thresholds) is a
11
+ * follow-up.
12
+ *
13
+ * Namespaces are resolved through the shared resolveCorpusNamespaceRoots helper
14
+ * (issue #2156 finding C) so the doctor and the /health surface enumerate the
15
+ * SAME tenant set and cannot drift. The caller supplies `storageFactory` (a
16
+ * `dir -> CorpusStorage`) so this module never imports the storage god-module
17
+ * directly (issue #1533 ratchet).
18
+ */
19
+
20
+ import {
21
+ type CorpusStorage,
22
+ type CorpusWatermark,
23
+ computeCorpusWatermarks,
24
+ resolveCorpusNamespaceRoots,
25
+ } from "./corpus-watermark.js";
26
+ import type { OperatorDoctorCheck } from "./operator-toolkit.js";
27
+ import type { PluginConfig } from "./types.js";
28
+
29
+ const DIGEST_PREFIX_LENGTH = 12;
30
+
31
+ export async function summarizeCorpusWatermark(
32
+ config: PluginConfig,
33
+ storageFactory: (dir: string) => CorpusStorage,
34
+ now?: Date
35
+ ): Promise<OperatorDoctorCheck> {
36
+ let resolved: string[] = [];
37
+ let watermarks: CorpusWatermark[] = [];
38
+ let enumerationFailed = false;
39
+ try {
40
+ // The doctor orchestrator has no live namespace catalog, so config-driven
41
+ // enumeration (deduped by root inside the shared helper) is the whole set.
42
+ const rootByNamespace = new Map<string, string>();
43
+ for (const root of await resolveCorpusNamespaceRoots({ config })) {
44
+ rootByNamespace.set(root.namespace, root.rootDir);
45
+ }
46
+ resolved = [...rootByNamespace.keys()];
47
+ watermarks = await computeCorpusWatermarks(
48
+ resolved,
49
+ (namespace) => storageFactory(rootByNamespace.get(namespace) ?? config.memoryDir),
50
+ now
51
+ );
52
+ } catch {
53
+ // Namespace ENUMERATION itself failed (distinct from a per-namespace scan).
54
+ enumerationFailed = true;
55
+ }
56
+
57
+ const computed = new Set(watermarks.map((w) => w.namespace));
58
+ const failed = resolved.filter((namespace) => !computed.has(namespace));
59
+ const lines = watermarks.map(
60
+ (w) =>
61
+ `${w.namespace}: ${w.memoryFileCount} files, newest=${w.newestWriteAt ?? "never"}, ` +
62
+ `digest=${w.digest.slice(0, DIGEST_PREFIX_LENGTH)}`
63
+ );
64
+
65
+ if (enumerationFailed) {
66
+ return {
67
+ key: "corpus_watermark",
68
+ status: "warn",
69
+ summary: "Could not resolve corpus namespaces (enumeration failed).",
70
+ remediation: "Ensure the memory directory and namespace catalog are readable, then rerun `remnic doctor`.",
71
+ details: { corpus: watermarks, resolved: resolved.length, failed: failed.length },
72
+ };
73
+ }
74
+ if (failed.length > 0) {
75
+ return {
76
+ key: "corpus_watermark",
77
+ status: "warn",
78
+ summary:
79
+ `Corpus watermark unavailable for ${failed.length} of ${resolved.length} namespace(s) ` +
80
+ `(unreadable or churning): ${failed.join(", ")}.` +
81
+ (lines.length > 0 ? ` Reporting ${lines.length}: ${lines.join("; ")}` : ""),
82
+ remediation: "Inspect the omitted namespace roots for permission/backend errors, then rerun `remnic doctor`.",
83
+ details: { corpus: watermarks, resolved: resolved.length, failed },
84
+ };
85
+ }
86
+ return {
87
+ key: "corpus_watermark",
88
+ status: "ok",
89
+ summary:
90
+ resolved.length === 0
91
+ ? "No corpus namespaces resolved yet."
92
+ : `Corpus watermark across ${watermarks.length} namespace(s): ${lines.join("; ")}`,
93
+ details: { corpus: watermarks },
94
+ };
95
+ }
@@ -28,7 +28,7 @@ import { analyzeGraphHealth, type GraphHealthReport } from "./graph.js";
28
28
  import { resolveAccessSetupCapabilities,
29
29
  resolveCapabilities,
30
30
  resolveGraphConstructionCapabilities,
31
- resolveQmdCapabilities,
31
+ resolveQmdCapabilities, resolveIndexingCapabilities,
32
32
  resolveEvalCapabilities, resolvePresentationCapabilities, resolveConsolidationCapabilities } from "./capabilities.js";
33
33
  import {
34
34
  analyzeSessionIntegrity,
@@ -55,8 +55,7 @@ import type {
55
55
  } from "./types.js";
56
56
  import { reportBufferSurpriseDistribution } from "./buffer-surprise-report.js";
57
57
  import { readJudgeVerdictStats } from "./extraction-judge-telemetry.js";
58
- import { resolveIndexingCapabilities } from "./capabilities.js";
59
-
58
+ import { summarizeCorpusWatermark } from "./operator-doctor-corpus.js";
60
59
 
61
60
  const OPENCLAW_REMNIC_PLUGIN_IDS = ["openclaw-remnic", "openclaw-engram"] as const;
62
61
 
@@ -1501,6 +1500,7 @@ export async function runOperatorDoctor(options: OperatorDoctorOptions): Promise
1501
1500
  // Surfaces per-phase: enabled status, cadence, threshold values, and the
1502
1501
  // best-available last-run timestamp for each of the three pipeline phases.
1503
1502
  checks.push(await summarizeDreamsPhases(config, storage));
1503
+ checks.push(await summarizeCorpusWatermark(config, (dir) => new StorageManager(dir))); // corpus watermark #2149
1504
1504
 
1505
1505
  // Security mitigation status (issue #565).
1506
1506
  // Reports whether the cross-namespace budget and anomaly detection
@@ -250,6 +250,17 @@ class ArchiveScoringWorkerPool {
250
250
  private idle: Worker[] = [];
251
251
  private waiters: Array<{ resolve: (worker: Worker) => void; reject: (err: Error) => void }> = [];
252
252
  private terminated = false;
253
+ private busy = 0;
254
+
255
+ /**
256
+ * Workers currently checked out and scoring — NOT callers waiting in line.
257
+ * A caller parked in `acquire()` is queued, not running, so this is the only
258
+ * honest measure of real task overlap: with a size-1 pool it never exceeds 1
259
+ * no matter how many callers are queued behind it.
260
+ */
261
+ get busyWorkers(): number {
262
+ return this.busy;
263
+ }
253
264
 
254
265
  constructor(size: number = defaultPoolSize()) {
255
266
  this.targetSize = Math.max(1, size);
@@ -265,11 +276,13 @@ class ArchiveScoringWorkerPool {
265
276
  return [];
266
277
  }
267
278
  let abandoned = false;
279
+ this.busy += 1;
268
280
  try {
269
281
  return await this.dispatch(worker, task, abortSignal, () => {
270
282
  abandoned = true;
271
283
  });
272
284
  } finally {
285
+ this.busy -= 1;
273
286
  if (abandoned) this.retireWorker(worker);
274
287
  else this.release(worker);
275
288
  }
@@ -424,6 +437,11 @@ export class OffThreadArchiveScoring implements ArchiveScoringStrategy {
424
437
  }
425
438
  }
426
439
 
440
+ /** Workers currently scoring; 0 when no pool has been created yet. */
441
+ get busyWorkers(): number {
442
+ return this.pool?.busyWorkers ?? 0;
443
+ }
444
+
427
445
  async score(
428
446
  items: ReadonlyArray<ArchiveScoreItem>,
429
447
  tokens: ReadonlyArray<string>,
@@ -9,7 +9,7 @@
9
9
  * listing.
10
10
  */
11
11
 
12
- import type { Dirent } from "node:fs";
12
+ import type { Dirent, Stats } from "node:fs";
13
13
  import { lstat, readdir, realpath, stat } from "node:fs/promises";
14
14
  import path from "node:path";
15
15
  import { toMemoryPathRel } from "../memory-lifecycle-ledger-utils.js";
@@ -79,6 +79,20 @@ export interface MemoryReadStoreDeps {
79
79
  readonly wearablesDir: string;
80
80
  }
81
81
 
82
+ /**
83
+ * A backend READ failure worth propagating from a strict (census) scan: an
84
+ * errno-coded fs error that is NOT `ENOENT`. `ENOENT` (an absent dir) and
85
+ * code-less rejections (symlink-escape containment failures from
86
+ * assertPathInsideRoot) are expected and skipped; `EACCES`/`EIO`/… mean the
87
+ * corpus is unreadable and a divergence census must fail loud rather than
88
+ * silently publish a partial count (issue #2156 round-5). Non-strict (recall)
89
+ * callers ignore this and degrade to a best-effort partial scan.
90
+ */
91
+ function isPropagatableReadError(err: unknown): boolean {
92
+ const code = (err as NodeJS.ErrnoException).code;
93
+ return typeof code === "string" && code !== "ENOENT";
94
+ }
95
+
82
96
  export class MemoryReadStore {
83
97
  constructor(
84
98
  private readonly deps: MemoryReadStoreDeps,
@@ -184,33 +198,66 @@ export class MemoryReadStore {
184
198
  return latestArtifacts;
185
199
  }
186
200
 
187
- async collectActiveMemoryPaths(): Promise<string[]> {
201
+ /**
202
+ * Recursively collect `*.md` paths under `startDirs`, hardened against symlink
203
+ * escape: a category dir symlinked outside memoryDir (e.g. decisions/ -> an
204
+ * external dir) must NOT pull out-of-store files into a scan (info leak). Same
205
+ * walker-hardening pattern as document-scanner.ts / cli.ts /
206
+ * consolidation-provenance-check.ts; reuses the shared containment helper.
207
+ * Paths only — no frontmatter parse.
208
+ */
209
+ private async collectContainedMarkdownPaths(
210
+ startDirs: readonly string[],
211
+ propagateReadErrors = false,
212
+ ): Promise<string[]> {
188
213
  const filePaths: string[] = [];
189
214
 
190
- // Resolve the memory root once for containment checks below. A category dir
191
- // symlinked outside memoryDir (e.g. decisions/ -> an external dir) must NOT
192
- // pull out-of-store files into the QMD-unavailable recall fallback (info
193
- // leak). Same walker-hardening pattern as document-scanner.ts / cli.ts /
194
- // consolidation-provenance-check.ts; reuses the shared containment helper.
215
+ // Resolve the memory root once for the containment checks below.
195
216
  let memoryRootReal: string;
196
217
  try {
197
218
  memoryRootReal = await realpath(this.deps.baseDir);
198
- } catch {
219
+ } catch (err) {
220
+ // ENOENT: a not-yet-created root ⇒ empty. Any other errno is a backend
221
+ // read failure — propagate for strict census callers (never publish an
222
+ // unreadable corpus as empty); non-strict (recall) callers degrade.
223
+ if (propagateReadErrors && isPropagatableReadError(err)) throw err;
199
224
  return filePaths;
200
225
  }
201
226
 
202
- const collectPaths = async (dir: string) => {
227
+ const collectPaths = async (dir: string): Promise<void> => {
203
228
  // Directory-level guard, isolated from per-entry handling: skip symlinked
204
229
  // or non-directory category dirs and assert the resolved dir stays inside
205
230
  // the memory root before reading. A failure here means the whole subtree
206
231
  // does not exist or escaped the store — fail closed by skipping it.
232
+ let dirStat: Stats;
233
+ try {
234
+ dirStat = await lstat(dir);
235
+ } catch (err) {
236
+ // ENOENT (absent category dir) is expected; a backend read error
237
+ // (EACCES/EIO/…) propagates in strict census mode.
238
+ if (propagateReadErrors && isPropagatableReadError(err)) throw err;
239
+ return;
240
+ }
241
+ if (dirStat.isSymbolicLink()) return; // never follow symlinks out of the store (both modes)
242
+ if (!dirStat.isDirectory()) {
243
+ // A category root that EXISTS but is not a directory (e.g. `facts/`
244
+ // replaced by a regular file) is a layout/backend failure: a strict
245
+ // census must not silently publish the remaining categories as healthy.
246
+ // Non-strict (recall) stays best-effort and skips it (issue #2156 round-10).
247
+ if (propagateReadErrors) {
248
+ throw new Error(`corpus scan: expected a directory but found a non-directory at ${dir}`);
249
+ }
250
+ return;
251
+ }
207
252
  let entries: Dirent[];
208
253
  try {
209
- const dirStat = await lstat(dir);
210
- if (dirStat.isSymbolicLink() || !dirStat.isDirectory()) return;
211
254
  assertPathInsideRoot(memoryRootReal, await realpath(dir), dir);
212
255
  entries = await readdir(dir, { withFileTypes: true });
213
- } catch {
256
+ } catch (err) {
257
+ // A containment failure carries no errno (skip); a backend read error
258
+ // (EACCES …) propagates in strict census mode so a partial subtree is
259
+ // not silently counted.
260
+ if (propagateReadErrors && isPropagatableReadError(err)) throw err;
214
261
  return;
215
262
  }
216
263
 
@@ -231,8 +278,11 @@ export class MemoryReadStore {
231
278
  try {
232
279
  assertPathInsideRoot(memoryRootReal, await realpath(fullPath), fullPath);
233
280
  filePaths.push(fullPath);
234
- } catch {
235
- // Skip just this entry (symlink/containment/realpath failure).
281
+ } catch (err) {
282
+ // ENOENT (vanished mid-scan) or a code-less containment/symlink
283
+ // rejection ⇒ skip just this file. A backend read error propagates
284
+ // in strict census mode.
285
+ if (propagateReadErrors && isPropagatableReadError(err)) throw err;
236
286
  }
237
287
  }
238
288
  }
@@ -243,6 +293,13 @@ export class MemoryReadStore {
243
293
  }
244
294
  };
245
295
 
296
+ for (const dir of startDirs) {
297
+ await collectPaths(dir);
298
+ }
299
+ return filePaths;
300
+ }
301
+
302
+ async collectActiveMemoryPaths(options?: { propagateReadErrors?: boolean }): Promise<string[]> {
246
303
  // Scan EVERY supported memory category directory, not just the legacy four
247
304
  // (facts/procedures/reasoning-traces/corrections). Issue #1497: the QMD
248
305
  // filesystem-fallback recall path (orchestrator `recent_scan` ->
@@ -269,14 +326,26 @@ export class MemoryReadStore {
269
326
  // those files (they have a `---` frontmatter block) and leak queue items
270
327
  // into recall. None of these excluded dirs are in RECALL_FALLBACK_DIRS; the
271
328
  // exclusion is asserted by tests in storage-fallback-category-dirs.test.ts.
272
- //
273
- // collectPaths() already ignores missing dirs (try/catch) and the parser
274
- // returns null for non-memory markdown, so unrelated files never crash the
275
- // scan.
276
- for (const dir of RECALL_FALLBACK_DIRS) {
277
- await collectPaths(path.join(this.deps.baseDir, dir));
278
- }
279
- return filePaths;
329
+ return this.collectContainedMarkdownPaths(
330
+ RECALL_FALLBACK_DIRS.map((dir) => path.join(this.deps.baseDir, dir)),
331
+ options?.propagateReadErrors ?? false,
332
+ );
333
+ }
334
+
335
+ /**
336
+ * Cold-tier memory paths for the corpus census (issue #2156 finding D).
337
+ * Demoted memories move to `<baseDir>/cold/...` but stay active and reachable
338
+ * via cold recall, so the divergence census MUST count them — otherwise two
339
+ * replicas whose cold tiers differ show a false "converged" digest. Paths
340
+ * only, same symlink/containment hardening as the hot scan. This is NOT part
341
+ * of the recall fallback corpus (collectActiveMemoryPaths), whose scan is
342
+ * intentionally unchanged.
343
+ */
344
+ async collectColdMemoryPaths(options?: { propagateReadErrors?: boolean }): Promise<string[]> {
345
+ return this.collectContainedMarkdownPaths(
346
+ [this.deps.resolveTierRootDir("cold")],
347
+ options?.propagateReadErrors ?? false,
348
+ );
280
349
  }
281
350
 
282
351
  async readMemoriesWindow(options: {
package/src/storage.ts CHANGED
@@ -4643,8 +4643,33 @@ export class StorageManager {
4643
4643
  return Math.max(1, Math.floor(batchSize));
4644
4644
  }
4645
4645
 
4646
- private async collectActiveMemoryPaths(): Promise<string[]> {
4647
- return this.memoryReadStore.collectActiveMemoryPaths();
4646
+ /**
4647
+ * Public cheap active-memory path scan (issue #2149 corpus watermark). Lists
4648
+ * active memory file paths without parsing frontmatter — safe on a 100k+
4649
+ * corpus, unlike readAllMemories().
4650
+ */
4651
+ async collectActiveMemoryPaths(options?: { propagateReadErrors?: boolean }): Promise<string[]> {
4652
+ return this.memoryReadStore.collectActiveMemoryPaths(options);
4653
+ }
4654
+
4655
+ /**
4656
+ * Public cheap cold-tier path scan (issue #2156 finding D corpus census).
4657
+ * Lists demoted-but-reachable memory paths under `cold/` without parsing
4658
+ * frontmatter, so the divergence watermark counts the cold tier too.
4659
+ */
4660
+ async collectColdMemoryPaths(options?: { propagateReadErrors?: boolean }): Promise<string[]> {
4661
+ return this.memoryReadStore.collectColdMemoryPaths(options);
4662
+ }
4663
+
4664
+ /**
4665
+ * Combined hot+cold corpus-mutation sentinel (issue #2156). Changes whenever
4666
+ * EITHER tier is written, so a divergence census can bracket its hot/cold scan
4667
+ * and detect a tier migration (write-cold-then-unlink-hot) racing the walkers,
4668
+ * then retry for a consistent snapshot instead of caching a transient
4669
+ * double-count or miss.
4670
+ */
4671
+ getCorpusScanVersion(): string {
4672
+ return `${this.getMemoryCorpusVersion()}:${this.readColdWriteVersion()}`;
4648
4673
  }
4649
4674
 
4650
4675
  private async readParsedMemoriesFromPaths(filePaths: string[], batchSize?: number): Promise<MemoryFile[]> {