recallnest 2.3.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (258) hide show
  1. package/CHANGELOG.md +187 -0
  2. package/README.md +49 -38
  3. package/README_CN.md +46 -31
  4. package/assets/ui/app.js +1031 -0
  5. package/assets/ui/index.html +224 -0
  6. package/assets/ui/styles.css +781 -0
  7. package/config.json.example +4 -1
  8. package/docs/api-reference.md +1291 -0
  9. package/docs/architecture.md +168 -0
  10. package/docs/connector-spec.md +126 -0
  11. package/eval/cases-canary.json +58 -0
  12. package/eval/cases-scope-robustness.json +278 -0
  13. package/eval/cases.json +117 -20
  14. package/package.json +25 -4
  15. package/src/__tests__/access-tracker-novelty.test.ts +304 -0
  16. package/src/__tests__/activity-counter.test.ts +125 -0
  17. package/src/__tests__/adaptive-fidelity.test.ts +102 -0
  18. package/src/__tests__/admission-control.test.ts +206 -0
  19. package/src/__tests__/anchor-generator.test.ts +67 -0
  20. package/src/__tests__/api-server-security.test.ts +102 -0
  21. package/src/__tests__/auto-consolidation.test.ts +168 -0
  22. package/src/__tests__/auto-recall.test.ts +131 -0
  23. package/src/__tests__/babel-integration.test.ts +144 -0
  24. package/src/__tests__/batch-dedup.test.ts +65 -0
  25. package/src/__tests__/belief-history.test.ts +220 -0
  26. package/src/__tests__/canary-eval.test.ts +114 -0
  27. package/src/__tests__/capture-engine.test.ts +1328 -0
  28. package/src/__tests__/capture-heuristic.test.ts +171 -0
  29. package/src/__tests__/cascade-forget.test.ts +210 -0
  30. package/src/__tests__/category-min-scores.test.ts +116 -0
  31. package/src/__tests__/cc8-cc1-essential-context.test.ts +298 -0
  32. package/src/__tests__/circuit-breaker.test.ts +99 -0
  33. package/src/__tests__/cluster-consolidation.test.ts +945 -0
  34. package/src/__tests__/confidence-metadata.test.ts +125 -0
  35. package/src/__tests__/confidence-tracker.test.ts +261 -0
  36. package/src/__tests__/conflict-advisor.test.ts +206 -0
  37. package/src/__tests__/conflict-escalation.test.ts +149 -0
  38. package/src/__tests__/conflict-lifecycle.test.ts +83 -0
  39. package/src/__tests__/conflict-output.test.ts +85 -0
  40. package/src/__tests__/conflict-store.test.ts +418 -0
  41. package/src/__tests__/connector-v1.test.ts +181 -0
  42. package/src/__tests__/consolidation-engine.test.ts +430 -0
  43. package/src/__tests__/constructive-retrieval.test.ts +509 -0
  44. package/src/__tests__/context-collapse-renderer.test.ts +214 -0
  45. package/src/__tests__/context-composer-pins.test.ts +64 -0
  46. package/src/__tests__/context-composer-task-selection.test.ts +24 -0
  47. package/src/__tests__/context-reconstructor.test.ts +65 -0
  48. package/src/__tests__/context-renderer.test.ts +91 -0
  49. package/src/__tests__/continuity-eval.test.ts +275 -0
  50. package/src/__tests__/continuity-schema.test.ts +97 -0
  51. package/src/__tests__/contracts.test.ts +262 -0
  52. package/src/__tests__/conversation-importer.test.ts +692 -0
  53. package/src/__tests__/core-recall-eval.test.ts +235 -0
  54. package/src/__tests__/core-summary.test.ts +109 -0
  55. package/src/__tests__/data-checkup.test.ts +276 -0
  56. package/src/__tests__/decay-exemption.test.ts +129 -0
  57. package/src/__tests__/dedup-actions.test.ts +58 -0
  58. package/src/__tests__/dedup-guard.test.ts +39 -0
  59. package/src/__tests__/deterministic-id.test.ts +39 -0
  60. package/src/__tests__/distill-lock.test.ts +305 -0
  61. package/src/__tests__/distill-session-argshape.test.ts +79 -0
  62. package/src/__tests__/doctor.test.ts +319 -0
  63. package/src/__tests__/dream-pipeline.test.ts +780 -0
  64. package/src/__tests__/drill-down.test.ts +108 -0
  65. package/src/__tests__/embedder.test.ts +122 -0
  66. package/src/__tests__/emotion-decay.test.ts +46 -0
  67. package/src/__tests__/emotion-detector.test.ts +56 -0
  68. package/src/__tests__/emotion-phase1-integration.test.ts +153 -0
  69. package/src/__tests__/emotion-retrieval.test.ts +56 -0
  70. package/src/__tests__/entity-resolver.test.ts +58 -0
  71. package/src/__tests__/env-config.test.ts +151 -0
  72. package/src/__tests__/error-signature-boost.test.ts +87 -0
  73. package/src/__tests__/error-signature.test.ts +95 -0
  74. package/src/__tests__/error-taxonomy.test.ts +129 -0
  75. package/src/__tests__/ethics-layer.test.ts +476 -0
  76. package/src/__tests__/event-segmenter.test.ts +153 -0
  77. package/src/__tests__/feature-flags-integration.test.ts +54 -0
  78. package/src/__tests__/frequency-tracker.test.ts +113 -0
  79. package/src/__tests__/freshness.test.ts +186 -0
  80. package/src/__tests__/graph-export.test.ts +489 -0
  81. package/src/__tests__/hotness-blend.test.ts +283 -0
  82. package/src/__tests__/ingest-chunking.test.ts +44 -0
  83. package/src/__tests__/ingest.test.ts +360 -0
  84. package/src/__tests__/interference-detector.test.ts +203 -0
  85. package/src/__tests__/kg-extractor.test.ts +192 -0
  86. package/src/__tests__/kg-store.test.ts +300 -0
  87. package/src/__tests__/llm-client-pivot.test.ts +628 -0
  88. package/src/__tests__/llm-consolidation.test.ts +226 -0
  89. package/src/__tests__/maintenance-locks.test.ts +189 -0
  90. package/src/__tests__/mcp-efficiency.test.ts +288 -0
  91. package/src/__tests__/mcp-related-scopes.test.ts +151 -0
  92. package/src/__tests__/memory-boundaries.test.ts +282 -0
  93. package/src/__tests__/memory-evolution-b2-b3-e1.test.ts +345 -0
  94. package/src/__tests__/memory-evolution.test.ts +271 -0
  95. package/src/__tests__/memory-health-rebalance.test.ts +144 -0
  96. package/src/__tests__/memory-lint.test.ts +421 -0
  97. package/src/__tests__/memory-output.test.ts +215 -0
  98. package/src/__tests__/memory-promotion.test.ts +315 -0
  99. package/src/__tests__/migrate-dedup-ids.test.ts +65 -0
  100. package/src/__tests__/multi-vector.test.ts +242 -0
  101. package/src/__tests__/narrative-architecture.test.ts +456 -0
  102. package/src/__tests__/noise-filter.test.ts +205 -0
  103. package/src/__tests__/noise-prototype-bank.test.ts +280 -0
  104. package/src/__tests__/obsidian-connector.test.ts +363 -0
  105. package/src/__tests__/omitted-reporting.test.ts +98 -0
  106. package/src/__tests__/pending-review.test.ts +77 -0
  107. package/src/__tests__/pii-redact.test.ts +333 -0
  108. package/src/__tests__/pivot-apply.test.ts +373 -0
  109. package/src/__tests__/pivot-distill-supervisor.test.ts +457 -0
  110. package/src/__tests__/pivot-distill.test.ts +1178 -0
  111. package/src/__tests__/post-retrieval-filter.test.ts +178 -0
  112. package/src/__tests__/ppr-traversal.test.ts +256 -0
  113. package/src/__tests__/prediction-engine.test.ts +349 -0
  114. package/src/__tests__/predictive-prospective-memory.test.ts +416 -0
  115. package/src/__tests__/preference-guard-smoke.test.ts +211 -0
  116. package/src/__tests__/preference-matcher.test.ts +282 -0
  117. package/src/__tests__/preference-slots.test.ts +205 -0
  118. package/src/__tests__/prospective-memory.test.ts +283 -0
  119. package/src/__tests__/pull-from-macbook-sync-only.test.ts +679 -0
  120. package/src/__tests__/query-entity-detector.test.ts +171 -0
  121. package/src/__tests__/query-expander-alias.test.ts +146 -0
  122. package/src/__tests__/query-expander.test.ts +34 -0
  123. package/src/__tests__/recall-gate.test.ts +266 -0
  124. package/src/__tests__/recall-governor.test.ts +227 -0
  125. package/src/__tests__/recall-mode.test.ts +64 -0
  126. package/src/__tests__/resolve-db-path.test.ts +130 -0
  127. package/src/__tests__/resolve-tier.test.ts +64 -0
  128. package/src/__tests__/result-synthesizer.test.ts +198 -0
  129. package/src/__tests__/resume-context.test.ts +4898 -0
  130. package/src/__tests__/retrieval-eval.test.ts +163 -0
  131. package/src/__tests__/retrieval-trace.test.ts +213 -0
  132. package/src/__tests__/retriever-boundary-weight.test.ts +136 -0
  133. package/src/__tests__/retriever-lazy-init-dispatch.test.ts +123 -0
  134. package/src/__tests__/retroactive-boost.test.ts +223 -0
  135. package/src/__tests__/salience-scoring.test.ts +106 -0
  136. package/src/__tests__/scope-inventory.test.ts +200 -0
  137. package/src/__tests__/scope-policy.test.ts +69 -0
  138. package/src/__tests__/scope-suggester.test.ts +80 -0
  139. package/src/__tests__/session-checkpoint.test.ts +291 -0
  140. package/src/__tests__/session-distiller.test.ts +640 -0
  141. package/src/__tests__/session-gc.test.ts +215 -0
  142. package/src/__tests__/skill-engine.test.ts +552 -0
  143. package/src/__tests__/skill-promotion.test.ts +654 -0
  144. package/src/__tests__/skill-verifier.test.ts +122 -0
  145. package/src/__tests__/source-heartbeat.test.ts +250 -0
  146. package/src/__tests__/stable-context-length-contract.test.ts +62 -0
  147. package/src/__tests__/store-patch-metadata.test.ts +205 -0
  148. package/src/__tests__/store-read-consistency.test.ts +122 -0
  149. package/src/__tests__/store-schema-migration.test.ts +87 -0
  150. package/src/__tests__/store-scope-audit.test.ts +81 -0
  151. package/src/__tests__/store-upsert.test.ts +201 -0
  152. package/src/__tests__/supersede-chain.test.ts +154 -0
  153. package/src/__tests__/temporal-parser.test.ts +241 -0
  154. package/src/__tests__/temporal-validity.test.ts +160 -0
  155. package/src/__tests__/term-registry-terminal-roster.test.ts +127 -0
  156. package/src/__tests__/term-registry.test.ts +121 -0
  157. package/src/__tests__/tool-error-surfacing.test.ts +98 -0
  158. package/src/__tests__/tool-output-compressor.test.ts +160 -0
  159. package/src/__tests__/topic-tag.test.ts +113 -0
  160. package/src/__tests__/trusted-memory.test.ts +839 -0
  161. package/src/__tests__/ui-server-security.test.ts +93 -0
  162. package/src/__tests__/usage-tracker.test.ts +287 -0
  163. package/src/__tests__/version-manager.test.ts +213 -0
  164. package/src/__tests__/workflow-observation-skill-outcome.test.ts +352 -0
  165. package/src/__tests__/workflow-observation.test.ts +433 -0
  166. package/src/__tests__/write-verifier.test.ts +122 -0
  167. package/src/access-tracker.ts +90 -34
  168. package/src/activity-counter.ts +46 -18
  169. package/src/adaptive-retrieval.ts +11 -0
  170. package/src/aliases.ts +133 -0
  171. package/src/api-server.ts +36 -9
  172. package/src/audit-log.ts +4 -2
  173. package/src/auto-consolidation.ts +8 -3
  174. package/src/auto-gc.ts +85 -21
  175. package/src/auto-recall.ts +61 -1
  176. package/src/belief-history.ts +164 -0
  177. package/src/capture-engine.ts +142 -32
  178. package/src/cli.ts +268 -41
  179. package/src/confidence-tracker.ts +36 -18
  180. package/src/conflict-engine.ts +24 -3
  181. package/src/consolidation-engine.ts +255 -23
  182. package/src/context-composer-stable-selection.ts +7 -1
  183. package/src/context-composer.ts +8 -1
  184. package/src/data-checkup.ts +96 -7
  185. package/src/decay-engine.ts +59 -7
  186. package/src/distill-lock.ts +194 -15
  187. package/src/doctor.ts +13 -4
  188. package/src/dream-pipeline.ts +473 -41
  189. package/src/env-config.ts +118 -0
  190. package/src/error-signature.ts +153 -0
  191. package/src/error-taxonomy.ts +115 -0
  192. package/src/eval.ts +402 -14
  193. package/src/freshness.ts +192 -0
  194. package/src/http-request-policy.ts +148 -0
  195. package/src/idempotency.ts +8 -0
  196. package/src/ingest.ts +308 -12
  197. package/src/kg-extractor.ts +13 -3
  198. package/src/kg-store.ts +236 -44
  199. package/src/llm-client.ts +106 -11
  200. package/src/llm-consolidation.ts +2 -1
  201. package/src/mcp-server.ts +87 -1800
  202. package/src/mcp-tool-deps.ts +48 -0
  203. package/src/mcp-tools-advanced.ts +954 -0
  204. package/src/mcp-tools-core.ts +637 -0
  205. package/src/mcp-tools-governance.ts +475 -0
  206. package/src/memory-boundaries.ts +26 -3
  207. package/src/memory-evolution.ts +73 -1
  208. package/src/memory-health-rebalance.ts +4 -0
  209. package/src/memory-lint.ts +183 -10
  210. package/src/memory-output.ts +129 -4
  211. package/src/memory-promotion.ts +246 -0
  212. package/src/memory-schema.ts +25 -2
  213. package/src/memory-store-port.ts +46 -0
  214. package/src/multi-vector.ts +2 -1
  215. package/src/narrative-schema.ts +2 -1
  216. package/src/noise-filter.ts +57 -6
  217. package/src/noise-prototype-bank.ts +408 -0
  218. package/src/pii-detector.ts +207 -5
  219. package/src/pivot-apply.ts +399 -0
  220. package/src/preference-matcher.ts +37 -3
  221. package/src/query-expander.ts +90 -9
  222. package/src/recall-gate.ts +305 -0
  223. package/src/result-synthesizer.ts +2 -1
  224. package/src/retention-policy.ts +2 -1
  225. package/src/retrieval-profiles.ts +46 -4
  226. package/src/retriever.ts +354 -157
  227. package/src/retroactive-boost.ts +6 -2
  228. package/src/runtime-config.ts +78 -6
  229. package/src/scope-suggester.ts +113 -0
  230. package/src/session-distiller.ts +23 -2
  231. package/src/session-engine.ts +2 -1
  232. package/src/session-schema.ts +1 -0
  233. package/src/session-store.ts +5 -0
  234. package/src/skill-engine.ts +78 -0
  235. package/src/skill-promotion.ts +304 -48
  236. package/src/skill-schema.ts +16 -3
  237. package/src/skill-verifier.ts +152 -0
  238. package/src/store.ts +321 -57
  239. package/src/term-registry.ts +35 -0
  240. package/src/tool-output-compressor.ts +3 -1
  241. package/src/ui-server.ts +9 -1
  242. package/src/usage-tracker.ts +242 -0
  243. package/src/version-manager.ts +3 -2
  244. package/src/version.ts +19 -0
  245. package/src/workflow-observation-engine.ts +2 -1
  246. package/src/workflow-observation-schema.ts +2 -1
  247. package/src/workflow-observation-store.ts +6 -2
  248. package/.env +0 -4
  249. package/CLAUDE.md +0 -46
  250. package/config.json +0 -55
  251. package/daily-recall.sh +0 -110
  252. package/logs/daily-recall.error.log +0 -0
  253. package/logs/daily-recall.log +0 -60
  254. package/logs/ingest-2026-04-11.log +0 -110
  255. package/logs/ingest-2026-04-12.log +0 -90
  256. package/logs/ingest-2026-04-13.log +0 -87
  257. package/logs/launchagent-stderr.log +0 -0
  258. package/logs/launchagent-stdout.log +0 -0
