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,39 +1,78 @@
1
1
  import { z } from "zod";
2
- import { indexAsset, indexPinnedAsset } from "./asset-sync.js";
2
+ import { indexAsset, pinMemory } from "./asset-sync.js";
3
3
  import { createAuditLogger } from "./audit-log.js";
4
4
  import { persistCaseMemory, persistMemoryBatch, persistWorkflowPattern, promoteMemory } from "./capture-engine.js";
5
5
  import { autoCapture } from "./capture-heuristic.js";
6
6
  import { computeSynthesisUptake } from "./consolidation-engine.js";
7
7
  import { runDataCheckup, formatCheckupReport } from "./data-checkup.js";
8
8
  import { runDream, formatDreamResult } from "./dream-pipeline.js";
9
- import { forgetMemory } from "./forget-engine.js";
9
+ import { forgetMemory, summarizeVerification, type ForgetPinArchive, type ForgetResult } from "./forget-engine.js";
10
10
  import { exportMemoryGraph, formatGraphExportResult } from "./graph-export.js";
11
- import { assetSummaryLine, buildBriefAsset, buildPinAsset, listMemoryAssets, listPinAssets, saveBriefAsset, savePinAsset, writeExportArtifact } from "./memory-assets.js";
11
+ import { archivePinAssetsForMemory, assetSummaryLine, buildBriefAsset, findPinAssetsForMemory, listMemoryAssets, listPinAssets, saveBriefAsset, writeExportArtifact } from "./memory-assets.js";
12
12
  import { runMemoryLint, formatMemoryLintReport } from "./memory-lint.js";
13
13
  import { distillResults, formatExplainResults, selectBriefSeedResults, summarizeResults } from "./memory-output.js";
14
14
  import { DurableMemoryCategorySchema, StoreMemorySourceSchema, DebugFramingSchema } from "./memory-schema.js";
15
15
  import { buildRetrievalContext, resolveScopeSelection } from "./scope-policy.js";
16
16
  import { persistSkill, retrieveSkills } from "./skill-engine.js";
17
17
  import { SkillImplementationTypeSchema } from "./skill-schema.js";
18
+ import { formatBatchStoreReceipt } from "./store-receipts.js";
18
19
  import type { ToolRegistryDeps } from "./mcp-tool-deps.js";
19
- import type { RetrievalResult } from "./retriever.js";
20
- import type { MemoryStore } from "./store.js";
21
20
 
