recallnest 3.0.0 → 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.
Files changed (246) hide show
  1. package/CHANGELOG.md +304 -0
  2. package/README.md +251 -258
  3. package/README_CN.md +226 -247
  4. package/assets/ui/app.js +6 -1
  5. package/bin/recallnest.mjs +20 -37
  6. package/docs/api-reference.md +8 -0
  7. package/docs/architecture.md +18 -13
  8. package/eval/cases-canary.json +107 -9
  9. package/integrations/examples/claude-agent-sdk/memory-agent.ts +2 -0
  10. package/integrations/examples/langchain/memory-chain.py +4 -0
  11. package/integrations/examples/openai-agents-sdk/memory-agent.py +4 -0
  12. package/package.json +12 -3
  13. package/src/activity-counter.ts +1 -0
  14. package/src/adaptive-retrieval.ts +23 -0
  15. package/src/api-server.ts +13 -11
  16. package/src/asset-sync.ts +26 -2
  17. package/src/audit-log.ts +2 -0
  18. package/src/auto-gc.ts +4 -3
  19. package/src/belief-history.ts +137 -31
  20. package/src/capture-engine.ts +459 -71
  21. package/src/cascade-forget.ts +7 -3
  22. package/src/checkpoint-mirror.ts +417 -0
  23. package/src/cli.ts +574 -54
  24. package/src/conflict-engine.ts +43 -15
  25. package/src/consolidation-engine.ts +24 -3
  26. package/src/context-collapse-renderer.ts +8 -1
  27. package/src/context-composer-stable.ts +5 -1
  28. package/src/context-composer.ts +11 -2
  29. package/src/distill-lock.ts +246 -74
  30. package/src/dream-pipeline.ts +3 -1
  31. package/src/embedder.ts +24 -0
  32. package/src/env-config.ts +136 -0
  33. package/src/flock.ts +97 -0
  34. package/src/forget-engine.ts +342 -58
  35. package/src/gateway-server.ts +4 -0
  36. package/src/ingest-plan.ts +56 -0
  37. package/src/ingest.ts +294 -121
  38. package/src/kg-contradiction.ts +753 -0
  39. package/src/kg-extractor.ts +17 -0
  40. package/src/kg-store.ts +1 -1
  41. package/src/known-item-eval.ts +455 -0
  42. package/src/llm-client.ts +13 -26
  43. package/src/llm-consolidation.ts +2 -2
  44. package/src/mcp-tool-deps.ts +6 -0
  45. package/src/mcp-tools-advanced.ts +80 -45
  46. package/src/mcp-tools-core.ts +23 -17
  47. package/src/mcp-tools-governance.ts +13 -7
  48. package/src/memory-assets.ts +144 -8
  49. package/src/memory-boundaries.ts +19 -0
  50. package/src/memory-evolution.ts +9 -0
  51. package/src/memory-lint.ts +1 -1
  52. package/src/memory-output.ts +311 -14
  53. package/src/memory-reconcile.ts +1437 -0
  54. package/src/memory-schema.ts +11 -0
  55. package/src/memory-store-port.ts +1 -0
  56. package/src/minis-archive.ts +151 -0
  57. package/src/noise-filter.ts +15 -2
  58. package/src/pii-detector.ts +31 -1
  59. package/src/pivot-apply.ts +55 -5
  60. package/src/pivot-evidence.ts +128 -0
  61. package/src/ppr-traversal.ts +25 -1
  62. package/src/preference-matcher.ts +40 -28
  63. package/src/prospective-memory.ts +7 -4
  64. package/src/retriever.ts +297 -57
  65. package/src/retroactive-boost.ts +3 -2
  66. package/src/runtime-config.ts +15 -1
  67. package/src/scope-policy.ts +1 -0
  68. package/src/session-distiller.ts +6 -2
  69. package/src/session-engine.ts +11 -4
  70. package/src/session-output.ts +20 -1
  71. package/src/session-schema.ts +5 -2
  72. package/src/session-store.ts +59 -12
  73. package/src/skill-promotion.ts +67 -1
  74. package/src/store-receipts.ts +109 -0
  75. package/src/store-write-budget.ts +37 -0
  76. package/src/store.ts +298 -57
  77. package/src/text-fingerprint.ts +25 -0
  78. package/src/trigger-store.ts +443 -0
  79. package/src/ui-server.ts +22 -21
  80. package/src/version-manager.ts +19 -5
  81. package/src/write-verifier.ts +16 -11
  82. package/src/__tests__/access-tracker-novelty.test.ts +0 -324
  83. package/src/__tests__/activity-counter.test.ts +0 -125
  84. package/src/__tests__/adaptive-fidelity.test.ts +0 -102
  85. package/src/__tests__/admission-control.test.ts +0 -206
  86. package/src/__tests__/age-label.test.ts +0 -33
  87. package/src/__tests__/anchor-generator.test.ts +0 -67
  88. package/src/__tests__/api-server-security.test.ts +0 -102
  89. package/src/__tests__/auto-consolidation.test.ts +0 -168
  90. package/src/__tests__/auto-recall.test.ts +0 -131
  91. package/src/__tests__/babel-integration.test.ts +0 -144
  92. package/src/__tests__/batch-dedup.test.ts +0 -65
  93. package/src/__tests__/belief-history.test.ts +0 -220
  94. package/src/__tests__/canary-eval.test.ts +0 -114
  95. package/src/__tests__/capture-engine.test.ts +0 -1328
  96. package/src/__tests__/capture-heuristic.test.ts +0 -171
  97. package/src/__tests__/cascade-forget.test.ts +0 -210
  98. package/src/__tests__/category-min-scores.test.ts +0 -116
  99. package/src/__tests__/cc8-cc1-essential-context.test.ts +0 -298
  100. package/src/__tests__/circuit-breaker.test.ts +0 -99
  101. package/src/__tests__/cluster-consolidation.test.ts +0 -1236
  102. package/src/__tests__/confidence-metadata.test.ts +0 -125
  103. package/src/__tests__/confidence-tracker.test.ts +0 -261
  104. package/src/__tests__/conflict-advisor.test.ts +0 -206
  105. package/src/__tests__/conflict-escalation.test.ts +0 -149
  106. package/src/__tests__/conflict-lifecycle.test.ts +0 -83
  107. package/src/__tests__/conflict-output.test.ts +0 -85
  108. package/src/__tests__/conflict-store.test.ts +0 -418
  109. package/src/__tests__/connector-v1.test.ts +0 -181
  110. package/src/__tests__/consolidation-engine.test.ts +0 -520
  111. package/src/__tests__/constructive-retrieval.test.ts +0 -509
  112. package/src/__tests__/context-collapse-renderer.test.ts +0 -214
  113. package/src/__tests__/context-composer-pins.test.ts +0 -64
  114. package/src/__tests__/context-composer-task-selection.test.ts +0 -24
  115. package/src/__tests__/context-reconstructor.test.ts +0 -65
  116. package/src/__tests__/context-renderer.test.ts +0 -91
  117. package/src/__tests__/continuity-eval.test.ts +0 -275
  118. package/src/__tests__/continuity-schema.test.ts +0 -97
  119. package/src/__tests__/contracts.test.ts +0 -262
  120. package/src/__tests__/conversation-importer.test.ts +0 -692
  121. package/src/__tests__/core-recall-eval.test.ts +0 -235
  122. package/src/__tests__/core-summary.test.ts +0 -109
  123. package/src/__tests__/data-checkup.test.ts +0 -304
  124. package/src/__tests__/decay-exemption.test.ts +0 -129
  125. package/src/__tests__/dedup-actions.test.ts +0 -58
  126. package/src/__tests__/dedup-guard.test.ts +0 -39
  127. package/src/__tests__/deterministic-id.test.ts +0 -39
  128. package/src/__tests__/distill-lock.test.ts +0 -305
  129. package/src/__tests__/distill-session-argshape.test.ts +0 -79
  130. package/src/__tests__/doctor.test.ts +0 -319
  131. package/src/__tests__/dream-pipeline.test.ts +0 -820
  132. package/src/__tests__/dream-scope-isolation.test.ts +0 -386
  133. package/src/__tests__/dream-write-batching.test.ts +0 -211
  134. package/src/__tests__/drill-down.test.ts +0 -108
  135. package/src/__tests__/embedder.test.ts +0 -122
  136. package/src/__tests__/emotion-decay.test.ts +0 -46
  137. package/src/__tests__/emotion-detector.test.ts +0 -56
  138. package/src/__tests__/emotion-phase1-integration.test.ts +0 -153
  139. package/src/__tests__/emotion-retrieval.test.ts +0 -56
  140. package/src/__tests__/entity-resolver.test.ts +0 -58
  141. package/src/__tests__/env-config-invariant.test.ts +0 -80
  142. package/src/__tests__/env-config.test.ts +0 -211
  143. package/src/__tests__/error-signature-boost.test.ts +0 -87
  144. package/src/__tests__/error-signature.test.ts +0 -95
  145. package/src/__tests__/error-taxonomy.test.ts +0 -129
  146. package/src/__tests__/ethics-layer.test.ts +0 -476
  147. package/src/__tests__/event-segmenter.test.ts +0 -153
  148. package/src/__tests__/feature-flags-integration.test.ts +0 -54
  149. package/src/__tests__/frequency-tracker.test.ts +0 -113
  150. package/src/__tests__/freshness.test.ts +0 -186
  151. package/src/__tests__/graph-export.test.ts +0 -489
  152. package/src/__tests__/hotness-blend.test.ts +0 -283
  153. package/src/__tests__/ingest-chunking.test.ts +0 -44
  154. package/src/__tests__/ingest-scope-prefix.test.ts +0 -69
  155. package/src/__tests__/ingest.test.ts +0 -360
  156. package/src/__tests__/interference-detector.test.ts +0 -203
  157. package/src/__tests__/kg-extractor.test.ts +0 -192
  158. package/src/__tests__/kg-store.test.ts +0 -300
  159. package/src/__tests__/llm-client-pivot.test.ts +0 -628
  160. package/src/__tests__/llm-consolidation.test.ts +0 -226
  161. package/src/__tests__/maintenance-locks.test.ts +0 -197
  162. package/src/__tests__/mcp-efficiency.test.ts +0 -288
  163. package/src/__tests__/mcp-related-scopes.test.ts +0 -151
  164. package/src/__tests__/memory-boundaries.test.ts +0 -282
  165. package/src/__tests__/memory-evolution-b2-b3-e1.test.ts +0 -362
  166. package/src/__tests__/memory-evolution.test.ts +0 -271
  167. package/src/__tests__/memory-health-rebalance.test.ts +0 -144
  168. package/src/__tests__/memory-lint.test.ts +0 -421
  169. package/src/__tests__/memory-output.test.ts +0 -224
  170. package/src/__tests__/memory-promotion.test.ts +0 -315
  171. package/src/__tests__/memory-utility.test.ts +0 -256
  172. package/src/__tests__/migrate-dedup-ids.test.ts +0 -65
  173. package/src/__tests__/multi-vector.test.ts +0 -242
  174. package/src/__tests__/narrative-architecture.test.ts +0 -456
  175. package/src/__tests__/noise-filter.test.ts +0 -205
  176. package/src/__tests__/noise-prototype-bank.test.ts +0 -280
  177. package/src/__tests__/obsidian-connector.test.ts +0 -363
  178. package/src/__tests__/omitted-reporting.test.ts +0 -98
  179. package/src/__tests__/openai-contract.test.ts +0 -513
  180. package/src/__tests__/pending-review.test.ts +0 -77
  181. package/src/__tests__/pii-redact.test.ts +0 -333
  182. package/src/__tests__/pivot-apply.test.ts +0 -373
  183. package/src/__tests__/pivot-distill-supervisor.test.ts +0 -457
  184. package/src/__tests__/pivot-distill.test.ts +0 -1178
  185. package/src/__tests__/post-retrieval-filter.test.ts +0 -178
  186. package/src/__tests__/ppr-traversal.test.ts +0 -256
  187. package/src/__tests__/prediction-engine.test.ts +0 -349
  188. package/src/__tests__/predictive-prospective-memory.test.ts +0 -416
  189. package/src/__tests__/preference-guard-smoke.test.ts +0 -211
  190. package/src/__tests__/preference-matcher.test.ts +0 -282
  191. package/src/__tests__/preference-slots.test.ts +0 -205
  192. package/src/__tests__/promotion-distinct-sources.test.ts +0 -162
  193. package/src/__tests__/prospective-memory.test.ts +0 -283
  194. package/src/__tests__/pull-from-macbook-sync-only.test.ts +0 -679
  195. package/src/__tests__/query-entity-detector.test.ts +0 -171
  196. package/src/__tests__/query-expander-alias.test.ts +0 -146
  197. package/src/__tests__/query-expander.test.ts +0 -34
  198. package/src/__tests__/recall-gate.test.ts +0 -266
  199. package/src/__tests__/recall-governor.test.ts +0 -227
  200. package/src/__tests__/recall-mode.test.ts +0 -64
  201. package/src/__tests__/resolve-db-path.test.ts +0 -130
  202. package/src/__tests__/resolve-tier.test.ts +0 -64
  203. package/src/__tests__/result-synthesizer.test.ts +0 -198
  204. package/src/__tests__/resume-context.test.ts +0 -4898
  205. package/src/__tests__/retrieval-eval.test.ts +0 -163
  206. package/src/__tests__/retrieval-trace.test.ts +0 -213
  207. package/src/__tests__/retrieve-audit-revision.test.ts +0 -255
  208. package/src/__tests__/retriever-boundary-weight.test.ts +0 -136
  209. package/src/__tests__/retriever-lazy-init-dispatch.test.ts +0 -123
  210. package/src/__tests__/retroactive-boost.test.ts +0 -223
  211. package/src/__tests__/salience-scoring.test.ts +0 -106
  212. package/src/__tests__/scope-inventory.test.ts +0 -200
  213. package/src/__tests__/scope-policy.test.ts +0 -69
  214. package/src/__tests__/scope-suggester.test.ts +0 -80
  215. package/src/__tests__/session-checkpoint.test.ts +0 -291
  216. package/src/__tests__/session-distiller.test.ts +0 -640
  217. package/src/__tests__/session-gc.test.ts +0 -215
  218. package/src/__tests__/skill-engine.test.ts +0 -552
  219. package/src/__tests__/skill-promotion.test.ts +0 -654
  220. package/src/__tests__/skill-verifier.test.ts +0 -122
  221. package/src/__tests__/source-heartbeat.test.ts +0 -254
  222. package/src/__tests__/stable-context-length-contract.test.ts +0 -62
  223. package/src/__tests__/store-patch-metadata.test.ts +0 -235
  224. package/src/__tests__/store-read-consistency.test.ts +0 -122
  225. package/src/__tests__/store-schema-migration.test.ts +0 -87
  226. package/src/__tests__/store-scope-audit.test.ts +0 -81
  227. package/src/__tests__/store-upsert.test.ts +0 -201
  228. package/src/__tests__/supersede-chain.test.ts +0 -154
  229. package/src/__tests__/synthesis-contract.test.ts +0 -252
  230. package/src/__tests__/synthesis-promotion.test.ts +0 -369
  231. package/src/__tests__/temporal-parser.test.ts +0 -241
  232. package/src/__tests__/temporal-validity.test.ts +0 -160
  233. package/src/__tests__/term-registry-terminal-roster.test.ts +0 -127
  234. package/src/__tests__/term-registry.test.ts +0 -121
  235. package/src/__tests__/tool-error-surfacing.test.ts +0 -98
  236. package/src/__tests__/tool-output-compressor.test.ts +0 -160
  237. package/src/__tests__/topic-tag.test.ts +0 -113
  238. package/src/__tests__/trusted-memory.test.ts +0 -867
  239. package/src/__tests__/ui-server-security.test.ts +0 -93
  240. package/src/__tests__/usage-tracker.test.ts +0 -287
  241. package/src/__tests__/utility-weight.test.ts +0 -57
  242. package/src/__tests__/verbatim-self-recall.test.ts +0 -279
  243. package/src/__tests__/version-manager.test.ts +0 -248
  244. package/src/__tests__/workflow-observation-skill-outcome.test.ts +0 -352
  245. package/src/__tests__/workflow-observation.test.ts +0 -433
  246. package/src/__tests__/write-verifier.test.ts +0 -122
