recallnest 3.0.1 → 4.0.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 +203 -0
- package/README.md +42 -17
- package/README_CN.md +31 -15
- package/assets/ui/app.js +6 -1
- package/bin/recallnest.mjs +20 -37
- package/docs/api-reference.md +8 -0
- package/docs/architecture.md +18 -13
- package/eval/cases-canary.json +107 -9
- package/integrations/examples/claude-agent-sdk/memory-agent.ts +2 -0
- package/integrations/examples/langchain/memory-chain.py +4 -0
- package/integrations/examples/openai-agents-sdk/memory-agent.py +4 -0
- package/package.json +10 -2
- package/src/activity-counter.ts +1 -0
- package/src/adaptive-retrieval.ts +23 -0
- package/src/api-server.ts +13 -11
- package/src/asset-sync.ts +26 -2
- package/src/audit-log.ts +2 -0
- package/src/auto-gc.ts +4 -3
- package/src/belief-history.ts +137 -31
- package/src/capture-engine.ts +434 -68
- package/src/cascade-forget.ts +7 -3
- package/src/checkpoint-mirror.ts +417 -0
- package/src/cli.ts +574 -54
- package/src/conflict-engine.ts +43 -15
- package/src/consolidation-engine.ts +24 -3
- package/src/context-collapse-renderer.ts +8 -1
- package/src/context-composer.ts +11 -2
- package/src/distill-lock.ts +246 -74
- package/src/dream-pipeline.ts +3 -1
- package/src/env-config.ts +118 -0
- package/src/flock.ts +97 -0
- package/src/forget-engine.ts +342 -58
- package/src/gateway-server.ts +4 -0
- package/src/ingest-plan.ts +56 -0
- package/src/ingest.ts +69 -104
- package/src/kg-contradiction.ts +753 -0
- package/src/kg-store.ts +1 -1
- package/src/known-item-eval.ts +455 -0
- package/src/llm-client.ts +13 -26
- package/src/llm-consolidation.ts +2 -2
- package/src/mcp-tool-deps.ts +6 -0
- package/src/mcp-tools-advanced.ts +80 -45
- package/src/mcp-tools-core.ts +23 -17
- package/src/mcp-tools-governance.ts +13 -7
- package/src/memory-assets.ts +144 -8
- package/src/memory-boundaries.ts +19 -0
- package/src/memory-evolution.ts +9 -0
- package/src/memory-lint.ts +1 -1
- package/src/memory-output.ts +276 -14
- package/src/memory-reconcile.ts +1437 -0
- package/src/memory-schema.ts +11 -0
- package/src/memory-store-port.ts +1 -0
- package/src/minis-archive.ts +151 -0
- package/src/noise-filter.ts +15 -2
- package/src/pii-detector.ts +31 -1
- package/src/pivot-apply.ts +3 -4
- package/src/preference-matcher.ts +40 -28
- package/src/prospective-memory.ts +7 -4
- package/src/retriever.ts +297 -57
- package/src/retroactive-boost.ts +3 -2
- package/src/runtime-config.ts +6 -1
- package/src/scope-policy.ts +1 -0
- package/src/session-distiller.ts +6 -2
- package/src/session-output.ts +11 -0
- package/src/session-schema.ts +5 -2
- package/src/session-store.ts +57 -9
- package/src/store-receipts.ts +109 -0
- package/src/store-write-budget.ts +37 -0
- package/src/store.ts +298 -57
- package/src/text-fingerprint.ts +25 -0
- package/src/trigger-store.ts +443 -0
- package/src/ui-server.ts +22 -21
- package/src/version-manager.ts +19 -5
- package/src/write-verifier.ts +16 -11
package/src/distill-lock.ts
CHANGED
|
@@ -1,9 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* CC-6: Distill gate
|
|
3
|
-
* Lock file mtime serves as lastDistillAt timestamp (no separate field).
|
|
2
|
+
* CC-6: Distill gate + P0-1 named cross-process locks.
|
|
4
3
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Mutual exclusion is a kernel advisory lock: flock(2) on a descriptor that stays open for as long
|
|
5
|
+
* as the lock is held (src/flock.ts). The kernel drops it when the holder closes the descriptor,
|
|
6
|
+
* exits, or is SIGKILLed, so there is no stale-lock reclaim, no PID or expiry check deciding that a
|
|
7
|
+
* live holder may be pushed out, no marker files and no heartbeat. The lock file still carries
|
|
8
|
+
* `<pid>:<token>:flock:<acquiredAtMs>:<maxHoldMs>` for diagnostics (inspectLock, timeout messages,
|
|
9
|
+
* the stuck-holder warning); nothing decides ownership from its content.
|
|
10
|
+
*
|
|
11
|
+
* ROLLOUT — the previous versions of this file (PID + mtime in the lock file, reclaimed by
|
|
12
|
+
* unlink-and-recreate) do not take the flock, so old and new processes do not exclude each other.
|
|
13
|
+
* Upgrade by stopping EVERY RecallNest process that shares the data directory (all mcp-server
|
|
14
|
+
* instances, the HTTP API, launchd jobs: incremental-ingest, dream, gc, memory-reconcile, …), then
|
|
15
|
+
* starting the new ones. As a guard for a missed old process: a lock file in the old format whose
|
|
16
|
+
* PID is alive is treated as held and is never taken over (see acquireLock); if it was left by a
|
|
17
|
+
* process that is gone and its PID has been reused, a warning names the file to delete.
|
|
18
|
+
*
|
|
19
|
+
* - acquireLock(): open + non-blocking flock, write the diagnostic body
|
|
20
|
+
* - releaseLock(): unlink (while still locked) + close; never touches a lock this process does not hold
|
|
7
21
|
* - rollbackLock(previousMtime): on failure, rewind lock mtime so next run retries
|
|
8
22
|
* - shouldDistill(checkpointCount): session gate — checkpoint count < 3 → skip
|
|
9
23
|
* - getLastDistillTime(): read lock file mtime, return 0 if absent
|
|
@@ -17,10 +31,16 @@ import {
|
|
|
17
31
|
statSync,
|
|
18
32
|
utimesSync,
|
|
19
33
|
existsSync,
|
|
34
|
+
fstatSync,
|
|
35
|
+
closeSync,
|
|
36
|
+
ftruncateSync,
|
|
37
|
+
writeSync,
|
|
20
38
|
} from "node:fs";
|
|
21
39
|
import { createHash, randomUUID } from "node:crypto";
|
|
22
40
|
import { join, dirname } from "node:path";
|
|
23
41
|
import * as envConfig from "./env-config.js";
|
|
42
|
+
import { openLockFile, tryLockExclusive, unlockFd } from "./flock.js";
|
|
43
|
+
import { logWarn } from "./stderr-log.js";
|
|
24
44
|
|
|
25
45
|
// ---------------------------------------------------------------------------
|
|
26
46
|
// Config
|
|
@@ -29,7 +49,11 @@ import * as envConfig from "./env-config.js";
|
|
|
29
49
|
export interface DistillLockConfig {
|
|
30
50
|
/** Path to the lock file (default: data/distill.lock) */
|
|
31
51
|
lockPath: string;
|
|
32
|
-
/**
|
|
52
|
+
/**
|
|
53
|
+
* How long this holder expects to keep the lock (default: 3600000 = 1h). Recorded in the lock
|
|
54
|
+
* file; holding it longer only raises the stuck-holder warning. It never lets another process
|
|
55
|
+
* take the lock: only the kernel releases a flock.
|
|
56
|
+
*/
|
|
33
57
|
expireMs: number;
|
|
34
58
|
/** Minimum checkpoint count before distill is allowed (default: 3) */
|
|
35
59
|
minCheckpoints: number;
|
|
@@ -58,96 +82,174 @@ function isPidAlive(pid: number): boolean {
|
|
|
58
82
|
}
|
|
59
83
|
}
|
|
60
84
|
|
|
61
|
-
|
|
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>`). */
|
|
85
|
+
/** Parse the PID prefix from a lock file body (`<pid>` legacy, `<pid>:<token>…`). */
|
|
68
86
|
function parsePid(content: string): number {
|
|
69
87
|
return parseInt((content.split(":")[0] ?? "").trim(), 10);
|
|
70
88
|
}
|
|
71
89
|
|
|
72
|
-
|
|
73
|
-
// Public API
|
|
74
|
-
// ---------------------------------------------------------------------------
|
|
90
|
+
const FLOCK_TAG = "flock";
|
|
75
91
|
|
|
76
|
-
/**
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
92
|
+
/** What this process holds: the open, flocked descriptor, its inode, and the body we wrote. */
|
|
93
|
+
interface HeldLock {
|
|
94
|
+
fd: number;
|
|
95
|
+
dev: number;
|
|
96
|
+
ino: number;
|
|
97
|
+
body: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Locks held by this process, by path. releaseLock only acts on an entry here, so a release can
|
|
101
|
+
// never remove a lock some other process (or another call in this process) holds.
|
|
102
|
+
const heldLocks = new Map<string, HeldLock>();
|
|
103
|
+
|
|
104
|
+
interface HolderInfo {
|
|
105
|
+
pid: number;
|
|
106
|
+
token: string;
|
|
107
|
+
acquiredAt: number;
|
|
108
|
+
maxHoldMs: number;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Parse a body written by this version; null for the legacy format or garbage. */
|
|
112
|
+
function parseFlockBody(content: string): HolderInfo | null {
|
|
113
|
+
const [pid, token, tag, acquiredAt, maxHoldMs] = content.trim().split(":");
|
|
114
|
+
if (tag !== FLOCK_TAG || pid === undefined || token === undefined) return null;
|
|
115
|
+
const info = { pid: parseInt(pid, 10), token, acquiredAt: Number(acquiredAt), maxHoldMs: Number(maxHoldMs) };
|
|
116
|
+
return Number.isNaN(info.pid) || Number.isNaN(info.acquiredAt) || Number.isNaN(info.maxHoldMs) ? null : info;
|
|
117
|
+
}
|
|
83
118
|
|
|
84
|
-
|
|
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;
|
|
119
|
+
const warnedOnce = new Set<string>();
|
|
90
120
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
121
|
+
function warnOnce(key: string, message: string): void {
|
|
122
|
+
if (warnedOnce.has(key)) return;
|
|
123
|
+
warnedOnce.add(key);
|
|
124
|
+
logWarn(message);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* A body from a pre-flock version naming a live PID. Such a process does not take the flock, so
|
|
129
|
+
* holding the flock proves nothing about it: refuse rather than take a lock it may be inside.
|
|
130
|
+
*/
|
|
131
|
+
function heldByLegacyProcess(lockPath: string, content: string, expectedHoldMs: number): boolean {
|
|
132
|
+
if (content.trim() === "" || parseFlockBody(content) !== null) return false;
|
|
133
|
+
const pid = parsePid(content);
|
|
134
|
+
if (isNaN(pid) || !isPidAlive(pid)) return false;
|
|
135
|
+
let ageMs = 0;
|
|
94
136
|
try {
|
|
95
|
-
|
|
96
|
-
mtime = statSync(lockPath).mtimeMs;
|
|
137
|
+
ageMs = Date.now() - statSync(lockPath).mtimeMs;
|
|
97
138
|
} catch {
|
|
98
|
-
|
|
99
|
-
return tryExclusiveCreate(lockPath);
|
|
139
|
+
/* gone: report age 0 */
|
|
100
140
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
141
|
+
if (ageMs > expectedHoldMs) {
|
|
142
|
+
warnOnce(`legacy|${lockPath}|${content}`,
|
|
143
|
+
`[distill-lock] ${lockPath} is in the pre-flock format and names live pid ${pid} ` +
|
|
144
|
+
`(${Math.round(ageMs / 1000)} s old); treating it as held. If no RecallNest process from ` +
|
|
145
|
+
`before the flock upgrade is still running, that pid has been reused: delete the file.`);
|
|
104
146
|
}
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
105
149
|
|
|
106
|
-
|
|
107
|
-
|
|
150
|
+
/** "pid 123, held 45 s (max hold 30 s)" for messages; null when the file says nothing useful. */
|
|
151
|
+
function describeHolder(lockPath: string): string | null {
|
|
152
|
+
let content: string;
|
|
108
153
|
try {
|
|
109
|
-
|
|
154
|
+
content = readFileSync(lockPath, "utf-8");
|
|
110
155
|
} catch {
|
|
111
|
-
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
const info = parseFlockBody(content);
|
|
159
|
+
if (info === null) {
|
|
160
|
+
const pid = parsePid(content);
|
|
161
|
+
return isNaN(pid) ? null : `pid ${pid} (pre-flock lock file)`;
|
|
112
162
|
}
|
|
113
|
-
|
|
163
|
+
const heldMs = Date.now() - info.acquiredAt;
|
|
164
|
+
return `pid ${info.pid}, held ${Math.round(heldMs / 1000)} s (max hold ${Math.round(info.maxHoldMs / 1000)} s)`;
|
|
114
165
|
}
|
|
115
166
|
|
|
116
167
|
/**
|
|
117
|
-
*
|
|
118
|
-
*
|
|
168
|
+
* Waiter-side stuck-holder warning: the holder's own watchdog cannot fire if its event loop is
|
|
169
|
+
* blocked, so whoever is waiting also says so, once per holder.
|
|
119
170
|
*/
|
|
120
|
-
function
|
|
121
|
-
|
|
171
|
+
function warnIfHolderOverdue(key: string, lockPath: string): void {
|
|
172
|
+
let content: string;
|
|
122
173
|
try {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
return
|
|
126
|
-
} catch (err) {
|
|
127
|
-
if ((err as { code?: unknown }).code === "EEXIST") return false;
|
|
128
|
-
throw err;
|
|
174
|
+
content = readFileSync(lockPath, "utf-8");
|
|
175
|
+
} catch {
|
|
176
|
+
return;
|
|
129
177
|
}
|
|
178
|
+
const info = parseFlockBody(content);
|
|
179
|
+
if (info === null) return;
|
|
180
|
+
const heldMs = Date.now() - info.acquiredAt;
|
|
181
|
+
if (heldMs <= info.maxHoldMs) return;
|
|
182
|
+
warnOnce(`overdue|${lockPath}|${info.token}`,
|
|
183
|
+
`[distill-lock] lock '${key}' has been held by pid ${info.pid} for ${Math.round(heldMs / 1000)} s, ` +
|
|
184
|
+
`past its max hold of ${Math.round(info.maxHoldMs / 1000)} s. Writers waiting on it will time out ` +
|
|
185
|
+
`until that process finishes or is restarted.`);
|
|
130
186
|
}
|
|
131
187
|
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
// Public API
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
|
|
132
192
|
/**
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
* When we hold no token (e.g. a direct caller/test-written lock) fall back to removing.
|
|
193
|
+
* Try to acquire the lock without waiting.
|
|
194
|
+
* Returns true if acquired, false if another holder (another process, or another call in this
|
|
195
|
+
* process) has it, or if a live pre-flock process is named in the lock file.
|
|
137
196
|
*/
|
|
138
|
-
export function
|
|
139
|
-
const { lockPath } = resolveConfig(cfg);
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
197
|
+
export function acquireLock(cfg?: Partial<DistillLockConfig>): boolean {
|
|
198
|
+
const { lockPath, expireMs } = resolveConfig(cfg);
|
|
199
|
+
if (heldLocks.has(lockPath)) return false;
|
|
200
|
+
mkdirSync(dirname(lockPath), { recursive: true });
|
|
201
|
+
|
|
202
|
+
// A releaser unlinks the file while it still holds the flock; a process that opened the path
|
|
203
|
+
// before that unlink can get the flock on the orphaned inode afterwards. So after locking,
|
|
204
|
+
// check that the path still names our inode, and start over if not.
|
|
205
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
206
|
+
const fd = openLockFile(lockPath);
|
|
207
|
+
let keep = false;
|
|
144
208
|
try {
|
|
145
|
-
if (
|
|
146
|
-
|
|
147
|
-
|
|
209
|
+
if (!tryLockExclusive(fd)) return false;
|
|
210
|
+
const ours = fstatSync(fd);
|
|
211
|
+
let onDisk: ReturnType<typeof statSync>;
|
|
212
|
+
try {
|
|
213
|
+
onDisk = statSync(lockPath);
|
|
214
|
+
} catch {
|
|
215
|
+
continue; // unlinked after we opened it
|
|
216
|
+
}
|
|
217
|
+
if (onDisk.ino !== ours.ino || onDisk.dev !== ours.dev) continue;
|
|
218
|
+
|
|
219
|
+
if (heldByLegacyProcess(lockPath, readFileSync(fd, "utf-8"), expireMs)) return false;
|
|
220
|
+
|
|
221
|
+
const body = `${process.pid}:${randomUUID()}:${FLOCK_TAG}:${Date.now()}:${expireMs}`;
|
|
222
|
+
ftruncateSync(fd, 0);
|
|
223
|
+
writeSync(fd, body, 0);
|
|
224
|
+
heldLocks.set(lockPath, { fd, dev: ours.dev, ino: ours.ino, body });
|
|
225
|
+
keep = true;
|
|
226
|
+
return true;
|
|
227
|
+
} finally {
|
|
228
|
+
if (!keep) closeSync(fd);
|
|
148
229
|
}
|
|
149
230
|
}
|
|
150
|
-
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Release a lock this process holds: remove the file while still holding the flock (so a waiter
|
|
236
|
+
* that opened it earlier sees the inode is gone and retries), then close the descriptor, which
|
|
237
|
+
* drops the flock. A lock this process does not hold is left alone — never unlinked blind.
|
|
238
|
+
*/
|
|
239
|
+
export function releaseLock(cfg?: Partial<DistillLockConfig>): void {
|
|
240
|
+
const { lockPath } = resolveConfig(cfg);
|
|
241
|
+
const held = heldLocks.get(lockPath);
|
|
242
|
+
if (held === undefined) return;
|
|
243
|
+
heldLocks.delete(lockPath);
|
|
244
|
+
try {
|
|
245
|
+
const onDisk = statSync(lockPath);
|
|
246
|
+
if (onDisk.ino === held.ino && onDisk.dev === held.dev) unlinkSync(lockPath);
|
|
247
|
+
} catch {
|
|
248
|
+
/* already gone */
|
|
249
|
+
} finally {
|
|
250
|
+
unlockFd(held.fd);
|
|
251
|
+
closeSync(held.fd);
|
|
252
|
+
}
|
|
151
253
|
}
|
|
152
254
|
|
|
153
255
|
/**
|
|
@@ -194,6 +296,21 @@ export function locksDir(lockDir?: string): string {
|
|
|
194
296
|
return lockDir ?? join(envConfig.dataDir(), "locks");
|
|
195
297
|
}
|
|
196
298
|
|
|
299
|
+
/**
|
|
300
|
+
* 只读查看一把命名锁:在不在、持有者进程活没活、锁文件多久没更新。不改任何东西。
|
|
301
|
+
* 记忆文件对账用它区分「dream / GC 正常在跑」和「某个进程占着锁很久没动静」(后者要报警,不然对账会一直静默跳过)。
|
|
302
|
+
*/
|
|
303
|
+
export function inspectLock(key: string, lockDir?: string): { exists: boolean; pid: number | null; pidAlive: boolean; ageMs: number | null } {
|
|
304
|
+
const lockPath = lockPathForKey(key, lockDir);
|
|
305
|
+
try {
|
|
306
|
+
const pid = parsePid(readFileSync(lockPath, "utf-8"));
|
|
307
|
+
const ageMs = Date.now() - statSync(lockPath).mtimeMs;
|
|
308
|
+
return { exists: true, pid: isNaN(pid) ? null : pid, pidAlive: !isNaN(pid) && isPidAlive(pid), ageMs };
|
|
309
|
+
} catch {
|
|
310
|
+
return { exists: false, pid: null, pidAlive: false, ageMs: null };
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
197
314
|
/**
|
|
198
315
|
* Resolve a lock key (e.g. "store-write", "dream-<scope>", "gc-global") to a safe
|
|
199
316
|
* lock-file path. The key is sanitized for the filesystem and suffixed with a short
|
|
@@ -206,11 +323,17 @@ export function lockPathForKey(key: string, lockDir?: string): string {
|
|
|
206
323
|
}
|
|
207
324
|
|
|
208
325
|
export interface WithLockOptions {
|
|
209
|
-
/**
|
|
326
|
+
/**
|
|
327
|
+
* How long this holder expects to keep the lock (default 600_000 = 10 min). Recorded in the lock
|
|
328
|
+
* file. Past it the holder logs a loud warning and `fn`'s AbortSignal fires; waiters log too.
|
|
329
|
+
* It never lets anyone take the lock away from a live holder.
|
|
330
|
+
*/
|
|
331
|
+
maxHoldMs?: number;
|
|
332
|
+
/** Older name for maxHoldMs, kept so existing callers read the same; maxHoldMs wins if both are set. */
|
|
210
333
|
expireMs?: number;
|
|
211
334
|
/** Override the base lock directory (tests). */
|
|
212
335
|
lockDir?: string;
|
|
213
|
-
/** Behavior when another
|
|
336
|
+
/** Behavior when another holder has the lock (default "skip"). */
|
|
214
337
|
onBusy?: "skip" | "wait";
|
|
215
338
|
/** For onBusy="wait": total time budget before throwing (default 10_000). */
|
|
216
339
|
waitTimeoutMs?: number;
|
|
@@ -222,21 +345,39 @@ export type WithLockOutcome<T> =
|
|
|
222
345
|
| { ran: true; result: T }
|
|
223
346
|
| { ran: false; reason: "locked_by_another_process" };
|
|
224
347
|
|
|
348
|
+
/** Reason carried by the AbortSignal handed to `fn` once the lock is held past its max hold. */
|
|
349
|
+
export class LockHoldDeadlineError extends Error {
|
|
350
|
+
constructor(readonly key: string, readonly maxHoldMs: number) {
|
|
351
|
+
super(`lock '${key}' held past its max hold of ${maxHoldMs} ms`);
|
|
352
|
+
this.name = "LockHoldDeadlineError";
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
225
356
|
const sleep = (ms: number): Promise<void> =>
|
|
226
357
|
new Promise((resolve) => setTimeout(resolve, ms));
|
|
227
358
|
|
|
359
|
+
/** setTimeout/setInterval treat longer delays as 1 ms; such a max hold means "no deadline". */
|
|
360
|
+
const MAX_TIMER_MS = 2_147_483_647;
|
|
361
|
+
|
|
228
362
|
/**
|
|
229
363
|
* Run `fn` while holding a named cross-process lock, releasing it afterwards
|
|
230
364
|
* (even on throw). Maintenance-type writes use onBusy:"skip" — for a personal
|
|
231
365
|
* store, skipping a redundant dream/consolidate/gc run beats queueing it.
|
|
366
|
+
*
|
|
367
|
+
* Stuck holders: once `fn` has held the lock for `maxHoldMs`, this process logs a warning (and
|
|
368
|
+
* again every `maxHoldMs` while it still holds it) and aborts the signal passed to `fn`. The lock
|
|
369
|
+
* itself is NOT released then — `fn` may be in the middle of a write, and handing the lock to the
|
|
370
|
+
* next writer would be exactly the double-writer bug this lock exists to prevent. A caller that can
|
|
371
|
+
* stop at a safe point (e.g. between independent batches) checks `signal.aborted` and returns.
|
|
232
372
|
*/
|
|
233
373
|
export async function withLock<T>(
|
|
234
374
|
key: string,
|
|
235
|
-
fn: () => Promise<T>,
|
|
375
|
+
fn: (signal: AbortSignal) => Promise<T>,
|
|
236
376
|
opts: WithLockOptions = {},
|
|
237
377
|
): Promise<WithLockOutcome<T>> {
|
|
238
378
|
const lockPath = lockPathForKey(key, opts.lockDir);
|
|
239
|
-
const
|
|
379
|
+
const maxHoldMs = opts.maxHoldMs ?? opts.expireMs ?? 600_000;
|
|
380
|
+
const lockCfg = { lockPath, expireMs: maxHoldMs };
|
|
240
381
|
|
|
241
382
|
if (!acquireLock(lockCfg)) {
|
|
242
383
|
if ((opts.onBusy ?? "skip") === "skip") {
|
|
@@ -248,6 +389,7 @@ export async function withLock<T>(
|
|
|
248
389
|
const pollMs = opts.pollMs ?? 50;
|
|
249
390
|
let acquired = false;
|
|
250
391
|
while (Date.now() < deadline) {
|
|
392
|
+
warnIfHolderOverdue(key, lockPath);
|
|
251
393
|
await sleep(pollMs);
|
|
252
394
|
if (acquireLock(lockCfg)) {
|
|
253
395
|
acquired = true;
|
|
@@ -255,13 +397,33 @@ export async function withLock<T>(
|
|
|
255
397
|
}
|
|
256
398
|
}
|
|
257
399
|
if (!acquired) {
|
|
258
|
-
|
|
400
|
+
const holder = describeHolder(lockPath);
|
|
401
|
+
throw new Error(`lock '${key}' timed out after ${timeoutMs}ms (held by ${holder ?? "another process"})`);
|
|
259
402
|
}
|
|
260
403
|
}
|
|
261
404
|
|
|
405
|
+
const controller = new AbortController();
|
|
406
|
+
const acquiredAt = Date.now();
|
|
407
|
+
let repeat: ReturnType<typeof setInterval> | undefined;
|
|
408
|
+
const overdue = (): void => {
|
|
409
|
+
logWarn(`[distill-lock] lock '${key}' held by this process (pid ${process.pid}) for ` +
|
|
410
|
+
`${Math.round((Date.now() - acquiredAt) / 1000)} s, past its max hold of ${Math.round(maxHoldMs / 1000)} s. ` +
|
|
411
|
+
`Its critical section looks stuck; other writers time out until it finishes or this process is restarted.`);
|
|
412
|
+
};
|
|
413
|
+
const watchdog = maxHoldMs <= MAX_TIMER_MS
|
|
414
|
+
? setTimeout(() => {
|
|
415
|
+
overdue();
|
|
416
|
+
controller.abort(new LockHoldDeadlineError(key, maxHoldMs));
|
|
417
|
+
repeat = setInterval(overdue, maxHoldMs);
|
|
418
|
+
repeat.unref?.();
|
|
419
|
+
}, maxHoldMs)
|
|
420
|
+
: undefined;
|
|
421
|
+
watchdog?.unref?.();
|
|
262
422
|
try {
|
|
263
|
-
return { ran: true, result: await fn() };
|
|
423
|
+
return { ran: true, result: await fn(controller.signal) };
|
|
264
424
|
} finally {
|
|
425
|
+
if (watchdog !== undefined) clearTimeout(watchdog);
|
|
426
|
+
if (repeat !== undefined) clearInterval(repeat);
|
|
265
427
|
releaseLock(lockCfg);
|
|
266
428
|
}
|
|
267
429
|
}
|
|
@@ -274,7 +436,7 @@ export async function withLock<T>(
|
|
|
274
436
|
*/
|
|
275
437
|
export async function withWriteLock<T>(
|
|
276
438
|
key: string,
|
|
277
|
-
fn: () => Promise<T>,
|
|
439
|
+
fn: (signal: AbortSignal) => Promise<T>,
|
|
278
440
|
opts: Omit<WithLockOptions, "onBusy"> = {},
|
|
279
441
|
): Promise<T> {
|
|
280
442
|
const outcome = await withLock(key, fn, { ...opts, onBusy: "wait" });
|
|
@@ -299,3 +461,13 @@ export function stampLock(cfg?: Partial<DistillLockConfig>): void {
|
|
|
299
461
|
// read back via getLastDistillTime.
|
|
300
462
|
writeFileSync(lockPath, String(process.pid), "utf-8");
|
|
301
463
|
}
|
|
464
|
+
|
|
465
|
+
/** Remove a stamp written by stampLock (tests). Stamps are markers, not locks: nobody holds them. */
|
|
466
|
+
export function clearStamp(cfg?: Partial<DistillLockConfig>): void {
|
|
467
|
+
const { lockPath } = resolveConfig(cfg);
|
|
468
|
+
try {
|
|
469
|
+
unlinkSync(lockPath);
|
|
470
|
+
} catch {
|
|
471
|
+
/* already gone */
|
|
472
|
+
}
|
|
473
|
+
}
|
package/src/dream-pipeline.ts
CHANGED
|
@@ -98,7 +98,9 @@ export const DEFAULT_DREAM_CONFIG: DreamConfig = {
|
|
|
98
98
|
maxEntriesPerRun: 500,
|
|
99
99
|
autoRunBudgetMs: 2 * 60 * 60 * 1000,
|
|
100
100
|
autoExcludeScopes: ["memory"],
|
|
101
|
-
|
|
101
|
+
// checkpoint(2026-10-07):每场会话一行的 checkpoint 镜像(src/checkpoint-mirror.ts),只由镜像自己按会话覆盖;
|
|
102
|
+
// 合并它会把不同会话的状态揉成一条,或把正在被覆盖的行标成 superseded。
|
|
103
|
+
neverDreamScopes: ["memory:pivot", "checkpoint"],
|
|
102
104
|
gc: DEFAULT_AUTO_GC_CONFIG,
|
|
103
105
|
};
|
|
104
106
|
|
package/src/env-config.ts
CHANGED
|
@@ -39,6 +39,47 @@
|
|
|
39
39
|
|
|
40
40
|
export const multiVector = (): boolean => process.env.RECALLNEST_MULTI_VECTOR === "true";
|
|
41
41
|
|
|
42
|
+
// --- 写入时预演触发器(T-Mem 借鉴,2026-09-22)---
|
|
43
|
+
// 默认开;显式 "false" 关。侧表 memory_triggers 不存在时 TriggerStore 自建,读路径失败一律 fail-open。
|
|
44
|
+
export const triggerRecall = (): boolean => process.env.RECALLNEST_TRIGGER_RECALL !== "false";
|
|
45
|
+
/**
|
|
46
|
+
* trigger 命中的余弦硬闸。T-Mem 论文默认 0.85 是他们的嵌入器;本库 jina-embeddings-v5-text-small
|
|
47
|
+
* query↔query 余弦压得很紧:2026-09-22 用 12 条 canary 校准——真联想命中 0.52–0.67、无关噪声最高 0.53,
|
|
48
|
+
* 0.50 让 canary-A-mem0-borrow 从 70% 掉到 40%,0.55 与接线前逐条一致。要换嵌入器先跑 `triggers-calibrate` 重定。
|
|
49
|
+
*/
|
|
50
|
+
export const triggerGate = (): number => {
|
|
51
|
+
const raw = Number(process.env.RECALLNEST_TRIGGER_GATE);
|
|
52
|
+
return Number.isFinite(raw) && raw > 0 && raw < 1 ? raw : 0.55;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* 软闸:余弦没过硬闸、但 query 与 trigger 词面重合够高时也放行(trigger 是她自己的口语问法,
|
|
56
|
+
* 她将来的问法与预演的问法常常共用同几个字:榜 / 排名 / 清一清)。09-22 校准:真命中 overlap 0.20–0.64,
|
|
57
|
+
* 无关噪声 0–0.20,但关键词短 query 会被共用词撑高——所以再加一道 query 长度门槛(见下);取余弦 ≥0.40 且 overlap ≥0.40。
|
|
58
|
+
*/
|
|
59
|
+
export const triggerSoftGate = (): number => {
|
|
60
|
+
const raw = Number(process.env.RECALLNEST_TRIGGER_SOFT_GATE);
|
|
61
|
+
return Number.isFinite(raw) && raw > 0 && raw < 1 ? raw : 0.40;
|
|
62
|
+
};
|
|
63
|
+
export const triggerMinOverlap = (): number => {
|
|
64
|
+
const raw = Number(process.env.RECALLNEST_TRIGGER_MIN_OVERLAP);
|
|
65
|
+
return Number.isFinite(raw) && raw > 0 && raw < 1 ? raw : 0.40;
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* 软闸只对「像一句话的问法」开:query 去停用词后至少这么多 token(CJK 按字)。
|
|
69
|
+
* 09-22 实测:「OpenClaw 记忆系统」(5 token) 与「taobao 这个 mcp 是官方的吗」(7 token) 这类关键词 query,
|
|
70
|
+
* 词面重合被一个共用词(记忆系统 / mcp)撑到 0.3–0.7,把同话题的旁支宿主顶到第一、二名,
|
|
71
|
+
* 20 条回归掉了两条;联想入口本来就是给「换个情境重提旧事」的整句问法用的,关键词 query 走向量 / BM25 就够。
|
|
72
|
+
*/
|
|
73
|
+
export const triggerSoftMinQueryTokens = (): number => {
|
|
74
|
+
const raw = Number(process.env.RECALLNEST_TRIGGER_SOFT_MIN_QUERY_TOKENS);
|
|
75
|
+
return Number.isFinite(raw) && raw >= 1 && raw <= 64 ? Math.floor(raw) : 8;
|
|
76
|
+
};
|
|
77
|
+
/** 每次检索最多让多少个宿主经 trigger 进入候选池。 */
|
|
78
|
+
export const triggerTopK = (): number => {
|
|
79
|
+
const raw = Number(process.env.RECALLNEST_TRIGGER_TOP_K);
|
|
80
|
+
return Number.isFinite(raw) && raw >= 1 && raw <= 50 ? Math.floor(raw) : 10;
|
|
81
|
+
};
|
|
82
|
+
|
|
42
83
|
export const emotionScoring = (): boolean => process.env.RECALLNEST_EMOTION_SCORING === "true";
|
|
43
84
|
|
|
44
85
|
export const predictiveMemory = (): boolean => process.env.RECALLNEST_PREDICTIVE_MEMORY === "true";
|
|
@@ -82,6 +123,71 @@ export const layerAdmissionMin = (): number => {
|
|
|
82
123
|
return Number.isFinite(n) && n > 0 ? n : 3;
|
|
83
124
|
};
|
|
84
125
|
|
|
126
|
+
// --- 排序上把相关度与流行度拆开(2026-09-25,open-loops「RecallNest 检索评分链」症状 C)---
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 流行度信号怎么进排序:
|
|
130
|
+
* bounded — 默认(2026-09-25 起,Alice 看过正式 shadow 后拍板切):三环都不跑;链尾一步
|
|
131
|
+
* score × (1 + popularityBonusMax · h),h = min(1, log2(1+evolution.accessCount)/5),
|
|
132
|
+
* 只加不减、这一环不截平(检索器内部按原值排序,retrieve() 出口才截到 1);下游「给全文」档用 0.80
|
|
133
|
+
* legacy — 只在显式设 `legacy` 时走:切默认之前的行为逐字节不变(访问计数加成、热度混合、频次加成三环,各自截在 1.0)。
|
|
134
|
+
* 切回旧行为只要在 mcp.env 里加这一行。
|
|
135
|
+
*/
|
|
136
|
+
export const popularityRanking = (): "legacy" | "bounded" =>
|
|
137
|
+
process.env.RECALLNEST_POPULARITY_RANKING === "legacy" ? "legacy" : "bounded";
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* trigger 那一路不做长度归一:以 trigger 分为底走一遍与正文相同的前置环节,长度归一后取两路较大。
|
|
141
|
+
* trigger 独自带入或主导的宿主两路相同,等于免长度归一。默认开(2026-09-25 起),只有显式设 `false` 才关,
|
|
142
|
+
* 同 RECALLNEST_TRIGGER_RECALL 的写法。
|
|
143
|
+
*/
|
|
144
|
+
export const triggerLengthExempt = (): boolean =>
|
|
145
|
+
process.env.RECALLNEST_TRIGGER_LENGTH_EXEMPT !== "false";
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* 下游「给全文」那一档的分数线(adaptive 全文、resume_context 折叠视图 L2)。
|
|
149
|
+
* bounded 下热门条目不再被乘到 1.0,分数回到相关度的水平,0.85 那条线就太高了——
|
|
150
|
+
* 同一份快照上判过分档的前 5 条目里,正确答案拿全文的从 26 条掉到 18 条;0.80 是 32 条
|
|
151
|
+
* (第二步 plan §一.1,正式 shadow 用整组渲染回放复核)。legacy 下仍是 0.85。
|
|
152
|
+
*/
|
|
153
|
+
export const fullTextScoreThreshold = (): number =>
|
|
154
|
+
popularityRanking() === "bounded" ? 0.80 : 0.85;
|
|
155
|
+
|
|
156
|
+
// --- search_memory 默认第一屏给多少正文(2026-10-05)---
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* `formatSearchResults` 出哪种样式——search_memory 默认档(detail_level=normal)、命令行 `search`、本地 UI 共用:
|
|
160
|
+
* fulltext — 默认(2026-10-05 起,Alice 看过第一屏盲判后拍板):按名次给正文全文,单条封顶、总量有预算,
|
|
161
|
+
* 缩短过的条目写明共多少字、给了多少(memory-output.ts `formatFullTextResults`)
|
|
162
|
+
* legacy — 只在显式设 `legacy` 时走:改动之前的表格 + 每条 120 字片段,逐字节不变。
|
|
163
|
+
* 退回旧样式只要在 mcp.env 里加这一行;已经开着的进程要重启才读得到。
|
|
164
|
+
*/
|
|
165
|
+
export const searchFirstScreen = (): "legacy" | "fulltext" =>
|
|
166
|
+
process.env.RECALLNEST_SEARCH_FIRST_SCREEN === "legacy" ? "legacy" : "fulltext";
|
|
167
|
+
|
|
168
|
+
// --- 对话原文入库(2026-10-06 起默认关)---
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* `ingest` 要不要把对话记录(cc / codex / kimi / gemini / desktop / minis)切块、嵌入、写进 memories 表:
|
|
172
|
+
* false — 默认(2026-10-06 起,Alice 拍板):对话原文由 Deja 统一树索引,记忆库只放提炼产物。
|
|
173
|
+
* 这六个来源不切片不嵌入;Minis 投递目录里的文件照样挪进 data/minis-archive(Deja 从那里读);
|
|
174
|
+
* 记忆文件对账照常。
|
|
175
|
+
* true — 只在显式设 `on` 时走:回到改动之前的行为。切块与入库的代码都还在(src/ingest.ts、
|
|
176
|
+
* src/event-segmenter.ts),重开只要在跑 ingest 的环境里加这一行;要把关掉期间以及此前的会话
|
|
177
|
+
* 全部重新入库,先把 data/ingested-files.json 挪走再跑。Minis 例外:关着的时候它的文件已经从
|
|
178
|
+
* 投递目录挪进 data/minis-archive,入库不扫那个目录,要补就把想补的文件从存档拷回投递目录再跑
|
|
179
|
+
* (拷,不是挪;跑完它们会被再挪回存档,同名同内容不会多存一份)。
|
|
180
|
+
* 只认 `on` 这一个写法。
|
|
181
|
+
*/
|
|
182
|
+
export const transcriptIngest = (): boolean => process.env.RECALLNEST_TRANSCRIPT_INGEST === "on";
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* RECALLNEST_CHECKPOINT_MIRROR(2026-10-07):checkpoint 存完后要不要同步镜像进记忆库(src/checkpoint-mirror.ts)。
|
|
186
|
+
* 默认开;只认 `off` 这一个写法来关。另有文件开关 data/checkpoint-mirror.off,每次调用时读,
|
|
187
|
+
* 已经在跑的进程不用重启就停(环境变量只在进程启动时读进来)。
|
|
188
|
+
*/
|
|
189
|
+
export const checkpointMirror = (): boolean => process.env.RECALLNEST_CHECKPOINT_MIRROR !== "off";
|
|
190
|
+
|
|
85
191
|
// --- String settings with `||` default (empty string falls through) ---
|
|
86
192
|
|
|
87
193
|
export const dataDir = (): string => process.env.RECALLNEST_DATA_DIR || "data";
|
|
@@ -164,6 +270,18 @@ export const embeddingTimeoutMs = (): number | undefined => {
|
|
|
164
270
|
return Number.isFinite(n) && n > 0 ? n : undefined;
|
|
165
271
|
};
|
|
166
272
|
|
|
273
|
+
/**
|
|
274
|
+
* 记忆文件对账(memory-reconcile.ts)单轮下架上限,覆盖默认的 max(300, 活跃文档切片数的 25%)。
|
|
275
|
+
* 首轮存量清理用 `reconcile-memory --max-retire` 放行更合适;这个变量留给「某台部署想长期调严 / 调松」。
|
|
276
|
+
* 未设 / 空 / 非有限数 / 负数 → undefined,走默认公式;0 是合法值(本部署只插入不下架)。
|
|
277
|
+
*/
|
|
278
|
+
export const memoryReconcileMaxRetire = (): number | undefined => {
|
|
279
|
+
const raw = process.env.RECALLNEST_MEMORY_RECONCILE_MAX_RETIRE;
|
|
280
|
+
if (raw === undefined || raw.trim() === "") return undefined;
|
|
281
|
+
const n = Number(raw);
|
|
282
|
+
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : undefined;
|
|
283
|
+
};
|
|
284
|
+
|
|
167
285
|
// --- Raw env values (caller validates / clamps / falls back to config) ---
|
|
168
286
|
|
|
169
287
|
export const recallModeRaw = (): string | undefined => process.env.RECALLNEST_RECALL_MODE;
|