recallnest 1.4.1 → 2.2.1

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 (233) hide show
  1. package/.env +4 -0
  2. package/CLAUDE.md +7 -3
  3. package/README.md +202 -130
  4. package/README_CN.md +263 -190
  5. package/bin/recallnest.mjs +62 -0
  6. package/config.json +55 -0
  7. package/daily-recall.sh +23 -0
  8. package/integrations/setup-all.sh +31 -0
  9. package/logs/daily-recall.error.log +0 -0
  10. package/logs/daily-recall.log +30 -0
  11. package/logs/ingest-2026-04-11.log +110 -0
  12. package/logs/ingest-2026-04-12.log +90 -0
  13. package/logs/launchagent-stderr.log +0 -0
  14. package/logs/launchagent-stdout.log +0 -0
  15. package/package.json +10 -3
  16. package/src/access-tracker.ts +3 -2
  17. package/src/activity-counter.ts +109 -0
  18. package/src/adaptive-retrieval.ts +6 -5
  19. package/src/admission-control.ts +163 -0
  20. package/src/anchor-generator.ts +80 -0
  21. package/src/api-server.ts +10 -0
  22. package/src/audit-log.ts +122 -0
  23. package/src/auto-gc.ts +98 -29
  24. package/src/auto-recall.ts +33 -4
  25. package/src/capture-engine.ts +372 -23
  26. package/src/cascade-forget.ts +3 -2
  27. package/src/cli.ts +107 -5
  28. package/src/compat.ts +22 -0
  29. package/src/confidence-tracker.ts +210 -47
  30. package/src/conflict-store.ts +12 -3
  31. package/src/consolidation-engine.ts +296 -8
  32. package/src/context-collapse-renderer.ts +222 -0
  33. package/src/context-composer-stable-selection.ts +5 -1
  34. package/src/context-composer.ts +239 -4
  35. package/src/context-reconstructor.ts +404 -0
  36. package/src/conversation-importer.ts +441 -0
  37. package/src/data-checkup.ts +232 -0
  38. package/src/decay-engine.ts +84 -0
  39. package/src/distill-lock.ts +122 -0
  40. package/src/doctor.ts +8 -10
  41. package/src/dream-pipeline.ts +227 -0
  42. package/src/emotion-detector.ts +90 -0
  43. package/src/eval.ts +3 -2
  44. package/src/forget-engine.ts +269 -0
  45. package/src/frequency-tracker.ts +175 -0
  46. package/src/graph-export.ts +644 -0
  47. package/src/ingest.ts +36 -6
  48. package/src/interference-detector.ts +187 -0
  49. package/src/kg-extractor.ts +15 -24
  50. package/src/kg-store.ts +2 -1
  51. package/src/language-hook.ts +67 -0
  52. package/src/llm-client.ts +289 -6
  53. package/src/mcp-server.ts +707 -80
  54. package/src/memory-assets.ts +22 -5
  55. package/src/memory-evolution.ts +365 -0
  56. package/src/memory-lint.ts +429 -0
  57. package/src/memory-output.ts +69 -0
  58. package/src/memory-schema.ts +74 -2
  59. package/src/narrative-schema.ts +79 -0
  60. package/src/narrative-tagger.ts +164 -0
  61. package/src/pii-detector.ts +98 -0
  62. package/src/post-retrieval-filter.ts +76 -0
  63. package/src/prediction-engine.ts +253 -0
  64. package/src/preference-slots.ts +65 -2
  65. package/src/prospective-memory.ts +215 -0
  66. package/src/query-expander.ts +67 -0
  67. package/src/recall-governor.ts +183 -0
  68. package/src/retention-policy.ts +90 -0
  69. package/src/retriever.ts +828 -55
  70. package/src/rif.ts +34 -8
  71. package/src/runtime-config.ts +23 -8
  72. package/src/scope-inventory.ts +4 -3
  73. package/src/scope-policy.ts +4 -0
  74. package/src/session-distiller.ts +307 -0
  75. package/src/session-output.ts +40 -0
  76. package/src/session-schema.ts +37 -0
  77. package/src/session-store.ts +7 -2
  78. package/src/skill-engine.ts +208 -0
  79. package/src/skill-promotion.ts +293 -0
  80. package/src/skill-schema.ts +39 -0
  81. package/src/store.ts +96 -10
  82. package/src/topic-tag.ts +66 -0
  83. package/src/tracker.ts +3 -1
  84. package/src/ui-server.ts +80 -8
  85. package/src/workflow-observation-scope-review.ts +2 -1
  86. package/src/workflow-observation-store.ts +2 -1
  87. package/src/write-verifier.ts +126 -0
  88. package/.claude-plugin/CLAUDE.md +0 -29
  89. package/.claude-plugin/marketplace.json +0 -18
  90. package/.github/workflows/ci.yml +0 -28
  91. package/.mcp.json +0 -9
  92. package/CHANGELOG.md +0 -51
  93. package/DEMO.md +0 -94
  94. package/EVAL.md +0 -89
  95. package/FAILURES.md +0 -75
  96. package/OPERATIONS.md +0 -59
  97. package/ROADMAP.md +0 -198
  98. package/assets/recallnest-workbench.png +0 -0
  99. package/assets/ui/app.js +0 -807
  100. package/assets/ui/index.html +0 -154
  101. package/assets/ui/styles.css +0 -605
  102. package/assets/wechat-qr.jpg +0 -0
  103. package/daily-reflections/2026-03-05.md +0 -15
  104. package/daily-reflections/2026-03-06.md +0 -15
  105. package/daily-reflections/2026-03-07.md +0 -7
  106. package/daily-reflections/2026-03-08.md +0 -34
  107. package/daily-reflections/2026-03-09.md +0 -35
  108. package/daily-reflections/2026-03-10.md +0 -36
  109. package/daily-reflections/2026-03-11.md +0 -35
  110. package/daily-reflections/2026-03-12.md +0 -36
  111. package/daily-reflections/2026-03-13.md +0 -35
  112. package/daily-reflections/2026-03-14.md +0 -35
  113. package/daily-reflections/2026-03-15.md +0 -35
  114. package/daily-reflections/2026-03-16.md +0 -41
  115. package/daily-reflections/2026-03-17.md +0 -10
  116. package/daily-reflections/2026-03-18.md +0 -10
  117. package/daily-reflections/2026-03-19.md +0 -10
  118. package/daily-reflections/2026-03-20.md +0 -10
  119. package/daily-reflections/2026-03-21.md +0 -10
  120. package/daily-reflections/2026-03-22.md +0 -10
  121. package/daily-reflections/2026-03-23.md +0 -10
  122. package/daily-reflections/2026-03-24.md +0 -10
  123. package/daily-reflections/2026-03-25.md +0 -32
  124. package/daily-reflections/2026-03-26.md +0 -37
  125. package/daily-reflections/2026-03-27.md +0 -35
  126. package/daily-reflections/2026-03-28.md +0 -35
  127. package/daily-reflections/2026-03-29.md +0 -35
  128. package/daily-reflections/2026-03-30.md +0 -36
  129. package/daily-reflections/2026-03-31.md +0 -35
  130. package/daily-reflections/2026-04-01.md +0 -35
  131. package/docs/api-reference.md +0 -1291
  132. package/docs/architecture.md +0 -168
  133. package/docs/memory-boundary-contract.md +0 -226
  134. package/docs/memory-categories.md +0 -119
  135. package/docs/pipeline-audit-ultramemory.md +0 -177
  136. package/docs/plans/2026-03-08-query-expansion.md +0 -270
  137. package/docs/plans/2026-03-16-continuity-layer.md +0 -196
  138. package/docs/plans/mcp-tiering-plan.md +0 -126
  139. package/docs/research/recall-quality-audit-2026-03-22.md +0 -153
  140. package/docs/self-evolution.md +0 -198
  141. package/docs/superpowers/plans/2026-03-19-ingest-pipeline-upgrade.md +0 -782
  142. package/docs/superpowers/specs/2026-03-19-ingest-pipeline-upgrade-design.md +0 -77
  143. package/docs/superpowers/specs/2026-03-23-pull-claude-desktop-design.md +0 -95
  144. package/eval/continuity/results-2026-03-16.md +0 -117
  145. package/eval/continuity/results-2026-03-19.md +0 -453
  146. package/eval/continuity/runbook.md +0 -110
  147. package/eval/continuity/seed-cases.md +0 -54
  148. package/eval/reports/2026-03-06-abstract-query-baseline.md +0 -96
  149. package/eval/reports/2026-03-06-aws-ssh-baseline.md +0 -82
  150. package/eval/reports/2026-03-06-baseline.md +0 -82
  151. package/eval/reports/query-expansion.md +0 -138
  152. package/lm +0 -19
  153. package/plan-v1.2-distributable.md +0 -105
  154. package/scripts/archive-distilled-junk.ts +0 -116
  155. package/scripts/backfill-l0l1l2.ts +0 -98
  156. package/scripts/blind-scan.ts +0 -588
  157. package/scripts/cleanup-step1-exact-dups.ts +0 -117
  158. package/scripts/cleanup-step2-template-dups.ts +0 -113
  159. package/scripts/cleanup-step3-stale-content.ts +0 -149
  160. package/scripts/cleanup-step4-orphan-scopes.ts +0 -131
  161. package/scripts/delete-distilled-junk.ts +0 -88
  162. package/scripts/distill-facts.ts +0 -305
  163. package/scripts/health-check.ts +0 -343
  164. package/scripts/incremental-ingest.sh +0 -71
  165. package/scripts/mlp-health-check.ts +0 -382
  166. package/scripts/pull-claude-desktop.ts +0 -515
  167. package/scripts/re-ingest-cc.sh +0 -66
  168. package/scripts/rebalance-memory-health.ts +0 -181
  169. package/scripts/run-desktop-ingest.sh +0 -11
  170. package/scripts/start-server.sh +0 -12
  171. package/scripts/viz-dashboard.html +0 -423
  172. package/scripts/viz-export.ts +0 -703
  173. package/src/__tests__/access-tracker-novelty.test.ts +0 -228
  174. package/src/__tests__/auto-consolidation.test.ts +0 -168
  175. package/src/__tests__/auto-recall.test.ts +0 -131
  176. package/src/__tests__/batch-dedup.test.ts +0 -65
  177. package/src/__tests__/capture-engine.test.ts +0 -1142
  178. package/src/__tests__/capture-heuristic.test.ts +0 -171
  179. package/src/__tests__/cascade-forget.test.ts +0 -210
  180. package/src/__tests__/category-min-scores.test.ts +0 -33
  181. package/src/__tests__/confidence-tracker.test.ts +0 -196
  182. package/src/__tests__/conflict-advisor.test.ts +0 -206
  183. package/src/__tests__/conflict-escalation.test.ts +0 -149
  184. package/src/__tests__/conflict-lifecycle.test.ts +0 -83
  185. package/src/__tests__/conflict-output.test.ts +0 -85
  186. package/src/__tests__/conflict-store.test.ts +0 -374
  187. package/src/__tests__/consolidation-engine.test.ts +0 -181
  188. package/src/__tests__/context-composer-pins.test.ts +0 -64
  189. package/src/__tests__/context-composer-task-selection.test.ts +0 -24
  190. package/src/__tests__/context-renderer.test.ts +0 -91
  191. package/src/__tests__/continuity-eval.test.ts +0 -275
  192. package/src/__tests__/continuity-schema.test.ts +0 -97
  193. package/src/__tests__/core-recall-eval.test.ts +0 -235
  194. package/src/__tests__/core-summary.test.ts +0 -109
  195. package/src/__tests__/dedup-actions.test.ts +0 -58
  196. package/src/__tests__/deterministic-id.test.ts +0 -39
  197. package/src/__tests__/doctor.test.ts +0 -303
  198. package/src/__tests__/drill-down.test.ts +0 -108
  199. package/src/__tests__/embedder.test.ts +0 -122
  200. package/src/__tests__/entity-resolver.test.ts +0 -58
  201. package/src/__tests__/event-segmenter.test.ts +0 -153
  202. package/src/__tests__/hotness-blend.test.ts +0 -76
  203. package/src/__tests__/ingest.test.ts +0 -360
  204. package/src/__tests__/kg-extractor.test.ts +0 -184
  205. package/src/__tests__/kg-store.test.ts +0 -198
  206. package/src/__tests__/llm-consolidation.test.ts +0 -226
  207. package/src/__tests__/memory-boundaries.test.ts +0 -237
  208. package/src/__tests__/memory-health-rebalance.test.ts +0 -144
  209. package/src/__tests__/memory-output.test.ts +0 -215
  210. package/src/__tests__/multi-vector.test.ts +0 -242
  211. package/src/__tests__/ppr-traversal.test.ts +0 -256
  212. package/src/__tests__/preference-guard-smoke.test.ts +0 -211
  213. package/src/__tests__/preference-matcher.test.ts +0 -230
  214. package/src/__tests__/preference-slots.test.ts +0 -106
  215. package/src/__tests__/prospective-memory.test.ts +0 -283
  216. package/src/__tests__/query-entity-detector.test.ts +0 -171
  217. package/src/__tests__/query-expander.test.ts +0 -34
  218. package/src/__tests__/recall-mode.test.ts +0 -64
  219. package/src/__tests__/result-synthesizer.test.ts +0 -198
  220. package/src/__tests__/resume-context.test.ts +0 -4743
  221. package/src/__tests__/retrieval-trace.test.ts +0 -213
  222. package/src/__tests__/retriever-boundary-weight.test.ts +0 -136
  223. package/src/__tests__/retroactive-boost.test.ts +0 -223
  224. package/src/__tests__/scope-inventory.test.ts +0 -200
  225. package/src/__tests__/scope-policy.test.ts +0 -69
  226. package/src/__tests__/session-checkpoint.test.ts +0 -259
  227. package/src/__tests__/session-gc.test.ts +0 -215
  228. package/src/__tests__/store-scope-audit.test.ts +0 -81
  229. package/src/__tests__/temporal-parser.test.ts +0 -241
  230. package/src/__tests__/term-registry.test.ts +0 -121
  231. package/src/__tests__/tool-output-compressor.test.ts +0 -133
  232. package/src/__tests__/version-manager.test.ts +0 -213
  233. package/src/__tests__/workflow-observation.test.ts +0 -400
