recallnest 2.3.0 → 2.6.0

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 (258) hide show
  1. package/CHANGELOG.md +187 -0
  2. package/README.md +49 -38
  3. package/README_CN.md +46 -31
  4. package/assets/ui/app.js +1031 -0
  5. package/assets/ui/index.html +224 -0
  6. package/assets/ui/styles.css +781 -0
  7. package/config.json.example +4 -1
  8. package/docs/api-reference.md +1291 -0
  9. package/docs/architecture.md +168 -0
  10. package/docs/connector-spec.md +126 -0
  11. package/eval/cases-canary.json +58 -0
  12. package/eval/cases-scope-robustness.json +278 -0
  13. package/eval/cases.json +117 -20
  14. package/package.json +25 -4
  15. package/src/__tests__/access-tracker-novelty.test.ts +304 -0
  16. package/src/__tests__/activity-counter.test.ts +125 -0
  17. package/src/__tests__/adaptive-fidelity.test.ts +102 -0
  18. package/src/__tests__/admission-control.test.ts +206 -0
  19. package/src/__tests__/anchor-generator.test.ts +67 -0
  20. package/src/__tests__/api-server-security.test.ts +102 -0
  21. package/src/__tests__/auto-consolidation.test.ts +168 -0
  22. package/src/__tests__/auto-recall.test.ts +131 -0
  23. package/src/__tests__/babel-integration.test.ts +144 -0
  24. package/src/__tests__/batch-dedup.test.ts +65 -0
  25. package/src/__tests__/belief-history.test.ts +220 -0
  26. package/src/__tests__/canary-eval.test.ts +114 -0
  27. package/src/__tests__/capture-engine.test.ts +1328 -0
  28. package/src/__tests__/capture-heuristic.test.ts +171 -0
  29. package/src/__tests__/cascade-forget.test.ts +210 -0
  30. package/src/__tests__/category-min-scores.test.ts +116 -0
  31. package/src/__tests__/cc8-cc1-essential-context.test.ts +298 -0
  32. package/src/__tests__/circuit-breaker.test.ts +99 -0
  33. package/src/__tests__/cluster-consolidation.test.ts +945 -0
  34. package/src/__tests__/confidence-metadata.test.ts +125 -0
  35. package/src/__tests__/confidence-tracker.test.ts +261 -0
  36. package/src/__tests__/conflict-advisor.test.ts +206 -0
  37. package/src/__tests__/conflict-escalation.test.ts +149 -0
  38. package/src/__tests__/conflict-lifecycle.test.ts +83 -0
  39. package/src/__tests__/conflict-output.test.ts +85 -0
  40. package/src/__tests__/conflict-store.test.ts +418 -0
  41. package/src/__tests__/connector-v1.test.ts +181 -0
  42. package/src/__tests__/consolidation-engine.test.ts +430 -0
  43. package/src/__tests__/constructive-retrieval.test.ts +509 -0
  44. package/src/__tests__/context-collapse-renderer.test.ts +214 -0
  45. package/src/__tests__/context-composer-pins.test.ts +64 -0
  46. package/src/__tests__/context-composer-task-selection.test.ts +24 -0
  47. package/src/__tests__/context-reconstructor.test.ts +65 -0
  48. package/src/__tests__/context-renderer.test.ts +91 -0
  49. package/src/__tests__/continuity-eval.test.ts +275 -0
  50. package/src/__tests__/continuity-schema.test.ts +97 -0
  51. package/src/__tests__/contracts.test.ts +262 -0
  52. package/src/__tests__/conversation-importer.test.ts +692 -0
  53. package/src/__tests__/core-recall-eval.test.ts +235 -0
  54. package/src/__tests__/core-summary.test.ts +109 -0
  55. package/src/__tests__/data-checkup.test.ts +276 -0
  56. package/src/__tests__/decay-exemption.test.ts +129 -0
  57. package/src/__tests__/dedup-actions.test.ts +58 -0
  58. package/src/__tests__/dedup-guard.test.ts +39 -0
  59. package/src/__tests__/deterministic-id.test.ts +39 -0
  60. package/src/__tests__/distill-lock.test.ts +305 -0
  61. package/src/__tests__/distill-session-argshape.test.ts +79 -0
  62. package/src/__tests__/doctor.test.ts +319 -0
  63. package/src/__tests__/dream-pipeline.test.ts +780 -0
  64. package/src/__tests__/drill-down.test.ts +108 -0
  65. package/src/__tests__/embedder.test.ts +122 -0
  66. package/src/__tests__/emotion-decay.test.ts +46 -0
  67. package/src/__tests__/emotion-detector.test.ts +56 -0
  68. package/src/__tests__/emotion-phase1-integration.test.ts +153 -0
  69. package/src/__tests__/emotion-retrieval.test.ts +56 -0
  70. package/src/__tests__/entity-resolver.test.ts +58 -0
  71. package/src/__tests__/env-config.test.ts +151 -0
  72. package/src/__tests__/error-signature-boost.test.ts +87 -0
  73. package/src/__tests__/error-signature.test.ts +95 -0
  74. package/src/__tests__/error-taxonomy.test.ts +129 -0
  75. package/src/__tests__/ethics-layer.test.ts +476 -0
  76. package/src/__tests__/event-segmenter.test.ts +153 -0
  77. package/src/__tests__/feature-flags-integration.test.ts +54 -0
  78. package/src/__tests__/frequency-tracker.test.ts +113 -0
  79. package/src/__tests__/freshness.test.ts +186 -0
  80. package/src/__tests__/graph-export.test.ts +489 -0
  81. package/src/__tests__/hotness-blend.test.ts +283 -0
  82. package/src/__tests__/ingest-chunking.test.ts +44 -0
  83. package/src/__tests__/ingest.test.ts +360 -0
  84. package/src/__tests__/interference-detector.test.ts +203 -0
  85. package/src/__tests__/kg-extractor.test.ts +192 -0
  86. package/src/__tests__/kg-store.test.ts +300 -0
  87. package/src/__tests__/llm-client-pivot.test.ts +628 -0
  88. package/src/__tests__/llm-consolidation.test.ts +226 -0
  89. package/src/__tests__/maintenance-locks.test.ts +189 -0
  90. package/src/__tests__/mcp-efficiency.test.ts +288 -0
  91. package/src/__tests__/mcp-related-scopes.test.ts +151 -0
  92. package/src/__tests__/memory-boundaries.test.ts +282 -0
  93. package/src/__tests__/memory-evolution-b2-b3-e1.test.ts +345 -0
  94. package/src/__tests__/memory-evolution.test.ts +271 -0
  95. package/src/__tests__/memory-health-rebalance.test.ts +144 -0
  96. package/src/__tests__/memory-lint.test.ts +421 -0
  97. package/src/__tests__/memory-output.test.ts +215 -0
  98. package/src/__tests__/memory-promotion.test.ts +315 -0
  99. package/src/__tests__/migrate-dedup-ids.test.ts +65 -0
  100. package/src/__tests__/multi-vector.test.ts +242 -0
  101. package/src/__tests__/narrative-architecture.test.ts +456 -0
  102. package/src/__tests__/noise-filter.test.ts +205 -0
  103. package/src/__tests__/noise-prototype-bank.test.ts +280 -0
  104. package/src/__tests__/obsidian-connector.test.ts +363 -0
  105. package/src/__tests__/omitted-reporting.test.ts +98 -0
  106. package/src/__tests__/pending-review.test.ts +77 -0
  107. package/src/__tests__/pii-redact.test.ts +333 -0
  108. package/src/__tests__/pivot-apply.test.ts +373 -0
  109. package/src/__tests__/pivot-distill-supervisor.test.ts +457 -0
  110. package/src/__tests__/pivot-distill.test.ts +1178 -0
  111. package/src/__tests__/post-retrieval-filter.test.ts +178 -0
  112. package/src/__tests__/ppr-traversal.test.ts +256 -0
  113. package/src/__tests__/prediction-engine.test.ts +349 -0
  114. package/src/__tests__/predictive-prospective-memory.test.ts +416 -0
  115. package/src/__tests__/preference-guard-smoke.test.ts +211 -0
  116. package/src/__tests__/preference-matcher.test.ts +282 -0
  117. package/src/__tests__/preference-slots.test.ts +205 -0
  118. package/src/__tests__/prospective-memory.test.ts +283 -0
  119. package/src/__tests__/pull-from-macbook-sync-only.test.ts +679 -0
  120. package/src/__tests__/query-entity-detector.test.ts +171 -0
  121. package/src/__tests__/query-expander-alias.test.ts +146 -0
  122. package/src/__tests__/query-expander.test.ts +34 -0
  123. package/src/__tests__/recall-gate.test.ts +266 -0
  124. package/src/__tests__/recall-governor.test.ts +227 -0
  125. package/src/__tests__/recall-mode.test.ts +64 -0
  126. package/src/__tests__/resolve-db-path.test.ts +130 -0
  127. package/src/__tests__/resolve-tier.test.ts +64 -0
  128. package/src/__tests__/result-synthesizer.test.ts +198 -0
  129. package/src/__tests__/resume-context.test.ts +4898 -0
  130. package/src/__tests__/retrieval-eval.test.ts +163 -0
  131. package/src/__tests__/retrieval-trace.test.ts +213 -0
  132. package/src/__tests__/retriever-boundary-weight.test.ts +136 -0
  133. package/src/__tests__/retriever-lazy-init-dispatch.test.ts +123 -0
  134. package/src/__tests__/retroactive-boost.test.ts +223 -0
  135. package/src/__tests__/salience-scoring.test.ts +106 -0
  136. package/src/__tests__/scope-inventory.test.ts +200 -0
  137. package/src/__tests__/scope-policy.test.ts +69 -0
  138. package/src/__tests__/scope-suggester.test.ts +80 -0
  139. package/src/__tests__/session-checkpoint.test.ts +291 -0
  140. package/src/__tests__/session-distiller.test.ts +640 -0
  141. package/src/__tests__/session-gc.test.ts +215 -0
  142. package/src/__tests__/skill-engine.test.ts +552 -0
  143. package/src/__tests__/skill-promotion.test.ts +654 -0
  144. package/src/__tests__/skill-verifier.test.ts +122 -0
  145. package/src/__tests__/source-heartbeat.test.ts +250 -0
  146. package/src/__tests__/stable-context-length-contract.test.ts +62 -0
  147. package/src/__tests__/store-patch-metadata.test.ts +205 -0
  148. package/src/__tests__/store-read-consistency.test.ts +122 -0
  149. package/src/__tests__/store-schema-migration.test.ts +87 -0
  150. package/src/__tests__/store-scope-audit.test.ts +81 -0
  151. package/src/__tests__/store-upsert.test.ts +201 -0
  152. package/src/__tests__/supersede-chain.test.ts +154 -0
  153. package/src/__tests__/temporal-parser.test.ts +241 -0
  154. package/src/__tests__/temporal-validity.test.ts +160 -0
  155. package/src/__tests__/term-registry-terminal-roster.test.ts +127 -0
  156. package/src/__tests__/term-registry.test.ts +121 -0
  157. package/src/__tests__/tool-error-surfacing.test.ts +98 -0
  158. package/src/__tests__/tool-output-compressor.test.ts +160 -0
  159. package/src/__tests__/topic-tag.test.ts +113 -0
  160. package/src/__tests__/trusted-memory.test.ts +839 -0
  161. package/src/__tests__/ui-server-security.test.ts +93 -0
  162. package/src/__tests__/usage-tracker.test.ts +287 -0
  163. package/src/__tests__/version-manager.test.ts +213 -0
  164. package/src/__tests__/workflow-observation-skill-outcome.test.ts +352 -0
  165. package/src/__tests__/workflow-observation.test.ts +433 -0
  166. package/src/__tests__/write-verifier.test.ts +122 -0
  167. package/src/access-tracker.ts +90 -34
  168. package/src/activity-counter.ts +46 -18
  169. package/src/adaptive-retrieval.ts +11 -0
  170. package/src/aliases.ts +133 -0
  171. package/src/api-server.ts +36 -9
  172. package/src/audit-log.ts +4 -2
  173. package/src/auto-consolidation.ts +8 -3
  174. package/src/auto-gc.ts +85 -21
  175. package/src/auto-recall.ts +61 -1
  176. package/src/belief-history.ts +164 -0
  177. package/src/capture-engine.ts +142 -32
  178. package/src/cli.ts +268 -41
  179. package/src/confidence-tracker.ts +36 -18
  180. package/src/conflict-engine.ts +24 -3
  181. package/src/consolidation-engine.ts +255 -23
  182. package/src/context-composer-stable-selection.ts +7 -1
  183. package/src/context-composer.ts +8 -1
  184. package/src/data-checkup.ts +96 -7
  185. package/src/decay-engine.ts +59 -7
  186. package/src/distill-lock.ts +194 -15
  187. package/src/doctor.ts +13 -4
  188. package/src/dream-pipeline.ts +473 -41
  189. package/src/env-config.ts +118 -0
  190. package/src/error-signature.ts +153 -0
  191. package/src/error-taxonomy.ts +115 -0
  192. package/src/eval.ts +402 -14
  193. package/src/freshness.ts +192 -0
  194. package/src/http-request-policy.ts +148 -0
  195. package/src/idempotency.ts +8 -0
  196. package/src/ingest.ts +308 -12
  197. package/src/kg-extractor.ts +13 -3
  198. package/src/kg-store.ts +236 -44
  199. package/src/llm-client.ts +106 -11
  200. package/src/llm-consolidation.ts +2 -1
  201. package/src/mcp-server.ts +87 -1800
  202. package/src/mcp-tool-deps.ts +48 -0
  203. package/src/mcp-tools-advanced.ts +954 -0
  204. package/src/mcp-tools-core.ts +637 -0
  205. package/src/mcp-tools-governance.ts +475 -0
  206. package/src/memory-boundaries.ts +26 -3
  207. package/src/memory-evolution.ts +73 -1
  208. package/src/memory-health-rebalance.ts +4 -0
  209. package/src/memory-lint.ts +183 -10
  210. package/src/memory-output.ts +129 -4
  211. package/src/memory-promotion.ts +246 -0
  212. package/src/memory-schema.ts +25 -2
  213. package/src/memory-store-port.ts +46 -0
  214. package/src/multi-vector.ts +2 -1
  215. package/src/narrative-schema.ts +2 -1
  216. package/src/noise-filter.ts +57 -6
  217. package/src/noise-prototype-bank.ts +408 -0
  218. package/src/pii-detector.ts +207 -5
  219. package/src/pivot-apply.ts +399 -0
  220. package/src/preference-matcher.ts +37 -3
  221. package/src/query-expander.ts +90 -9
  222. package/src/recall-gate.ts +305 -0
  223. package/src/result-synthesizer.ts +2 -1
  224. package/src/retention-policy.ts +2 -1
  225. package/src/retrieval-profiles.ts +46 -4
  226. package/src/retriever.ts +354 -157
  227. package/src/retroactive-boost.ts +6 -2
  228. package/src/runtime-config.ts +78 -6
  229. package/src/scope-suggester.ts +113 -0
  230. package/src/session-distiller.ts +23 -2
  231. package/src/session-engine.ts +2 -1
  232. package/src/session-schema.ts +1 -0
  233. package/src/session-store.ts +5 -0
  234. package/src/skill-engine.ts +78 -0
  235. package/src/skill-promotion.ts +304 -48
  236. package/src/skill-schema.ts +16 -3
  237. package/src/skill-verifier.ts +152 -0
  238. package/src/store.ts +321 -57
  239. package/src/term-registry.ts +35 -0
  240. package/src/tool-output-compressor.ts +3 -1
  241. package/src/ui-server.ts +9 -1
  242. package/src/usage-tracker.ts +242 -0
  243. package/src/version-manager.ts +3 -2
  244. package/src/version.ts +19 -0
  245. package/src/workflow-observation-engine.ts +2 -1
  246. package/src/workflow-observation-schema.ts +2 -1
  247. package/src/workflow-observation-store.ts +6 -2
  248. package/.env +0 -4
  249. package/CLAUDE.md +0 -46
  250. package/config.json +0 -55
  251. package/daily-recall.sh +0 -110
  252. package/logs/daily-recall.error.log +0 -0
  253. package/logs/daily-recall.log +0 -60
  254. package/logs/ingest-2026-04-11.log +0 -110
  255. package/logs/ingest-2026-04-12.log +0 -90
  256. package/logs/ingest-2026-04-13.log +0 -87
  257. package/logs/launchagent-stderr.log +0 -0
  258. package/logs/launchagent-stdout.log +0 -0
