@remnic/core 9.3.755 → 9.3.756

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 (181) hide show
  1. package/dist/access-admin-ops-surface.d.ts +1 -1
  2. package/dist/access-admin-ops-surface.js +34 -34
  3. package/dist/access-boundary.d.ts +1 -1
  4. package/dist/access-boundary.js +35 -35
  5. package/dist/access-cli.js +83 -83
  6. package/dist/access-http.d.ts +1 -1
  7. package/dist/access-http.js +44 -44
  8. package/dist/access-lcm-surface.d.ts +1 -1
  9. package/dist/access-lcm-surface.js +34 -34
  10. package/dist/access-mcp.d.ts +1 -1
  11. package/dist/access-mcp.js +41 -41
  12. package/dist/access-observe-write-surface.d.ts +225 -0
  13. package/dist/access-observe-write-surface.js +128 -0
  14. package/dist/access-operations-batch.js +38 -38
  15. package/dist/access-operations.d.ts +1 -1
  16. package/dist/access-operations.js +40 -40
  17. package/dist/access-recall-surface.d.ts +1 -1
  18. package/dist/access-recall-surface.js +34 -34
  19. package/dist/access-schema.d.ts +2 -2
  20. package/dist/access-schema.js +3 -3
  21. package/dist/{access-service-C3jkLi2U.d.ts → access-service-CJwcDoQp.d.ts} +4 -5
  22. package/dist/access-service.d.ts +1 -1
  23. package/dist/access-service.js +34 -34
  24. package/dist/access-surface-catalog.d.ts +1 -1
  25. package/dist/active-recall.js +1 -1
  26. package/dist/adapters/index.js +4 -4
  27. package/dist/adapters/registry.js +2 -2
  28. package/dist/{auto-sync-G4YKBPOU.js → auto-sync-RZO5ZPHR.js} +3 -3
  29. package/dist/briefing.js +7 -7
  30. package/dist/{capsule-crypto-CZJSLEFG.js → capsule-crypto-FFWJ4X4A.js} +2 -2
  31. package/dist/causal-behavior.js +2 -2
  32. package/dist/causal-chain.js +2 -2
  33. package/dist/causal-consolidation.js +15 -15
  34. package/dist/causal-retrieval.js +2 -2
  35. package/dist/causal-trajectory.js +1 -1
  36. package/dist/{chunk-M5QKGHCR.js → chunk-3I2IASNK.js} +2 -2
  37. package/dist/{chunk-OCFITZRV.js → chunk-4AC57RCM.js} +4 -4
  38. package/dist/{chunk-3DOD6ICJ.js → chunk-4I45UZ4V.js} +5 -5
  39. package/dist/{chunk-TAPSNIMA.js → chunk-5CEJH5ZN.js} +4 -4
  40. package/dist/{chunk-I6IAJCJS.js → chunk-5VWRYFQE.js} +7 -7
  41. package/dist/{chunk-4LQUYBZM.js → chunk-5ZKT7WTR.js} +7 -7
  42. package/dist/{chunk-VL5QHTJI.js → chunk-6DA5BBLS.js} +117 -117
  43. package/dist/{chunk-LFKEFIJA.js → chunk-6DXXIXED.js} +4 -4
  44. package/dist/{chunk-XBM5DG4O.js → chunk-7APVFQ34.js} +680 -595
  45. package/dist/chunk-7APVFQ34.js.map +1 -0
  46. package/dist/{chunk-NCNOYM6F.js → chunk-7CFGXWYX.js} +2 -2
  47. package/dist/{chunk-TJATYB3F.js → chunk-A6XCHI53.js} +5 -5
  48. package/dist/{chunk-UQJ6YTKR.js → chunk-BTH6MQJT.js} +7 -7
  49. package/dist/{chunk-G7LCG56O.js → chunk-CXW6Y7SI.js} +4 -4
  50. package/dist/{chunk-GCEV6SXP.js → chunk-DWDDQLSZ.js} +2298 -2218
  51. package/dist/chunk-DWDDQLSZ.js.map +1 -0
  52. package/dist/{chunk-DS4QM6I7.js → chunk-EEKDRAWX.js} +4 -4
  53. package/dist/{chunk-I3UWEZTK.js → chunk-EF3VFQC4.js} +2 -2
  54. package/dist/{chunk-437S3G37.js → chunk-EJJQDP3H.js} +1 -1
  55. package/dist/{chunk-FQLM6ZUP.js → chunk-EVVLGYHR.js} +3 -3
  56. package/dist/{chunk-GGYUR4M2.js → chunk-GBXP62CL.js} +10 -10
  57. package/dist/{chunk-N4AGSRVA.js → chunk-GG6G2RYR.js} +9 -9
  58. package/dist/{chunk-JITAVROY.js → chunk-H2T4OJNI.js} +4 -4
  59. package/dist/{chunk-2MR3MFQB.js → chunk-JTYM4HPA.js} +2 -2
  60. package/dist/{chunk-LD53WPMU.js → chunk-LQ6JI4VH.js} +4 -4
  61. package/dist/{chunk-GPFXPC2V.js → chunk-NG2NHB4C.js} +7 -7
  62. package/dist/{chunk-CIDGUFAL.js → chunk-NLXKSM46.js} +2 -2
  63. package/dist/{chunk-T6WHI3JW.js → chunk-OLAVQS6S.js} +6 -6
  64. package/dist/{chunk-ER3CCWK2.js → chunk-ORAK3LNZ.js} +2 -2
  65. package/dist/{chunk-PJ7DIEFC.js → chunk-PBZQVVLG.js} +5 -5
  66. package/dist/{chunk-PJQMISPD.js → chunk-PPAEJXVF.js} +5 -5
  67. package/dist/{chunk-GMWKWA6N.js → chunk-PRX77NPC.js} +5 -5
  68. package/dist/{chunk-24463PIV.js → chunk-PS572XYO.js} +2 -2
  69. package/dist/{chunk-KKOCFUI5.js → chunk-REZ4TUD2.js} +10 -10
  70. package/dist/{chunk-2QSZNTDO.js → chunk-RKNJBZ55.js} +4 -4
  71. package/dist/{chunk-V5UV7BOA.js → chunk-SQNED75R.js} +1 -1
  72. package/dist/{chunk-FLVL4L3L.js → chunk-TCKP4EHY.js} +2 -2
  73. package/dist/{chunk-SP42M3XP.js → chunk-TXV3I2O6.js} +7 -7
  74. package/dist/{chunk-S6UCPBPL.js → chunk-UNV6ZGTS.js} +2 -2
  75. package/dist/{chunk-2BFLDH5F.js → chunk-WEPWY4HB.js} +2 -2
  76. package/dist/{chunk-YQGHW5ML.js → chunk-WRFKZEO6.js} +4 -4
  77. package/dist/{chunk-XCXN3D2U.js → chunk-Y4TAFMUE.js} +6 -6
  78. package/dist/{chunk-H6S3DJVE.js → chunk-YMGQXBPS.js} +2 -2
  79. package/dist/{chunk-T422SYM6.js → chunk-ZUDM75KG.js} +5 -5
  80. package/dist/{chunk-XD6JVK5P.js → chunk-ZUHD6XZZ.js} +43 -43
  81. package/dist/{chunk-FOJY75BL.js → chunk-ZW5SZ3TH.js} +12 -12
  82. package/dist/{cli-BJQ2ahrh.d.ts → cli-cZyANcmY.d.ts} +1 -1
  83. package/dist/cli.d.ts +2 -2
  84. package/dist/cli.js +59 -59
  85. package/dist/compounding/engine.js +8 -8
  86. package/dist/config.js +1 -1
  87. package/dist/connectors/codex-materialize-runner.js +8 -8
  88. package/dist/connectors/index.js +9 -9
  89. package/dist/contradiction/index.js +2 -2
  90. package/dist/{contradiction-scan-2SODRNUF.js → contradiction-scan-3FJYWS3G.js} +3 -3
  91. package/dist/contradiction-scan-3FJYWS3G.js.map +1 -0
  92. package/dist/conversation-index/backend.js +2 -2
  93. package/dist/entity-retrieval.js +8 -8
  94. package/dist/extraction.js +2 -2
  95. package/dist/index.d.ts +2 -2
  96. package/dist/index.js +126 -126
  97. package/dist/lcm/engine.js +2 -2
  98. package/dist/lcm/index.js +2 -2
  99. package/dist/maintenance/memory-governance.js +8 -8
  100. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +7 -7
  101. package/dist/maintenance/rebuild-memory-projection.js +9 -9
  102. package/dist/mcp-memory-inspector-app.d.ts +1 -1
  103. package/dist/namespaces/migrate.js +18 -18
  104. package/dist/namespaces/search.js +9 -9
  105. package/dist/namespaces/storage.js +9 -9
  106. package/dist/operator-toolkit.js +24 -24
  107. package/dist/orchestration/maintenance.js +12 -12
  108. package/dist/orchestrator.js +82 -82
  109. package/dist/resume-bundles.js +2 -2
  110. package/dist/schemas.d.ts +10 -10
  111. package/dist/search/factory.js +8 -8
  112. package/dist/search/index.js +8 -8
  113. package/dist/search/lancedb-backend.js +2 -2
  114. package/dist/search/meilisearch-backend.js +2 -2
  115. package/dist/search/orama-backend.js +2 -2
  116. package/dist/semantic-consolidation.js +11 -11
  117. package/dist/semantic-rule-promotion.js +7 -7
  118. package/dist/semantic-rule-verifier.js +8 -8
  119. package/dist/shared-context/manager.d.ts +2 -2
  120. package/dist/storage.d.ts +60 -78
  121. package/dist/storage.js +14 -6
  122. package/dist/summarizer.js +2 -2
  123. package/dist/transfer/backup.js +2 -2
  124. package/dist/transfer/capsule-export.js +2 -2
  125. package/dist/transfer/capsule-import.js +2 -2
  126. package/dist/transfer/types.d.ts +12 -12
  127. package/dist/verified-recall.js +8 -8
  128. package/package.json +2 -2
  129. package/src/access-observe-write-surface.ts +1007 -0
  130. package/src/access-service.ts +48 -794
  131. package/src/orchestration/self-deps.ts +9 -1
  132. package/src/storage/entity-store.ts +4 -2
  133. package/src/storage/memory-read-store.ts +756 -0
  134. package/src/storage.ts +67 -627
  135. package/dist/chunk-GCEV6SXP.js.map +0 -1
  136. package/dist/chunk-XBM5DG4O.js.map +0 -1
  137. /package/dist/{capsule-crypto-CZJSLEFG.js.map → access-observe-write-surface.js.map} +0 -0
  138. /package/dist/{auto-sync-G4YKBPOU.js.map → auto-sync-RZO5ZPHR.js.map} +0 -0
  139. /package/dist/{contradiction-scan-2SODRNUF.js.map → capsule-crypto-FFWJ4X4A.js.map} +0 -0
  140. /package/dist/{chunk-M5QKGHCR.js.map → chunk-3I2IASNK.js.map} +0 -0
  141. /package/dist/{chunk-OCFITZRV.js.map → chunk-4AC57RCM.js.map} +0 -0
  142. /package/dist/{chunk-3DOD6ICJ.js.map → chunk-4I45UZ4V.js.map} +0 -0
  143. /package/dist/{chunk-TAPSNIMA.js.map → chunk-5CEJH5ZN.js.map} +0 -0
  144. /package/dist/{chunk-I6IAJCJS.js.map → chunk-5VWRYFQE.js.map} +0 -0
  145. /package/dist/{chunk-4LQUYBZM.js.map → chunk-5ZKT7WTR.js.map} +0 -0
  146. /package/dist/{chunk-VL5QHTJI.js.map → chunk-6DA5BBLS.js.map} +0 -0
  147. /package/dist/{chunk-LFKEFIJA.js.map → chunk-6DXXIXED.js.map} +0 -0
  148. /package/dist/{chunk-NCNOYM6F.js.map → chunk-7CFGXWYX.js.map} +0 -0
  149. /package/dist/{chunk-TJATYB3F.js.map → chunk-A6XCHI53.js.map} +0 -0
  150. /package/dist/{chunk-UQJ6YTKR.js.map → chunk-BTH6MQJT.js.map} +0 -0
  151. /package/dist/{chunk-G7LCG56O.js.map → chunk-CXW6Y7SI.js.map} +0 -0
  152. /package/dist/{chunk-DS4QM6I7.js.map → chunk-EEKDRAWX.js.map} +0 -0
  153. /package/dist/{chunk-I3UWEZTK.js.map → chunk-EF3VFQC4.js.map} +0 -0
  154. /package/dist/{chunk-437S3G37.js.map → chunk-EJJQDP3H.js.map} +0 -0
  155. /package/dist/{chunk-FQLM6ZUP.js.map → chunk-EVVLGYHR.js.map} +0 -0
  156. /package/dist/{chunk-GGYUR4M2.js.map → chunk-GBXP62CL.js.map} +0 -0
  157. /package/dist/{chunk-N4AGSRVA.js.map → chunk-GG6G2RYR.js.map} +0 -0
  158. /package/dist/{chunk-JITAVROY.js.map → chunk-H2T4OJNI.js.map} +0 -0
  159. /package/dist/{chunk-2MR3MFQB.js.map → chunk-JTYM4HPA.js.map} +0 -0
  160. /package/dist/{chunk-LD53WPMU.js.map → chunk-LQ6JI4VH.js.map} +0 -0
  161. /package/dist/{chunk-GPFXPC2V.js.map → chunk-NG2NHB4C.js.map} +0 -0
  162. /package/dist/{chunk-CIDGUFAL.js.map → chunk-NLXKSM46.js.map} +0 -0
  163. /package/dist/{chunk-T6WHI3JW.js.map → chunk-OLAVQS6S.js.map} +0 -0
  164. /package/dist/{chunk-ER3CCWK2.js.map → chunk-ORAK3LNZ.js.map} +0 -0
  165. /package/dist/{chunk-PJ7DIEFC.js.map → chunk-PBZQVVLG.js.map} +0 -0
  166. /package/dist/{chunk-PJQMISPD.js.map → chunk-PPAEJXVF.js.map} +0 -0
  167. /package/dist/{chunk-GMWKWA6N.js.map → chunk-PRX77NPC.js.map} +0 -0
  168. /package/dist/{chunk-24463PIV.js.map → chunk-PS572XYO.js.map} +0 -0
  169. /package/dist/{chunk-KKOCFUI5.js.map → chunk-REZ4TUD2.js.map} +0 -0
  170. /package/dist/{chunk-2QSZNTDO.js.map → chunk-RKNJBZ55.js.map} +0 -0
  171. /package/dist/{chunk-V5UV7BOA.js.map → chunk-SQNED75R.js.map} +0 -0
  172. /package/dist/{chunk-FLVL4L3L.js.map → chunk-TCKP4EHY.js.map} +0 -0
  173. /package/dist/{chunk-SP42M3XP.js.map → chunk-TXV3I2O6.js.map} +0 -0
  174. /package/dist/{chunk-S6UCPBPL.js.map → chunk-UNV6ZGTS.js.map} +0 -0
  175. /package/dist/{chunk-2BFLDH5F.js.map → chunk-WEPWY4HB.js.map} +0 -0
  176. /package/dist/{chunk-YQGHW5ML.js.map → chunk-WRFKZEO6.js.map} +0 -0
  177. /package/dist/{chunk-XCXN3D2U.js.map → chunk-Y4TAFMUE.js.map} +0 -0
  178. /package/dist/{chunk-H6S3DJVE.js.map → chunk-YMGQXBPS.js.map} +0 -0
  179. /package/dist/{chunk-T422SYM6.js.map → chunk-ZUDM75KG.js.map} +0 -0
  180. /package/dist/{chunk-XD6JVK5P.js.map → chunk-ZUHD6XZZ.js.map} +0 -0
  181. /package/dist/{chunk-FOJY75BL.js.map → chunk-ZW5SZ3TH.js.map} +0 -0
