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
package/src/rif.ts CHANGED
@@ -22,34 +22,60 @@ import type { RetrievalResult } from "./retriever.js";
22
22
  * @param results - Scored results sorted by score descending
23
23
  * @param similarityThreshold - Cosine similarity above which two results are "near-duplicate" (default: 0.85)
24
24
  * @param scoreRatio - A result scoring below this ratio of its near-duplicate is demoted (default: 0.80)
25
+ * @param clusterTopK - F2: Max results per semantic cluster before demotion (default: 3)
25
26
  */
26
27
  export function filterInterference(
27
28
  results: RetrievalResult[],
28
29
  similarityThreshold = 0.85,
29
30
  scoreRatio = 0.80,
31
+ clusterTopK = 3,
30
32
  ): RetrievalResult[] {
31
33
  if (results.length <= 2) return results;
32
34
 
33
35
  const kept: RetrievalResult[] = [];
34
36
  const demoted: RetrievalResult[] = [];
35
37
 
38
+ // F2: Track cluster membership — each kept result seeds or joins a cluster
39
+ // clusterMap: kept-index → cluster-id
40
+ const clusterMap: number[] = [];
41
+ const clusterCounts = new Map<number, number>(); // cluster-id → count
42
+ let nextClusterId = 0;
43
+
36
44
  for (const candidate of results) {
37
- // Check if this candidate is a weak near-duplicate of any kept result
38
45
  let isDemoted = false;
46
+ let matchedClusterId: number | null = null;
39
47
 
40
- for (const selected of kept) {
41
- const sim = cosineSimilarity(candidate.entry.vector, selected.entry.vector);
42
- if (sim > similarityThreshold && candidate.score < selected.score * scoreRatio) {
43
- isDemoted = true;
48
+ for (let i = 0; i < kept.length; i++) {
49
+ const sim = cosineSimilarity(candidate.entry.vector, kept[i].entry.vector);
50
+ if (sim > similarityThreshold) {
51
+ matchedClusterId = clusterMap[i];
52
+ // Original RIF: demote if score too low relative to stronger result
53
+ if (candidate.score < kept[i].score * scoreRatio) {
54
+ isDemoted = true;
55
+ break;
56
+ }
57
+ // F2: Cluster top-K — demote if cluster already has K members
58
+ const count = clusterCounts.get(matchedClusterId) ?? 0;
59
+ if (count >= clusterTopK) {
60
+ // Demote with 50% score penalty instead of full demotion
61
+ demoted.push({ ...candidate, score: candidate.score * 0.5 });
62
+ isDemoted = true;
63
+ break;
64
+ }
44
65
  break;
45
66
  }
46
67
  }
47
68
 
48
- if (isDemoted) {
49
- demoted.push(candidate);
50
- } else {
69
+ if (!isDemoted) {
70
+ const cid = matchedClusterId ?? nextClusterId++;
71
+ clusterMap.push(cid);
72
+ clusterCounts.set(cid, (clusterCounts.get(cid) ?? 0) + 1);
51
73
  kept.push(candidate);
74
+ } else if (matchedClusterId == null) {
75
+ // Demoted by score ratio, not cluster overflow
76
+ demoted.push(candidate);
52
77
  }
78
+ // cluster overflow case already pushed to demoted above
53
79
  }
54
80
 
55
81
  // Demoted results go to the end as fallback
@@ -2,15 +2,17 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { resolve, join } from "node:path";
3
3
  import { homedir } from "node:os";
4
4
 
5
+ import { metaDir } from "./compat.js";
5
6
  import { MemoryStore, validateStoragePath } from "./store.js";
6
7
  import { createEmbedder, getVectorDimensions, type EmbeddingConfig } from "./embedder.js";
7
8
  import { createRetriever, type RetrievalConfig, DEFAULT_RETRIEVAL_CONFIG } from "./retriever.js";
8
9
  import { applyRetrievalProfile } from "./retrieval-profiles.js";
9
10
  import { AccessTracker } from "./access-tracker.js";
11
+ import { FrequencyTracker } from "./frequency-tracker.js";
10
12
  import { createLLMClient, type LLMClient, type LLMConfig } from "./llm-client.js";
11
13
  import { logInfo } from "./stderr-log.js";
12
14
 
13
- export type RecallMode = "full" | "summary" | "off";
15
+ export type RecallMode = "full" | "light" | "summary" | "off";
14
16
 
15
17
  export interface LocalMemoryConfig {
16
18
  dbPath: string;
@@ -42,7 +44,7 @@ export interface LocalMemoryConfig {
42
44
  }
43
45
 
44
46
  export function loadDotEnv(): void {
45
- const envPath = resolve(import.meta.dir, "../.env");
47
+ const envPath = resolve(metaDir(import.meta), "../.env");
46
48
  if (!existsSync(envPath)) return;
47
49
  const content = readFileSync(envPath, "utf-8");
48
50
  for (const line of content.split("\n")) {
@@ -60,13 +62,13 @@ export function loadDotEnv(): void {
60
62
  export function findConfigPath(): string {
61
63
  if (process.env.LOCAL_MEMORY_CONFIG) return resolve(process.env.LOCAL_MEMORY_CONFIG);
62
64
 
63
- const localConfig = resolve(import.meta.dir, "../config.json");
65
+ const localConfig = resolve(metaDir(import.meta), "../config.json");
64
66
  if (existsSync(localConfig)) return localConfig;
65
67
 
66
68
  const branded = join(homedir(), ".config", "recallnest", "config.json");
67
69
  if (existsSync(branded)) return branded;
68
70
 
69
- const exampleExists = existsSync(resolve(import.meta.dir, "../config.json.example"));
71
+ const exampleExists = existsSync(resolve(metaDir(import.meta), "../config.json.example"));
70
72
  throw new Error(
71
73
  "Config not found.\n" +
72
74
  (exampleExists
@@ -95,7 +97,7 @@ export function expandHome(p: string): string {
95
97
  }
96
98
 
97
99
  export function createComponents(config: LocalMemoryConfig, profileName?: string) {
98
- const dbPath = resolve(import.meta.dir, "..", expandHome(config.dbPath));
100
+ const dbPath = resolve(metaDir(import.meta), "..", expandHome(config.dbPath));
99
101
  validateStoragePath(dbPath);
100
102
 
101
103
  const embeddingConfig: EmbeddingConfig = {
@@ -121,6 +123,13 @@ export function createComponents(config: LocalMemoryConfig, profileName?: string
121
123
  const accessTracker = new AccessTracker(store);
122
124
  retriever.setAccessTracker(accessTracker);
123
125
 
126
+ // P0.2: Attach frequency tracker for hit-count based boosting
127
+ const dataDir = resolve(metaDir(import.meta), "..", expandHome(config.dbPath), "..");
128
+ const frequencyTracker = new FrequencyTracker({
129
+ filePath: join(dataDir, "frequency-stats.json"),
130
+ });
131
+ retriever.setFrequencyTracker(frequencyTracker);
132
+
124
133
  // Create LLM client if configured (optional, graceful)
125
134
  let llm: LLMClient | null = null;
126
135
  if (config.llm) {
@@ -130,11 +139,11 @@ export function createComponents(config: LocalMemoryConfig, profileName?: string
130
139
  }
131
140
  }
132
141
 
133
- return { store, embedder, retriever, profile, accessTracker, llm };
142
+ return { store, embedder, retriever, profile, accessTracker, frequencyTracker, llm };
134
143
  }
135
144
 
136
145
  export function createStoreOnly(config: LocalMemoryConfig): MemoryStore {
137
- const dbPath = resolve(import.meta.dir, "..", expandHome(config.dbPath));
146
+ const dbPath = resolve(metaDir(import.meta), "..", expandHome(config.dbPath));
138
147
  validateStoragePath(dbPath);
139
148
  return new MemoryStore({
140
149
  dbPath,
@@ -144,18 +153,24 @@ export function createStoreOnly(config: LocalMemoryConfig): MemoryStore {
144
153
 
145
154
  export function createComponentResolver(config: LocalMemoryConfig) {
146
155
  const cache = new Map<string, ReturnType<typeof createComponents>>();
156
+ const MAX_COMPONENT_CACHE_SIZE = 32;
147
157
 
148
158
  return function getComponents(profileName?: string) {
149
159
  const key = profileName || "default";
150
160
  const cached = cache.get(key);
151
161
  if (cached) return cached;
162
+ // Evict oldest entry if cache is full
163
+ if (cache.size >= MAX_COMPONENT_CACHE_SIZE) {
164
+ const firstKey = cache.keys().next().value;
165
+ if (firstKey !== undefined) cache.delete(firstKey);
166
+ }
152
167
  const created = createComponents(config, profileName);
153
168
  cache.set(key, created);
154
169
  return created;
155
170
  };
156
171
  }
157
172
 
158
- const VALID_RECALL_MODES: RecallMode[] = ["full", "summary", "off"];
173
+ const VALID_RECALL_MODES: RecallMode[] = ["full", "light", "summary", "off"];
159
174
 
160
175
  /**
161
176
  * Resolve effective recall mode: per-call override > env var > config > default ("summary").
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync, readdirSync } from "node:fs";
2
2
  import { basename, join, resolve } from "node:path";
3
3
 
4
+ import { metaDir } from "./compat.js";
4
5
  import { suppressesLegacyScopeIssue } from "./legacy-scope-review.js";
5
6
  import { classifyLegacyScope, type LegacyScopeAudit, type LegacyScopeIssueKind, type MemoryStore } from "./store.js";
6
7
 
@@ -205,9 +206,9 @@ export async function collectScopeInventory(options: ScopeInventoryOptions): Pro
205
206
  recommendation: "Migrate or delete historical unscoped memory rows before removing compatibility shims.",
206
207
  };
207
208
 
208
- const pinsDir = options.pinsDir || resolve(import.meta.dir, "../data/pins");
209
- const checkpointsDir = options.checkpointsDir || resolve(import.meta.dir, "../data/session-checkpoints");
210
- const workflowObservationsDir = options.workflowObservationsDir || resolve(import.meta.dir, "../data/workflow-observations");
209
+ const pinsDir = options.pinsDir || resolve(metaDir(import.meta), "../data/pins");
210
+ const checkpointsDir = options.checkpointsDir || resolve(metaDir(import.meta), "../data/session-checkpoints");
211
+ const workflowObservationsDir = options.workflowObservationsDir || resolve(metaDir(import.meta), "../data/workflow-observations");
211
212
 
212
213
  const pinLayer = scanJsonLayer({
213
214
  dir: pinsDir,
@@ -137,6 +137,10 @@ export function buildRetrievalContext(
137
137
  includeArchived: base.includeArchived,
138
138
  trace: base.trace,
139
139
  graph: base.graph,
140
+ topicTag: base.topicTag,
141
+ reconstruct: base.reconstruct,
142
+ validAt: base.validAt,
143
+ includeExpired: base.includeExpired,
140
144
  scopeFilter: selection.scopeFilter,
141
145
  };
142
146
  }
@@ -0,0 +1,307 @@
1
+ import type { LLMClient } from "./llm-client.js";
2
+ import type { Embedder } from "./embedder.js";
3
+ import type { MemoryStore } from "./store.js";
4
+ import type { ConflictCandidateStore } from "./conflict-store.js";
5
+ import type { KGExtractor } from "./kg-extractor.js";
6
+ import type { AuditLogger } from "./audit-log.js";
7
+ import type { StoredMemoryRecord, DurableMemoryCategory } from "./memory-schema.js";
8
+ import { persistMemory } from "./capture-engine.js";
9
+ import { detectLang, getSessionPromptHook } from "./language-hook.js";
10
+
11
+ // ============================================================================
12
+ // Types
13
+ // ============================================================================
14
+
15
+ export interface ConversationMessage {
16
+ role: "user" | "assistant" | "system" | "tool";
17
+ content: string;
18
+ tool_name?: string;
19
+ timestamp?: string;
20
+ }
21
+
22
+ export interface MicrocompactResult {
23
+ tokens_freed: number;
24
+ tools_cleared: number;
25
+ }
26
+
27
+ export interface SummaryResult {
28
+ text: string;
29
+ dimensions: Record<string, string>;
30
+ }
31
+
32
+ export interface PersistResult {
33
+ memories_stored: number;
34
+ memories_deduped: number;
35
+ memories_conflicted: number;
36
+ ids: string[];
37
+ }
38
+
39
+ export interface DistillResult {
40
+ microcompact: MicrocompactResult;
41
+ summary: SummaryResult;
42
+ persisted: PersistResult;
43
+ compacted_messages: ConversationMessage[];
44
+ }
45
+
46
+ interface PersistDeps {
47
+ store: Pick<MemoryStore, "store" | "list" | "update" | "getById" | "get" | "vectorSearch">;
48
+ embedder: Pick<Embedder, "embedPassage">;
49
+ conflictStore?: Pick<ConflictCandidateStore, "save" | "replace" | "getOpenByFingerprint" | "getLatestByFingerprint">;
50
+ llm?: LLMClient | null;
51
+ kgExtractor?: KGExtractor | null;
52
+ auditLogger?: AuditLogger | null;
53
+ }
54
+
55
+ // ============================================================================
56
+ // Constants
57
+ // ============================================================================
58
+
59
+ const CLEARABLE_TOOLS = new Set([
60
+ "read_file", "bash", "grep", "glob", "web_search", "web_fetch",
61
+ "edit_file", "write_file", "Read", "Write", "Edit", "Bash",
62
+ "Grep", "Glob", "WebSearch", "WebFetch",
63
+ ]);
64
+
65
+ // Session summary fallback prompts (used when babel-memory is not installed)
66
+
67
+ const DEFAULT_DIMENSION_LABELS: Record<string, string> = {
68
+ user_intent: "User intent and requests",
69
+ technical_concepts: "Key technical concepts",
70
+ files_and_code: "Files and code segments involved",
71
+ errors_and_fixes: "Errors and fix records",
72
+ problem_solving: "Problem solving process",
73
+ user_quotes: "User original quotes preserved",
74
+ unfinished_tasks: "Unfinished tasks",
75
+ current_state: "Current work state",
76
+ next_steps: "Suggested next steps",
77
+ };
78
+
79
+ const DEFAULT_SESSION_SYSTEM = `You are a session summarizer. Analyze the conversation and produce a structured summary.
80
+ Wrap the summary in <summary></summary> tags.
81
+ Use numbered ## headings for each dimension:
82
+ ${Object.entries(DEFAULT_DIMENSION_LABELS).map(([, label], i) => `## ${i + 1}. ${label}`).join("\n")}
83
+ If a dimension has no relevant content, write "N/A".`;
84
+
85
+ interface DimensionMapping {
86
+ category: DurableMemoryCategory;
87
+ importance: number;
88
+ }
89
+
90
+ const DIMENSION_TO_MEMORY: Record<string, DimensionMapping> = {
91
+ user_intent: { category: "events", importance: 0.5 },
92
+ files_and_code: { category: "entities", importance: 0.6 },
93
+ errors_and_fixes: { category: "cases", importance: 0.7 },
94
+ problem_solving: { category: "patterns", importance: 0.8 },
95
+ user_quotes: { category: "preferences", importance: 0.7 },
96
+ };
97
+
98
+ // ============================================================================
99
+ // Layer 1: Microcompact
100
+ // ============================================================================
101
+
102
+ export function microcompact(
103
+ messages: ConversationMessage[],
104
+ opts: { preserveRecent?: number; keepRecentTools?: number } = {},
105
+ ): { messages: ConversationMessage[]; result: MicrocompactResult } {
106
+ const preserveRecent = opts.preserveRecent ?? 6;
107
+ const keepRecentTools = opts.keepRecentTools ?? 5;
108
+
109
+ if (messages.length === 0) {
110
+ return { messages: [], result: { tokens_freed: 0, tools_cleared: 0 } };
111
+ }
112
+
113
+ const cutoff = Math.max(0, messages.length - preserveRecent);
114
+ let tokensFreed = 0;
115
+ let toolsCleared = 0;
116
+
117
+ // Find tool messages eligible for clearing (before the preserve window)
118
+ const toolIndices: number[] = [];
119
+ for (let i = 0; i < cutoff; i++) {
120
+ const msg = messages[i];
121
+ if (msg.role === "tool" && msg.tool_name && CLEARABLE_TOOLS.has(msg.tool_name)) {
122
+ toolIndices.push(i);
123
+ }
124
+ }
125
+
126
+ // Keep the most recent N tool results even if they're before the cutoff
127
+ const indicesToClear = new Set(
128
+ toolIndices.slice(0, Math.max(0, toolIndices.length - keepRecentTools)),
129
+ );
130
+
131
+ const compacted: ConversationMessage[] = [];
132
+ for (let i = 0; i < messages.length; i++) {
133
+ const msg = messages[i];
134
+ if (indicesToClear.has(i)) {
135
+ const tokenCount = Math.ceil(msg.content.length / 4);
136
+ tokensFreed += tokenCount;
137
+ toolsCleared++;
138
+ compacted.push({
139
+ ...msg,
140
+ content: `[Cleared: ${msg.tool_name}, ~${tokenCount} tokens]`,
141
+ });
142
+ } else {
143
+ compacted.push({ ...msg });
144
+ }
145
+ }
146
+
147
+ return {
148
+ messages: compacted,
149
+ result: { tokens_freed: tokensFreed, tools_cleared: toolsCleared },
150
+ };
151
+ }
152
+
153
+ // ============================================================================
154
+ // Layer 2: LLM Structured Summary
155
+ // ============================================================================
156
+
157
+ export async function summarizeSession(
158
+ messages: ConversationMessage[],
159
+ llm: LLMClient,
160
+ ): Promise<SummaryResult> {
161
+ const conversationText = messages
162
+ .map((m) => `[${m.role}${m.tool_name ? `:${m.tool_name}` : ""}] ${m.content.slice(0, 500)}`)
163
+ .join("\n")
164
+ .slice(0, 8000);
165
+
166
+ // Use babel-memory bilingual prompt if available, otherwise fallback to defaults
167
+ const lang = detectLang(conversationText);
168
+ const babelPrompt = getSessionPromptHook(lang);
169
+ const sessionSystemPrompt = babelPrompt?.system ?? DEFAULT_SESSION_SYSTEM;
170
+ const dimensionLabels = babelPrompt?.dimensionLabels ?? DEFAULT_DIMENSION_LABELS;
171
+ const dimensionKeys = Object.keys(dimensionLabels);
172
+
173
+ const raw = await llm.chatLong(sessionSystemPrompt, conversationText, 2000);
174
+
175
+ if (!raw) {
176
+ return { text: "", dimensions: {} };
177
+ }
178
+
179
+ // Extract <summary> block, discard <analysis>
180
+ const summaryMatch = raw.match(/<summary>([\s\S]*?)<\/summary>/);
181
+ const summaryText = summaryMatch ? summaryMatch[1].trim() : raw;
182
+
183
+ // Parse dimensions from ## headings
184
+ const dimensions: Record<string, string> = {};
185
+ const sections = summaryText.split(/^## \d+\.\s*/m).filter(Boolean);
186
+
187
+ for (const section of sections) {
188
+ const firstLine = section.split("\n")[0].trim().toLowerCase();
189
+ for (const key of dimensionKeys) {
190
+ const label = dimensionLabels[key].toLowerCase();
191
+ if (firstLine.includes(label) || label.includes(firstLine.replace(/\s+/g, " ").trim())) {
192
+ const body = section.split("\n").slice(1).join("\n").trim();
193
+ if (body && body !== "N/A") {
194
+ dimensions[key] = body;
195
+ }
196
+ break;
197
+ }
198
+ }
199
+ }
200
+
201
+ return { text: summaryText, dimensions };
202
+ }
203
+
204
+ // ============================================================================
205
+ // Layer 3: Extract and Persist
206
+ // ============================================================================
207
+
208
+ export async function extractAndPersist(
209
+ summary: SummaryResult,
210
+ deps: PersistDeps,
211
+ scope: string,
212
+ ): Promise<PersistResult> {
213
+ const result: PersistResult = {
214
+ memories_stored: 0,
215
+ memories_deduped: 0,
216
+ memories_conflicted: 0,
217
+ ids: [],
218
+ };
219
+
220
+ for (const [dimKey, mapping] of Object.entries(DIMENSION_TO_MEMORY)) {
221
+ const dimText = summary.dimensions[dimKey];
222
+ if (!dimText || dimText.trim().length < 10) continue;
223
+
224
+ try {
225
+ const stored = await persistMemory(deps, {
226
+ text: dimText.slice(0, 4000),
227
+ category: mapping.category,
228
+ importance: mapping.importance,
229
+ scope,
230
+ source: "agent" as const,
231
+ tags: ["session_distill", dimKey],
232
+ });
233
+
234
+ trackDisposition(result, stored);
235
+ } catch {
236
+ // Non-fatal: continue with other dimensions
237
+ }
238
+ }
239
+
240
+ return result;
241
+ }
242
+
243
+ function trackDisposition(result: PersistResult, stored: StoredMemoryRecord): void {
244
+ result.ids.push(stored.id);
245
+ switch (stored.disposition) {
246
+ case "deduped":
247
+ result.memories_deduped++;
248
+ break;
249
+ case "conflict":
250
+ result.memories_conflicted++;
251
+ break;
252
+ default:
253
+ result.memories_stored++;
254
+ break;
255
+ }
256
+ }
257
+
258
+ // ============================================================================
259
+ // Full Pipeline
260
+ // ============================================================================
261
+
262
+ export async function distillSession(
263
+ messages: ConversationMessage[],
264
+ deps: PersistDeps & { llm: LLMClient },
265
+ opts: {
266
+ scope?: string;
267
+ preserveRecent?: number;
268
+ keepRecentTools?: number;
269
+ persist?: boolean;
270
+ } = {},
271
+ ): Promise<DistillResult> {
272
+ const scope = opts.scope || "default";
273
+ const shouldPersist = opts.persist ?? true;
274
+
275
+ // Layer 1: Microcompact
276
+ const { messages: compacted, result: microcompactResult } = microcompact(
277
+ messages,
278
+ { preserveRecent: opts.preserveRecent, keepRecentTools: opts.keepRecentTools },
279
+ );
280
+
281
+ // Layer 2: Summarize (using compacted messages minus preserved recent)
282
+ const preserveRecent = opts.preserveRecent ?? 6;
283
+ const cutoff = Math.max(0, compacted.length - preserveRecent);
284
+ const oldMessages = compacted.slice(0, cutoff);
285
+ const summaryResult = oldMessages.length > 0
286
+ ? await summarizeSession(oldMessages, deps.llm)
287
+ : { text: "", dimensions: {} };
288
+
289
+ // Layer 3: Persist (optional)
290
+ let persistResult: PersistResult = {
291
+ memories_stored: 0,
292
+ memories_deduped: 0,
293
+ memories_conflicted: 0,
294
+ ids: [],
295
+ };
296
+
297
+ if (shouldPersist && Object.keys(summaryResult.dimensions).length > 0) {
298
+ persistResult = await extractAndPersist(summaryResult, deps, scope);
299
+ }
300
+
301
+ return {
302
+ microcompact: microcompactResult,
303
+ summary: summaryResult,
304
+ persisted: persistResult,
305
+ compacted_messages: compacted,
306
+ };
307
+ }
@@ -82,6 +82,41 @@ export function formatResumeContext(response: ResumeContextResponse): string {
82
82
  ...listBlock("Recent cases", response.recentCases),
83
83
  );
84
84
 
85
+ // CC-7: Collapsed items with renderLevel + staleness hints
86
+ if (response.collapsedItems && response.collapsedItems.length > 0) {
87
+ lines.push("Collapsed context (mixed granularity):");
88
+ for (const item of response.collapsedItems) {
89
+ const hint = item.stalenessHint ? ` ${item.stalenessHint}` : "";
90
+ lines.push(`[${item.renderLevel}] ${item.text}${hint}`);
91
+ }
92
+ }
93
+
94
+ // CC-8: Essential context (pinned memories, active patterns, open loops)
95
+ if (response.essentialContext) {
96
+ const ec = response.essentialContext;
97
+ const hasContent = (ec.pinnedMemories && ec.pinnedMemories.length > 0)
98
+ || (ec.activePatterns && ec.activePatterns.length > 0)
99
+ || (ec.openLoops && ec.openLoops.length > 0);
100
+ if (hasContent) {
101
+ lines.push("Essential context:");
102
+ if (ec.pinnedMemories && ec.pinnedMemories.length > 0) {
103
+ for (const pin of ec.pinnedMemories) {
104
+ lines.push(`- Pinned: ${pin}`);
105
+ }
106
+ }
107
+ if (ec.activePatterns && ec.activePatterns.length > 0) {
108
+ for (const pattern of ec.activePatterns) {
109
+ lines.push(`- Pattern: ${pattern}`);
110
+ }
111
+ }
112
+ if (ec.openLoops && ec.openLoops.length > 0) {
113
+ for (const loop of ec.openLoops) {
114
+ lines.push(`- Open loop: ${loop}`);
115
+ }
116
+ }
117
+ }
118
+ }
119
+
85
120
  if (response.latestCheckpoint) {
86
121
  lines.push("Latest checkpoint:");
87
122
  lines.push(`Session: ${response.latestCheckpoint.sessionId}`);
@@ -92,5 +127,10 @@ export function formatResumeContext(response: ResumeContextResponse): string {
92
127
  lines.push(`Summary: ${response.latestCheckpoint.summary}`);
93
128
  }
94
129
 
130
+ // CC-1: Injection hint for prompt placement
131
+ if (response.injectionHint) {
132
+ lines.push(`Injection hint: ${response.injectionHint} (place recalled context as user message attachment for better prompt cache hit rate)`);
133
+ }
134
+
95
135
  return lines.join("\n");
96
136
  }
@@ -40,15 +40,51 @@ export const ResumeCheckpointSummarySchema = z.object({
40
40
 
41
41
  export const ResumeResponseModeSchema = z.enum(["default", "recall-only"]);
42
42
 
43
+ export const CollapsedItemSchema = z.object({
44
+ entryId: z.string(),
45
+ text: z.string(),
46
+ renderLevel: z.enum(["L0", "L1", "L2"]),
47
+ stalenessHint: z.string().optional(),
48
+ });
49
+
50
+ /** CC-8: Essential context reconstructed after compact — pinned memories, active patterns, open loops. */
51
+ export const EssentialContextSchema = z.object({
52
+ pinnedMemories: z.array(z.string()).max(3).optional(),
53
+ activePatterns: z.array(z.string()).max(2).optional(),
54
+ openLoops: z.array(z.string()).max(3).optional(),
55
+ });
56
+
43
57
  export const ResumeContextResponseSchema = z.object({
44
58
  summary: boundedStringSchema("summary", 800),
45
59
  resolvedScope: optionalBoundedStringSchema(160),
46
60
  stableContext: normalizedStringListSchema("stableContext", 6, 220),
47
61
  relevantPatterns: normalizedStringListSchema("relevantPatterns", 6, 220),
48
62
  recentCases: normalizedStringListSchema("recentCases", 6, 220),
63
+ /** CC-7: Mixed-granularity collapsed view of all recalled items. */
64
+ collapsedItems: z.array(CollapsedItemSchema).max(20).optional(),
65
+ /** CC-8: Essential context reconstructed after compact. */
66
+ essentialContext: EssentialContextSchema.optional(),
49
67
  latestCheckpoint: ResumeCheckpointSummarySchema.optional(),
68
+ /** CC-1: Hint for where to inject recalled context in the prompt. */
69
+ injectionHint: z.enum(["system_prompt", "user_attachment"]).default("user_attachment").optional(),
70
+ /** Upstream #345: Mark recalled context as ephemeral — host should discard on compaction, not persist to transcript. */
71
+ ephemeral: z.boolean().default(true).optional(),
50
72
  responseMode: ResumeResponseModeSchema.default("default"),
51
73
  responseGuidance: optionalBoundedStringSchema(400),
74
+ /** Constructive retrieval: LLM-synthesized reconstruction of context. */
75
+ reconstructedContext: z.string().max(2000).optional(),
76
+ /** Confidence score (0-1) for the reconstructed context. */
77
+ reconstructionConfidence: z.number().min(0).max(1).optional(),
78
+ /** Phase 4: Contradictions detected during reconstruction. */
79
+ reconstructionContradictions: z.array(z.object({
80
+ memoryIds: z.tuple([z.string(), z.string()]),
81
+ description: z.string().max(300),
82
+ })).max(5).optional(),
83
+ /** HP-narrative: Memories grouped by autobiographical life period. */
84
+ narrativeGroups: z.array(z.object({
85
+ period: z.string().max(120),
86
+ items: z.array(z.string().max(240)).max(5),
87
+ })).max(10).optional(),
52
88
  generatedAt: z.string().datetime(),
53
89
  });
54
90
 
@@ -56,5 +92,6 @@ export type SessionCheckpointInput = z.infer<typeof SessionCheckpointInputSchema
56
92
  export type SessionCheckpointRecord = z.infer<typeof SessionCheckpointRecordSchema>;
57
93
  export type ResumeContextRequest = z.infer<typeof ResumeContextRequestSchema>;
58
94
  export type ResumeContextResponse = z.infer<typeof ResumeContextResponseSchema>;
95
+ export type EssentialContext = z.infer<typeof EssentialContextSchema>;
59
96
  export type RetrievalProfileName = z.infer<typeof RetrievalProfileSchema>;
60
97
  export type ResumeResponseMode = z.infer<typeof ResumeResponseModeSchema>;
@@ -1,6 +1,7 @@
1
1
  import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { basename, join, resolve } from "node:path";
3
3
 
4
+ import { metaDir } from "./compat.js";
4
5
  import type { SessionCheckpointRecord } from "./session-schema.js";
5
6
  import { SessionCheckpointRecordSchema } from "./session-schema.js";
6
7
  import { normalizeCheckpointScope, type CheckpointQuality } from "./session-engine.js";
@@ -66,7 +67,7 @@ export interface CheckpointGcResult {
66
67
  }
67
68
 
68
69
  export class SessionCheckpointStore {
69
- constructor(private readonly dir = resolve(import.meta.dir, "../data/session-checkpoints")) {}
70
+ constructor(private readonly dir = resolve(metaDir(import.meta), "../data/session-checkpoints")) {}
70
71
 
71
72
  get dataDir(): string {
72
73
  return ensureDir(this.dir);
@@ -76,7 +77,11 @@ export class SessionCheckpointStore {
76
77
  const parsed = SessionCheckpointRecordSchema.parse(record);
77
78
  const timestampToken = parsed.updatedAt.replace(/[:.]/g, "-");
78
79
  const path = join(this.dataDir, `${timestampToken}-${parsed.checkpointId}.json`);
79
- writeFileSync(path, JSON.stringify(parsed, null, 2) + "\n");
80
+ try {
81
+ writeFileSync(path, JSON.stringify(parsed, null, 2) + "\n");
82
+ } catch (err) {
83
+ console.error("[recallnest] Failed to save session checkpoint:", err instanceof Error ? err.message : String(err));
84
+ }
80
85
  return parsed;
81
86
  }
82
87