@@ -1,9 +1,23 @@
1
1
  /**
2
- * CC-6: Distill gate — PID-based lock file + session 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
- * - acquireLock(): write current PID, check existing lock PID liveness + mtime expiry
6
- * - releaseLock(): delete lock file (mtime naturally updates to now on success)
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
- /** Lock expiry in ms (default: 3600000 = 1h) */
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
- // 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>`). */
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
- * Try to acquire the distill lock.
78
- * Returns true if lock was acquired, false if another live process holds it.
79
- */
80
- export function acquireLock(cfg?: Partial<DistillLockConfig>): boolean {
81
- const { lockPath, expireMs } = resolveConfig(cfg);
82
- mkdirSync(dirname(lockPath), { recursive: true });
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
- // 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;
119
+ const warnedOnce = new Set<string>();
90
120
 
91
- // Lock file exists — decide whether it is reclaimable (dead holder or expired).
92
- let existingPid = NaN;
93
- let mtime = 0;
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
- existingPid = parsePid(readFileSync(lockPath, "utf-8"));
96
- mtime = statSync(lockPath).mtimeMs;
137
+ ageMs = Date.now() - statSync(lockPath).mtimeMs;
97
138
  } catch {
98
- // Holder released between our failed create and this read — retry the create.
99
- return tryExclusiveCreate(lockPath);
139
+ /* gone: report age 0 */
100
140
  }