@@ -0,0 +1,163 @@
1
+ /**
2
+ * LME-8: Admission Control — 写入准入控制
3
+ *
4
+ * 在 capture-engine.ts 的 persistMemory 入口处执行写前门控,
5
+ * 拦截低质量记忆,避免垃圾进库再过滤。
6
+ *
7
+ * 4 层准入检查:
8
+ * 1. 最短文本长度 — 过短文本拒绝
9
+ * 2. Noise filter — 噪声模式前置过滤
10
+ * 3. Importance 下限 — 低 importance 拒绝
11
+ * 4. Scope 写入频率限制 — 防 flood
12
+ */
13
+
14
+ import { isNoise } from "./noise-filter.js";
15
+ import { logInfo } from "./stderr-log.js";
16
+
17
+ // ============================================================================
18
+ // Configuration
19
+ // ============================================================================
20
+
21
+ export interface AdmissionConfig {
22
+ /** Minimum text length to accept (default: 10) */
23
+ minTextLength: number;
24
+ /** Enable noise-filter pre-check (default: true) */
25
+ noiseFilterEnabled: boolean;
26
+ /** Minimum importance to accept (default: 0.2) */
27
+ minImportance: number;
28
+ /** Max writes per scope within the rate window (default: 50) */
29
+ maxWritesPerScope: number;
30
+ /** Rate limit window in milliseconds (default: 60_000 = 1 minute) */
31
+ rateLimitWindowMs: number;
32
+ }
33
+
34
+ const DEFAULT_CONFIG: AdmissionConfig = {
35
+ minTextLength: 10,
36
+ noiseFilterEnabled: true,
37
+ minImportance: 0.2,
38
+ maxWritesPerScope: 50,
39
+ rateLimitWindowMs: 60_000,
40
+ };
41
+
42
+ export function resolveAdmissionConfig(
43
+ overrides?: Partial<AdmissionConfig>,
44
+ ): AdmissionConfig {
45
+ return { ...DEFAULT_CONFIG, ...overrides };
46
+ }
47
+
48
+ // ============================================================================
49
+ // Admission Result
50
+ // ============================================================================
51
+
52
+ export type AdmissionVerdict = "accepted" | "rejected";
53
+ export type RejectionReason =
54
+ | "text_too_short"
55
+ | "noise_detected"
56
+ | "importance_too_low"
57
+ | "rate_limited";
58
+
59
+ export interface AdmissionResult {
60
+ verdict: AdmissionVerdict;
61
+ reason?: RejectionReason;
62
+ }
63
+
64
+ const ACCEPTED: AdmissionResult = { verdict: "accepted" };
65
+
66
+ // ============================================================================
67
+ // Rate Limiter — per-scope write frequency tracking
68
+ // ============================================================================
69
+
70
+ export class ScopeRateLimiter {
71
+ private readonly windows = new Map<string, number[]>();
72
+
73
+ /**
74
+ * Record a write for a scope and check if rate limit is exceeded.
75
+ * Returns true if the write should be allowed.
76
+ */
77
+ check(scope: string, config: AdmissionConfig): boolean {
78
+ const now = Date.now();
79
+ const cutoff = now - config.rateLimitWindowMs;
80
+
81
+ let timestamps = this.windows.get(scope);
82
+ if (!timestamps) {
83
+ timestamps = [];
84
+ this.windows.set(scope, timestamps);
85
+ }
86
+
87
+ // Prune expired timestamps
88
+ const firstValid = timestamps.findIndex((t) => t >= cutoff);
89
+ if (firstValid > 0) {
90
+ timestamps.splice(0, firstValid);
91
+ } else if (firstValid === -1) {
92
+ timestamps.length = 0;
93
+ }
94
+
95
+ if (timestamps.length >= config.maxWritesPerScope) {
96
+ return false;
97
+ }
98
+
99
+ timestamps.push(now);
100
+ return true;
101
+ }
102
+
103
+ /** Number of scopes currently tracked. */
104
+ get size(): number {
105
+ return this.windows.size;
106
+ }
107
+
108
+ /** Clear all tracking state. */
109
+ clear(): void {
110
+ this.windows.clear();
111
+ }
112
+ }
113
+
114
+ // ============================================================================
115
+ // Public API — admission gate
116
+ // ============================================================================
117
+
118
+ /**
119
+ * Check whether a memory write should be admitted.
120
+ * Call this at the top of persistMemory, before embedding.
121
+ *
122
+ * @param text - Memory text to evaluate
123
+ * @param importance - Importance score (0–1)
124
+ * @param scope - Target scope
125
+ * @param rateLimiter - Optional shared rate limiter instance
126
+ * @param config - Optional config overrides
127
+ */
128
+ export function checkAdmission(
129
+ text: string,
130
+ importance: number,
131
+ scope: string,
132
+ rateLimiter?: ScopeRateLimiter,
133
+ config?: Partial<AdmissionConfig>,
134
+ ): AdmissionResult {
135
+ const cfg = resolveAdmissionConfig(config);
136
+
137
+ // Layer 1: minimum text length
138
+ const trimmed = text.trim();
139
+ if (trimmed.length < cfg.minTextLength) {
140
+ logInfo(`[admission-control] rejected: text too short (${trimmed.length} < ${cfg.minTextLength})`);
141
+ return { verdict: "rejected", reason: "text_too_short" };
142
+ }
143
+
144
+ // Layer 2: noise filter pre-check
145
+ if (cfg.noiseFilterEnabled && isNoise(trimmed)) {
146
+ logInfo(`[admission-control] rejected: noise detected`);
147
+ return { verdict: "rejected", reason: "noise_detected" };
148
+ }
149
+
150
+ // Layer 3: importance floor
151
+ if (importance < cfg.minImportance) {
152
+ logInfo(`[admission-control] rejected: importance too low (${importance} < ${cfg.minImportance})`);
153
+ return { verdict: "rejected", reason: "importance_too_low" };
154
+ }
155
+
156
+ // Layer 4: scope rate limiting
157
+ if (rateLimiter && !rateLimiter.check(scope, cfg)) {
158
+ logInfo(`[admission-control] rejected: rate limited for scope "${scope}"`);
159
+ return { verdict: "rejected", reason: "rate_limited" };
160
+ }
161
+
162
+ return ACCEPTED;
163
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * P0.1 — Anchor Generator
3
+ *
4
+ * Generates a short (≤80 chars) retrieval anchor for each memory.
5
+ * Short anchors bridge the embedding distance gap between brief queries
6
+ * (e.g. "轮巡") and long stored documents.
7
+ *
8
+ * Heuristic-only — no LLM calls, no latency added to writes.
9
+ */
10
+
11
+ const MAX_ANCHOR_LENGTH = 80;
12
+
13
+ /** CJK character detector */
14
+ const CJK_RE = /[\u4e00-\u9fff\u3400-\u4dbf]/;
15
+
16
+ /** Extract first meaningful sentence (period, or newline). */
17
+ function firstSentence(text: string): string {
18
+ // Split on newline first (preserves paragraph structure)
19
+ const firstLine = text.split(/\n/)[0].trim();
20
+
21
+ // Then split on sentence boundaries within that line
22
+ const match = firstLine.match(/^(.+?)[。!?.!?]/);
23
+ return match ? match[1].trim() : firstLine;
24
+ }
25
+
26
+ /** Extract structured title from metadata (patterns, cases). */
27
+ function extractTitle(metadata?: Record<string, unknown>): string | undefined {
28
+ if (!metadata) return undefined;
29
+ const wp = metadata.workflowPattern as Record<string, unknown> | undefined;
30
+ if (wp?.title && typeof wp.title === "string") return wp.title;
31
+ const cm = metadata.caseMemory as Record<string, unknown> | undefined;
32
+ if (cm?.title && typeof cm.title === "string") return cm.title;
33
+ return undefined;
34
+ }
35
+
36
+ /**
37
+ * Generate a retrieval anchor (≤80 chars) for a memory entry.
38
+ *
39
+ * Priority:
40
+ * 1. Structured title from metadata (patterns/cases always have one)
41
+ * 2. First sentence of text (heuristic)
42
+ *
43
+ * If the text is already short (≤ MAX_ANCHOR_LENGTH), returns undefined
44
+ * (no anchor needed — the text itself is a good match target).
45
+ */
46
+ export function generateAnchor(
47
+ text: string,
48
+ metadata?: Record<string, unknown>,
49
+ ): string | undefined {
50
+ // Short text already matches short queries well — skip anchor
51
+ if (text.length <= MAX_ANCHOR_LENGTH) return undefined;
52
+
53
+ // Try structured title first (most precise)
54
+ const title = extractTitle(metadata);
55
+ if (title && title.length <= MAX_ANCHOR_LENGTH) {
56
+ return title;
57
+ }
58
+
59
+ // Fall back to first sentence
60
+ let anchor = firstSentence(text);
61
+
62
+ // Truncate to max length, preserving word/char boundary
63
+ if (anchor.length > MAX_ANCHOR_LENGTH) {
64
+ if (CJK_RE.test(anchor)) {
65
+ // CJK: cut at char boundary
66
+ anchor = anchor.slice(0, MAX_ANCHOR_LENGTH);
67
+ } else {
68
+ // Latin: cut at word boundary
69
+ anchor = anchor.slice(0, MAX_ANCHOR_LENGTH).replace(/\s+\S*$/, "");
70
+ }
71
+ }
72
+
73
+ // If anchor is too similar to full text start, it adds no value
74
+ if (anchor === text.slice(0, anchor.length)) {
75
+ // Still useful — the shorter embedding will be closer to short queries
76
+ return anchor;
77
+ }
78
+
79
+ return anchor || undefined;
80
+ }
package/src/api-server.ts CHANGED
@@ -24,6 +24,7 @@ import { WorkflowObservationStore } from "./workflow-observation-store.js";
24
24
  import { buildManagedCheckpointObservation, buildManagedResumeObservation } from "./workflow-observation-managed.js";
25
25
  import { runAutoRecall } from "./auto-recall.js";
26
26
  import { buildRetrievalContext, resolveScopeSelection } from "./scope-policy.js";
27
+ import { runMemoryLint } from "./memory-lint.js";
27
28
 
28
29
  const config = (loadDotEnv(), loadConfig());
29
30
  const getComponents = createComponentResolver(config);
@@ -589,6 +590,14 @@ async function handleStats(): Promise<Response> {
589
590
  });
