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.
- package/CHANGELOG.md +187 -0
- package/README.md +49 -38
- package/README_CN.md +46 -31
- package/assets/ui/app.js +1031 -0
- package/assets/ui/index.html +224 -0
- package/assets/ui/styles.css +781 -0
- package/config.json.example +4 -1
- package/docs/api-reference.md +1291 -0
- package/docs/architecture.md +168 -0
- package/docs/connector-spec.md +126 -0
- package/eval/cases-canary.json +58 -0
- package/eval/cases-scope-robustness.json +278 -0
- package/eval/cases.json +117 -20
- package/package.json +25 -4
- package/src/__tests__/access-tracker-novelty.test.ts +304 -0
- package/src/__tests__/activity-counter.test.ts +125 -0
- package/src/__tests__/adaptive-fidelity.test.ts +102 -0
- package/src/__tests__/admission-control.test.ts +206 -0
- package/src/__tests__/anchor-generator.test.ts +67 -0
- package/src/__tests__/api-server-security.test.ts +102 -0
- package/src/__tests__/auto-consolidation.test.ts +168 -0
- package/src/__tests__/auto-recall.test.ts +131 -0
- package/src/__tests__/babel-integration.test.ts +144 -0
- package/src/__tests__/batch-dedup.test.ts +65 -0
- package/src/__tests__/belief-history.test.ts +220 -0
- package/src/__tests__/canary-eval.test.ts +114 -0
- package/src/__tests__/capture-engine.test.ts +1328 -0
- package/src/__tests__/capture-heuristic.test.ts +171 -0
- package/src/__tests__/cascade-forget.test.ts +210 -0
- package/src/__tests__/category-min-scores.test.ts +116 -0
- package/src/__tests__/cc8-cc1-essential-context.test.ts +298 -0
- package/src/__tests__/circuit-breaker.test.ts +99 -0
- package/src/__tests__/cluster-consolidation.test.ts +945 -0
- package/src/__tests__/confidence-metadata.test.ts +125 -0
- package/src/__tests__/confidence-tracker.test.ts +261 -0
- package/src/__tests__/conflict-advisor.test.ts +206 -0
- package/src/__tests__/conflict-escalation.test.ts +149 -0
- package/src/__tests__/conflict-lifecycle.test.ts +83 -0
- package/src/__tests__/conflict-output.test.ts +85 -0
- package/src/__tests__/conflict-store.test.ts +418 -0
- package/src/__tests__/connector-v1.test.ts +181 -0
- package/src/__tests__/consolidation-engine.test.ts +430 -0
- package/src/__tests__/constructive-retrieval.test.ts +509 -0
- package/src/__tests__/context-collapse-renderer.test.ts +214 -0
- package/src/__tests__/context-composer-pins.test.ts +64 -0
- package/src/__tests__/context-composer-task-selection.test.ts +24 -0
- package/src/__tests__/context-reconstructor.test.ts +65 -0
- package/src/__tests__/context-renderer.test.ts +91 -0
- package/src/__tests__/continuity-eval.test.ts +275 -0
- package/src/__tests__/continuity-schema.test.ts +97 -0
- package/src/__tests__/contracts.test.ts +262 -0
- package/src/__tests__/conversation-importer.test.ts +692 -0
- package/src/__tests__/core-recall-eval.test.ts +235 -0
- package/src/__tests__/core-summary.test.ts +109 -0
- package/src/__tests__/data-checkup.test.ts +276 -0
- package/src/__tests__/decay-exemption.test.ts +129 -0
- package/src/__tests__/dedup-actions.test.ts +58 -0
- package/src/__tests__/dedup-guard.test.ts +39 -0
- package/src/__tests__/deterministic-id.test.ts +39 -0
- package/src/__tests__/distill-lock.test.ts +305 -0
- package/src/__tests__/distill-session-argshape.test.ts +79 -0
- package/src/__tests__/doctor.test.ts +319 -0
- package/src/__tests__/dream-pipeline.test.ts +780 -0
- package/src/__tests__/drill-down.test.ts +108 -0
- package/src/__tests__/embedder.test.ts +122 -0
- package/src/__tests__/emotion-decay.test.ts +46 -0
- package/src/__tests__/emotion-detector.test.ts +56 -0
- package/src/__tests__/emotion-phase1-integration.test.ts +153 -0
- package/src/__tests__/emotion-retrieval.test.ts +56 -0
- package/src/__tests__/entity-resolver.test.ts +58 -0
- package/src/__tests__/env-config.test.ts +151 -0
- package/src/__tests__/error-signature-boost.test.ts +87 -0
- package/src/__tests__/error-signature.test.ts +95 -0
- package/src/__tests__/error-taxonomy.test.ts +129 -0
- package/src/__tests__/ethics-layer.test.ts +476 -0
- package/src/__tests__/event-segmenter.test.ts +153 -0
- package/src/__tests__/feature-flags-integration.test.ts +54 -0
- package/src/__tests__/frequency-tracker.test.ts +113 -0
- package/src/__tests__/freshness.test.ts +186 -0
- package/src/__tests__/graph-export.test.ts +489 -0
- package/src/__tests__/hotness-blend.test.ts +283 -0
- package/src/__tests__/ingest-chunking.test.ts +44 -0
- package/src/__tests__/ingest.test.ts +360 -0
- package/src/__tests__/interference-detector.test.ts +203 -0
- package/src/__tests__/kg-extractor.test.ts +192 -0
- package/src/__tests__/kg-store.test.ts +300 -0
- package/src/__tests__/llm-client-pivot.test.ts +628 -0
- package/src/__tests__/llm-consolidation.test.ts +226 -0
- package/src/__tests__/maintenance-locks.test.ts +189 -0
- package/src/__tests__/mcp-efficiency.test.ts +288 -0
- package/src/__tests__/mcp-related-scopes.test.ts +151 -0
- package/src/__tests__/memory-boundaries.test.ts +282 -0
- package/src/__tests__/memory-evolution-b2-b3-e1.test.ts +345 -0
- package/src/__tests__/memory-evolution.test.ts +271 -0
- package/src/__tests__/memory-health-rebalance.test.ts +144 -0
- package/src/__tests__/memory-lint.test.ts +421 -0
- package/src/__tests__/memory-output.test.ts +215 -0
- package/src/__tests__/memory-promotion.test.ts +315 -0
- package/src/__tests__/migrate-dedup-ids.test.ts +65 -0
- package/src/__tests__/multi-vector.test.ts +242 -0
- package/src/__tests__/narrative-architecture.test.ts +456 -0
- package/src/__tests__/noise-filter.test.ts +205 -0
- package/src/__tests__/noise-prototype-bank.test.ts +280 -0
- package/src/__tests__/obsidian-connector.test.ts +363 -0
- package/src/__tests__/omitted-reporting.test.ts +98 -0
- package/src/__tests__/pending-review.test.ts +77 -0
- package/src/__tests__/pii-redact.test.ts +333 -0
- package/src/__tests__/pivot-apply.test.ts +373 -0
- package/src/__tests__/pivot-distill-supervisor.test.ts +457 -0
- package/src/__tests__/pivot-distill.test.ts +1178 -0
- package/src/__tests__/post-retrieval-filter.test.ts +178 -0
- package/src/__tests__/ppr-traversal.test.ts +256 -0
- package/src/__tests__/prediction-engine.test.ts +349 -0
- package/src/__tests__/predictive-prospective-memory.test.ts +416 -0
- package/src/__tests__/preference-guard-smoke.test.ts +211 -0
- package/src/__tests__/preference-matcher.test.ts +282 -0
- package/src/__tests__/preference-slots.test.ts +205 -0
- package/src/__tests__/prospective-memory.test.ts +283 -0
- package/src/__tests__/pull-from-macbook-sync-only.test.ts +679 -0
- package/src/__tests__/query-entity-detector.test.ts +171 -0
- package/src/__tests__/query-expander-alias.test.ts +146 -0
- package/src/__tests__/query-expander.test.ts +34 -0
- package/src/__tests__/recall-gate.test.ts +266 -0
- package/src/__tests__/recall-governor.test.ts +227 -0
- package/src/__tests__/recall-mode.test.ts +64 -0
- package/src/__tests__/resolve-db-path.test.ts +130 -0
- package/src/__tests__/resolve-tier.test.ts +64 -0
- package/src/__tests__/result-synthesizer.test.ts +198 -0
- package/src/__tests__/resume-context.test.ts +4898 -0
- package/src/__tests__/retrieval-eval.test.ts +163 -0
- package/src/__tests__/retrieval-trace.test.ts +213 -0
- package/src/__tests__/retriever-boundary-weight.test.ts +136 -0
- package/src/__tests__/retriever-lazy-init-dispatch.test.ts +123 -0
- package/src/__tests__/retroactive-boost.test.ts +223 -0
- package/src/__tests__/salience-scoring.test.ts +106 -0
- package/src/__tests__/scope-inventory.test.ts +200 -0
- package/src/__tests__/scope-policy.test.ts +69 -0
- package/src/__tests__/scope-suggester.test.ts +80 -0
- package/src/__tests__/session-checkpoint.test.ts +291 -0
- package/src/__tests__/session-distiller.test.ts +640 -0
- package/src/__tests__/session-gc.test.ts +215 -0
- package/src/__tests__/skill-engine.test.ts +552 -0
- package/src/__tests__/skill-promotion.test.ts +654 -0
- package/src/__tests__/skill-verifier.test.ts +122 -0
- package/src/__tests__/source-heartbeat.test.ts +250 -0
- package/src/__tests__/stable-context-length-contract.test.ts +62 -0
- package/src/__tests__/store-patch-metadata.test.ts +205 -0
- package/src/__tests__/store-read-consistency.test.ts +122 -0
- package/src/__tests__/store-schema-migration.test.ts +87 -0
- package/src/__tests__/store-scope-audit.test.ts +81 -0
- package/src/__tests__/store-upsert.test.ts +201 -0
- package/src/__tests__/supersede-chain.test.ts +154 -0
- package/src/__tests__/temporal-parser.test.ts +241 -0
- package/src/__tests__/temporal-validity.test.ts +160 -0
- package/src/__tests__/term-registry-terminal-roster.test.ts +127 -0
- package/src/__tests__/term-registry.test.ts +121 -0
- package/src/__tests__/tool-error-surfacing.test.ts +98 -0
- package/src/__tests__/tool-output-compressor.test.ts +160 -0
- package/src/__tests__/topic-tag.test.ts +113 -0
- package/src/__tests__/trusted-memory.test.ts +839 -0
- package/src/__tests__/ui-server-security.test.ts +93 -0
- package/src/__tests__/usage-tracker.test.ts +287 -0
- package/src/__tests__/version-manager.test.ts +213 -0
- package/src/__tests__/workflow-observation-skill-outcome.test.ts +352 -0
- package/src/__tests__/workflow-observation.test.ts +433 -0
- package/src/__tests__/write-verifier.test.ts +122 -0
- package/src/access-tracker.ts +90 -34
- package/src/activity-counter.ts +46 -18
- package/src/adaptive-retrieval.ts +11 -0
- package/src/aliases.ts +133 -0
- package/src/api-server.ts +36 -9
- package/src/audit-log.ts +4 -2
- package/src/auto-consolidation.ts +8 -3
- package/src/auto-gc.ts +85 -21
- package/src/auto-recall.ts +61 -1
- package/src/belief-history.ts +164 -0
- package/src/capture-engine.ts +142 -32
- package/src/cli.ts +268 -41
- package/src/confidence-tracker.ts +36 -18
- package/src/conflict-engine.ts +24 -3
- package/src/consolidation-engine.ts +255 -23
- package/src/context-composer-stable-selection.ts +7 -1
- package/src/context-composer.ts +8 -1
- package/src/data-checkup.ts +96 -7
- package/src/decay-engine.ts +59 -7
- package/src/distill-lock.ts +194 -15
- package/src/doctor.ts +13 -4
- package/src/dream-pipeline.ts +473 -41
- package/src/env-config.ts +118 -0
- package/src/error-signature.ts +153 -0
- package/src/error-taxonomy.ts +115 -0
- package/src/eval.ts +402 -14
- package/src/freshness.ts +192 -0
- package/src/http-request-policy.ts +148 -0
- package/src/idempotency.ts +8 -0
- package/src/ingest.ts +308 -12
- package/src/kg-extractor.ts +13 -3
- package/src/kg-store.ts +236 -44
- package/src/llm-client.ts +106 -11
- package/src/llm-consolidation.ts +2 -1
- package/src/mcp-server.ts +87 -1800
- package/src/mcp-tool-deps.ts +48 -0
- package/src/mcp-tools-advanced.ts +954 -0
- package/src/mcp-tools-core.ts +637 -0
- package/src/mcp-tools-governance.ts +475 -0
- package/src/memory-boundaries.ts +26 -3
- package/src/memory-evolution.ts +73 -1
- package/src/memory-health-rebalance.ts +4 -0
- package/src/memory-lint.ts +183 -10
- package/src/memory-output.ts +129 -4
- package/src/memory-promotion.ts +246 -0
- package/src/memory-schema.ts +25 -2
- package/src/memory-store-port.ts +46 -0
- package/src/multi-vector.ts +2 -1
- package/src/narrative-schema.ts +2 -1
- package/src/noise-filter.ts +57 -6
- package/src/noise-prototype-bank.ts +408 -0
- package/src/pii-detector.ts +207 -5
- package/src/pivot-apply.ts +399 -0
- package/src/preference-matcher.ts +37 -3
- package/src/query-expander.ts +90 -9
- package/src/recall-gate.ts +305 -0
- package/src/result-synthesizer.ts +2 -1
- package/src/retention-policy.ts +2 -1
- package/src/retrieval-profiles.ts +46 -4
- package/src/retriever.ts +354 -157
- package/src/retroactive-boost.ts +6 -2
- package/src/runtime-config.ts +78 -6
- package/src/scope-suggester.ts +113 -0
- package/src/session-distiller.ts +23 -2
- package/src/session-engine.ts +2 -1
- package/src/session-schema.ts +1 -0
- package/src/session-store.ts +5 -0
- package/src/skill-engine.ts +78 -0
- package/src/skill-promotion.ts +304 -48
- package/src/skill-schema.ts +16 -3
- package/src/skill-verifier.ts +152 -0
- package/src/store.ts +321 -57
- package/src/term-registry.ts +35 -0
- package/src/tool-output-compressor.ts +3 -1
- package/src/ui-server.ts +9 -1
- package/src/usage-tracker.ts +242 -0
- package/src/version-manager.ts +3 -2
- package/src/version.ts +19 -0
- package/src/workflow-observation-engine.ts +2 -1
- package/src/workflow-observation-schema.ts +2 -1
- package/src/workflow-observation-store.ts +6 -2
- package/.env +0 -4
- package/CLAUDE.md +0 -46
- package/config.json +0 -55
- package/daily-recall.sh +0 -110
- package/logs/daily-recall.error.log +0 -0
- package/logs/daily-recall.log +0 -60
- package/logs/ingest-2026-04-11.log +0 -110
- package/logs/ingest-2026-04-12.log +0 -90
- package/logs/ingest-2026-04-13.log +0 -87
- package/logs/launchagent-stderr.log +0 -0
- package/logs/launchagent-stdout.log +0 -0
package/src/decay-engine.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
162
|
-
|
|
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"
|
|
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
|
-
|
|
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";
|
package/src/distill-lock.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
90
|
-
|
|
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
|
|
327
|
-
results.push(pass("JINA_API_KEY",
|
|
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
|
-
|
|
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));
|