recallnest 2.3.0 → 2.5.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 (221) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/README.md +17 -31
  3. package/README_CN.md +12 -22
  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 +22 -1
  15. package/src/__tests__/access-tracker-novelty.test.ts +238 -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__/auto-consolidation.test.ts +168 -0
  21. package/src/__tests__/auto-recall.test.ts +131 -0
  22. package/src/__tests__/babel-integration.test.ts +144 -0
  23. package/src/__tests__/batch-dedup.test.ts +65 -0
  24. package/src/__tests__/canary-eval.test.ts +114 -0
  25. package/src/__tests__/capture-engine.test.ts +1238 -0
  26. package/src/__tests__/capture-heuristic.test.ts +171 -0
  27. package/src/__tests__/cascade-forget.test.ts +210 -0
  28. package/src/__tests__/category-min-scores.test.ts +33 -0
  29. package/src/__tests__/cc8-cc1-essential-context.test.ts +298 -0
  30. package/src/__tests__/circuit-breaker.test.ts +99 -0
  31. package/src/__tests__/cluster-consolidation.test.ts +782 -0
  32. package/src/__tests__/confidence-metadata.test.ts +125 -0
  33. package/src/__tests__/confidence-tracker.test.ts +261 -0
  34. package/src/__tests__/conflict-advisor.test.ts +206 -0
  35. package/src/__tests__/conflict-escalation.test.ts +149 -0
  36. package/src/__tests__/conflict-lifecycle.test.ts +83 -0
  37. package/src/__tests__/conflict-output.test.ts +85 -0
  38. package/src/__tests__/conflict-store.test.ts +374 -0
  39. package/src/__tests__/connector-v1.test.ts +181 -0
  40. package/src/__tests__/consolidation-engine.test.ts +333 -0
  41. package/src/__tests__/constructive-retrieval.test.ts +509 -0
  42. package/src/__tests__/context-collapse-renderer.test.ts +214 -0
  43. package/src/__tests__/context-composer-pins.test.ts +64 -0
  44. package/src/__tests__/context-composer-task-selection.test.ts +24 -0
  45. package/src/__tests__/context-reconstructor.test.ts +65 -0
  46. package/src/__tests__/context-renderer.test.ts +91 -0
  47. package/src/__tests__/continuity-eval.test.ts +275 -0
  48. package/src/__tests__/continuity-schema.test.ts +97 -0
  49. package/src/__tests__/contracts.test.ts +216 -0
  50. package/src/__tests__/conversation-importer.test.ts +692 -0
  51. package/src/__tests__/core-recall-eval.test.ts +235 -0
  52. package/src/__tests__/core-summary.test.ts +109 -0
  53. package/src/__tests__/data-checkup.test.ts +276 -0
  54. package/src/__tests__/decay-exemption.test.ts +84 -0
  55. package/src/__tests__/dedup-actions.test.ts +58 -0
  56. package/src/__tests__/dedup-guard.test.ts +39 -0
  57. package/src/__tests__/deterministic-id.test.ts +39 -0
  58. package/src/__tests__/distill-lock.test.ts +305 -0
  59. package/src/__tests__/distill-session-argshape.test.ts +79 -0
  60. package/src/__tests__/doctor.test.ts +319 -0
  61. package/src/__tests__/dream-pipeline.test.ts +321 -0
  62. package/src/__tests__/drill-down.test.ts +108 -0
  63. package/src/__tests__/embedder.test.ts +122 -0
  64. package/src/__tests__/emotion-decay.test.ts +46 -0
  65. package/src/__tests__/emotion-detector.test.ts +56 -0
  66. package/src/__tests__/emotion-phase1-integration.test.ts +153 -0
  67. package/src/__tests__/emotion-retrieval.test.ts +56 -0
  68. package/src/__tests__/entity-resolver.test.ts +58 -0
  69. package/src/__tests__/env-config.test.ts +151 -0
  70. package/src/__tests__/error-signature-boost.test.ts +87 -0
  71. package/src/__tests__/error-signature.test.ts +95 -0
  72. package/src/__tests__/error-taxonomy.test.ts +129 -0
  73. package/src/__tests__/ethics-layer.test.ts +476 -0
  74. package/src/__tests__/event-segmenter.test.ts +153 -0
  75. package/src/__tests__/feature-flags-integration.test.ts +54 -0
  76. package/src/__tests__/frequency-tracker.test.ts +113 -0
  77. package/src/__tests__/graph-export.test.ts +489 -0
  78. package/src/__tests__/hotness-blend.test.ts +76 -0
  79. package/src/__tests__/ingest-chunking.test.ts +44 -0
  80. package/src/__tests__/ingest.test.ts +360 -0
  81. package/src/__tests__/interference-detector.test.ts +203 -0
  82. package/src/__tests__/kg-extractor.test.ts +192 -0
  83. package/src/__tests__/kg-store.test.ts +300 -0
  84. package/src/__tests__/llm-consolidation.test.ts +226 -0
  85. package/src/__tests__/maintenance-locks.test.ts +174 -0
  86. package/src/__tests__/mcp-efficiency.test.ts +288 -0
  87. package/src/__tests__/mcp-related-scopes.test.ts +151 -0
  88. package/src/__tests__/memory-boundaries.test.ts +250 -0
  89. package/src/__tests__/memory-evolution-b2-b3-e1.test.ts +345 -0
  90. package/src/__tests__/memory-evolution.test.ts +271 -0
  91. package/src/__tests__/memory-health-rebalance.test.ts +144 -0
  92. package/src/__tests__/memory-lint.test.ts +421 -0
  93. package/src/__tests__/memory-output.test.ts +215 -0
  94. package/src/__tests__/memory-promotion.test.ts +315 -0
  95. package/src/__tests__/migrate-dedup-ids.test.ts +65 -0
  96. package/src/__tests__/multi-vector.test.ts +242 -0
  97. package/src/__tests__/narrative-architecture.test.ts +456 -0
  98. package/src/__tests__/noise-filter.test.ts +205 -0
  99. package/src/__tests__/obsidian-connector.test.ts +363 -0
  100. package/src/__tests__/omitted-reporting.test.ts +98 -0
  101. package/src/__tests__/pending-review.test.ts +77 -0
  102. package/src/__tests__/pii-redact.test.ts +314 -0
  103. package/src/__tests__/post-retrieval-filter.test.ts +178 -0
  104. package/src/__tests__/ppr-traversal.test.ts +256 -0
  105. package/src/__tests__/prediction-engine.test.ts +349 -0
  106. package/src/__tests__/predictive-prospective-memory.test.ts +416 -0
  107. package/src/__tests__/preference-guard-smoke.test.ts +211 -0
  108. package/src/__tests__/preference-matcher.test.ts +230 -0
  109. package/src/__tests__/preference-slots.test.ts +205 -0
  110. package/src/__tests__/prospective-memory.test.ts +283 -0
  111. package/src/__tests__/query-entity-detector.test.ts +171 -0
  112. package/src/__tests__/query-expander-alias.test.ts +146 -0
  113. package/src/__tests__/query-expander.test.ts +34 -0
  114. package/src/__tests__/recall-governor.test.ts +227 -0
  115. package/src/__tests__/recall-mode.test.ts +64 -0
  116. package/src/__tests__/result-synthesizer.test.ts +198 -0
  117. package/src/__tests__/resume-context.test.ts +4898 -0
  118. package/src/__tests__/retrieval-trace.test.ts +213 -0
  119. package/src/__tests__/retriever-boundary-weight.test.ts +136 -0
  120. package/src/__tests__/retroactive-boost.test.ts +223 -0
  121. package/src/__tests__/salience-scoring.test.ts +106 -0
  122. package/src/__tests__/scope-inventory.test.ts +200 -0
  123. package/src/__tests__/scope-policy.test.ts +69 -0
  124. package/src/__tests__/scope-suggester.test.ts +80 -0
  125. package/src/__tests__/session-checkpoint.test.ts +291 -0
  126. package/src/__tests__/session-distiller.test.ts +640 -0
  127. package/src/__tests__/session-gc.test.ts +215 -0
  128. package/src/__tests__/skill-engine.test.ts +552 -0
  129. package/src/__tests__/skill-promotion.test.ts +601 -0
  130. package/src/__tests__/skill-verifier.test.ts +122 -0
  131. package/src/__tests__/source-heartbeat.test.ts +250 -0
  132. package/src/__tests__/store-patch-metadata.test.ts +205 -0
  133. package/src/__tests__/store-scope-audit.test.ts +81 -0
  134. package/src/__tests__/store-upsert.test.ts +184 -0
  135. package/src/__tests__/supersede-chain.test.ts +154 -0
  136. package/src/__tests__/temporal-parser.test.ts +241 -0
  137. package/src/__tests__/temporal-validity.test.ts +160 -0
  138. package/src/__tests__/term-registry.test.ts +121 -0
  139. package/src/__tests__/tool-error-surfacing.test.ts +98 -0
  140. package/src/__tests__/tool-output-compressor.test.ts +160 -0
  141. package/src/__tests__/topic-tag.test.ts +113 -0
  142. package/src/__tests__/trusted-memory.test.ts +839 -0
  143. package/src/__tests__/usage-tracker.test.ts +287 -0
  144. package/src/__tests__/version-manager.test.ts +213 -0
  145. package/src/__tests__/workflow-observation-skill-outcome.test.ts +352 -0
  146. package/src/__tests__/workflow-observation.test.ts +433 -0
  147. package/src/__tests__/write-verifier.test.ts +122 -0
  148. package/src/access-tracker.ts +38 -32
  149. package/src/activity-counter.ts +46 -18
  150. package/src/aliases.ts +133 -0
  151. package/src/api-server.ts +27 -7
  152. package/src/audit-log.ts +4 -2
  153. package/src/auto-consolidation.ts +8 -3
  154. package/src/auto-gc.ts +85 -21
  155. package/src/capture-engine.ts +54 -21
  156. package/src/cli.ts +169 -37
  157. package/src/confidence-tracker.ts +36 -18
  158. package/src/consolidation-engine.ts +109 -11
  159. package/src/context-composer.ts +8 -1
  160. package/src/data-checkup.ts +95 -6
  161. package/src/distill-lock.ts +194 -15
  162. package/src/doctor.ts +9 -2
  163. package/src/dream-pipeline.ts +118 -34
  164. package/src/env-config.ts +77 -0
  165. package/src/error-signature.ts +153 -0
  166. package/src/error-taxonomy.ts +115 -0
  167. package/src/eval.ts +251 -6
  168. package/src/idempotency.ts +8 -0
  169. package/src/ingest.ts +31 -10
  170. package/src/kg-extractor.ts +6 -2
  171. package/src/kg-store.ts +236 -44
  172. package/src/llm-consolidation.ts +2 -1
  173. package/src/mcp-server.ts +85 -1799
  174. package/src/mcp-tool-deps.ts +48 -0
  175. package/src/mcp-tools-advanced.ts +941 -0
  176. package/src/mcp-tools-core.ts +633 -0
  177. package/src/mcp-tools-governance.ts +475 -0
  178. package/src/memory-boundaries.ts +3 -1
  179. package/src/memory-evolution.ts +73 -1
  180. package/src/memory-lint.ts +183 -10
  181. package/src/memory-output.ts +114 -3
  182. package/src/memory-promotion.ts +246 -0
  183. package/src/memory-schema.ts +25 -2
  184. package/src/memory-store-port.ts +43 -0
  185. package/src/multi-vector.ts +2 -1
  186. package/src/narrative-schema.ts +2 -1
  187. package/src/noise-filter.ts +57 -6
  188. package/src/pii-detector.ts +157 -5
  189. package/src/query-expander.ts +90 -9
  190. package/src/result-synthesizer.ts +2 -1
  191. package/src/retention-policy.ts +2 -1
  192. package/src/retriever.ts +259 -139
  193. package/src/runtime-config.ts +8 -1
  194. package/src/scope-suggester.ts +113 -0
  195. package/src/session-distiller.ts +23 -2
  196. package/src/session-engine.ts +2 -1
  197. package/src/session-schema.ts +1 -0
  198. package/src/session-store.ts +5 -0
  199. package/src/skill-engine.ts +78 -0
  200. package/src/skill-promotion.ts +247 -47
  201. package/src/skill-schema.ts +16 -3
  202. package/src/skill-verifier.ts +152 -0
  203. package/src/store.ts +267 -38
  204. package/src/tool-output-compressor.ts +3 -1
  205. package/src/ui-server.ts +2 -1
  206. package/src/usage-tracker.ts +242 -0
  207. package/src/version-manager.ts +3 -2
  208. package/src/workflow-observation-engine.ts +2 -1
  209. package/src/workflow-observation-schema.ts +2 -1
  210. package/src/workflow-observation-store.ts +6 -2
  211. package/.env +0 -4
  212. package/CLAUDE.md +0 -46
  213. package/config.json +0 -55
  214. package/daily-recall.sh +0 -110
  215. package/logs/daily-recall.error.log +0 -0
  216. package/logs/daily-recall.log +0 -60
  217. package/logs/ingest-2026-04-11.log +0 -110
  218. package/logs/ingest-2026-04-12.log +0 -90
  219. package/logs/ingest-2026-04-13.log +0 -87
  220. package/logs/launchagent-stderr.log +0 -0
  221. package/logs/launchagent-stdout.log +0 -0