590
591
  }
591
592
 
593
+ /** GET /v1/lint — memory quality lint */
594
+ async function handleLint(searchParams: URLSearchParams): Promise<Response> {
595
+ const { store } = getComponents();
596
+ const scope = searchParams.get("scope") || undefined;
597
+ const report = await runMemoryLint({ store, scope });
598
+ return jsonResponse(report);
599
+ }
600
+
592
601
  /** GET /v1/health — health check */
593
602
  async function handleHealth(): Promise<Response> {
594
603
  try {
@@ -652,6 +661,7 @@ const server = Bun.serve({
652
661
  if (pathname === "/v1/workflow-health") return await handleWorkflowHealth(request);
653
662
  if (pathname === "/v1/workflow-evidence") return await handleWorkflowEvidence(request);
654
663
  if (pathname === "/v1/stats") return await handleStats();
664
+ if (pathname === "/v1/lint") return await handleLint(url.searchParams);
655
665
  if (pathname === "/v1/health") return await handleHealth();
656
666
  }
657
667
 
@@ -0,0 +1,122 @@
1
+ /**
2
+ * F-1: Audit log — record every store/update/delete/retrieve operation.
3
+ * Logs are written to a separate file (not mixed into memory data).
4
+ *
5
+ * Each log entry: { timestamp, operation, scope, memoryId, actor, details }
6
+ */
7
+
8
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
+
11
+ export type AuditOperation =
12
+ | "store"
13
+ | "update"
14
+ | "delete"
15
+ | "retrieve"
16
+ | "archive"
17
+ | "supersede"
18
+ | "consolidate"
19
+ | "forget"
20
+ | "cascade_forget";
21
+
22
+ export interface AuditEntry {
23
+ timestamp: string; // ISO 8601
24
+ operation: AuditOperation;
25
+ scope?: string;
26
+ memoryId?: string;
27
+ actor: string; // "manual" | "agent" | "api" | "system"
28
+ details?: string; // brief context (<=200 chars)
29
+ }
30
+
31
+ export interface AuditLogger {
32
+ log(entry: Omit<AuditEntry, "timestamp">): void;
33
+ /** Read recent entries (newest first) */
34
+ getRecent(limit?: number): AuditEntry[];
35
+ /** Export all entries as JSON array */
36
+ exportAll(): AuditEntry[];
37
+ /** Count total entries */
38
+ count(): number;
39
+ }
40
+
41
+ function parseLines(raw: string): AuditEntry[] {
42
+ const entries: AuditEntry[] = [];
43
+ for (const line of raw.split("\n")) {
44
+ const trimmed = line.trim();
45
+ if (!trimmed) continue;
46
+ try {
47
+ entries.push(JSON.parse(trimmed) as AuditEntry);
48
+ } catch {
49
+ // skip malformed lines
50
+ }
51
+ }
52
+ return entries;
53
+ }
54
+
55
+ /**
56
+ * File-based audit logger. Appends JSON lines to a log file.
57
+ * Non-blocking: write failures are silently ignored (audit must never block operations).
58
+ */
59
+ export function createAuditLogger(logPath?: string): AuditLogger {
60
+ const resolvedPath =
61
+ logPath ??
62
+ join(process.env.RECALLNEST_DATA_DIR || "data", "audit.jsonl");
63
+
64
+ // Ensure directory exists (silent on failure)
65
+ try {
66
+ const dir = dirname(resolvedPath);
67
+ if (!existsSync(dir)) {
68
+ mkdirSync(dir, { recursive: true });
69
+ }
70
+ } catch {
71
+ // silent — audit must never block
72
+ }
73
+
74
+ return {
75
+ log(entry: Omit<AuditEntry, "timestamp">): void {
76
+ try {
77
+ const full: AuditEntry = {
78
+ timestamp: new Date().toISOString(),
79
+ ...entry,
80
+ };
81
+ // Truncate details to 200 chars
82
+ if (full.details && full.details.length > 200) {
83
+ full.details = full.details.slice(0, 200);
84
+ }
85
+ appendFileSync(resolvedPath, JSON.stringify(full) + "\n", "utf-8");
86
+ } catch {
87
+ // silent — audit must never block operations
88
+ }
89
+ },
90
+
91
+ getRecent(limit = 20): AuditEntry[] {
92
+ try {
93
+ if (!existsSync(resolvedPath)) return [];
94
+ const raw = readFileSync(resolvedPath, "utf-8");
95
+ const all = parseLines(raw);
96
+ return all.slice(-limit).reverse();
97
+ } catch {
98
+ return [];
99
+ }
100
+ },
101
+
102
+ exportAll(): AuditEntry[] {
103
+ try {
104
+ if (!existsSync(resolvedPath)) return [];
105
+ const raw = readFileSync(resolvedPath, "utf-8");
106
+ return parseLines(raw);
107
+ } catch {
108
+ return [];
109
+ }
110
+ },
111
+
112
+ count(): number {
113
+ try {
114
+ if (!existsSync(resolvedPath)) return 0;
115
+ const raw = readFileSync(resolvedPath, "utf-8");
116
+ return parseLines(raw).length;
117
+ } catch {
118
+ return 0;
119
+ }
120
+ },
121
+ };
122
+ }
package/src/auto-gc.ts CHANGED
@@ -5,10 +5,20 @@
5
5
  * AND enough time has passed since last GC. Inspired by NREM triple-coupling:
6
6
  * multiple conditions must be satisfied before consolidation fires.
7
7
  *
8
- * Wraps existing cleanup scripts into a programmatic API.
8
+ * B-2 upgrade: Uses evolution system's computeDecayScore + buildArchivedMetadata
9
+ * instead of raw age/importance heuristics. Archive-first, delete-never.
9
10
  */
10
11
 
11
12
  import type { MemoryStore } from "./store.js";
13
+ import {
14
+ parseEvolution,
15
+ computeDecayScore,
16
+ buildArchivedMetadata,
17
+ isActiveMemory,
18
+ } from "./memory-evolution.js";
19
+ import { loadRetentionPolicy, shouldArchiveByPolicy } from "./retention-policy.js";
20
+ import type { RetentionPolicy } from "./retention-policy.js";
21
+ import type { AuditLogger } from "./audit-log.js";
12
22
 
13
23
  // ---------------------------------------------------------------------------
14
24
  // Config
@@ -19,17 +29,20 @@ export interface AutoGcConfig {
19
29
  minMemoryCount: number;
20
30
  /** Minimum hours since last GC (default: 24) */
21
31
  minHoursSinceLastGc: number;
22
- /** Score below which memories are candidates for archival (default: 0.2) */
23
- staleScoreThreshold: number;
32
+ /** Decay score below which memories are archive candidates (default: 0.15) */
33
+ decayScoreThreshold: number;
24
34
  /** Max entries to archive per run (default: 100) */
25
35
  maxArchivePerRun: number;
36
+ /** Minimum age in days before a memory can be archived (default: 30) */
37
+ minAgeDays: number;
26
38
  }
27
39
 
28
40
  export const DEFAULT_AUTO_GC_CONFIG: AutoGcConfig = {
29
41
  minMemoryCount: 1000,
30
42
  minHoursSinceLastGc: 24,
31
- staleScoreThreshold: 0.2,
43
+ decayScoreThreshold: 0.15,
32
44
  maxArchivePerRun: 100,
45
+ minAgeDays: 30,
33
46
  };
34
47
 
35
48
  // ---------------------------------------------------------------------------
@@ -52,10 +65,18 @@ export interface GcResult {
52
65
  /**
53
66
  * Check conditions and run GC if all thresholds are met.
54
67
  * Returns immediately if conditions not met (no-op).
68
+ *
69
+ * Archive criteria (B-2 evolution-aware):
70
+ * - Memory must be "active" (not already superseded/archived/consolidated)
71
+ * - Composite decay_score < threshold (default 0.15)
72
+ * - Age > minAgeDays (default 30 days)
73
+ * - Not pinned (importance >= 0.95 is considered pinned)
55
74
  */
56
75
  export async function maybeRunGc(
57
76
  store: MemoryStore,
58
77
  config: AutoGcConfig = DEFAULT_AUTO_GC_CONFIG,
78
+ retentionConfigDir?: string,
79
+ auditLogger?: AuditLogger,
59
80
  ): Promise<GcResult> {
60
81
  const stats = await store.stats();
61
82
  const totalMemories = stats.total ?? 0;
@@ -74,8 +95,7 @@ export async function maybeRunGc(
74
95
  // All conditions met — run GC
75
96
  lastGcTimestamp = Date.now();
76
97
 
77
- // Find stale peripheral memories with low importance and no recent access
78
- const cutoffMs = Date.now() - 60 * 86_400_000; // 60 days ago
98
+ const now = Date.now();
79
99
  let archivedCount = 0;
80
100
  let totalChecked = 0;
81
101
 
@@ -83,33 +103,82 @@ export async function maybeRunGc(
83
103
  const entries = await store.list({ limit: 5000 });
84
104
  totalChecked = entries.length;
85
105
 
106
+ // Pre-compute per-scope active memory counts and cache retention policies.
107
+ // This avoids repeated file reads inside the loop.
108
+ const activeCounts = new Map<string, number>();
109
+ const policyCache = new Map<string, RetentionPolicy>();
110
+ const scopesSeen = new Set<string>();
111
+
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);
117
+ }
118
+ }
119
+
120
+ // Batch-load retention policies for all scopes (one file read per scope)
121
+ for (const scope of scopesSeen) {
122
+ policyCache.set(scope, loadRetentionPolicy(scope, retentionConfigDir));
123
+ }
124
+
86
125
  for (const entry of entries) {
87
126
  if (archivedCount >= config.maxArchivePerRun) break;
88
127
 
89
- let meta: Record<string, any> = {};
90
- try { meta = JSON.parse(entry.metadata || "{}"); } catch { /* skip */ }
91
-
92
- const isArchived = meta.archived === true;
93
- if (isArchived) continue;
94
-
95
- const tier = meta.tier || "peripheral";
96
- const accessCount = meta.accessCount || 0;
97
- const importance = entry.importance ?? 0;
98
- const age = Date.now() - entry.timestamp;
99
- const ageDays = age / 86_400_000;
100
-
101
- // Archive criteria: peripheral + old + low importance + rarely accessed
102
- if (
103
- tier === "peripheral" &&
104
- ageDays > 60 &&
105
- importance < config.staleScoreThreshold &&
106
- accessCount < 2
107
- ) {
108
- meta.archived = true;
109
- meta.archivedAt = new Date().toISOString();
110
- meta.archivedReason = "auto-gc:stale-peripheral";
111
- await store.update(entry.id, { metadata: JSON.stringify(meta) });
128
+ // Only archive active memories
129
+ if (!isActiveMemory(entry.metadata)) continue;
130
+
131
+ // Never archive pinned memories (importance >= 0.95)
132
+ const importance = entry.importance ?? 0.5;
133
+ if (importance >= 0.95) continue;
134
+
135
+ // Check minimum age
136
+ const ageDays = (now - entry.timestamp) / 86_400_000;
137
+
138
+ // Decay-based archival (existing logic)
139
+ let shouldArchive = false;
140
+ if (ageDays >= config.minAgeDays) {
141
+ const evo = parseEvolution(entry.metadata, entry.timestamp);
142
+ let decayScore = computeDecayScore(evo, importance, now, entry.metadata);
143
+ // F3: Expired memories (validUntil < now) get 2x decay acceleration
144
+ if (evo.validUntil != null && evo.validUntil < now) {
145
+ decayScore *= 0.5; // Halve the score → archives faster
146
+ }
147
+ if (decayScore < config.decayScoreThreshold) {
148
+ shouldArchive = true;
149
+ }
150
+ }
151
+
152
+ // Retention-policy-based archival (F-2): OR with decay-based
153
+ if (!shouldArchive) {
154
+ const scope = entry.scope ?? "";
155
+ const policy = policyCache.get(scope) ?? loadRetentionPolicy(scope, retentionConfigDir);
156
+ const activeCount = activeCounts.get(scope) ?? 0;
157
+ const policyCheck = shouldArchiveByPolicy(policy, ageDays, activeCount);
158
+ if (policyCheck.archive) {
159
+ shouldArchive = true;
160
+ }
161
+ }
162
+
163
+ if (shouldArchive) {
164
+ const archivedMeta = buildArchivedMetadata(entry.metadata);
165
+ await store.update(entry.id, { metadata: archivedMeta });
112
166
  archivedCount++;
167
+ // F-1: Audit log — record archive operation (silent on failure)
168
+ try {
169
+ auditLogger?.log({
170
+ operation: "archive",
171
+ memoryId: entry.id,
172
+ actor: "system",
173
+ details: `auto-gc: age=${Math.floor(ageDays)}d`,
174
+ });
175
+ } catch { /* Audit must never block GC */ }
176
+ // Decrement active count for the scope (memory is no longer active)
177
+ const scope = entry.scope ?? "";
178
+ const prev = activeCounts.get(scope) ?? 0;
179
+ if (prev > 0) {
180
+ activeCounts.set(scope, prev - 1);
181
+ }
113
182
  }
114
183
  }
115
184
 
@@ -1,5 +1,7 @@
1
1
  import { composeResumeContext, type ResumeContextDeps } from "./context-composer.js";
2
+ import { resolveTier } from "./decay-engine.js";
2
3
  import type { DurableMemoryCategory } from "./memory-schema.js";
4
+ import { governResults, truncateQuery, type GovernorConfig, type GovernorSession } from "./recall-governor.js";
3
5
  import type { RetrievalResult } from "./retriever.js";
4
6
  import { buildRetrievalContext, resolveScopeSelection } from "./scope-policy.js";
5
7
  import type { RetrievalProfileName, ResumeContextResponse } from "./session-schema.js";
@@ -17,6 +19,10 @@ export interface AutoRecallRequest {
17
19
  profile?: RetrievalProfileName;
18
20
  env?: NodeJS.ProcessEnv;
19
21
  operation?: string;
22
+ /** Governor config overrides (charBudget, maxItems, maxQueryChars) */
23
+ governor?: Partial<GovernorConfig>;
24
+ /** Governor session for cross-call dedup within a single conversation turn */
25
+ governorSession?: GovernorSession;
20
26
  }
21
27
 
22
28
  export interface AutoRecallResponse {
@@ -31,8 +37,10 @@ export async function runAutoRecall(
31
37
  deps: ResumeContextDeps,
32
38
  request: AutoRecallRequest,
33
39
  ): Promise<AutoRecallResponse> {
34
- const message = request.message.replace(/\s+/g, " ").trim();
35
- const task = (request.task || message).replace(/\s+/g, " ").trim();
40
+ const govCfg = request.governor;
41
+ const maxQ = govCfg?.maxQueryChars ?? 1000;
42
+ const message = truncateQuery(request.message.replace(/\s+/g, " ").trim(), maxQ);
43
+ const task = truncateQuery((request.task || message).replace(/\s+/g, " ").trim(), maxQ);
36
44
 
37
45
  if (!message) {
38
46
  throw new Error("message is required");
@@ -72,9 +80,11 @@ export async function runAutoRecall(
72
80
  };
73
81
  }
74
82
 
75
- const results = await deps.retriever.retrieve(buildRetrievalContext({
83
+ const effectiveLimit = request.limit || 5;
84
+ // Fetch extra candidates so tier filtering still yields enough results
85
+ const rawResults = await deps.retriever.retrieve(buildRetrievalContext({
76
86
  query: message,
77
- limit: request.limit || 5,
87
+ limit: effectiveLimit * 3,
78
88
  category: request.category,
79
89
  scope: resolvedScope,
80
90
  sessionId: request.sessionId,
@@ -86,6 +96,25 @@ export async function runAutoRecall(
86
96
  allowUnscoped: request.allScopes === true,
87
97
  }));
88
98
 
99
+ // Tier-aware filtering: prefer core/working memories, backfill with peripheral.
100
+ // Peripheral memories get a 15% score discount to reduce noise injection.
101
+ const promoted: RetrievalResult[] = [];
102
+ const demoted: RetrievalResult[] = [];
103
+ for (const r of rawResults) {
104
+ const tier = resolveTier(r.entry.metadata);
105
+ if (tier === "core" || tier === "working") {
106
+ promoted.push(r);
107
+ } else {
108
+ demoted.push({ ...r, score: r.score * 0.85 });
109
+ }
110
+ }
111
+ const tierFiltered = [...promoted, ...demoted]
112
+ .sort((a, b) => b.score - a.score)
113
+ .slice(0, effectiveLimit);
114
+
115
+ // LME-7: Recall Governor — evolution filter, budget control, session dedup
116
+ const results = governResults(tierFiltered, request.governorSession, govCfg);
117
+
89
118
  return {
90
119
  mode: "resume+search",
91
120
  resolvedScope,