@@ -0,0 +1,756 @@
1
+ /**
2
+ * Memory read store (extracted from storage.ts StorageManager; god-file
3
+ * decomposition, #1526 playbook: verbatim move + live selfDeps wiring).
4
+ *
5
+ * Owns the bulk/windowed read surfaces of the storage layer: active
6
+ * memory path collection, windowed and cold reads, path-addressed reads,
7
+ * cached artifact index reads, questions, buffer-surprise ledger
8
+ * read/append, compression-guideline state, and wearable transcript-day
9
+ * listing.
10
+ */
11
+
12
+ import type { Dirent } from "node:fs";
13
+ import { lstat, readdir, realpath, stat } from "node:fs/promises";
14
+ import path from "node:path";
15
+ import { toMemoryPathRel } from "../memory-lifecycle-ledger-utils.js";
16
+ import { SecureStoreLockedError, readMaybeEncryptedFile } from "../secure-store/secure-fs.js";
17
+ import { type BufferSurpriseEvent, type CompressionGuidelineOptimizerState, type MemoryFile, type PluginConfig, confidenceTier } from "../types.js";
18
+ import { RECALL_FALLBACK_DIRS } from "../utils/category-dir.js";
19
+ import { isErrnoCode } from "../utils/errno.js";
20
+ import { assertPathInsideRoot } from "../utils/path-containment.js";
21
+ import { isValidTranscriptDate } from "../wearables/day-store.js";
22
+ import {
23
+ isValidBufferSurpriseEvent,
24
+ normalizeFrontmatterForPath,
25
+ parseEntityFile,
26
+ parseFrontmatter,
27
+ StorageManager,
28
+ } from "../storage.js";
29
+
30
+ export interface MemoryReadStoreDeps {
31
+ /** Live class object of the host instance — shared static caches (see storage.ts storageManagerClass). */
32
+ readonly storageManagerClass: typeof StorageManager;
33
+ readonly _secureStoreKey: Buffer | null;
34
+ appendStorageSecureFile(filePath: string, content: string): Promise<void>;
35
+ artifactIndexCache: { memories: MemoryFile[]; loadedAtMs: number; writeVersion: number } | null;
36
+ readonly artifactsDir: string;
37
+ readonly baseDir: string;
38
+ readonly bufferSurpriseLedgerPath: string;
39
+ collectActiveMemoryPaths(): Promise<string[]>;
40
+ readonly correctionsDir: string;
41
+ ensureDirectories(): Promise<void>;
42
+ readonly entitySchemas: PluginConfig["entitySchemas"] | undefined;
43
+ readonly factsDir: string;
44
+ filterWindowPathsByUpdatedAfter(filePaths: string[], updatedAfterMs: number): Promise<string[]>;
45
+ getArtifactWriteVersion(): number;
46
+ normalizeMemoryReadBatchSize(batchSize?: number): number;
47
+ orderWindowPaths(filePaths: string[]): string[];
48
+ parseQuestionFile(
49
+ raw: string,
50
+ filePath: string,
51
+ ): {
52
+ id: string;
53
+ question: string;
54
+ context: string;
55
+ priority: number;
56
+ resolved: boolean;
57
+ created: string;
58
+ filePath: string;
59
+ } | null;
60
+ readonly proceduresDir: string;
61
+ readonly questionsDir: string;
62
+ readColdWriteVersion(): number;
63
+ readMemoryByPath(filePath: string): Promise<MemoryFile | null>;
64
+ readParsedMemoriesFromPaths(
65
+ filePaths: string[],
66
+ batchSize?: number,
67
+ ): Promise<MemoryFile[]>;
68
+ readStorageSecureFile(filePath: string): Promise<string>;
69
+ readWindowBoundedBatch(
70
+ candidateBatchPaths: string[],
71
+ remainingSlots: number,
72
+ remainingInspectionBudget: number,
73
+ readBatchSize: number,
74
+ ): Promise<{ memories: MemoryFile[]; filePaths: string[] }>;
75
+ readonly reasoningTracesDir: string;
76
+ resolveTierRootDir(tier: "hot" | "cold"): string;
77
+ readonly wearablesDir: string;
78
+ }
79
+
80
+ export class MemoryReadStore {
81
+ constructor(
82
+ private readonly deps: MemoryReadStoreDeps,
83
+ ) {}
84
+
85
+ /**
86
+ * List stored transcript days, newest first, optionally scoped to one
87
+ * source. Non-transcript files in the tree are ignored.
88
+ */
89
+ async listWearableTranscriptDays(
90
+ sourceId?: string,
91
+ ): Promise<Array<{ source: string; date: string }>> {
92
+ const days: Array<{ source: string; date: string }> = [];
93
+ let sources: string[];
94
+ if (sourceId !== undefined) {
95
+ sources = [sourceId];
96
+ } else {
97
+ try {
98
+ const entries = await readdir(this.deps.wearablesDir, { withFileTypes: true });
99
+ sources = entries
100
+ .filter((entry) => entry.isDirectory())
101
+ .map((entry) => entry.name);
102
+ } catch (err) {
103
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
104
+ throw err;
105
+ }
106
+ }
107
+ for (const source of sources) {
108
+ if (!/^[a-z][a-z0-9-]{0,63}$/.test(source)) continue;
109
+ let entries: string[];
110
+ try {
111
+ entries = await readdir(path.join(this.deps.wearablesDir, source));
112
+ } catch (err) {
113
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") continue;
114
+ throw err;
115
+ }
116
+ for (const entry of entries) {
117
+ if (!entry.endsWith(".md")) continue;
118
+ const date = entry.slice(0, -3);
119
+ if (!isValidTranscriptDate(date)) continue;
120
+ days.push({ source, date });
121
+ }
122
+ }
123
+ days.sort((a, b) => {
124
+ if (a.date > b.date) return -1;
125
+ if (a.date < b.date) return 1;
126
+ if (a.source < b.source) return -1;
127
+ if (a.source > b.source) return 1;
128
+ return 0;
129
+ });
130
+ return days;
131
+ }
132
+
133
+ async readAllArtifactsCached(): Promise<MemoryFile[]> {
134
+ if (
135
+ this.deps.artifactIndexCache &&
136
+ Date.now() - this.deps.artifactIndexCache.loadedAtMs <= this.deps.storageManagerClass.ARTIFACT_INDEX_CACHE_TTL_MS &&
137
+ this.deps.artifactIndexCache.writeVersion === this.deps.getArtifactWriteVersion()
138
+ ) {
139
+ return this.deps.artifactIndexCache.memories;
140
+ }
141
+
142
+ const scanArtifacts = async (): Promise<MemoryFile[]> => {
143
+ const artifacts: MemoryFile[] = [];
144
+ const readDir = async (dir: string) => {
145
+ try {
146
+ const entries = await readdir(dir, { withFileTypes: true });
147
+ for (const entry of entries) {
148
+ const fullPath = path.join(dir, entry.name);
149
+ if (entry.isDirectory()) {
150
+ await readDir(fullPath);
151
+ continue;
152
+ }
153
+ if (!entry.name.endsWith(".md")) continue;
154
+ const memory = await this.deps.readMemoryByPath(fullPath);
155
+ if (!memory) continue;
156
+ artifacts.push(memory);
157
+ }
158
+ } catch {
159
+ // Directory doesn't exist yet
160
+ }
161
+ };
162
+ await readDir(this.deps.artifactsDir);
163
+ return artifacts;
164
+ };
165
+
166
+ const MAX_REBUILD_RETRIES = 2;
167
+ let latestArtifacts: MemoryFile[] = [];
168
+ for (let attempt = 0; attempt <= MAX_REBUILD_RETRIES; attempt += 1) {
169
+ const versionBefore = this.deps.getArtifactWriteVersion();
170
+ const artifacts = await scanArtifacts();
171
+ const versionAfter = this.deps.getArtifactWriteVersion();
172
+ latestArtifacts = artifacts;
173
+ if (versionAfter === versionBefore) {
174
+ this.deps.artifactIndexCache = { memories: artifacts, loadedAtMs: Date.now(), writeVersion: versionAfter };
175
+ return artifacts;
176
+ }
177
+ }
178
+
179
+ // Highly concurrent writer churn; keep cache invalid so next read retries a clean rebuild.
180
+ // Return best-effort latest scan instead of an empty set to avoid dropping recall entirely.
181
+ this.deps.artifactIndexCache = null;
182
+ return latestArtifacts;
183
+ }
184
+
185
+ async collectActiveMemoryPaths(): Promise<string[]> {
186
+ const filePaths: string[] = [];
187
+
188
+ // Resolve the memory root once for containment checks below. A category dir
189
+ // symlinked outside memoryDir (e.g. decisions/ -> an external dir) must NOT
190
+ // pull out-of-store files into the QMD-unavailable recall fallback (info
191
+ // leak). Same walker-hardening pattern as document-scanner.ts / cli.ts /
192
+ // consolidation-provenance-check.ts; reuses the shared containment helper.
193
+ let memoryRootReal: string;
194
+ try {
195
+ memoryRootReal = await realpath(this.deps.baseDir);
196
+ } catch {
197
+ return filePaths;
198
+ }
199
+
200
+ const collectPaths = async (dir: string) => {
201
+ // Directory-level guard, isolated from per-entry handling: skip symlinked
202
+ // or non-directory category dirs and assert the resolved dir stays inside
203
+ // the memory root before reading. A failure here means the whole subtree
204
+ // does not exist or escaped the store — fail closed by skipping it.
205
+ let entries: Dirent[];
206
+ try {
207
+ const dirStat = await lstat(dir);
208
+ if (dirStat.isSymbolicLink() || !dirStat.isDirectory()) return;
209
+ assertPathInsideRoot(memoryRootReal, await realpath(dir), dir);
210
+ entries = await readdir(dir, { withFileTypes: true });
211
+ } catch {
212
+ return;
213
+ }
214
+
215
+ const subdirs: string[] = [];
216
+ for (const entry of entries) {
217
+ // Never follow symlinked entries out of the store.
218
+ if (entry.isSymbolicLink()) continue;
219
+ const fullPath = path.join(dir, entry.name);
220
+ if (entry.isDirectory()) {
221
+ subdirs.push(fullPath);
222
+ } else if (entry.name.endsWith(".md")) {
223
+ // Isolate per-entry failures in their own try/catch: a containment or
224
+ // realpath failure on ONE .md entry must not drop sibling files or,
225
+ // crucially, the deferred subdir recursion below (Cursor Bugbot:
226
+ // "Poisoned md skips sibling subdirs"). Mirrors the per-file try/catch
227
+ // in search/document-scanner.ts scanDir and
228
+ // consolidation-provenance-check.ts walkMarkdownFiles.
229
+ try {
230
+ assertPathInsideRoot(memoryRootReal, await realpath(fullPath), fullPath);
231
+ filePaths.push(fullPath);
232
+ } catch {
233
+ // Skip just this entry (symlink/containment/realpath failure).
234
+ }
235
+ }
236
+ }
237
+ // Recurse into real subdirectories regardless of any single poisoned entry
238
+ // above, so valid nested in-store memories are never dropped.
239
+ for (const subdir of subdirs) {
240
+ await collectPaths(subdir);
241
+ }
242
+ };
243
+
244
+ // Scan EVERY supported memory category directory, not just the legacy four
245
+ // (facts/procedures/reasoning-traces/corrections). Issue #1497: the QMD
246
+ // filesystem-fallback recall path (orchestrator `recent_scan` ->
247
+ // readAllMemoriesForNamespaces -> readAllMemories -> here) must read every
248
+ // recall category dir so on-disk memories in preferences/decisions/moments/
249
+ // commitments/principles/rules/skills/relationships are not missed when QMD
250
+ // is disabled, missing, or unhealthy. RECALL_FALLBACK_DIRS is the single
251
+ // source of truth derived from ALL_CATEGORY_DIRS (shared with
252
+ // ensureDirectories() and the write routing in utils/category-dir.ts).
253
+ // These paths resolve identically to the legacy this.deps.factsDir /
254
+ // this.deps.correctionsDir / this.deps.proceduresDir / this.deps.reasoningTracesDir
255
+ // getters (all `path.join(this.deps.baseDir, <dir>)`), so the scan stays
256
+ // namespace-aware: this.deps.baseDir is per-namespace, set by the storage router.
257
+ // Deliberately EXCLUDED (issue #1497 + PR #1503 review): the non-category
258
+ // content dirs that ensureDirectories() also creates — entities/, state/,
259
+ // artifacts/, identity/, config/ — plus the root profile.md, AND the
260
+ // questions/ queue dir. questions/ holds operational question-QUEUE items
261
+ // written by writeQuestion() (frontmatter `{ id, created, priority,
262
+ // resolved }`), read only via readQuestions() and surfaced through the
263
+ // dedicated, disabled-by-default `injectQuestions` recall-pipeline stage —
264
+ // never as standard recall memories. The QMD primary recall corpus does not
265
+ // include them, so the fallback must not either (corpus parity; CLAUDE.md
266
+ // rule #39). Were questions/ scanned here, parseFrontmatter() would accept
267
+ // those files (they have a `---` frontmatter block) and leak queue items
268
+ // into recall. None of these excluded dirs are in RECALL_FALLBACK_DIRS; the
269
+ // exclusion is asserted by tests in storage-fallback-category-dirs.test.ts.
270
+ //
271
+ // collectPaths() already ignores missing dirs (try/catch) and the parser
272
+ // returns null for non-memory markdown, so unrelated files never crash the
273
+ // scan.
274
+ for (const dir of RECALL_FALLBACK_DIRS) {
275
+ await collectPaths(path.join(this.deps.baseDir, dir));
276
+ }
277
+ return filePaths;
278
+ }
279
+
280
+ async readMemoriesWindow(options: {
281
+ maxMemories?: number;
282
+ batchSize?: number;
283
+ updatedAfter?: Date;
284
+ } = {}): Promise<{ memories: MemoryFile[]; filePaths: string[] }> {
285
+ const allPaths = await this.deps.collectActiveMemoryPaths();
286
+ const sortedPaths = this.deps.orderWindowPaths(allPaths);
287
+ const maxMemories =
288
+ typeof options.maxMemories === "number" && Number.isFinite(options.maxMemories)
289
+ ? Math.max(1, Math.floor(options.maxMemories))
290
+ : undefined;
291
+ const maxCandidatePaths = maxMemories === undefined ? undefined : maxMemories * 2;
292
+ const updatedAfterMs = options.updatedAfter?.getTime();
293
+ const normalizedBatchSize = this.deps.normalizeMemoryReadBatchSize(options.batchSize);
294
+ const memories: MemoryFile[] = [];
295
+ const selectedPaths: string[] = [];
296
+
297
+ for (let i = 0; i < sortedPaths.length; i += normalizedBatchSize) {
298
+ if (
299
+ maxMemories !== undefined
300
+ && (memories.length >= maxMemories || (maxCandidatePaths !== undefined && selectedPaths.length >= maxCandidatePaths))
301
+ ) {
302
+ return { memories, filePaths: selectedPaths };
303
+ }
304
+ const batchPaths = sortedPaths.slice(i, i + normalizedBatchSize);
305
+ const candidateBatchPaths = updatedAfterMs === undefined
306
+ ? batchPaths
307
+ : await this.deps.filterWindowPathsByUpdatedAfter(batchPaths, updatedAfterMs);
308
+ const remainingSlots = maxMemories === undefined ? undefined : Math.max(0, maxMemories - memories.length);
309
+ const remainingInspectionBudget = maxCandidatePaths === undefined ? undefined : Math.max(0, maxCandidatePaths - selectedPaths.length);
310
+ const { memories: batchMemories, filePaths: parsedCandidatePaths } = remainingSlots === undefined
311
+ ? {
312
+ memories: await this.deps.readParsedMemoriesFromPaths(candidateBatchPaths, normalizedBatchSize),
313
+ filePaths: candidateBatchPaths,
314
+ }
315
+ : await this.deps.readWindowBoundedBatch(
316
+ candidateBatchPaths,
317
+ remainingSlots,
318
+ remainingInspectionBudget ?? remainingSlots,
319
+ normalizedBatchSize,
320
+ );
321
+ selectedPaths.push(...parsedCandidatePaths);
322
+ for (const memory of batchMemories) {
323
+ memories.push(memory);
324
+ if (maxMemories !== undefined && memories.length >= maxMemories) {
325
+ return { memories, filePaths: selectedPaths };
326
+ }
327
+ }
328
+ }
329
+
330
+ return { memories, filePaths: selectedPaths };
331
+ }
332
+
333
+ /**
334
+ * Read all memories from the cold tier by scanning the entire cold/ root
335
+ * tree. Previously this only scanned cold/facts/ and cold/corrections/, but
336
+ * structuredAttributes can appear on any MemoryCategory (preference, decision,
337
+ * entity, etc.). buildTierMemoryPath now routes each category to its own
338
+ * cold/<dir>/ subtree via the shared categoryDirName() chokepoint (issue
339
+ * #1546), so cold decisions/preferences/... live outside cold/facts/.
340
+ * Scanning the full coldRoot covers every category dir and guards against
341
+ * files placed in unexpected subdirectories during manual operations or future
342
+ * refactors.
343
+ *
344
+ * Broadened in PR #402 round-6 (Finding UTsP): scanning only facts/ and
345
+ * corrections/ was a narrower-than-necessary subset of the cold directory
346
+ * tree. Correctness trumps the minor performance difference — cold scans
347
+ * already happen at most once per supersession write.
348
+ *
349
+ * Used by applyTemporalSupersession so that memories already demoted to
350
+ * cold/ can still be marked superseded when a newer hot fact arrives.
351
+ *
352
+ * Cached with a TTL (Finding UOGi, PR #402 round-6): back-to-back
353
+ * structured-attribute writes in the same burst reuse the cached result
354
+ * instead of re-scanning the cold tree on every call. The cache is
355
+ * invalidated whenever a write calls invalidateAllMemoriesCache() (which
356
+ * covers any hot→cold demotion that changes cold-tier contents) and
357
+ * expires after COLD_SCAN_CACHE_TTL_MS as a safety net.
358
+ */
359
+ async readAllColdMemories(): Promise<MemoryFile[]> {
360
+ const coldRoot = this.deps.resolveTierRootDir("cold");
361
+
362
+ // Read the on-disk cold-version sentinel BEFORE checking the cache so that
363
+ // writes made by other processes (gateway + CLI) are detected immediately.
364
+ // Finding UvUy (PR #402 round-11): without this check the cache served
365
+ // stale data for up to 30s when another process wrote a new cold memory.
366
+ const currentColdVersion = this.deps.readColdWriteVersion();
367
+
368
+ // Return cached result if still valid by both TTL and sentinel version.
369
+ const cached = this.deps.storageManagerClass.coldMemoriesCache.get(coldRoot);
370
+ if (
371
+ cached &&
372
+ Date.now() - cached.loadedAt < this.deps.storageManagerClass.COLD_SCAN_CACHE_TTL_MS &&
373
+ cached.coldVersion === currentColdVersion
374
+ ) {
375
+ return cached.memories;
376
+ }
377
+
378
+ const filePaths: string[] = [];
379
+
380
+ const collectPaths = async (dir: string) => {
381
+ try {
382
+ const entries = await readdir(dir, { withFileTypes: true });
383
+ const subdirs: string[] = [];
384
+ for (const entry of entries) {
385
+ const fullPath = path.join(dir, entry.name);
386
+ if (entry.isDirectory()) {
387
+ subdirs.push(fullPath);
388
+ } else if (entry.name.endsWith(".md")) {
389
+ filePaths.push(fullPath);
390
+ }
391
+ }
392
+ for (const subdir of subdirs) {
393
+ await collectPaths(subdir);
394
+ }
395
+ } catch {
396
+ // Directory does not exist yet — cold tier may be empty.
397
+ }
398
+ };
399
+
400
+ // Scan the entire cold root so that memories in any subdirectory (facts/,
401
+ // corrections/, artifacts/, or any future category-specific subdirectory)
402
+ // are included. This is broader than the previous facts/+corrections/ scan
403
+ // and ensures that any memory with structuredAttributes is found regardless
404
+ // of which category it was written with.
405
+ await collectPaths(coldRoot);
406
+ const memories = await this.deps.readParsedMemoriesFromPaths(filePaths, 50);
407
+
408
+ // Store in cache with the sentinel version captured above so that any
409
+ // subsequent cold-version bump (by this or another process) invalidates it.
410
+ this.deps.storageManagerClass.coldMemoriesCache.set(coldRoot, { memories, loadedAt: Date.now(), coldVersion: currentColdVersion });
411
+ return memories;
412
+ }
413
+
414
+ /** Read a single memory file by its absolute path. Returns null if unreadable. */
415
+ async readMemoryByPath(filePath: string): Promise<MemoryFile | null> {
416
+ try {
417
+ const raw = await readMaybeEncryptedFile(filePath, this.deps._secureStoreKey, this.deps.baseDir);
418
+ // Note: the outer catch intentionally swallows most errors (ENOENT etc.)
419
+ // but SecureStoreLockedError must propagate — see re-throw below.
420
+ const parsed = parseFrontmatter(raw);
421
+ if (parsed) {
422
+ return {
423
+ path: filePath,
424
+ frontmatter: normalizeFrontmatterForPath(
425
+ parsed.frontmatter,
426
+ toMemoryPathRel(this.deps.baseDir, filePath),
427
+ parsed.content,
428
+ ),
429
+ content: parsed.content,
430
+ };
431
+ }
432
+
433
+ // Entity files use a `# Name` + `**Type:** ...` markdown format rather than
434
+ // YAML frontmatter. Build a synthetic MemoryFile so entity files returned by
435
+ // the direct retrieval agent participate in boostSearchResults and last-recall
436
+ // tracking rather than being silently dropped.
437
+ const normalizedPath = filePath.split(path.sep).join("/");
438
+ if (normalizedPath.includes("/entities/") && filePath.endsWith(".md")) {
439
+ const entity = parseEntityFile(raw, this.deps.entitySchemas);
440
+ if (!entity.name) return null;
441
+ const nameWithoutExt = path.basename(filePath, ".md");
442
+ // Fall back to file mtime rather than new Date() so that entities without
443
+ // an explicit Updated: timestamp are not treated as freshly created on every
444
+ // read. Using new Date() would inflate boostSearchResults recency scores for
445
+ // every entity that lacks a timestamp.
446
+ // Use epoch as the last-resort fallback so that entities without a
447
+ // parseable timestamp don't appear as "freshly created" and inflate scores.
448
+ const fileMtime = entity.updated
449
+ || await stat(filePath).then((s) => s.mtime.toISOString()).catch(() => new Date(0).toISOString());
450
+ return {
451
+ path: filePath,
452
+ frontmatter: {
453
+ id: nameWithoutExt,
454
+ category: "entity",
455
+ created: fileMtime,
456
+ updated: fileMtime,
457
+ source: "entity_extraction",
458
+ confidence: 0.9,
459
+ confidenceTier: confidenceTier(0.9),
460
+ tags: entity.type ? [entity.type] : [],
461
+ },
462
+ content: raw,
463
+ };
464
+ }
465
+
466
+ return null;
467
+ } catch (err) {
468
+ // Re-throw store-locked errors — callers need to distinguish "locked"
469
+ // from "file not found / parse error". Swallowing a locked error here
470
+ // would silently return null and leave the daemon appearing to work
471
+ // while returning no memories (subtle data loss).
472
+ if (err instanceof SecureStoreLockedError) throw err;
473
+ return null;
474
+ }
475
+ }
476
+
477
+ /**
478
+ * Append a batch of `BUFFER_SURPRISE` telemetry events (issue #563 PR 3).
479
+ *
480
+ * Each event records a single buffer flush decision driven by the
481
+ * surprise gate. The ledger is consumed by
482
+ * `reportBufferSurpriseDistribution` (Doctor report) and by downstream
483
+ * benchmark analysis. This method is fire-and-forget by contract:
484
+ * callers log but do not fail the hot path if the append throws.
485
+ */
486
+ async appendBufferSurpriseEvents(
487
+ events: BufferSurpriseEvent[],
488
+ ): Promise<number> {
489
+ if (events.length === 0) return 0;
490
+ await this.deps.ensureDirectories();
491
+
492
+ const nowIso = new Date().toISOString();
493
+ const payload = events
494
+ .map((event) => {
495
+ const normalized: BufferSurpriseEvent = {
496
+ ...event,
497
+ event: "BUFFER_SURPRISE",
498
+ timestamp:
499
+ event.timestamp && event.timestamp.length > 0
500
+ ? event.timestamp
501
+ : nowIso,
502
+ };
503
+ return `${JSON.stringify(normalized)}\n`;
504
+ })
505
+ .join("");
506
+
507
+ await this.deps.appendStorageSecureFile(this.deps.bufferSurpriseLedgerPath, payload);
508
+ return events.length;
509
+ }
510
+
511
+ /**
512
+ * Read the buffer-surprise ledger, most recent rows last.
513
+ *
514
+ * `limit` bounds the number of **valid rows** returned (not the
515
+ * number of raw lines parsed). We parse every row, discard malformed
516
+ * ones, then take the tail — so a partial/truncated trailing line
517
+ * (the common failure mode after an interrupted append) cannot hide
518
+ * otherwise-valid recent data above it.
519
+ *
520
+ * Non-positive / non-integer / non-finite limits return `[]` rather
521
+ * than the entire file, matching the other ledger readers in this
522
+ * class and protecting against `slice(-0.5)` → `slice(-0)` silently
523
+ * devolving into an unbounded parse.
524
+ *
525
+ * # Performance note
526
+ *
527
+ * For very large ledgers (issue #563 follow-up), a tail-first reader
528
+ * would avoid parsing the full file when only a recent window is
529
+ * needed. We keep the full-scan implementation here because:
530
+ *
531
+ * - the ledger is opt-in (flag off by default), so early deployments
532
+ * accumulate rows slowly;
533
+ * - telemetry rows are small (~200 bytes), so even 100k rows parse
534
+ * in well under a second;
535
+ * - the governance archive/cleanup flow can trim the ledger when
536
+ * size becomes a concern, reusing the existing maintenance hooks.
537
+ *
538
+ * Swap to a chunked tail-reader if production logs show this is a
539
+ * hot path — leaving that work for a follow-up keeps this PR scoped
540
+ * to correctness, not optimization.
541
+ */
542
+ async readBufferSurpriseEvents(
543
+ options: { limit?: number } = {},
544
+ ): Promise<BufferSurpriseEvent[]> {
545
+ let raw: string;
546
+ try {
547
+ raw = await this.deps.readStorageSecureFile(this.deps.bufferSurpriseLedgerPath);
548
+ } catch (err) {
549
+ if (err instanceof SecureStoreLockedError) throw err;
550
+ const code = (err as NodeJS.ErrnoException).code;
551
+ if (code === "ENOENT") return [];
552
+ throw err;
553
+ }
554
+
555
+ // Resolve the effective limit up front. Any non-finite / non-positive
556
+ // value returns no rows — callers who want "everything" should OMIT
557
+ // the `limit` key (treated as "no bound" below). We intentionally
558
+ // reject `Infinity` too, because the slice math `events.slice(-Inf)`
559
+ // is surprising and ambiguous; omit the key instead. Fractional
560
+ // values <1 floor to 0, which would make `slice(-0)` return the
561
+ // entire file — guard against that too.
562
+ let effectiveLimit: number | null = null;
563
+ if (options.limit !== undefined) {
564
+ if (
565
+ typeof options.limit !== "number" ||
566
+ !Number.isFinite(options.limit) ||
567
+ options.limit <= 0
568
+ ) {
569
+ return [];
570
+ }
571
+ const floored = Math.floor(options.limit);
572
+ if (floored <= 0) return [];
573
+ effectiveLimit = floored;
574
+ }
575
+
576
+ const lines = raw.split("\n");
577
+ const events: BufferSurpriseEvent[] = [];
578
+ for (const line of lines) {
579
+ const trimmed = line.trim();
580
+ if (trimmed.length === 0) continue;
581
+ try {
582
+ const parsed = JSON.parse(trimmed);
583
+ if (isValidBufferSurpriseEvent(parsed)) {
584
+ events.push(parsed);
585
+ }
586
+ } catch {
587
+ // Malformed row — fail open, skip.
588
+ }
589
+ }
590
+
591
+ events.sort(
592
+ (left, right) => Date.parse(left.timestamp) - Date.parse(right.timestamp),
593
+ );
594
+
595
+ if (effectiveLimit === null) return events;
596
+ // Slice over VALID rows, not raw lines, so malformed tails cannot
597
+ // mask good data above them. Sort by event timestamp before slicing
598
+ // so concurrent probe completion order cannot make an older scored
599
+ // turn look newer than a later scored turn.
600
+ return events.slice(-effectiveLimit);
601
+ }
602
+
603
+ async readCompressionGuidelineStateFile(
604
+ filePath: string,
605
+ ): Promise<CompressionGuidelineOptimizerState | null> {
606
+ const isFiniteNonNegativeInteger = (value: unknown): value is number =>
607
+ typeof value === "number" && Number.isFinite(value) && Number.isInteger(value) && value >= 0;
608
+ const isValidActionSummary = (
609
+ value: unknown,
610
+ ): value is NonNullable<CompressionGuidelineOptimizerState["actionSummaries"]>[number] => {
611
+ if (!value || typeof value !== "object") return false;
612
+ const summary = value as NonNullable<CompressionGuidelineOptimizerState["actionSummaries"]>[number];
613
+ return (
614
+ typeof summary.action === "string" &&
615
+ isFiniteNonNegativeInteger(summary.total) &&
616
+ summary.outcomes !== null &&
617
+ typeof summary.outcomes === "object" &&
618
+ isFiniteNonNegativeInteger(summary.outcomes.applied) &&
619
+ isFiniteNonNegativeInteger(summary.outcomes.skipped) &&
620
+ isFiniteNonNegativeInteger(summary.outcomes.failed) &&
621
+ summary.quality !== null &&
622
+ typeof summary.quality === "object" &&
623
+ isFiniteNonNegativeInteger(summary.quality.good) &&
624
+ isFiniteNonNegativeInteger(summary.quality.poor) &&
625
+ isFiniteNonNegativeInteger(summary.quality.unknown)
626
+ );
627
+ };
628
+ const isValidRuleUpdate = (
629
+ value: unknown,
630
+ ): value is NonNullable<CompressionGuidelineOptimizerState["ruleUpdates"]>[number] => {
631
+ if (!value || typeof value !== "object") return false;
632
+ const rule = value as NonNullable<CompressionGuidelineOptimizerState["ruleUpdates"]>[number];
633
+ return (
634
+ typeof rule.action === "string" &&
635
+ typeof rule.delta === "number" &&
636
+ Number.isFinite(rule.delta) &&
637
+ (rule.direction === "increase" || rule.direction === "decrease" || rule.direction === "hold") &&
638
+ (rule.confidence === "low" || rule.confidence === "medium" || rule.confidence === "high") &&
639
+ Array.isArray(rule.notes) &&
640
+ rule.notes.every((note) => typeof note === "string")
641
+ );
642
+ };
643
+
644
+ try {
645
+ const raw = await this.deps.readStorageSecureFile(filePath);
646
+ const parsed = JSON.parse(raw) as Partial<CompressionGuidelineOptimizerState>;
647
+ const sourceWindow = parsed?.sourceWindow as Partial<CompressionGuidelineOptimizerState["sourceWindow"]>;
648
+ const eventCounts = parsed?.eventCounts as Partial<CompressionGuidelineOptimizerState["eventCounts"]>;
649
+ const activationState =
650
+ parsed?.activationState === "draft" || parsed?.activationState === "active"
651
+ ? parsed.activationState
652
+ : undefined;
653
+ const contentHash =
654
+ typeof parsed?.contentHash === "string" && parsed.contentHash.length > 0
655
+ ? parsed.contentHash
656
+ : undefined;
657
+ const actionSummaries = Array.isArray(parsed?.actionSummaries)
658
+ ? parsed.actionSummaries.filter(isValidActionSummary)
659
+ : undefined;
660
+ const ruleUpdates = Array.isArray(parsed?.ruleUpdates)
661
+ ? parsed.ruleUpdates.filter(isValidRuleUpdate)
662
+ : undefined;
663
+ if (
664
+ !isFiniteNonNegativeInteger(parsed?.version) ||
665
+ typeof parsed?.updatedAt !== "string" ||
666
+ parsed.updatedAt.length === 0 ||
667
+ !sourceWindow ||
668
+ typeof sourceWindow.from !== "string" ||
669
+ sourceWindow.from.length === 0 ||
670
+ typeof sourceWindow.to !== "string" ||
671
+ sourceWindow.to.length === 0 ||
672
+ !eventCounts ||
673
+ !isFiniteNonNegativeInteger(eventCounts.total) ||
674
+ !isFiniteNonNegativeInteger(eventCounts.applied) ||
675
+ !isFiniteNonNegativeInteger(eventCounts.skipped) ||
676
+ !isFiniteNonNegativeInteger(eventCounts.failed) ||
677
+ !isFiniteNonNegativeInteger(parsed?.guidelineVersion)
678
+ ) {
679
+ return null;
680
+ }
681
+
682
+ return {
683
+ version: parsed.version,
684
+ updatedAt: parsed.updatedAt,
685
+ sourceWindow: {
686
+ from: sourceWindow.from,
687
+ to: sourceWindow.to,
688
+ },
689
+ eventCounts: {
690
+ total: eventCounts.total,
691
+ applied: eventCounts.applied,
692
+ skipped: eventCounts.skipped,
693
+ failed: eventCounts.failed,
694
+ },
695
+ guidelineVersion: parsed.guidelineVersion,
696
+ ...(contentHash ? { contentHash } : {}),
697
+ ...(activationState ? { activationState } : {}),
698
+ ...(actionSummaries ? { actionSummaries } : {}),
699
+ ...(ruleUpdates ? { ruleUpdates } : {}),
700
+ };
701
+ } catch (err) {
702
+ if (err instanceof SecureStoreLockedError) throw err;
703
+ if (!isErrnoCode(err, "ENOENT")) throw err;
704
+ return null;
705
+ }
706
+ }
707
+
708
+ async readQuestions(
709
+ opts?: { unresolvedOnly?: boolean },
710
+ ): Promise<
711
+ Array<{
712
+ id: string;
713
+ question: string;
714
+ context: string;
715
+ priority: number;
716
+ resolved: boolean;
717
+ created: string;
718
+ filePath: string;
719
+ }>
720
+ > {
721
+ const cacheKey = this.deps.questionsDir;
722
+ const cached = this.deps.storageManagerClass.questionsCache.get(cacheKey);
723
+ if (cached && Date.now() - cached.loadedAt < this.deps.storageManagerClass.QUESTIONS_CACHE_TTL_MS) {
724
+ // Check dir mtime for cross-process invalidation — if another process
725
+ // wrote/resolved a question, the directory mtime will be newer than loadedAt.
726
+ try {
727
+ const dirStat = await stat(this.deps.questionsDir);
728
+ if (dirStat.mtimeMs <= cached.loadedAt) {
729
+ const all = cached.questions;
730
+ return opts?.unresolvedOnly ? all.filter((q) => !q.resolved) : all;
731
+ }
732
+ } catch {
733
+ // Dir doesn't exist — fall through to re-read
734
+ }
735
+ }
736
+
737
+ try {
738
+ const files = await readdir(this.deps.questionsDir);
739
+ const questions = [];
740
+ for (const file of files) {
741
+ if (!file.endsWith(".md")) continue;
742
+ const filePath = path.join(this.deps.questionsDir, file);
743
+ const raw = await readMaybeEncryptedFile(filePath, this.deps._secureStoreKey, this.deps.baseDir);
744
+ const parsed = this.deps.parseQuestionFile(raw, filePath);
745
+ if (parsed) {
746
+ questions.push(parsed);
747
+ }
748
+ }
749
+ const sorted = questions.sort((a, b) => b.priority - a.priority);
750
+ this.deps.storageManagerClass.questionsCache.set(cacheKey, { questions: sorted, loadedAt: Date.now() });
751
+ return opts?.unresolvedOnly ? sorted.filter((q) => !q.resolved) : sorted;
752
+ } catch {
753
+ return [];
754
+ }
755
+ }
756
+ }