@@ -17,7 +17,9 @@
17
17
  * LLM-free — deterministic clustering and merge only.
18
18
  */
19
19
 
20
- import type { MemoryEntry, MemorySearchResult, MemoryStore } from "./store.js";
20
+ import type { MemoryEntry, MemorySearchResult } from "./store.js";
21
+ import { deterministicId } from "./store.js";
22
+ import type { MemoryStorePort } from "./memory-store-port.js";
21
23
  import { createVersionGroup } from "./version-manager.js";
22
24
  import { isActiveMemory, parseEvolution, buildSupersedeMetadata, buildConsolidatedMetadata, patchEvolution } from "./memory-evolution.js";
23
25
  import { cosineSimilarity } from "./multi-vector.js";
@@ -35,12 +37,22 @@ export interface ConsolidationConfig {
35
37
  mergeThreshold: number;
36
38
  /** Maximum entries to scan per consolidation run (default 500) */
37
39
  maxEntriesPerRun: number;
40
+ /**
41
+ * KG evidence: triple-set Jaccard at/above this merges a grey-zone pair
42
+ * (vector sim between clusterThreshold and mergeThreshold). Default 0.5 —
43
+ * provisional until calibrated on production data (plan Q3).
44
+ */
45
+ tripleJaccardThreshold?: number;
46
+ /** KG evidence: both sides need at least this many triples to qualify (default 2) */
47
+ minTriplesForEvidence?: number;
38
48
  }