101
- const expired = Date.now() - mtime > expireMs;
102
- if (!isNaN(existingPid) && isPidAlive(existingPid) && !expired) {
103
- return false; // held by a live process, not expired
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
- // 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.
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
- unlinkSync(lockPath);
154
+ content = readFileSync(lockPath, "utf-8");
110
155
  } catch {
111
- /* already gone — fall through to the atomic create */
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
- return tryExclusiveCreate(lockPath);
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
- * 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.
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 tryExclusiveCreate(lockPath: string): boolean {
121
- const token = `${process.pid}:${randomUUID()}`;
171
+ function warnIfHolderOverdue(key: string, lockPath: string): void {
172
+ let content: string;
122
173
  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;
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
- * 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.
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 releaseLock(cfg?: Partial<DistillLockConfig>): void {
139
- const { lockPath } = resolveConfig(cfg);
140
- const ourToken = heldTokens.get(lockPath);
141
- heldTokens.delete(lockPath);
142
- if (!existsSync(lockPath)) return;
143
- if (ourToken !== undefined) {
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 (readFileSync(lockPath, "utf-8").trim() !== ourToken) return; // reclaimed by another owner
146
- } catch {
147
- return;
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
- unlinkSync(lockPath);
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
- /** Stale-lock reclaim window in ms (default 600_000 = 10min). */
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 live process holds the lock (default "skip"). */
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 lockCfg = { lockPath, expireMs: opts.expireMs ?? 600_000 };
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
- throw new Error(`lock '${key}' timed out after ${timeoutMs}ms (held by another process)`);
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
+ }
@@ -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
- neverDreamScopes: ["memory:pivot"],
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/embedder.ts CHANGED
@@ -103,6 +103,24 @@ export interface EmbeddingConfig {
103
103
  normalized?: boolean;
104
104
  /** Enable automatic chunking for documents exceeding context limits (default: true) */
105
105
  chunking?: boolean;
106
+
107
+ /**
108
+ * Per-request timeout (ms) handed to the OpenAI-compatible client.
109
+ *
110
+ * **Unset is the default and keeps the historical behavior**: no `timeout` key is
111
+ * passed to the SDK at all, so the client falls back to its own default (600s in
112
+ * openai v7). That default is deliberately left alone — bulk embedding of long
113
+ * documents is legitimately slow, and lowering it globally would turn working
114
+ * ingests into failures.
115
+ *
116
+ * Worth setting on latency-sensitive callers: the SDK default stacks with the SDK's
117
+ * own 2 retries *and* Embedder's 2 transient retries, so a black-holed endpoint can
118
+ * hold a single embed call for far longer than any caller expects.
119
+ *
120
+ * Non-finite or non-positive values are ignored (treated as unset) rather than
121
+ * throwing, matching how `env-config.ts` validates its other numeric knobs.
122
+ */
123
+ timeoutMs?: number;
106
124
  }
107
125
 
108
126
  // Known embedding model dimensions
@@ -205,9 +223,15 @@ export class Embedder {
205
223
  // Enable auto-chunking by default for better handling of long documents
206
224
  this._autoChunk = config.chunking !== false;
207
225
 
226
+ const timeoutMs =
227
+ typeof config.timeoutMs === "number" && Number.isFinite(config.timeoutMs) && config.timeoutMs > 0
228
+ ? config.timeoutMs
229
+ : undefined;
230
+
208
231
  this.client = new OpenAI({
209
232
  apiKey: resolvedApiKey,
210
233
  ...(config.baseURL ? { baseURL: config.baseURL } : {}),
234
+ ...(timeoutMs !== undefined ? { timeout: timeoutMs } : {}),
211
235
  });
212
236
 
213
237
  this.dimensions = getVectorDimensions(config.model, config.dimensions);
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";
@@ -146,6 +252,36 @@ export const synthesisModel = (): string | undefined => {
146
252
  return trimmed ? trimmed : undefined;
147
253
  };
148
254
 
255
+ /**
256
+ * Per-request timeout (ms) for the embedding client, overriding `embedding.timeoutMs`
257
+ * in config.json.
258
+ *
259
+ * Exists so the timeout can be set per deployment (the resident MCP server, a one-off
260
+ * CLI ingest) without editing the shared config file — the same reason
261
+ * `RECALLNEST_LAYER_ADMISSION` lives here.
262
+ *
263
+ * Unset / empty / non-finite / <= 0 → undefined, i.e. fall through to config.json and
264
+ * ultimately to the SDK default. **Not setting it anywhere keeps the old behavior.**
265
+ */
266
+ export const embeddingTimeoutMs = (): number | undefined => {
267
+ const raw = process.env.RECALLNEST_EMBEDDING_TIMEOUT_MS;
268
+ if (raw === undefined || raw.trim() === "") return undefined;
269
+ const n = Number(raw);
270
+ return Number.isFinite(n) && n > 0 ? n : undefined;
271
+ };
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
+
149
285
  // --- Raw env values (caller validates / clamps / falls back to config) ---
150
286
 
151
287
  export const recallModeRaw = (): string | undefined => process.env.RECALLNEST_RECALL_MODE;