@@ -122,26 +122,63 @@ export function computeArousalBoost(
122
122
  // HP-7: Decay Exemptions
123
123
  // ============================================================================
124
124
 
125
+ /**
126
+ * Categories whose entries are procedural memory (knowing *how*), not declarative.
127
+ *
128
+ * Only `patterns` qualifies: RecallNest's skill schema stores skills under
129
+ * `category = "patterns"` (see memory-schema.ts), so this covers both workflow
130
+ * patterns and skills.
131
+ *
132
+ * `cases` is deliberately NOT here. A case is a problem/solution pair with a real
133
+ * episodic component ("that CI failure on 07-24"), and its solution can go stale
134
+ * when the underlying tool changes — it *should* decay. Procedural rules do not:
135
+ * see the "什么该衰减" discussion in hippo [[条件失效优于时间衰减]].
136
+ */
137
+ export const PROCEDURAL_CATEGORIES = new Set<string>(["patterns"]);
138
+
125
139
  /**
126
140
  * Check if a memory should be exempt from time decay.
127
141
  * Exempt entries keep their original score without time-based penalties.
128
142
  *
129
143
  * Exemption rules:
144
+ * 0. Procedural category → knowing-how doesn't fade with wall-clock time
130
145
  * 1. Core tier + high importance (≥ 0.95) → identity/correction, must not fade
131
146
  * 2. Recently accessed (within 7 days) → actively used, not stale
132
147
  * 3. Pinned → user explicitly marked as persistent
148
+ *
149
+ * @param category Optional entry category. Omitting it preserves the pre-Rule-0
150
+ * behaviour exactly, so existing callers stay valid.
133
151
  */
134
152
  export function isDecayExempt(
135
153
  metadata: string | undefined,
136
154
  importance: number,
155
+ category?: string,
137
156
  ): boolean {
157
+ // Rule 0: Procedural memory is exempt from *time* decay.
158
+ //
159
+ // Rules 1-3 all key off access statistics or explicit user action, which
160
+ // systematically punishes procedural memory: its defining trait is low access
161
+ // frequency + long validity ("restart launchd with bootout+sleep+bootstrap" may
162
+ // be needed once a quarter but is correct every time). Combined with `patterns`
163
+ // carrying the highest retrieval threshold in DEFAULT_CATEGORY_MIN_SCORES (0.45),
164
+ // that produced a feedback trap: ranked lower → read less → ranked lower still.
165
+ //
166
+ // Cognitive-science backing: procedural memory is non-declarative (Squire) and is
167
+ // the most decay-resistant human memory type — a different curve, not a slower one.
168
+ //
169
+ // NOTE: this exempts from *time* decay only. Procedural memory still expires, but
170
+ // by condition (its premise changed), not by age. Deliberately checked before the
171
+ // `!metadata` guard so entries without metadata are still covered.
172
+ if (category !== undefined && PROCEDURAL_CATEGORIES.has(category)) return true;
173
+
138
174
  if (!metadata) return false;
139
175
 
140
176
  try {
141
177
  const meta = JSON.parse(metadata) as Record<string, unknown>;
142
178
 
143
179
  // Rule 1: Core + very high importance
144
- const tier = meta.tier ?? resolveTierFromMeta(meta);
180
+ // importance 是本函数的入参(来自 entry 列),一并传下去——否则 heuristic 读不到它。
181
+ const tier = meta.tier ?? resolveTierFromMeta(meta, importance);
145
182
  if (tier === "core" && importance >= 0.95) return true;
146
183
 
147
184
  // Rule 2: Accessed within last 7 days
@@ -158,12 +195,21 @@ export function isDecayExempt(
158
195
  }
159
196
  }
160
197
 
161
- /** Internal helper: resolve tier from parsed metadata (avoids re-parsing). */
162
- function resolveTierFromMeta(meta: Record<string, unknown>): MemoryTier {
198
+ /**
199
+ * Internal helper: resolve tier from parsed metadata (avoids re-parsing).
200
+ *
201
+ * `entryImportance` 是 MemoryEntry 的列字段。必须由调用方传进来:importance 不存在
202
+ * 于 metadata(accessCount / lastAccessedAt / tier 才是 access-tracker 写在顶层的),
203
+ * 所以只读 meta.importance 会恒等于 0,让所有还没有显式 tier 的条目一律落到
204
+ * peripheral——2026-07 实测全库 3214 条走这个分支,顶层有 importance 的是 0 条。
205
+ */
206
+ function resolveTierFromMeta(meta: Record<string, unknown>, entryImportance?: number): MemoryTier {
163
207
  if (meta.tier === "core" || meta.tier === "working" || meta.tier === "peripheral") {
164
208
  return meta.tier;
165
209
  }
166
- const imp = typeof meta.importance === "number" ? meta.importance : 0;
210
+ const imp = typeof meta.importance === "number"
211
+ ? meta.importance
212
+ : (typeof entryImportance === "number" ? entryImportance : 0);
167
213
  const ac = typeof meta.accessCount === "number" ? meta.accessCount : 0;
168
214
  if (imp >= 0.95 || ac >= 10) return "core";
169
215
  if (imp >= 0.8 || ac >= 3) return "working";
@@ -178,13 +224,13 @@ function resolveTierFromMeta(meta: Record<string, unknown>): MemoryTier {
178
224
  * Determine a memory's current tier from its metadata.
179
225
  * Falls back to heuristic based on importance if no tier is stored.
180
226
  */
181
- export function resolveTier(metadata?: string): MemoryTier {
227
+ export function resolveTier(metadata?: string, entryImportance?: number): MemoryTier {
182
228
  if (!metadata) return "peripheral";
183
229
 
184
230
  try {
185
231
  const meta = JSON.parse(metadata) as Record<string, unknown>;
186
232
 
187
- // Explicit tier stored in metadata
233
+ // Explicit tier stored in metadata(access-tracker 算过一次就会写在这里)
188
234
  if (meta.tier === "core" || meta.tier === "working" || meta.tier === "peripheral") {
189
235
  return meta.tier;
190
236
  }
@@ -193,7 +239,13 @@ export function resolveTier(metadata?: string): MemoryTier {
193
239
  // - Pinned assets (importance ≥ 0.95) → core
194
240
  // - High importance (≥ 0.8) → working
195
241
  // - Everything else → peripheral
196
- const importance = typeof meta.importance === "number" ? meta.importance : 0;
242
+ //
243
+ // importance 优先取调用方传入的 entry 列值。metadata 里没有这个字段——顶层住着的是
244
+ // accessCount / lastAccessedAt / tier(access-tracker 写的),importance 从来不在。
245
+ // 不传就只能退回 0,那会把所有还没被访问过的条目一律压成 peripheral。
246
+ const importance = typeof meta.importance === "number"
247
+ ? meta.importance
248
+ : (typeof entryImportance === "number" ? entryImportance : 0);
197
249
  const accessCount = typeof meta.accessCount === "number" ? meta.accessCount : 0;
198
250
 
199
251
  if (importance >= 0.95 || accessCount >= 10) return "core";
@@ -18,7 +18,9 @@ import {
18
18
  utimesSync,
19
19
  existsSync,
20
20
  } from "node:fs";
21
+ import { createHash, randomUUID } from "node:crypto";
21
22
  import { join, dirname } from "node:path";
23
+ import * as envConfig from "./env-config.js";
22
24
 
23
25
  // ---------------------------------------------------------------------------
24
26
  // Config
@@ -34,7 +36,7 @@ export interface DistillLockConfig {
34
36
  }
35
37
 
36
38
  export const DEFAULT_DISTILL_LOCK_CONFIG: DistillLockConfig = {
37
- lockPath: join(process.env.RECALLNEST_DATA_DIR || "data", "distill.lock"),
39
+ lockPath: join(envConfig.dataDir(), "distill.lock"),
38
40
  expireMs: 3_600_000,
39
41
  minCheckpoints: 3,
40
42
  };
@@ -56,6 +58,17 @@ function isPidAlive(pid: number): boolean {
56
58
  }
57
59
  }
58
60
 
61
+ // P2: in-process record of the ownership token we wrote into each lock file we hold.
62
+ // releaseLock only removes a lock whose on-disk token still matches — so if our lock
63
+ // expired mid-run and another process legitimately reclaimed it, our finally-release
64
+ // won't delete the new owner's lock (which would break mutual exclusion).
65
+ const heldTokens = new Map<string, string>();
66
+
67
+ /** Parse the PID prefix from a lock file body (`<pid>` legacy or `<pid>:<token>`). */
68
+ function parsePid(content: string): number {
69
+ return parseInt((content.split(":")[0] ?? "").trim(), 10);
70
+ }
71
+
59
72
  // ---------------------------------------------------------------------------
60
73
  // Public API
61
74
  // ---------------------------------------------------------------------------
@@ -66,29 +79,75 @@ function isPidAlive(pid: number): boolean {
66
79
  */
67
80
  export function acquireLock(cfg?: Partial<DistillLockConfig>): boolean {
68
81
  const { lockPath, expireMs } = resolveConfig(cfg);
82
+ mkdirSync(dirname(lockPath), { recursive: true });
69
83
 
70
- if (existsSync(lockPath)) {
71
- const existingPid = parseInt(readFileSync(lockPath, "utf-8").trim(), 10);
72
- const mtime = statSync(lockPath).mtimeMs;
73
- const expired = Date.now() - mtime > expireMs;
84
+ // Fast path: atomic exclusive create. On POSIX the O_EXCL ("wx") open wins the
85
+ // cross-process race — only one of N processes creating the same lock succeeds.
86
+ // This closes the check-then-write TOCTOU the previous existsSync+writeFileSync
87
+ // form had (two processes could both see "no lock" then both write); the P0-1
88
+ // store-write lock relies on acquisition being truly exclusive.
89
+ if (tryExclusiveCreate(lockPath)) return true;
74
90
 
75
- if (!isNaN(existingPid) && isPidAlive(existingPid) && !expired) {
76
- return false;
77
- }
78
- // PID dead or lock expired — overwrite
91
+ // Lock file exists — decide whether it is reclaimable (dead holder or expired).
92
+ let existingPid = NaN;
93
+ let mtime = 0;
94
+ try {
95
+ existingPid = parsePid(readFileSync(lockPath, "utf-8"));
96
+ mtime = statSync(lockPath).mtimeMs;
97
+ } catch {
98
+ // Holder released between our failed create and this read — retry the create.
99
+ return tryExclusiveCreate(lockPath);
100
+ }
101
+ const expired = Date.now() - mtime > expireMs;
102
+ if (!isNaN(existingPid) && isPidAlive(existingPid) && !expired) {
103
+ return false; // held by a live process, not expired
79
104
  }
80
105
 
81
- mkdirSync(dirname(lockPath), { recursive: true });
82
- writeFileSync(lockPath, String(process.pid), "utf-8");
83
- return true;
106
+ // Reclaim a stale lock: remove it, then re-create atomically. If a competing
107
+ // process reclaims first, our O_EXCL create fails and we concede the race.
108
+ try {
109
+ unlinkSync(lockPath);
110
+ } catch {
111
+ /* already gone — fall through to the atomic create */
112
+ }
113
+ return tryExclusiveCreate(lockPath);
114
+ }
115
+
116
+ /**
117
+ * Atomic O_EXCL create of the lock file. Writes a unique ownership token (`<pid>:<uuid>`)
118
+ * and records it so releaseLock can verify we still own the lock. false on EEXIST.
119
+ */
120
+ function tryExclusiveCreate(lockPath: string): boolean {
121
+ const token = `${process.pid}:${randomUUID()}`;
122
+ try {
123
+ writeFileSync(lockPath, token, { encoding: "utf-8", flag: "wx" });
124
+ heldTokens.set(lockPath, token);
125
+ return true;
126
+ } catch (err) {
127
+ if ((err as { code?: unknown }).code === "EEXIST") return false;
128
+ throw err;
129
+ }
84
130
  }
85
131
 
86
- /** Release the distill lock by removing the lock file. */
132
+ /**
133
+ * Release the distill lock. If we hold a known ownership token for this path, only
134
+ * remove the file when its on-disk token still matches ours — otherwise our lock was
135
+ * expired and reclaimed by another process, and deleting would evict the new owner.
136
+ * When we hold no token (e.g. a direct caller/test-written lock) fall back to removing.
137
+ */
87
138
  export function releaseLock(cfg?: Partial<DistillLockConfig>): void {
88
139
  const { lockPath } = resolveConfig(cfg);
89
- if (existsSync(lockPath)) {
90
- unlinkSync(lockPath);
140
+ const ourToken = heldTokens.get(lockPath);
141
+ heldTokens.delete(lockPath);
142
+ if (!existsSync(lockPath)) return;
143
+ if (ourToken !== undefined) {
144
+ try {
145
+ if (readFileSync(lockPath, "utf-8").trim() !== ourToken) return; // reclaimed by another owner
146
+ } catch {
147
+ return;
148
+ }
91
149
  }
150
+ unlinkSync(lockPath);
92
151
  }
93
152
 
94
153
  /**
@@ -120,3 +179,123 @@ export function getLastDistillTime(cfg?: Partial<DistillLockConfig>): number {
120
179
  if (!existsSync(lockPath)) return 0;
121
180
  return statSync(lockPath).mtimeMs;
122
181
  }
182
+
183
+ // ---------------------------------------------------------------------------
184
+ // P0-1: Named cross-process locks (store-write + maintenance families)
185
+ //
186
+ // distill-lock was dead code (0 src imports). P0-1 wires it up: one global
187
+ // `store-write` lock serializes every `memories`-table write across the 11
188
+ // mcp-server processes, and scope-keyed maintenance locks (dream/consolidate/
189
+ // distill) + a global gc lock stop redundant concurrent maintenance runs.
190
+ // ---------------------------------------------------------------------------
191
+
192
+ /** Base directory for named locks. Defaults to <dataDir>/locks; overridable in tests. */
193
+ export function locksDir(lockDir?: string): string {
194
+ return lockDir ?? join(envConfig.dataDir(), "locks");
195
+ }
196
+
197
+ /**
198
+ * Resolve a lock key (e.g. "store-write", "dream-<scope>", "gc-global") to a safe
199
+ * lock-file path. The key is sanitized for the filesystem and suffixed with a short
200
+ * hash of the raw key so distinct keys never collide after sanitization/truncation.
201
+ */
202
+ export function lockPathForKey(key: string, lockDir?: string): string {
203
+ const safe = key.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 80);
204
+ const hash = createHash("sha256").update(key).digest("hex").slice(0, 8);
205
+ return join(locksDir(lockDir), `${safe}-${hash}.lock`);
206
+ }
207
+
208
+ export interface WithLockOptions {
209
+ /** Stale-lock reclaim window in ms (default 600_000 = 10min). */
210
+ expireMs?: number;
211
+ /** Override the base lock directory (tests). */
212
+ lockDir?: string;
213
+ /** Behavior when another live process holds the lock (default "skip"). */
214
+ onBusy?: "skip" | "wait";
215
+ /** For onBusy="wait": total time budget before throwing (default 10_000). */
216
+ waitTimeoutMs?: number;
217
+ /** For onBusy="wait": poll interval in ms (default 50). */
218
+ pollMs?: number;
219
+ }
220
+
221
+ export type WithLockOutcome<T> =
222
+ | { ran: true; result: T }
223
+ | { ran: false; reason: "locked_by_another_process" };
224
+
225
+ const sleep = (ms: number): Promise<void> =>
226
+ new Promise((resolve) => setTimeout(resolve, ms));
227
+
228
+ /**
229
+ * Run `fn` while holding a named cross-process lock, releasing it afterwards
230
+ * (even on throw). Maintenance-type writes use onBusy:"skip" — for a personal
231
+ * store, skipping a redundant dream/consolidate/gc run beats queueing it.
232
+ */
233
+ export async function withLock<T>(
234
+ key: string,
235
+ fn: () => Promise<T>,
236
+ opts: WithLockOptions = {},
237
+ ): Promise<WithLockOutcome<T>> {
238
+ const lockPath = lockPathForKey(key, opts.lockDir);
239
+ const lockCfg = { lockPath, expireMs: opts.expireMs ?? 600_000 };
240
+
241
+ if (!acquireLock(lockCfg)) {
242
+ if ((opts.onBusy ?? "skip") === "skip") {
243
+ return { ran: false, reason: "locked_by_another_process" };
244
+ }
245
+ // wait: poll until acquired or the budget is exhausted.
246
+ const timeoutMs = opts.waitTimeoutMs ?? 10_000;
247
+ const deadline = Date.now() + timeoutMs;
248
+ const pollMs = opts.pollMs ?? 50;
249
+ let acquired = false;
250
+ while (Date.now() < deadline) {
251
+ await sleep(pollMs);
252
+ if (acquireLock(lockCfg)) {
253
+ acquired = true;
254
+ break;
255
+ }
256
+ }
257
+ if (!acquired) {
258
+ throw new Error(`lock '${key}' timed out after ${timeoutMs}ms (held by another process)`);
259
+ }
260
+ }
261
+
262
+ try {
263
+ return { ran: true, result: await fn() };
264
+ } finally {
265
+ releaseLock(lockCfg);
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Run `fn` under a named write lock, waiting for the lock rather than skipping —
271
+ * losing a memory write would be data loss. Throws if the lock can't be acquired
272
+ * within the timeout. Returns fn's result directly. Used by store.upsert/storeBatch
273
+ * so all `memories`-table writes serialize across the 11 mcp-server processes.
274
+ */
275
+ export async function withWriteLock<T>(
276
+ key: string,
277
+ fn: () => Promise<T>,
278
+ opts: Omit<WithLockOptions, "onBusy"> = {},
279
+ ): Promise<T> {
280
+ const outcome = await withLock(key, fn, { ...opts, onBusy: "wait" });
281
+ // onBusy:"wait" resolves to {ran:true} or throws; ran:false is unreachable here.
282
+ if (!outcome.ran) {
283
+ throw new Error(`write lock '${key}' unavailable`);
284
+ }
285
+ return outcome.result;
286
+ }
287
+
288
+ /**
289
+ * Stamp a lock file's mtime to now without releasing it, marking "last completed".
290
+ * gc (auto-gc.ts) uses this to persist a cross-process throttle timestamp in the
291
+ * lock file's mtime (getLastDistillTime pattern) so the 24h throttle holds across
292
+ * processes rather than each process tracking its own module-level timestamp.
293
+ */
294
+ export function stampLock(cfg?: Partial<DistillLockConfig>): void {
295
+ const { lockPath } = resolveConfig(cfg);
296
+ mkdirSync(dirname(lockPath), { recursive: true });
297
+ // Create-or-overwrite so the mtime becomes now. Unlike releaseLock this leaves the
298
+ // file in place — it is a persistent "last run" marker (gc's cross-process throttle),
299
+ // read back via getLastDistillTime.
300
+ writeFileSync(lockPath, String(process.pid), "utf-8");
301
+ }
package/src/doctor.ts CHANGED
@@ -14,7 +14,7 @@ import { existsSync, accessSync, constants, readFileSync } from "node:fs";
14
14
  import { resolve, join } from "node:path";
15
15
  import { homedir } from "node:os";
16
16
  import { metaDir } from "./compat.js";
17
- import { loadConfig, expandHome, resolveEnv, findConfigPath, loadDotEnv } from "./runtime-config.js";
17
+ import { loadConfig, expandHome, resolveEnv, findConfigPath, loadDotEnv, resolveDbPath } from "./runtime-config.js";
18
18
  import { createEmbedder, type EmbeddingConfig } from "./embedder.js";
19
19
  import { extractCanonicalKey, normalizeCanonicalKey } from "./memory-boundaries.js";
20
20
  import {
@@ -62,6 +62,13 @@ function warn(name: string, message: string, fix?: string): CheckResult {
62
62
  return { name, status: "warn", message, fix };
63
63
  }
64
64
 
65
+ export function describeSecretPresence(
66
+ value: string | undefined,
67
+ placeholder: string,
68
+ ): string | null {
69
+ return value && value !== placeholder ? "set" : null;
70
+ }
71
+
65
72
  function summarizeScopeInventoryLayers(report: ScopeInventoryReport): string {
66
73
  return report.layers
67
74
  .filter((layer) => layer.anomalyCount > 0 || layer.invalidCount > 0)
@@ -323,8 +330,8 @@ export async function runDoctor(options: { ci?: boolean } = {}): Promise<CheckRe
323
330
  // 3. .env + JINA_API_KEY
324
331
  loadDotEnv();
325
332
  const jinaKey = process.env.JINA_API_KEY;
326
- if (jinaKey && jinaKey !== "your_jina_api_key_here") {
327
- results.push(pass("JINA_API_KEY", `set (${jinaKey.slice(0, 8)}...)`));
333
+ if (describeSecretPresence(jinaKey, "your_jina_api_key_here")) {
334
+ results.push(pass("JINA_API_KEY", "set"));
328
335
  } else if (options.ci) {
329
336
  results.push(warn(
330
337
  "JINA_API_KEY",
@@ -349,7 +356,9 @@ export async function runDoctor(options: { ci?: boolean } = {}): Promise<CheckRe
349
356
  }
350
357
 
351
358
  // 5. LanceDB data directory
352
- const dbPath = resolve(configPath ? join(configPath, "..") : process.cwd(), expandHome(config.dbPath));
359
+ // Must resolve exactly like the server does — a doctor that reports on a
360
+ // different directory than the one actually in use is worse than no doctor.
361
+ const dbPath = resolveDbPath(config);
353
362
  try {
354
363
  validateStoragePath(dbPath);
355
364
  results.push(pass("Data directory", dbPath));