package/src/auto-gc.ts CHANGED
@@ -9,16 +9,20 @@
9
9
  * instead of raw age/importance heuristics. Archive-first, delete-never.
10
10
  */
11
11
 
12
- import type { MemoryStore } from "./store.js";
12
+ import type { MemoryStorePort } from "./memory-store-port.js";
13
13
  import {
14
14
  parseEvolution,
15
15
  computeDecayScore,
16
+ computeUsageAdjustedDecayScore,
16
17
  buildArchivedMetadata,
17
18
  isActiveMemory,
18
19
  } from "./memory-evolution.js";
19
20
  import { loadRetentionPolicy, shouldArchiveByPolicy } from "./retention-policy.js";
20
21
  import type { RetentionPolicy } from "./retention-policy.js";
21
22
  import type { AuditLogger } from "./audit-log.js";
23
+ import * as envConfig from "./env-config.js";
24
+ import { isUsageSignalActive } from "./usage-tracker.js";
25
+ import { acquireLock, releaseLock, getLastDistillTime, stampLock, lockPathForKey } from "./distill-lock.js";
22
26
 
23
27
  // ---------------------------------------------------------------------------
24
28
  // Config
@@ -73,48 +77,89 @@ export interface GcResult {
73
77
  * - Not pinned (importance >= 0.95 is considered pinned)
74
78
  */
75
79
  export async function maybeRunGc(
76
- store: MemoryStore,
80
+ store: MemoryStorePort,
77
81
  config: AutoGcConfig = DEFAULT_AUTO_GC_CONFIG,
78
82
  retentionConfigDir?: string,
79
83
  auditLogger?: AuditLogger,
80
84
  ): Promise<GcResult> {
81
85
  const stats = await store.stats();
82
- const totalMemories = stats.total ?? 0;
86
+ const totalMemories = stats.totalCount ?? 0;
83
87
 
84
88
  // Condition 1: enough memories to warrant GC
85
89
  if (totalMemories < config.minMemoryCount) {
86
90
  return { triggered: false, reason: "below_memory_threshold", archivedCount: 0, totalChecked: 0 };
87
91
  }
88
92
 
89
- // Condition 2: enough time since last GC
90
- const hoursSinceLastGc = (Date.now() - lastGcTimestamp) / 3_600_000;
93
+ // Condition 2: enough time since last GC. P0-1: the throttle is cross-process now —
94
+ // take the max of this process's in-memory timestamp and the shared gc-last stamp
95
+ // file's mtime. The old module-level var alone let each of the 11 mcp-server
96
+ // processes run gc on its own independent 24h clock.
97
+ const gcStampPath = lockPathForKey("gc-last");
98
+ const lastGc = Math.max(lastGcTimestamp, getLastDistillTime({ lockPath: gcStampPath }));
99
+ const hoursSinceLastGc = (Date.now() - lastGc) / 3_600_000;
91
100
  if (hoursSinceLastGc < config.minHoursSinceLastGc) {
92
101
  return { triggered: false, reason: "too_soon", archivedCount: 0, totalChecked: 0 };
93
102
  }
94
103
 
104
+ // Concurrency guard: only one process scans the full corpus at a time. A process that
105
+ // passed the throttle in the same instant loses the O_EXCL race here and skips.
106
+ const gcRunLock = { lockPath: lockPathForKey("gc-run"), expireMs: 30 * 60_000 };
107
+ if (!acquireLock(gcRunLock)) {
108
+ return { triggered: false, reason: "locked_by_another_process", archivedCount: 0, totalChecked: 0 };
109
+ }
110
+
95
111
  // All conditions met — run GC
96
- lastGcTimestamp = Date.now();
112
+ lastGcTimestamp = Date.now(); // in-process immediate throttle
97
113
 
114
+ try {
115
+ const { archivedCount, totalChecked } = await runGcScan(store, config, retentionConfigDir, auditLogger);
116
+ // Stamp completion into the shared throttle marker (create-or-touch → mtime = now)
117
+ // so the other processes observe "last run" via getLastDistillTime.
118
+ stampLock({ lockPath: gcStampPath });
119
+ return { triggered: true, archivedCount, totalChecked };
120
+ } finally {
121
+ releaseLock(gcRunLock);
122
+ }
123
+ }
124
+
125
+ /**
126
+ * The full-corpus archive scan — the body of a triggered GC run. Extracted from
127
+ * maybeRunGc so the P0-1 lock/throttle wiring stays readable; the scan logic itself
128
+ * is unchanged.
129
+ */
130
+ async function runGcScan(
131
+ store: MemoryStorePort,
132
+ config: AutoGcConfig,
133
+ retentionConfigDir: string | undefined,
134
+ auditLogger: AuditLogger | undefined,
135
+ ): Promise<{ archivedCount: number; totalChecked: number }> {
98
136
  const now = Date.now();
99
137
  let archivedCount = 0;
100
138
  let totalChecked = 0;
101
139
 
102
- // Scan memories by listing them
103
- const entries = await store.list({ limit: 5000 });
104
- totalChecked = entries.length;
140
+ // Full-corpus scan, page by page. The previous implementation listed only
141
+ // the newest 5000 rows (list() sorts by timestamp desc before slicing) —
142
+ // old low-value memories, the very target of GC, never entered the scan
143
+ // window on a 99K+ corpus. Two passes keep memory at one page instead of
144
+ // the whole corpus; pass 2 mutates rows (archive), which may shift fragment
145
+ // scan order — a row missed this run is caught by the next one.
146
+ const GC_PAGE_SIZE = 2000;
105
147
 
106
- // Pre-compute per-scope active memory counts and cache retention policies.
107
- // This avoids repeated file reads inside the loop.
148
+ // Pass 1: per-scope active memory counts for retention policies.
108
149
  const activeCounts = new Map<string, number>();
109
150
  const policyCache = new Map<string, RetentionPolicy>();
110
151
  const scopesSeen = new Set<string>();
111
152
 
112
- for (const entry of entries) {
113
- const scope = entry.scope ?? "";
114
- scopesSeen.add(scope);
115
- if (isActiveMemory(entry.metadata)) {
116
- activeCounts.set(scope, (activeCounts.get(scope) ?? 0) + 1);
153
+ for (let offset = 0; ; offset += GC_PAGE_SIZE) {
154
+ const page = await store.listPage({ limit: GC_PAGE_SIZE, offset });
155
+ for (const entry of page) {
156
+ const scope = entry.scope ?? "";
157
+ scopesSeen.add(scope);
158
+ if (isActiveMemory(entry.metadata)) {
159
+ activeCounts.set(scope, (activeCounts.get(scope) ?? 0) + 1);
160
+ }
117
161
  }
162
+ if (page.length < GC_PAGE_SIZE) break;
118
163
  }
119
164
 
120
165
  // Batch-load retention policies for all scopes (one file read per scope)
@@ -122,8 +167,15 @@ export async function maybeRunGc(
122
167
  policyCache.set(scope, loadRetentionPolicy(scope, retentionConfigDir));
123
168
  }
124
169
 
125
- for (const entry of entries) {
126
- if (archivedCount >= config.maxArchivePerRun) break;
170
+ // Pass 2: archive sweep.
171
+ const useUsageAdjustedDecay = envConfig.usageDecay() && isUsageSignalActive();
172
+ outer:
173
+ for (let offset = 0; ; offset += GC_PAGE_SIZE) {
174
+ const page = await store.listPage({ limit: GC_PAGE_SIZE, offset });
175
+ totalChecked += page.length;
176
+
177
+ for (const entry of page) {
178
+ if (archivedCount >= config.maxArchivePerRun) break outer;
127
179
 
128
180
  // Only archive active memories
129
181
  if (!isActiveMemory(entry.metadata)) continue;
@@ -139,7 +191,9 @@ export async function maybeRunGc(
139
191
  let shouldArchive = false;
140
192
  if (ageDays >= config.minAgeDays) {
141
193
  const evo = parseEvolution(entry.metadata, entry.timestamp);
142
- let decayScore = computeDecayScore(evo, importance, now, entry.metadata);
194
+ let decayScore = useUsageAdjustedDecay
195
+ ? computeUsageAdjustedDecayScore(evo, importance, now, entry.metadata)
196
+ : computeDecayScore(evo, importance, now, entry.metadata);
143
197
  // F3: Expired memories (validUntil < now) get 2x decay acceleration
144
198
  if (evo.validUntil != null && evo.validUntil < now) {
145
199
  decayScore *= 0.5; // Halve the score → archives faster
@@ -182,12 +236,22 @@ export async function maybeRunGc(
182
236
  }
183
237
  }
184
238
 
185
- return { triggered: true, archivedCount, totalChecked };
239
+ if (page.length < GC_PAGE_SIZE) break;
240
+ }
241
+
242
+ return { archivedCount, totalChecked };
186
243
  }
187
244
 
188
245
  /**
189
- * Reset last GC timestamp (for testing).
246
+ * Reset last GC timestamp (for testing). Clears both the in-memory timestamp and the
247
+ * persisted cross-process throttle marker / run lock so tests start from a clean slate.
190
248
  */
191
249
  export function resetGcTimestamp(): void {
192
250
  lastGcTimestamp = 0;
251
+ try {
252
+ releaseLock({ lockPath: lockPathForKey("gc-last") });
253
+ releaseLock({ lockPath: lockPathForKey("gc-run") });
254
+ } catch {
255
+ /* ignore — nothing to clear */
256
+ }
193
257
  }
@@ -1,6 +1,12 @@
1
1
  import { composeResumeContext, type ResumeContextDeps } from "./context-composer.js";
2
2
  import { resolveTier } from "./decay-engine.js";
3
3
  import type { DurableMemoryCategory } from "./memory-schema.js";
4
+ import {
5
+ classifyRecallGate,
6
+ logRecallGateShadow,
7
+ resolveRecallGateMode,
8
+ type RecallGateResult,
9
+ } from "./recall-gate.js";
4
10
  import { governResults, truncateQuery, type GovernorConfig, type GovernorSession } from "./recall-governor.js";
5
11
  import type { RetrievalResult } from "./retriever.js";
6
12
  import { buildRetrievalContext, resolveScopeSelection } from "./scope-policy.js";
@@ -33,6 +39,20 @@ export interface AutoRecallResponse {
33
39
  searchSkippedReason?: string;
34
40
  }
35
41
 
42
+ /** Minimal valid ResumeContextResponse for enforce-mode skip-all short-circuits. */
43
+ function emptyResumeContext(): ResumeContextResponse {
44
+ return {
45
+ summary: "",
46
+ stableContext: [],
47
+ relevantPatterns: [],
48
+ recentCases: [],
49
+ injectionHint: "user_attachment",
50
+ ephemeral: true,
51
+ responseMode: "default",
52
+ generatedAt: new Date().toISOString(),
53
+ };
54
+ }
55
+
36
56
  export async function runAutoRecall(
37
57
  deps: ResumeContextDeps,
38
58
  request: AutoRecallRequest,
@@ -46,6 +66,34 @@ export async function runAutoRecall(
46
66
  throw new Error("message is required");
47
67
  }
48
68
 
69
+ // Recall gate (mlp adaptive-retrieval borrow, three-way per Codex review).
70
+ // Sits BEFORE composeResumeContext — compose itself hits the retriever, so a
71
+ // gate placed after it would save nothing. Default mode is observe: verdicts
72
+ // are computed and shadow-logged but behavior is unchanged until real-sample
73
+ // false-positive rates have been reviewed (shared-behaviors §5).
74
+ const gateMode = resolveRecallGateMode(request.env);
75
+ let gateVerdict: RecallGateResult | undefined;
76
+ if (gateMode !== "off") {
77
+ gateVerdict = classifyRecallGate(message);
78
+ logRecallGateShadow({
79
+ ts: new Date().toISOString(),
80
+ decision: gateVerdict.decision,
81
+ ruleId: gateVerdict.ruleId,
82
+ msgLen: message.length,
83
+ mode: gateMode,
84
+ source: request.operation,
85
+ });
86
+ if (gateMode === "enforce" && gateVerdict.decision === "skip-all") {
87
+ return {
88
+ mode: "resume-only",
89
+ resolvedScope: undefined,
90
+ resume: emptyResumeContext(),
91
+ results: [],
92
+ searchSkippedReason: `recall-gate: skip-all (${gateVerdict.ruleId})`,
93
+ };
94
+ }
95
+ }
96
+
49
97
  const scopeSelection = resolveScopeSelection({
50
98
  scope: request.scope,
51
99
  sessionId: request.sessionId,
@@ -67,6 +115,18 @@ export async function runAutoRecall(
67
115
  profile: request.profile,
68
116
  });
69
117
 
118
+ // Continuity nudges ("继续", "下一步") want the resume context they just got,
119
+ // not an embedding search over their own two characters.
120
+ if (gateMode === "enforce" && gateVerdict?.decision === "resume-only") {
121
+ return {
122
+ mode: "resume-only",
123
+ resolvedScope: resume.resolvedScope || resume.latestCheckpoint?.resolvedScope || scopeSelection.resolvedScope,
124
+ resume,
125
+ results: [],
126
+ searchSkippedReason: `recall-gate: resume-only (${gateVerdict.ruleId})`,
127
+ };
128
+ }
129
+
70
130
  const resolvedScope = request.allScopes
71
131
  ? undefined
72
132
  : (resume.resolvedScope || resume.latestCheckpoint?.resolvedScope || scopeSelection.resolvedScope);
@@ -101,7 +161,7 @@ export async function runAutoRecall(
101
161
  const promoted: RetrievalResult[] = [];
102
162
  const demoted: RetrievalResult[] = [];
103
163
  for (const r of rawResults) {
104
- const tier = resolveTier(r.entry.metadata);
164
+ const tier = resolveTier(r.entry.metadata, r.entry.importance);
105
165
  if (tier === "core" || tier === "working") {
106
166
  promoted.push(r);
107
167
  } else {
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Belief history — copy-on-write archival for belief-changing writes.
3
+ *
4
+ * Problem this solves: the latest-wins and conflict-resolution paths used to call
5
+ * store.update() on the canonical row, replacing text + vector in place. The previous
6
+ * belief was physically gone, so no retrieval strategy could ever answer
7
+ * "what did I believe last month?".
8
+ *
9
+ * Approach: before overwriting canonical row C, copy C's current content into a new
10
+ * `superseded` history row H, then update C in place as before. C keeps its id, so the
11
+ * `deterministicId(scope, canonicalKey)` invariant every canonical lookup depends on is
12
+ * untouched — history lives beside it rather than replacing it.
13
+ *
14
+ * Chain shape (C is the stable canonical id, H1 older than H2):
15
+ * H1 --supersededBy--> C C --supersedes--> H2
16
+ * H2 --supersededBy--> C H2 --supersedes--> H1
17
+ * so traceEvolution() walks backward C → H2 → H1 to rebuild the timeline, and forward
18
+ * from any history row to the belief that is live now.
19
+ */
20
+
21
+ import { deterministicId, type MemoryEntry, type MemoryStore } from "./store.js";
22
+ import { parseEvolution, patchEvolution, type EvolutionMetadata } from "./memory-evolution.js";
23
+ import { detectLang, tokenizeFts } from "./language-hook.js";
24
+ import type { Embedder } from "./embedder.js";
25
+
26
+ /** Namespace for history-row ids — keeps them deterministic (retry-safe) yet distinct from C. */
27
+ const BELIEF_HISTORY_ID_PREFIX = "belief-history:";
28
+
29
+ export interface BeliefHistoryDeps {
30
+ store: Pick<MemoryStore, "store"> & Partial<Pick<MemoryStore, "upsert" | "getById">>;
31
+ embedder: Pick<Embedder, "embedPassage">;
32
+ }
33
+
34
+ export interface BeliefArchiveResult {
35
+ /** Id of the newly written history row — becomes the canonical row's `supersedes`. */
36
+ historyId: string;
37
+ /** When the archived version stopped being the live belief. */
38
+ validUntil: number;
39
+ }
40
+
41
+ /**
42
+ * Resolve a usable vector for the row being archived.
43
+ *
44
+ * store.list() deliberately returns `vector: []` for performance, and that is where the
45
+ * canonical-match candidates come from — so the entry handed to us usually has no vector.
46
+ * Re-read the full row first; only re-embed if the store can't give it back.
47
+ */
48
+ async function resolveVector(deps: BeliefHistoryDeps, current: MemoryEntry): Promise<number[]> {
49
+ if (current.vector && current.vector.length > 0) return current.vector;
50
+
51
+ if (deps.store.getById) {
52
+ const full = await deps.store.getById(current.id);
53
+ if (full?.vector && full.vector.length > 0) return full.vector;
54
+ }
55
+
56
+ return await deps.embedder.embedPassage(current.text);
57
+ }
58
+
59
+ /**
60
+ * Archive the current content of a canonical memory as a `superseded` history row.
61
+ *
62
+ * Throws rather than degrading to a silent overwrite: a belief-changing write that cannot
63
+ * preserve the old belief is exactly the failure this module exists to prevent, so callers
64
+ * must not proceed with the overwrite if this rejects.
65
+ *
66
+ * Known limitation — archive and overwrite are two writes with no transaction around them.
67
+ * If the caller's update() fails after this returns, the history row is already committed
68
+ * while the canonical row still holds that same text, leaving a superseded duplicate of a
69
+ * belief that is in fact still live. Damage is bounded: nothing is destroyed, and the
70
+ * deterministic id means a retry reuses the same row rather than piling up copies.
71
+ */
72
+ export async function archiveBeliefVersion(
73
+ deps: BeliefHistoryDeps,
74
+ current: MemoryEntry,
75
+ options: { now?: number } = {},
76
+ ): Promise<BeliefArchiveResult> {
77
+ const now = options.now ?? Date.now();
78
+ const currentEvo = parseEvolution(current.metadata, current.timestamp);
79
+
80
+ // Text is part of the id, not just (id, timestamp): two belief changes landing in the
81
+ // same millisecond would otherwise collide and the second archive would overwrite the
82
+ // first. With the text folded in, a collision means the content is identical anyway.
83
+ const historyId = deterministicId(
84
+ current.scope,
85
+ `${BELIEF_HISTORY_ID_PREFIX}${current.id}@${current.timestamp}\n${current.text}`,
86
+ );
87
+ if (historyId === current.id) {
88
+ throw new Error(`Belief history id collided with canonical id ${current.id}`);
89
+ }
90
+
91
+ const vector = await resolveVector(deps, current);
92
+ if (vector.length === 0) {
93
+ throw new Error(`Cannot archive belief ${current.id}: no vector available`);
94
+ }
95
+
96
+ // The history row inherits the old metadata wholesale (canonicalKey, boundary, emotion…)
97
+ // and only has its evolution block rewritten to close the interval.
98
+ const historyMetadata = patchEvolution(current.metadata, {
99
+ status: "superseded",
100
+ validFrom: currentEvo.validFrom,
101
+ validUntil: now,
102
+ supersededBy: current.id,
103
+ // Point at the version this one replaced, so the backward walk stays a chain.
104
+ supersedes: currentEvo.supersedes,
105
+ });
106
+
107
+ const historyRow: MemoryEntry = {
108
+ id: historyId,
109
+ text: current.text,
110
+ vector,
111
+ category: current.category,
112
+ scope: current.scope,
113
+ importance: current.importance,
114
+ // Keep the original timestamp: canonical matching scans the most recent
115
+ // CANONICAL_SCAN_LIMIT rows, and stamping history rows "now" would let them crowd
116
+ // live entries out of that window.
117
+ timestamp: current.timestamp,
118
+ metadata: historyMetadata,
119
+ // Derive rather than copy: neither list() nor getById() selects these columns, so
120
+ // `current.language` / `current.fts_text` are all but guaranteed to be undefined here.
121
+ // Falling back to the "en" / raw-text defaults would mislabel Chinese memories and
122
+ // store them unsegmented for FTS — so recompute them the same way live entries do.
123
+ language: current.language || detectLang(current.text),
124
+ fts_text: current.fts_text || tokenizeFts(current.text, current.language || detectLang(current.text)),
125
+ };
126
+
127
+ if (deps.store.upsert) {
128
+ await deps.store.upsert(historyRow);
129
+ } else {
130
+ // Fallback for narrower store deps (mocks): store() stamps its own timestamp, which
131
+ // only costs us the scan-window optimisation above — the history row is still written.
132
+ await deps.store.store(historyRow);
133
+ }
134
+
135
+ return { historyId, validUntil: now };
136
+ }
137
+
138
+ /**
139
+ * Build the evolution block for the canonical row that is about to be overwritten.
140
+ *
141
+ * Callers previously passed the incoming metadata straight to store.update(), which wiped
142
+ * the evolution block along with the text. This merges the incoming metadata with a fresh
143
+ * evolution block that links back to the archived version.
144
+ */
145
+ export function buildSupersedingBeliefMetadata(
146
+ incomingMetadata: string | undefined,
147
+ previousEvolution: EvolutionMetadata,
148
+ historyId: string,
149
+ now: number,
150
+ evolutionNote?: string | null,
151
+ ): string {
152
+ return patchEvolution(incomingMetadata, {
153
+ status: "active",
154
+ version: previousEvolution.version + 1,
155
+ supersedes: historyId,
156
+ supersededBy: null,
157
+ validFrom: now,
158
+ validUntil: null,
159
+ // Access stats belong to the canonical key, not to a single version's text.
160
+ accessCount: previousEvolution.accessCount,
161
+ lastAccessedAt: previousEvolution.lastAccessedAt,
162
+ ...(evolutionNote ? { evolutionNote } : {}),
163
+ });
164
+ }