39
49
 
40
50
  export const DEFAULT_CONSOLIDATION_CONFIG: ConsolidationConfig = {
41
51
  clusterThreshold: 0.82,
42
52
  mergeThreshold: 0.92,
43
53
  maxEntriesPerRun: 500,
54
+ tripleJaccardThreshold: 0.5,
55
+ minTriplesForEvidence: 2,
44
56
  };
45
57
 
46
58
  export interface ConflictEvent {
@@ -54,10 +66,52 @@ export interface ConsolidationResult {
54
66
  clustersFound: number;
55
67
  mergedCount: number;
56
68
  relationsAdded: number;
69
+ /** Of mergedCount, how many were below mergeThreshold and merged on KG triple evidence */
70
+ tripleEvidenceMerges: number;
57
71
  conflictsDetected: ConflictEvent[];
58
72
  scope: string;
59
73
  }
60
74
 
75
+ // ---------------------------------------------------------------------------
76
+ // KG evidence source (duck-typed subset of KGStore)
77
+ // ---------------------------------------------------------------------------
78
+
79
+ /** The two triple fields consolidation needs — structurally satisfied by KGTriple. */
80
+ export interface ConsolidationTripleEvidence {
81
+ id: string;
82
+ mention_count: number;
83
+ }
84
+
85
+ export interface ConsolidationKGSource {
86
+ getTriplesBySourceMemories(memoryIds: string[]): Promise<Map<string, ConsolidationTripleEvidence[]>>;
87
+ }
88
+
89
+ /**
90
+ * Jaccard overlap of two triple-id sets. Below `minSize` on either side the
91
+ * evidence is considered too weak and 0 is returned (a single shared triple
92
+ * must not merge two memories on its own).
93
+ */
94
+ export function tripleJaccard(
95
+ a: ReadonlySet<string> | undefined,
96
+ b: ReadonlySet<string> | undefined,
97
+ minSize = 2,
98
+ ): number {
99
+ if (!a || !b || a.size < minSize || b.size < minSize) return 0;
100
+ let inter = 0;
101
+ for (const x of a) if (b.has(x)) inter++;
102
+ return inter / (a.size + b.size - inter);
103
+ }
104
+
105
+ /**
106
+ * Mention-frequency boost in canonical selection: tie-breaker ONLY, never an
107
+ * override of a clear canonicalScore gap (an entry with one hot triple must
108
+ * not demote a substantially more important memory to consolidated status).
109
+ * α=0.06 keeps the boost differentiating (m=1 → 1.042, m=9 → 1.138) while the
110
+ * 1.1 cap bounds the maximum flip at ~10% of the base score — effectively ties.
111
+ */
112
+ const MENTION_ALPHA = 0.06;
113
+ const MENTION_BOOST_CAP = 1.1;
114
+
61
115
  // ---------------------------------------------------------------------------
62
116
  // Helpers
63
117
  // ---------------------------------------------------------------------------
@@ -108,7 +162,7 @@ export function detectHeuristicContradiction(textA: string, textB: string): bool
108
162
  // Store interface (duck-typed to avoid hard dependency)
109
163
  // ---------------------------------------------------------------------------
110
164
 
111
- type ConsolidationStore = Pick<MemoryStore, "list" | "getById" | "vectorSearch" | "update">;
165
+ type ConsolidationStore = Pick<MemoryStorePort, "list" | "getById" | "vectorSearch" | "update">;
112
166
 
113
167
  // ---------------------------------------------------------------------------
114
168
  // Engine
@@ -118,17 +172,25 @@ export class ConsolidationEngine {
118
172
  constructor(
119
173
  private store: ConsolidationStore,
120
174
  private config: ConsolidationConfig = DEFAULT_CONSOLIDATION_CONFIG,
175
+ /** Optional KG evidence source — absent = pure vector behavior, unchanged */
176
+ private kgSource: ConsolidationKGSource | null = null,
121
177
  ) {}
122
178
 
123
179
  async run(scope: string): Promise<ConsolidationResult> {
124
- const { clusterThreshold, mergeThreshold, maxEntriesPerRun } = this.config;
180
+ const {
181
+ clusterThreshold,
182
+ mergeThreshold,
183
+ maxEntriesPerRun,
184
+ tripleJaccardThreshold = 0.5,
185
+ minTriplesForEvidence = 2,
186
+ } = this.config;
125
187
 
126
188
  // 1. Fetch entries in scope
127
189
  const entries = await this.store.list([scope], undefined, maxEntriesPerRun, 0);
128
190
  const active = entries.filter(isActive);
129
191
 
130
192
  if (active.length === 0) {
131
- return { originalCount: 0, clustersFound: 0, mergedCount: 0, relationsAdded: 0, conflictsDetected: [], scope };
193
+ return { originalCount: 0, clustersFound: 0, mergedCount: 0, relationsAdded: 0, tripleEvidenceMerges: 0, conflictsDetected: [], scope };
132
194
  }
133
195
 
134
196
  // 2. Group by category
@@ -142,6 +204,7 @@ export class ConsolidationEngine {
142
204
  let clustersFound = 0;
143
205
  let mergedCount = 0;
144
206
  let relationsAdded = 0;
207
+ let tripleEvidenceMerges = 0;
145
208
  const conflictsDetected: ConflictEvent[] = [];
146
209
 
147
210
  // 3. Cluster within each category
@@ -183,8 +246,27 @@ export class ConsolidationEngine {
183
246
  }
184
247
  if (memberEntries.length < 2) continue;
185
248
 
186
- // Determine canonical: highest canonicalScore
187
- const sorted = [...memberEntries].sort((a, b) => canonicalScore(b) - canonicalScore(a));
249
+ // KG triple evidence for this cluster (single batch query; best-effort —
250
+ // on failure consolidation proceeds vector-only)
251
+ const tripleIdsByMember = new Map<string, Set<string>>();
252
+ const maxMentionByMember = new Map<string, number>();
253
+ if (this.kgSource) {
254
+ try {
255
+ const triplesByMember = await this.kgSource.getTriplesBySourceMemories(memberEntries.map(e => e.id));
256
+ for (const [mid, triples] of triplesByMember) {
257
+ tripleIdsByMember.set(mid, new Set(triples.map(t => t.id)));
258
+ maxMentionByMember.set(mid, triples.reduce((m, t) => Math.max(m, t.mention_count), 0));
259
+ }
260
+ } catch { /* KG evidence is best-effort */ }
261
+ }
262
+
263
+ // Determine canonical: highest canonicalScore, frequency-boosted when
264
+ // KG evidence exists (memories carrying often-mentioned facts win ties)
265
+ const mentionBoost = (e: MemoryEntry) =>
266
+ Math.min(MENTION_BOOST_CAP, 1 + MENTION_ALPHA * Math.log((maxMentionByMember.get(e.id) ?? 0) + 1));
267
+ const sorted = [...memberEntries].sort(
268
+ (a, b) => canonicalScore(b) * mentionBoost(b) - canonicalScore(a) * mentionBoost(a),
269
+ );
188
270
  const canonical = sorted[0];
189
271
 
190
272
  if (!canonical.vector?.length) continue;
@@ -199,7 +281,16 @@ export class ConsolidationEngine {
199
281
  for (const member of sorted.slice(1)) {
200
282
  const sim = scoreMap.get(member.id) ?? clusterThreshold;
201
283
 
202
- if (sim >= mergeThreshold) {
284
+ // Second evidence source: two memories whose extracted fact sets
285
+ // overlap heavily are duplicates even when vector sim sits in the
286
+ // grey zone (the "pairwise near-duplicates" the 0.92 bar misses).
287
+ const jaccard = tripleJaccard(
288
+ tripleIdsByMember.get(canonical.id),
289
+ tripleIdsByMember.get(member.id),
290
+ minTriplesForEvidence,
291
+ );
292
+
293
+ if (sim >= mergeThreshold || jaccard >= tripleJaccardThreshold) {
203
294
  // Tier 3.3: Version coexistence — both stay but grouped.
204
295
  await createVersionGroup(this.store, canonical, member, scope);
205
296
  // C-1: Also mark the weaker entry as consolidated via evolution metadata
@@ -214,6 +305,7 @@ export class ConsolidationEngine {
214
305
  await this.store.update(canonical.id, { metadata: updatedCanon }, [scope]);
215
306
  }
216
307
  mergedCount++;
308
+ if (sim < mergeThreshold) tripleEvidenceMerges++;
217
309
  } else {
218
310
  // Link: add clustered_with relation
219
311
  const memberMeta = parseMetadata(member);
@@ -245,7 +337,7 @@ export class ConsolidationEngine {
245
337
  }
246
338
  }
247
339
 
248
- return { originalCount: active.length, clustersFound, mergedCount, relationsAdded, conflictsDetected, scope };
340
+ return { originalCount: active.length, clustersFound, mergedCount, relationsAdded, tripleEvidenceMerges, conflictsDetected, scope };
249
341
  }
250
342
  }
251
343
 
@@ -278,7 +370,7 @@ export async function clusterAndConsolidate(params: {
278
370
  entries: MemoryEntry[];
279
371
  embedder: Pick<Embedder, "embedPassage">;
280
372
  llm: LLMClient;
281
- store: Pick<MemoryStore, "store" | "update">;
373
+ store: Pick<MemoryStorePort, "store" | "update">;
282
374
  scope: string;
283
375
  /** Minimum cluster size to trigger consolidation (default: 3) */
284
376
  minClusterSize?: number;
@@ -400,8 +492,13 @@ export async function clusterAndConsolidate(params: {
400
492
  // Embed the insight text
401
493
  const insightVector = await params.embedder.embedPassage(insight);
402
494
 
403
- // Store the insight as a new memory
495
+ // Store the insight as a new memory. P0-2/P1-2: derive a deterministic id from the
496
+ // (sorted) source member ids so re-running dream/consolidate on the same cluster
497
+ // upserts the same insight row instead of appending a near-duplicate each run
498
+ // (the highest-frequency dup-id source). Cross-run stable because sourceIds are stable.
499
+ const sortedSourceIds = cluster.map(m => m.id).slice().sort();
404
500
  const insightEntry = await store.store({
501
+ id: deterministicId(scope, `cluster-insight:${sortedSourceIds.join(",")}`),
405
502
  text: insight,
406
503
  vector: insightVector,
407
504
  category: bestCat,
@@ -444,6 +541,7 @@ export async function clusterAndConsolidate(params: {
444
541
  const patternImportance = Math.min(maxImportance + 0.1, 1.0);
445
542
  const patternVector = await params.embedder.embedPassage(patternText);
446
543
  const patternEntry = await store.store({
544
+ id: deterministicId(scope, `cluster-pattern:${sortedSourceIds.join(",")}`),
447
545
  text: patternText,
448
546
  vector: patternVector,
449
547
  category: "patterns",
@@ -492,7 +590,7 @@ export function formatConsolidationResult(result: ConsolidationResult): string {
492
590
  `Consolidation complete for scope: ${result.scope}`,
493
591
  `Scanned: ${result.originalCount} active entries`,
494
592
  `Clusters found: ${result.clustersFound}`,
495
- `Merged (versioned): ${result.mergedCount}`,
593
+ `Merged (versioned): ${result.mergedCount}${result.tripleEvidenceMerges > 0 ? ` (${result.tripleEvidenceMerges} on triple evidence)` : ""}`,
496
594
  `Relations added: ${result.relationsAdded}`,
497
595
  `Conflicts detected: ${result.conflictsDetected.length}`,
498
596
  ];
@@ -1,4 +1,5 @@
1
1
  import { listPinAssets, type PinAsset } from "./memory-assets.js";
2
+ import * as envConfig from "./env-config.js";
2
3
  import { buildStableContextSections } from "./context-composer-stable.js";
3
4
  import {
4
5
  buildAssociativeNestEntityFallbackQuery,
@@ -32,6 +33,8 @@ type ResumeCategory = "profile" | "preferences" | "entities" | "patterns" | "cas
32
33
 
33
34
  interface ResumeRetriever {
34
35
  retrieve(context: RetrievalContext): Promise<RetrievalResult[]>;
36
+ /** Optional: 影子记录 resume reconstruction 引用的记忆(injection-vs-use)。未实现则 no-op。 */
37
+ recordReconstructionUsage?(citedIds: string[]): Promise<void>;
35
38
  }
36
39
 
37
40
  interface CheckpointLookup {
@@ -467,7 +470,7 @@ export async function composeResumeContext(
467
470
  let reconstructedContext: string | undefined;
468
471
  let reconstructionConfidence: number | undefined;
469
472
  let reconstructionContradictions: Array<{ memoryIds: [string, string]; description: string }> | undefined;
470
- const constructiveFlag = process.env.RECALLNEST_CONSTRUCTIVE_RETRIEVAL === "true";
473
+ const constructiveFlag = envConfig.constructiveRetrieval();
471
474
  if (constructiveFlag && deps.llm?.isAvailable?.()) {
472
475
  const allReconResults = [...profileResults, ...preferenceResults, ...entityResults, ...filteredPatterns, ...filteredCases];
473
476
  if (allReconResults.length >= 3) {
@@ -489,6 +492,10 @@ export async function composeResumeContext(
489
492
  if (recon.contradictions.length > 0) {
490
493
  reconstructionContradictions = recon.contradictions;
491
494
  }
495
+ // Shadow usage:resume reconstruction 真正引用的记忆记 use 计数(不动 ranking/forget)。
496
+ if (recon.sources.length > 0) {
497
+ await deps.retriever.recordReconstructionUsage?.(recon.sources.map(s => s.id));
498
+ }
492
499
  } catch { /* silent fallback */ }
493
500
  }
494
501
  }
@@ -16,6 +16,7 @@ import type { MemoryEntry, MemoryStore } from "./store.js";
16
16
  import { resolveTier, type MemoryTier } from "./decay-engine.js";
17
17
  import { measureInterferenceDensity } from "./interference-detector.js";
18
18
  import { readHeartbeats, checkSourceStaleness, formatAge } from "./source-heartbeat.js";
19
+ import { deriveUsageStatus, isUsageSignalActive, type UsageStatus } from "./usage-tracker.js";
19
20
 
20
21
  // ---------------------------------------------------------------------------
21
22
  // Types
@@ -30,6 +31,8 @@ export interface CheckResult {
30
31
  export interface CheckupReport {
31
32
  checks: CheckResult[];
32
33
  totalEntries: number;
34
+ /** Total entries in store; when > totalEntries, only the most recent were scanned. */
35
+ totalAvailable: number;
33
36
  timestamp: string;
34
37
  }
35
38
 
@@ -39,15 +42,33 @@ export interface CheckupReport {
39
42
 
40
43
  /** Check 1: All vectors should have the same dimension. */
41
44
  function checkVectorDimensions(entries: MemoryEntry[]): CheckResult {
45
+ // 只对真正取到向量的条目判维度一致性;取不到向量的(dim=0)单独计数后跳过——
46
+ // 否则会把"未补到向量"误报成"维度不一致",或把"全 0 维空向量"假报成健康。
42
47
  const dims = new Map<number, number>(); // dim → count
48
+ let missingVectors = 0;
43
49
  for (const e of entries) {
44
50
  const d = e.vector?.length ?? 0;
51
+ if (d === 0) { missingVectors++; continue; }
45
52
  dims.set(d, (dims.get(d) ?? 0) + 1);
46
53
  }
47
54
 
48
- if (dims.size <= 1) {
55
+ const withVectors = entries.length - missingVectors;
56
+ if (entries.length === 0) {
57
+ return { name: "vector_dimensions", status: "ok", detail: "No entries to check" };
58
+ }
59
+ if (withVectors === 0) {
60
+ return {
61
+ name: "vector_dimensions",
62
+ status: "warning",
63
+ detail: `No retrievable vectors among ${entries.length} entries — dimension consistency not verified`,
64
+ };
65
+ }
66
+
67
+ const suffix = missingVectors > 0 ? ` (${missingVectors} without retrievable vector, skipped)` : "";
68
+
69
+ if (dims.size === 1) {
49
70
  const dim = dims.keys().next().value ?? 0;
50
- return { name: "vector_dimensions", status: "ok", detail: `All ${entries.length} entries have dimension ${dim}` };
71
+ return { name: "vector_dimensions", status: "ok", detail: `All ${withVectors} vectors have dimension ${dim}${suffix}` };
51
72
  }
52
73
 
53
74
  const sorted = [...dims.entries()].sort((a, b) => b[1] - a[1]);
@@ -56,7 +77,7 @@ function checkVectorDimensions(entries: MemoryEntry[]): CheckResult {
56
77
  return {
57
78
  name: "vector_dimensions",
58
79
  status: "error",
59
- detail: `${mismatched} entries have wrong dimension (expected ${expected}): ${sorted.map(([d, c]) => `dim=${d}: ${c}`).join(", ")}`,
80
+ detail: `${mismatched} entries have wrong dimension (expected ${expected}): ${sorted.map(([d, c]) => `dim=${d}: ${c}`).join(", ")}${suffix}`,
60
81
  };
61
82
  }
62
83
 
@@ -216,15 +237,78 @@ function checkInterferenceDensity(entries: MemoryEntry[]): CheckResult {
216
237
  // Main
217
238
  // ---------------------------------------------------------------------------
218
239
 
240
+ /** Check 8 (P0 B-1 观察): usage 四档分布 — injection-vs-use 影子统计的离线观察口。
241
+ * 纯报告,不接任何决策;cold = 被反复 surface(accessCount≥6)却从未被
242
+ * reconstruction 引用,是第二阶段 decay 联动前要先观察校准的核心对象。 */
243
+ function checkUsageDistribution(entries: MemoryEntry[]): CheckResult {
244
+ // use 信号未采集时(reconstruction flag 关),useCount 恒零 → "cold" 全是假阳性。
245
+ // 如实报告信号缺失,不输出不可解释的判定。
246
+ if (!isUsageSignalActive()) {
247
+ return {
248
+ name: "usage_distribution",
249
+ status: "ok",
250
+ detail: "use signal inactive (RECALLNEST_CONSTRUCTIVE_RETRIEVAL off) — useCount 无写入路径,cold/warm/hot 不可判,统计跳过。该 flag 因合成召回缺失被 Alice 主动关闭(2026-06-11),usage 线搁置中。",
251
+ };
252
+ }
253
+
254
+ const counts: Record<UsageStatus, number> = { unused: 0, cold: 0, warm: 0, hot: 0 };
255
+ const coldSamples: Array<{ id: string; injection: number; ageDays: number; scope: string }> = [];
256
+ const now = Date.now();
257
+
258
+ for (const e of entries) {
259
+ const status = deriveUsageStatus(e);
260
+ counts[status]++;
261
+ if (status === "cold") {
262
+ let injection = 0;
263
+ try {
264
+ const meta = JSON.parse(e.metadata || "{}") as Record<string, unknown>;
265
+ injection = typeof meta.accessCount === "number" ? meta.accessCount : 0;
266
+ } catch { /* keep 0 */ }
267
+ coldSamples.push({
268
+ id: e.id.slice(0, 8),
269
+ injection,
270
+ ageDays: Math.floor((now - e.timestamp) / 86_400_000),
271
+ scope: e.scope || "(none)",
272
+ });
273
+ }
274
+ }
275
+
276
+ const total = entries.length;
277
+ const coldPct = total > 0 ? (counts.cold / total) * 100 : 0;
278
+ const top = coldSamples
279
+ .sort((a, b) => b.injection - a.injection)
280
+ .slice(0, 20)
281
+ .map(s => `${s.id}(inj=${s.injection}, ${s.ageDays}d, ${s.scope})`)
282
+ .join(", ");
283
+
284
+ return {
285
+ name: "usage_distribution",
286
+ status: coldPct > 10 ? "warning" : "ok",
287
+ detail:
288
+ `unused=${counts.unused} cold=${counts.cold} (${coldPct.toFixed(1)}%) ` +
289
+ `warm=${counts.warm} hot=${counts.hot}` +
290
+ (coldSamples.length > 0 ? `; cold top-${Math.min(20, coldSamples.length)}: ${top}` : ""),
291
+ };
292
+ }
293
+
219
294
  export interface CheckupDeps {
220
- store: Pick<MemoryStore, "list" | "stats">;
295
+ store: Pick<MemoryStore, "list" | "stats" | "getVectors">;
221
296
  openConflictCount: number;
222
297
  /** Optional override path for source-heartbeat.json (used in tests). */
223
298
  heartbeatPath?: string;
224
299
  }
225
300
 
226
301
  export async function runDataCheckup(deps: CheckupDeps): Promise<CheckupReport> {
227
- const entries = await deps.store.list(undefined, undefined, 10000, 0);
302
+ const SCAN_LIMIT = 10000;
303
+ const listed = await deps.store.list(undefined, undefined, SCAN_LIMIT, 0);
304
+
305
+ // list() 为性能不返回向量(vector: []);维度/干扰检查靠向量,否则把"全 0 维"
306
+ // 误判成健康、干扰分析永远"样本不足"。补回真实向量再检查。
307
+ const vectorMap = await deps.store.getVectors(listed.map(e => e.id));
308
+ const entries = listed.map(e => ({ ...e, vector: vectorMap.get(e.id) ?? [] }));
309
+
310
+ // 截断披露:库总量 > 已扫,说明只体检了最近 SCAN_LIMIT 条。
311
+ const totalAvailable = (await deps.store.stats()).totalCount;
228
312
 
229
313
  return {
230
314
  checks: [
@@ -235,16 +319,21 @@ export async function runDataCheckup(deps: CheckupDeps): Promise<CheckupReport>
235
319
  checkVersionGroups(entries),
236
320
  checkSourceHealth(deps.heartbeatPath),
237
321
  checkInterferenceDensity(entries),
322
+ checkUsageDistribution(entries),
238
323
  ],
239
324
  totalEntries: entries.length,
325
+ totalAvailable,
240
326
  timestamp: new Date().toISOString(),
241
327
  };
242
328
  }
243
329
 
244
330
  export function formatCheckupReport(report: CheckupReport): string {
331
+ const truncationNote = report.totalAvailable > report.totalEntries
332
+ ? ` (of ${report.totalAvailable} total — only the most recent ${report.totalEntries} analyzed)`
333
+ : "";
245
334
  const lines = [
246
335
  `Data Checkup Report (${report.timestamp})`,
247
- `Total entries scanned: ${report.totalEntries}`,
336
+ `Total entries scanned: ${report.totalEntries}${truncationNote}`,
248
337
  "",
249
338
  ];
250
339
 
@@ -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
+ }