22
- function entryToRetrievalResult(entry: Awaited<ReturnType<MemoryStore["get"]>>): RetrievalResult {
23
- if (!entry) {
24
- throw new Error("Memory entry not found.");
21
+ /** The data/pins directory, as forget_memory uses it when nothing else is injected. */
22
+ const defaultForgetPins: ForgetPinArchive = {
23
+ archiveForMemory: (id) => archivePinAssetsForMemory(id).length,
24
+ countForMemory: (id) => findPinAssetsForMemory(id).length,
25
+ };
26
+
27
+ /**
28
+ * forget_memory 的输出:逐项报数,回读结果写在明处。
29
+ * 第一行只有在回读「干净」时才打勾:id 取不到了、哪一层都没数出残留、也没有哪一层查不了。
30
+ */
31
+ function formatForgetResult(result: ForgetResult): string {
32
+ const id = result.memoryId.slice(0, 8);
33
+ const count = (n: number | null | undefined) => (n === null || n === undefined ? "n/a" : String(n));
34
+ const v = result.verification;
35
+ const state = v ? summarizeVerification(v) : "unverified";
36
+
37
+ let head: string;
38
+ if (state === "clean") {
39
+ head = `✅ Memory ${id} forgotten.`;
40
+ } else if (state === "leftovers" && v) {
41
+ const leftovers: string[] = [];
42
+ if (v.triggerRowsLeft) leftovers.push(`trigger rows ${v.triggerRowsLeft}`);
43
+ if (v.pinsLeft) leftovers.push(`pin files ${v.pinsLeft}`);
44
+ if (v.pinIndexRowsLeft) leftovers.push(`pin index rows ${v.pinIndexRowsLeft}`);
45
+ if (v.kgTriplesLeft) leftovers.push(`KG triples ${v.kgTriplesLeft}`);
46
+ head = `⚠️ Memory ${id} forgotten, but the read-back found leftovers: ${leftovers.join(", ")}.`;
47
+ } else {
48
+ const unchecked = v && v.unverified.length > 0 ? v.unverified.join(", ") : "primary";
49
+ head = `⚠️ Memory ${id} deleted, but the read-back could not check: ${unchecked}.`;
25
50
  }
26
- return {
27
- entry,
28
- score: entry.importance || 0.7,
29
- sources: {
30
- fused: { score: entry.importance || 0.7 },
31
- },
32
- };
51
+
52
+ const lines = [
53
+ head,
54
+ `Privacy tier: ${result.evidence?.privacyTier || "unknown"}`,
55
+ `KG triples removed: ${result.kgTriplesRemoved ? "yes" : "no/N/A"}`,
56
+ `Trigger rows removed: ${count(result.triggerRowsRemoved)}`,
57
+ `Pins archived: ${count(result.pinsArchived)} (index rows removed: ${count(result.pinIndexRowsRemoved)})`,
58
+ `Cascade demoted: ${result.cascadeResult.demotedCount} related memories`,
59
+ `Read-back: ${v?.primary ?? "unverified"} (left: trigger rows ${count(v?.triggerRowsLeft)}, pin files ${count(v?.pinsLeft)}, pin index rows ${count(v?.pinIndexRowsLeft)}, KG triples ${count(v?.kgTriplesLeft)})`,
60
+ ];
61
+ if (v && v.unverified.length > 0 && state !== "unverified") {
62
+ lines.push(`Could not check: ${v.unverified.join(", ")}`);
63
+ }
64
+ if (result.pinsArchived) {
65
+ // 归档是挪走不是删:文件里还有那段正文,调用方得知道它在哪
66
+ lines.push("Archived pin files were moved, not erased: they are in data/archive/forgotten-pins and still hold the snippet. Delete them there if the content itself has to go.");
67
+ }
68
+ if (result.evidence?.reason) {
69
+ lines.push(`Reason: ${result.evidence.reason}`);
70
+ }
71
+ return lines.join("\n");
33
72
  }
34
73
 
35
74
  export function registerAdvancedTools(deps: ToolRegistryDeps): void {
36
- const { registerTool, getComponents, conflictStore, getKGExtractor, getKGStore, auditLogger } = deps;
75
+ const { registerTool, getComponents, conflictStore, getKGExtractor, getKGStore, auditLogger, forgetPins } = deps;
37
76
 
38
77
  registerTool(
39
78
  "store_workflow_pattern",
@@ -391,15 +430,16 @@ registerTool(
391
430
  return { content: [{ type: "text" as const, text: `Memory not found: ${memory_id}` }] };
392
431
  }
393
432
 
394
- await store.update(entry.id, { importance: Math.max(entry.importance || 0.7, 0.95) }, scopeSelection.scopeFilter);
395
- const asset = buildPinAsset(entryToRetrievalResult(entry), {
433
+ const pinned = await pinMemory(store, embedder, entry, scopeSelection.scopeFilter, {
396
434
  title,
397
435
  summary,
398
436
  query,
399
437
  profile: profileName || "default",
400
438
  });
401
- const path = savePinAsset(asset);
402
- await indexPinnedAsset(store, embedder, asset);
439
+ if (!pinned) {
440
+ return { content: [{ type: "text" as const, text: `Memory not found: ${memory_id}` }] };
441
+ }
442
+ const { asset, path } = pinned;
403
443
 
404
444
  return {
405
445
  content: [{
@@ -473,7 +513,7 @@ registerTool(
473
513
 
474
514
  registerTool(
475
515
  "forget_memory",
476
- "Permanently forget a memory with full cascade: delete primary entry, remove KG triples, demote related memories, and log an audit trail. Requires confirm=true for durable-tier memories. Use when the user explicitly requests a memory be forgotten, or to clean up sensitive/incorrect data.",
516
+ "Permanently forget a memory: delete the primary entry, remove its KG triples and trigger rows, remove pins made from it (the file is archived, its indexed copy deleted), demote related memories, read back to confirm, and log an audit trail. Requires confirm=true for durable-tier memories. Use when the user explicitly requests a memory be forgotten, or to clean up sensitive/incorrect data.",
477
517
  {
478
518
  memoryId: z.string().min(1).max(128).describe("Memory ID to forget (full UUID or 8+ hex prefix)"),
479
519
  confirm: z.boolean().default(false).describe("Required confirmation — must be true for durable-tier memories"),
@@ -481,13 +521,19 @@ registerTool(
481
521
  scope: z.string().min(1).max(160).optional().describe("Optional scope filter for permission check"),
482
522
  },
483
523
  async ({ memoryId, confirm, reason, scope }) => {
484
- const { store } = getComponents();
524
+ const { store, triggerStore } = getComponents();
485
525
  const kgStoreInstance = getKGStore();
486
526
  const auditLogger = createAuditLogger();
487
527
  const scopeFilter = scope ? [scope] : undefined;
488
528
 
489
529
  const result = await forgetMemory(
490
- { store, kgStore: kgStoreInstance, auditLogger },
530
+ {
531
+ store,
532
+ kgStore: kgStoreInstance,
533
+ auditLogger,
534
+ triggerStore,
535
+ pins: forgetPins ?? defaultForgetPins,
536
+ },
491
537
  { memoryId, confirm, reason, scopeFilter },
492
538
  );
493
539
 
@@ -497,20 +543,10 @@ registerTool(
497
543
  throw new Error(result.error || "forget_memory failed");
498
544
  }
499
545
 
500
- const lines = [
501
- `✅ Memory ${result.memoryId.slice(0, 8)} forgotten.`,
502
- `Privacy tier: ${result.evidence?.privacyTier || "unknown"}`,
503
- `KG triples removed: ${result.kgTriplesRemoved ? "yes" : "no/N/A"}`,
504
- `Cascade demoted: ${result.cascadeResult.demotedCount} related memories`,
505
- ];
506
- if (result.evidence?.reason) {
507
- lines.push(`Reason: ${result.evidence.reason}`);
508
- }
509
-
510
546
  return {
511
547
  content: [{
512
548
  type: "text" as const,
513
- text: lines.join("\n"),
549
+ text: formatForgetResult(result),
514
550
  }],
515
551
  };
516
552
  },
@@ -555,9 +591,14 @@ registerTool(
555
591
  // 在产无人消费的内容或读打点断链,是能报警的健康度数字。
556
592
  // cap 取 totalCount 自适应库大小(2026-07-23 实测库已 117K,固定 cap 追不上增长)。
557
593
  const uptake = await computeSynthesisUptake(store, stats.totalCount + 1_000);
594
+ // 两行两个口径(2026-09-09):第一行是老数字,保留可比但标明它可被检索刷高、只能读成
595
+ // "被碰过";第二行 distinctReaderCount≥2 是更难被扰动的口径。判断"合成产物有没有用"看第二行。
558
596
  const uptakeLine = uptake.uptakeRate === null
559
- ? " uptake: n/a (no derived insights found)"
560
- : ` uptake: ${(uptake.uptakeRate * 100).toFixed(1)}% (${uptake.derivedRead}/${uptake.derivedTotal} read ≥1)`;
597
+ ? " touched ≥1: n/a (no derived insights found)"
598
+ : ` touched ≥1 (accessCount>0; every search hit counts — inflatable, not a usefulness signal): ${(uptake.uptakeRate * 100).toFixed(1)}% (${uptake.derivedRead}/${uptake.derivedTotal})`;
599
+ const multiReaderLine = uptake.multiReaderRate === null
600
+ ? " read by ≥2 distinct readers: n/a"
601
+ : ` read by ≥2 distinct readers (harder to inflate — use this one): ${(uptake.multiReaderRate * 100).toFixed(1)}% (${uptake.derivedReadByMultiple}/${uptake.derivedTotal})`;
561
602
 
562
603
  const lines = [
563
604
  `Total entries: ${stats.totalCount}`,
@@ -575,6 +616,7 @@ registerTool(
575
616
  `Synthesis uptake${uptake.truncated ? ` (scan capped at ${uptake.scanned})` : ""}:`,
576
617
  ` derived insights: ${uptake.derivedTotal}`,
577
618
  uptakeLine,
619
+ multiReaderLine,
578
620
  ];
579
621
 
580
622
  return {
@@ -761,7 +803,7 @@ registerTool(
761
803
 
762
804
  registerTool(
763
805
  "retrieve_skill",
764
- "Retrieve executable skills matching a task description by semantic similarity. Read-only. Use when you need a stored procedure to act on, not just recall knowledge.",
806
+ "Retrieve agent-readable runbooks matching a task description by semantic similarity. Read-only. RecallNest returns stored instructions; it does not execute them.",
765
807
  {
766
808
  query: z.string().min(1).max(300).describe("Natural language task description to match, e.g. 'deploy the app to production'"),
767
809
  scope: z.string().min(1).max(160).optional().describe("Restrict to skills in a specific scope, e.g. 'project:myapp'. Omit to search all scopes"),
@@ -838,17 +880,10 @@ registerTool(
838
880
  })),
839
881
  });
840
882
 
841
- const counts = { new: 0, deduped: 0, updated: 0 };
842
- for (const r of stored) {
843
- if (r.disposition === "deduped") counts.deduped++;
844
- else if (r.disposition === "updated") counts.updated++;
845
- else counts.new++;
846
- }
847
-
848
883
  return {
849
884
  content: [{
850
885
  type: "text" as const,
851
- text: `Stored ${stored.length} memories (${counts.new} new, ${counts.deduped} deduped, ${counts.updated} updated)`,
886
+ text: formatBatchStoreReceipt(stored),
852
887
  }],
853
888
  };
854
889
  },
@@ -1,15 +1,17 @@
1
1
  import { z } from "zod";
2
2
  import { persistMemory } from "./capture-engine.js";
3
+ import { formatStoreMemoryReceipt } from "./store-receipts.js";
3
4
  import { composeLightResumeContext, composeResumeContext } from "./context-composer.js";
4
5
  import { renderMemories } from "./context-renderer.js";
5
6
  import { formatSearchResults, formatBriefResults, formatFullResults, formatCollapsedResults } from "./memory-output.js";
6
- import { DurableMemoryCategorySchema, StoreMemorySourceSchema, PrivacyTierSchema, isPredictiveMemoryEnabled } from "./memory-schema.js";
7
+ import { DurableMemoryCategorySchema, StoreMemorySourceSchema, PrivacyTierSchema, isPredictiveMemoryEnabled, MemoryTriggersSchema } from "./memory-schema.js";
7
8
  import type { PredictionContext } from "./prediction-engine.js";
8
9
  import { setReminder, checkTriggers, fireReminder, suggestPredictedReminders, formatSuggestedReminders } from "./prospective-memory.js";
9
10
  import { resolveRecallMode } from "./runtime-config.js";
10
11
  import { buildRetrievalContext, resolveScopeSelection } from "./scope-policy.js";
11
12
  import { createScopeSuggester } from "./scope-suggester.js";
12
13
  import { buildSessionCheckpointResult } from "./session-engine.js";
14
+ import { mirrorCheckpointWithDeadline } from "./checkpoint-mirror.js";
13
15
  import { formatCheckpointSaved, formatCheckpointSummary, formatResumeContext } from "./session-output.js";
14
16
  import { matchesTemporalConstraint, type TemporalConstraint } from "./temporal-parser.js";
15
17
  import { buildManagedCheckpointObservation, buildManagedResumeObservation } from "./workflow-observation-managed.js";
@@ -95,6 +97,7 @@ registerTool(
95
97
  canonicalKey: z.string().min(1).max(120).optional().describe("Optional stable key for merge/update semantics"),
96
98
  topicTag: z.string().min(1).max(60).optional().describe("Optional topic tag for intra-scope partitioning (e.g. 'auth', 'deploy', 'testing'). Auto-detected if omitted."),
97
99
  privacyTier: PrivacyTierSchema.default("durable").describe("Privacy tier: ephemeral (auto-expire, no KG), private (persist, no KG), durable (default), shared (cross-scope)"),
100
+ triggers: MemoryTriggersSchema.describe("写入时预演触发器:这条记忆「她以后会怎么问起」的 2–5 句口语问法,各说一个角度、别互为变体、别复述正文。只参与召回、不进正文。scope 为 memory:pivot 时必填(§5.3)。例:[\"那个跑分要不要再跑一次\", \"别人的榜我们要不要也去排个名\"]"),
98
101
  validUntil: z.union([z.string(), z.number()]).optional().describe("Optional expiration: ISO date string or ms timestamp. Memory will be deprioritized after this time."),
99
102
  eventTime: z.union([z.string(), z.number()]).optional().describe("Optional event time: when the event actually happened (ISO date or ms), distinct from storage time."),
100
103
  confidence: z.union([
@@ -106,8 +109,8 @@ registerTool(
106
109
  ]).optional().describe("Optional confidence override: number (0-1) or {score, reliability}. Auto-assigned from source if omitted."),
107
110
  dependsOn: DependsOnSchema.optional().describe("Optional freshness dependencies (borrowed). Declare what this memory depends on so recall shows a cheap validity verdict (exact/compatible/uncertain/invalid). Items: {kind:'file'|'git-rev', ref, expected?}. file → ref=path, expected=mtime-ms string; git-rev → ref=repo path, expected=commit hash (short ok). expected may be an array: first=exact, rest=compatible set. Omit expected for existence-only checks."),
108
111
  },
109
- async ({ text, category, importance, scope, source, tags, canonicalKey, topicTag, privacyTier, validUntil, eventTime, confidence, dependsOn }) => {
110
- const { store, embedder } = getComponents();
112
+ async ({ text, category, importance, scope, source, tags, canonicalKey, topicTag, privacyTier, triggers, validUntil, eventTime, confidence, dependsOn }) => {
113
+ const { store, embedder, triggerStore } = getComponents();
111
114
  const kgExtractor = getKGExtractor();
112
115
  const stored = await persistMemory({
113
116
  store,
@@ -115,6 +118,8 @@ registerTool(
115
118
  conflictStore,
116
119
  kgExtractor,
117
120
  auditLogger,
121
+ triggerStore,
122
+ embedQueryBatch: (texts: string[]) => embedder.embedBatchQuery(texts),
118
123
  }, {
119
124
  text,
120
125
  category,
@@ -125,6 +130,7 @@ registerTool(
125
130
  canonicalKey,
126
131
  topicTag,
127
132
  privacyTier,
133
+ triggers,
128
134
  // F3: Pass temporal validity params (extracted by persistMemory before Zod parse)
129
135
  validUntil,
130
136
  eventTime,
@@ -137,16 +143,7 @@ registerTool(
137
143
  return {
138
144
  content: [{
139
145
  type: "text" as const,
140
- text: [
141
- `Stored memory ${stored.id.slice(0, 8)}`,
142
- `Disposition: ${stored.disposition}`,
143
- ...(stored.rejectionReason ? [`Rejection reason: ${stored.rejectionReason}`] : []),
144
- `Category: ${stored.category}`,
145
- `Scope: ${stored.resolvedScope}`,
146
- `Canonical key: ${stored.canonicalKey}`,
147
- ...(stored.conflictId ? [`Conflict: ${stored.conflictId.slice(0, 8)}`] : []),
148
- `Stored at: ${stored.storedAt}`,
149
- ].join("\n"),
146
+ text: formatStoreMemoryReceipt(stored, { requestedTriggers: triggers?.length ?? 0, scope }),
150
147
  }],
151
148
  };
152
149
  }
@@ -215,6 +212,11 @@ registerTool(
215
212
  ...result,
216
213
  record: storedRecord,
217
214
  }));
215
+ // Mirror into the memory table so search_memory can find it; never throws, capped wait.
216
+ await mirrorCheckpointWithDeadline(() => {
217
+ const { store, embedder } = getComponents();
218
+ return { store, embedder };
219
+ }, storedRecord);
218
220
  return {
219
221
  content: [{
220
222
  type: "text" as const,
@@ -332,7 +334,9 @@ registerTool(
332
334
  return {
333
335
  content: [{
334
336
  type: "text" as const,
335
- text: lightResult.text,
337
+ text: lightResult.resolvedScope
338
+ ? `Scope: ${lightResult.resolvedScope}\n${lightResult.text}`
339
+ : lightResult.text,
336
340
  }],
337
341
  };
338
342
  }
@@ -373,11 +377,11 @@ registerTool(
373
377
 
374
378
  registerTool(
375
379
  "search_memory",
376
- "Search indexed memories by hybrid relevance (vector + BM25 + reranking) and return ranked results with optional temporal filtering. The shown score is a fused ranking score (0-100%), NOT pure cosine similarity — read it as relative ranking within this result set, not as match confidence. Read-only, but may fire stored reminders as a side effect. Use proactively at the start of tasks, when debugging, writing, or when the user references past work.",
380
+ "Search indexed memories by semantic relevance (vector search + reranking; BM25 keyword fusion runs only when retrieval.mode is \"hybrid\", and the default is vector-only) and return ranked results with optional temporal filtering. With the default mode there is no keyword channel, so exact identifiers such as commit hashes or variable names may not surface — use a raw-text search for those. The shown score is a fused ranking score (0-100%), NOT pure cosine similarity — read it as relative ranking within this result set, not as match confidence. Read-only, but may fire stored reminders as a side effect. Use proactively at the start of tasks, when debugging, writing, or when the user references past work.",
377
381
  {
378
382
  query: z.string().describe("Search query — natural language or keywords"),
379
383
  limit: z.number().min(1).max(20).default(5).describe("Max results to return"),
380
- scope: z.string().optional().describe("Optional explicit scope"),
384
+ scope: z.string().optional().describe("Explicit scope; reuse Scope returned by resume_context. May be omitted when sessionId or configured environment supplies a scope, or allScopes=true explicitly requests cross-scope search. Omission alone does not mean global search."),
381
385
  sessionId: z.string().min(1).max(160).optional().describe("Optional session identifier to infer session:<id> scope"),
382
386
  allScopes: z.boolean().default(false).describe("When true, explicitly allow cross-scope search"),
383
387
  includeRelatedScopes: z.boolean().default(false).describe("When true, also query configured related scopes from scopeRelations and show them in a separate sidecar section. Requires an explicit or inferred scoped search; never changes the main result ranking."),
@@ -389,7 +393,7 @@ registerTool(
389
393
  graph: z.boolean().default(false).optional().describe("Enable KG graph traversal (PPR) for relationship-aware search. Use when query involves entity relationships (e.g. 'what tools does Alice use', 'Bob的朋友')."),
390
394
  includeArchived: z.boolean().default(false).optional().describe("When true, also return archived/superseded/consolidated memories (default: only active)"),
391
395
  detail_level: z.enum(["brief", "normal", "full", "adaptive"]).default("normal").optional()
392
- .describe("Result detail level: brief (ID+score+one-liner), normal (default, table), full (include metadata), adaptive (per-result L0/L1/L2 fidelity by relevance within an 8k token budget — high-relevance gets full text, lower gets summary/one-line)"),
396
+ .describe("Result detail level: normal (default — the full text of each hit, in rank order. One entry is capped at 2400 chars. Full text is handed out until an 8k-token budget is used up; hits past that point get a query-aware snippet instead of being dropped, so a long list can run past 8k by one snippet per remaining hit, and each related-scope block has its own budget. Any shortened entry says how much of it is shown and gives the id for memory_drill_down), brief (ID+score+one-liner), full (table with 120-char snippets plus extra metadata), adaptive (full text only for hits scoring above the threshold, query-aware snippet for the rest, 8k-token budget)"),
393
397
  topicTag: z.string().min(1).max(60).optional()
394
398
  .describe("Filter by topic tag (e.g. 'auth', 'deploy', 'testing'). Only returns memories tagged with this topic."),
395
399
  reconstruct: z.boolean().default(false).describe(
@@ -531,6 +535,8 @@ registerTool(
531
535
  includeArchived,
532
536
  topicTag,
533
537
  source: "auto-recall",
538
+ // 旁路标 auto-recall 只为不做强化写入;查询本身是人发起的,不过琐碎查询闸
539
+ trivialQueryGate: false,
534
540
  validAt: validAt ? new Date(validAt).getTime() : undefined,
535
541
  includeExpired: includeExpired ?? undefined,
536
542
  }, {
@@ -7,7 +7,7 @@ import { CONFLICT_ATTENTION_LEVELS, summarizeConflictLifecycle } from "./conflic
7
7
  import { formatConflictAudit, formatConflictClusters, formatConflictEscalation, formatConflictList, formatConflictRecord, formatConflictResolution } from "./conflict-output.js";
8
8
  import { ConflictStatusSchema } from "./conflict-schema.js";
9
9
  import { ConsolidationEngine, formatConsolidationResult } from "./consolidation-engine.js";
10
- import { archiveDirtyBriefAsset, listDirtyBriefAssets } from "./memory-assets.js";
10
+ import { archiveDirtyBriefAsset, formatDirtyBriefEmptyMessage, formatDirtyBriefReadErrors, scanDirtyBriefAssets } from "./memory-assets.js";
11
11
  import {
12
12
  scanMemoryPromotions,
13
13
  scanSynthesisPromotions,
@@ -266,15 +266,18 @@ registerTool(
266
266
  "List memory briefs generated before current cleanup rules that may need re-indexing. Read-only. Use when auditing brief quality or before running clean_dirty_briefs.",
267
267
  {},
268
268
  async () => {
269
- const rows = listDirtyBriefAssets();
269
+ const scan = scanDirtyBriefAssets();
270
+ const rows = scan.items;
270
271
  if (rows.length === 0) {
271
- return { content: [{ type: "text" as const, text: "No dirty briefs found." }] };
272
+ return { content: [{ type: "text" as const, text: formatDirtyBriefEmptyMessage(scan) }] };
272
273
  }
273
274
  const lines = [
274
275
  "Brief ID Title Scope Reasons",
275
276
  "-------- ----- ----- ----------------------------------------",
276
277
  ...rows.map(row => `${row.id.slice(0, 8)} ${row.title} [${row.scope}] ${row.reasons.join("; ")}`),
277
278
  ];
279
+ const readErrors = formatDirtyBriefReadErrors(scan.readErrors);
280
+ if (readErrors) lines.push("", readErrors);
278
281
  return { content: [{ type: "text" as const, text: lines.join("\n") }] };
279
282
  }
280
283
  );
@@ -287,9 +290,12 @@ registerTool(
287
290
  },
288
291
  async ({ apply }) => {
289
292
  const { store } = getComponents();
290
- const rows = listDirtyBriefAssets();
293
+ const scan = scanDirtyBriefAssets();
294
+ const rows = scan.items;
295
+ const readErrors = formatDirtyBriefReadErrors(scan.readErrors);
296
+ const readErrorsTail = readErrors ? `\n\n${readErrors}` : "";
291
297
  if (rows.length === 0) {
292
- return { content: [{ type: "text" as const, text: "No dirty briefs found." }] };
298
+ return { content: [{ type: "text" as const, text: formatDirtyBriefEmptyMessage(scan) }] };
293
299
  }
294
300
 
295
301
  if (!apply) {
@@ -297,7 +303,7 @@ registerTool(
297
303
  return {
298
304
  content: [{
299
305
  type: "text" as const,
300
- text: `Dirty briefs detected: ${rows.length}\n\n${preview}\n\nCall clean_dirty_briefs with apply=true to archive them.`,
306
+ text: `Dirty briefs detected: ${rows.length}\n\n${preview}\n\nCall clean_dirty_briefs with apply=true to archive them.${readErrorsTail}`,
301
307
  }],
302
308
  };
303
309
  }
@@ -321,7 +327,7 @@ registerTool(
321
327
  return {
322
328
  content: [{
323
329
  type: "text" as const,
324
- text: `Dirty briefs: ${rows.length}\nArchived: ${archived}\nIndex rows deleted: ${deleted}`,
330
+ text: `Dirty briefs: ${rows.length}\nArchived: ${archived}\nIndex rows deleted: ${deleted}${readErrorsTail}`,
325
331
  }],
326
332
  };
327
333
  }
@@ -76,6 +76,7 @@ const DATA_DIR = resolve(metaDir(import.meta), "../data");
76
76
  const PINS_DIR = join(DATA_DIR, "pins");
77
77
  const ASSETS_DIR = join(DATA_DIR, "assets");
78
78
  const ARCHIVE_DIR = join(DATA_DIR, "archive", "dirty-briefs");
79
+ const FORGOTTEN_PINS_DIR = join(DATA_DIR, "archive", "forgotten-pins");
79
80
  const EXPORTS_DIR = join(DATA_DIR, "exports");
80
81
 
81
82
  function ensureDir(dir: string): string {
@@ -112,6 +113,7 @@ function retrievalPath(result: RetrievalResult): string {
112
113
  if (result.sources.vector) parts.push("vector");
113
114
  if (result.sources.bm25) parts.push("bm25");
114
115
  if (result.sources.reranked) parts.push("reranked");
116
+ if (result.sources.trigger) parts.push("trigger");
115
117
  return parts.join("+") || "direct";
116
118
  }
117
119
 
@@ -231,7 +233,98 @@ export function listPinAssets(limit = 20): Array<PinAsset & { path: string }> {
231
233
  return items;
232
234
  }
233
235
 
234
- function listJsonRecords<T extends MemoryAsset>(dir: string, limit = 20): Array<T & { path: string }> {
236
+ /**
237
+ * 来源是这条记忆的 pin 文件(不限条数)。pins 目录不存在就返回空,不顺手建目录。
238
+ *
239
+ * 「读不了」和「读出来不是 JSON」分开处理:
240
+ * - 读不了(EIO、EACCES、iCloud 占位文件的 EDEADLK……)直接抛出。说不清这个文件属于谁,
241
+ * 就不能当它不存在——forget 靠这里判断「还有没有 pin」,吞掉错误等于谎报没有。
242
+ * - 不是 JSON 的跳过:list_pins 和 resume_context 同样读不了它,它进不了任何输出。
243
+ */
244
+ export function findPinAssetsForMemory(
245
+ memoryId: string,
246
+ pinsDir: string = PINS_DIR,
247
+ ): Array<PinAsset & { path: string }> {
248
+ if (!existsSync(pinsDir)) return [];
249
+ const items: Array<PinAsset & { path: string }> = [];
250
+ for (const dirent of readdirSync(pinsDir, { withFileTypes: true })) {
251
+ if (dirent.isDirectory() || !dirent.name.endsWith(".json")) continue;
252
+ const path = join(pinsDir, dirent.name);
253
+ const raw = readFileSync(path, "utf-8");
254
+ let parsed: PinAsset;
255
+ try {
256
+ parsed = JSON.parse(raw) as PinAsset;
257
+ } catch {
258
+ continue;
259
+ }
260
+ if (parsed?.source?.memoryId === memoryId) items.push({ ...parsed, path });
261
+ }
262
+ return items;
263
+ }
264
+
265
+ /**
266
+ * archivePinAssetsForMemory 挪到一半出错时抛这个。已经挪走的不挪回去:数量与新路径带在错误上,
267
+ * 调用方(forget 引擎)据此如实报「已经动过什么」,而不是当作什么都没发生。
268
+ */
269
+ export class PinArchiveIncompleteError extends Error {
270
+ /** 出错之前已经挪到归档目录的文件(新路径)。 */
271
+ readonly archived: string[];
272
+ /** 同上,条数。forget 引擎按这个字段名读(ForgetPinArchive 的约定)。 */
273
+ readonly archivedCount: number;
274
+
275
+ constructor(cause: unknown, archived: string[], total: number) {
276
+ const reason = cause instanceof Error ? cause.message : String(cause);
277
+ super(`${reason} (${archived.length} of ${total} pin file(s) had already been moved to the archive)`, { cause });
278
+ this.name = "PinArchiveIncompleteError";
279
+ this.archived = [...archived];
280
+ this.archivedCount = archived.length;
281
+ }
282
+ }
283
+
284
+ /**
285
+ * 把来源是这条记忆的 pin 挪出 pins 目录(forget 用)。
286
+ *
287
+ * pin 文件是记忆正文的一份拷贝(标题、摘要、320 字片段),会被 list_pins 列出、被 resume_context
288
+ * 拼进上下文;记忆被 forget 之后它不该继续出现。这里挪到 data/archive/forgotten-pins/ 而不是删:
289
+ * 文件离开了会被读到的目录,误删时还能挪回。同名不覆盖。
290
+ * 返回挪到的新路径。读不了或挪不动就抛错,由调用方决定要不要继续;已经挪走几个之后才出错的,
291
+ * 抛 PinArchiveIncompleteError,已挪走的数量与新路径带在错误上(不挪回去)。
292
+ *
293
+ * 只管文件这一半。pin 在主表里还有一行可检索的索引副本(asset-sync.ts),那一半由
294
+ * forget 引擎在同一步里删。
295
+ */
296
+ export function archivePinAssetsForMemory(
297
+ memoryId: string,
298
+ dirs: { pinsDir?: string; archiveDir?: string } = {},
299
+ ): string[] {
300
+ const matches = findPinAssetsForMemory(memoryId, dirs.pinsDir ?? PINS_DIR);
301
+ if (matches.length === 0) return [];
302
+ const archiveDir = ensureDir(dirs.archiveDir ?? FORGOTTEN_PINS_DIR);
303
+ const moved: string[] = [];
304
+ for (const asset of matches) {
305
+ const stem = basename(asset.path, ".json");
306
+ let target = join(archiveDir, `${stem}.json`);
307
+ for (let n = 2; existsSync(target); n++) {
308
+ target = join(archiveDir, `${stem}-${n}.json`);
309
+ }
310
+ try {
311
+ renameSync(asset.path, target);
312
+ } catch (err) {
313
+ // 第一个就挪不动:什么都没变,原样抛。后面的才挪不动:前面的已经离开 pins 目录了,得让调用方知道。
314
+ if (moved.length === 0) throw err;
315
+ throw new PinArchiveIncompleteError(err, moved, matches.length);
316
+ }
317
+ moved.push(target);
318
+ }
319
+ return moved;
320
+ }
321
+
322
+ export interface AssetReadError {
323
+ path: string;
324
+ reason: string;
325
+ }
326
+
327
+ function readJsonRecords<T extends MemoryAsset>(dir: string, limit = 20): { items: Array<T & { path: string }>; readErrors: AssetReadError[] } {
235
328
  ensureDir(dir);
236
329
  const files = readdirSync(dir)
237
330
  .filter(name => name.endsWith(".json"))
@@ -240,15 +333,21 @@ function listJsonRecords<T extends MemoryAsset>(dir: string, limit = 20): Array<
240
333
  .slice(0, limit);
241
334
 
242
335
  const items: Array<T & { path: string }> = [];
336
+ const readErrors: AssetReadError[] = [];
243
337
  for (const path of files) {
244
338
  try {
245
339
  const parsed = JSON.parse(readFileSync(path, "utf-8")) as T;
246
340
  items.push({ ...parsed, path });
247
- } catch {
248
- // Skip corrupt records.
341
+ } catch (err) {
342
+ readErrors.push({ path, reason: err instanceof Error ? err.message : String(err) });
249
343
  }
250
344
  }
251
- return items;
345
+ return { items, readErrors };
346
+ }
347
+
348
+ function listJsonRecords<T extends MemoryAsset>(dir: string, limit = 20): Array<T & { path: string }> {
349
+ // 读不了的文件在这里照旧跳过;要知道有哪些读不了,用 readJsonRecords。
350
+ return readJsonRecords<T>(dir, limit).items;
252
351
  }
253
352
 
254
353
  export function listBriefAssets(limit = 20): Array<BriefAsset & { path: string }> {
@@ -275,8 +374,14 @@ function detectDirtyBriefReasons(asset: BriefAsset): string[] {
275
374
  return reasons;
276
375
  }
277
376
 
278
- export function listDirtyBriefAssets(limit = 200): DirtyBriefAssetRecord[] {
279
- return listBriefAssets(limit)
377
+ export interface DirtyBriefScan {
378
+ items: DirtyBriefAssetRecord[];
379
+ /** 读取或解析失败的文件:没有判定、不会被归档,原样留在资产目录里。 */
380
+ readErrors: AssetReadError[];
381
+ }
382
+
383
+ function toDirtyBriefRecords(assets: Array<BriefAsset & { path: string }>): DirtyBriefAssetRecord[] {
384
+ return assets
280
385
  .map((asset) => {
281
386
  const reasons = detectDirtyBriefReasons(asset);
282
387
  return {
@@ -288,8 +393,39 @@ export function listDirtyBriefAssets(limit = 200): DirtyBriefAssetRecord[] {
288
393
  .filter((asset) => asset.reasons.length > 0);
289
394
  }
290
395
 
291
- export function archiveDirtyBriefAsset(asset: DirtyBriefAssetRecord): string {
292
- const dir = getDirtyBriefArchiveDir();
396
+ export function scanDirtyBriefAssets(limit = 200, assetsDir?: string): DirtyBriefScan {
397
+ const { items, readErrors } = readJsonRecords<BriefAsset>(assetsDir ?? getAssetsDir(), limit);
398
+ return { items: toDirtyBriefRecords(items), readErrors };
399
+ }
400
+
401
+ export function listDirtyBriefAssets(limit = 200): DirtyBriefAssetRecord[] {
402
+ return scanDirtyBriefAssets(limit).items;
403
+ }
404
+
405
+ /**
406
+ * 脏 brief 扫描结果里「没有要清的」那句话。有文件读不了时不说「没有」,
407
+ * 免得把坏文件藏起来:真干净才回 "No dirty briefs found."。
408
+ */
409
+ export function formatDirtyBriefEmptyMessage(scan: DirtyBriefScan): string {
410
+ if (scan.readErrors.length === 0) return "No dirty briefs found.";
411
+ return [
412
+ "No dirty briefs among readable files, but the scan is incomplete.",
413
+ formatDirtyBriefReadErrors(scan.readErrors),
414
+ ].join("\n");
415
+ }
416
+
417
+ /** 读不了的文件清单;没有读取失败时返回空串。 */
418
+ export function formatDirtyBriefReadErrors(readErrors: AssetReadError[]): string {
419
+ if (readErrors.length === 0) return "";
420
+ const noun = readErrors.length === 1 ? "file" : "files";
421
+ return [
422
+ `${readErrors.length} brief ${noun} could not be read (left in place, not archived):`,
423
+ ...readErrors.map((item) => ` ${item.path} — ${item.reason}`),
424
+ ].join("\n");
425
+ }
426
+
427
+ export function archiveDirtyBriefAsset(asset: DirtyBriefAssetRecord, archiveDir?: string): string {
428
+ const dir = archiveDir ? ensureDir(archiveDir) : getDirtyBriefArchiveDir();
293
429
  const targetPath = join(dir, basename(asset.path));
294
430
  renameSync(asset.path, targetPath);
295
431
  return targetPath;
@@ -99,6 +99,25 @@ export function isTranscriptScope(scope: string): boolean {
99
99
  return TRANSCRIPT_SCOPE_PREFIXES.some((prefix) => scope.startsWith(prefix));
100
100
  }
101
101
 
102
+ /**
103
+ * Scope of the session-checkpoint mirror (src/checkpoint-mirror.ts): one `events` row per session.
104
+ * Lives here so the retriever can refer to it without importing the mirror module.
105
+ */
106
+ export const CHECKPOINT_MIRROR_SCOPE = "checkpoint";
107
+
108
+ /**
109
+ * Scopes a retrieval must keep out of its candidate pool. The vector / BM25 candidate pool is
110
+ * cut to at most 20 rows before the category filter runs, so rows that the category filter is
111
+ * certain to drop still take candidate slots from the rows it would keep. Mirror rows are all
112
+ * `events`; any other category filter therefore drops every one of them — excluding the scope up
113
+ * front is lossless and keeps category searches as they were before the mirror existed.
114
+ */
115
+ export function resolveExcludedScopes(params: { category?: string; excludeScopes?: string[] }): string[] {
116
+ const excluded = new Set(params.excludeScopes ?? []);
117
+ if (params.category && params.category !== "events") excluded.add(CHECKPOINT_MIRROR_SCOPE);
118
+ return [...excluded];
119
+ }
120
+
102
121
  export function isDurableMemoryScope(scope: string): boolean {
103
122
  return scope.startsWith("memory:") || scope.startsWith("asset:");
104
123
  }
@@ -158,6 +158,15 @@ export function recordAccess(metadata: string | undefined): string {
158
158
  });
159
159
  }
160
160
 
161
+ /**
162
+ * recordAccess 的对象版:给 patchMetadataBatch 的 patchFn 用——在写锁内、以库里最新的元数据为底座加访问计数,
163
+ * 不再拿检索时读到的元数据整行写回(那样会把检索与写回之间别的进程写下的状态盖掉)。
164
+ */
165
+ export function recordAccessOnMeta(meta: Record<string, unknown>, now: number = Date.now()): Record<string, unknown> {
166
+ const evo = parseEvolution(JSON.stringify(meta));
167
+ return patchEvolutionOnMeta(meta, { accessCount: evo.accessCount + 1, lastAccessedAt: now });
168
+ }
169
+
161
170
  // ---------------------------------------------------------------------------
162
171
  // Supersede / Consolidate helpers
163
172
  // ---------------------------------------------------------------------------