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
@@ -19,6 +19,10 @@
19
19
 
20
20
  import type { MemoryEntry, MemorySearchResult, MemoryStore } from "./store.js";
21
21
  import { createVersionGroup } from "./version-manager.js";
22
+ import { isActiveMemory, parseEvolution, buildSupersedeMetadata, buildConsolidatedMetadata, patchEvolution } from "./memory-evolution.js";
23
+ import { cosineSimilarity } from "./multi-vector.js";
24
+ import type { LLMClient } from "./llm-client.js";
25
+ import type { Embedder } from "./embedder.js";
22
26
 
23
27
  // ---------------------------------------------------------------------------
24
28
  // Config & Types
@@ -64,18 +68,17 @@ function parseMetadata(entry: MemoryEntry): Record<string, unknown> {
64
68
  }
65
69
 
66
70
  function isActive(entry: MemoryEntry): boolean {
67
- const meta = parseMetadata(entry);
68
- return meta.state !== "archived" && meta.state !== "superseded";
71
+ // Unified lifecycle check via evolution.status (no legacy meta.state fallback)
72
+ return isActiveMemory(entry.metadata);
69
73
  }
70
74
 
71
75
  function canonicalScore(entry: MemoryEntry): number {
72
- const meta = parseMetadata(entry);
73
- const accessCount = typeof meta.accessCount === "number" ? meta.accessCount : 0;
74
- return entry.importance * (1 + Math.log(accessCount + 1));
76
+ const evo = parseEvolution(entry.metadata, entry.timestamp);
77
+ return entry.importance * (1 + Math.log(evo.accessCount + 1));
75
78
  }
76
79
 
77
80
  /** Simple heuristic contradiction: negation pattern check between two texts. */
78
- function detectHeuristicContradiction(textA: string, textB: string): boolean {
81
+ export function detectHeuristicContradiction(textA: string, textB: string): boolean {
79
82
  const a = textA.toLowerCase();
80
83
  const b = textB.toLowerCase();
81
84
 
@@ -197,9 +200,19 @@ export class ConsolidationEngine {
197
200
  const sim = scoreMap.get(member.id) ?? clusterThreshold;
198
201
 
199
202
  if (sim >= mergeThreshold) {
200
- // Tier 3.3: Version coexistence instead of SUPERSEDE.
201
- // Both entries stay active but are grouped — retrieval picks the best.
203
+ // Tier 3.3: Version coexistence — both stay but grouped.
202
204
  await createVersionGroup(this.store, canonical, member, scope);
205
+ // C-1: Also mark the weaker entry as consolidated via evolution metadata
206
+ const consolidatedMeta = buildConsolidatedMetadata(member.metadata, canonical.id);
207
+ await this.store.update(member.id, { metadata: consolidatedMeta }, [scope]);
208
+ // Mark canonical as having source memories
209
+ const canonEvo = parseEvolution(canonical.metadata, canonical.timestamp);
210
+ if (!canonEvo.sourceMemories.includes(member.id)) {
211
+ const updatedCanon = patchEvolution(canonical.metadata, {
212
+ sourceMemories: [...canonEvo.sourceMemories, member.id],
213
+ });
214
+ await this.store.update(canonical.id, { metadata: updatedCanon }, [scope]);
215
+ }
203
216
  mergedCount++;
204
217
  } else {
205
218
  // Link: add clustered_with relation
@@ -236,6 +249,243 @@ export class ConsolidationEngine {
236
249
  }
237
250
  }
238
251
 
252
+ // ---------------------------------------------------------------------------
253
+ // C-2: Cluster Consolidation — group similar memories and generate insights
254
+ // ---------------------------------------------------------------------------
255
+
256
+ export interface ClusterConsolidationResult {
257
+ clustersFound: number;
258
+ clustersConsolidated: number;
259
+ insightsGenerated: number;
260
+ /** HP-5: Number of cross-memory patterns discovered */
261
+ patternsExtracted: number;
262
+ entriesLinked: number;
263
+ /** CC-9: Set when consecutive low-yield rounds trigger early termination */
264
+ earlyStop?: "diminishing_returns";
265
+ }
266
+
267
+ /**
268
+ * C-2: Cluster consolidation — group similar memories and generate insights.
269
+ *
270
+ * Algorithm:
271
+ * 1. Take active memories in a scope
272
+ * 2. Cluster by embedding similarity (simple greedy clustering, not full K-means)
273
+ * 3. For clusters with > minClusterSize members, generate a high-level insight via LLM
274
+ * 4. Store insight as new memory, link source memories via evolution sourceMemories field
275
+ * 5. Source memories marked consolidated_into but remain active (still individually searchable)
276
+ */
277
+ export async function clusterAndConsolidate(params: {
278
+ entries: MemoryEntry[];
279
+ embedder: Pick<Embedder, "embedPassage">;
280
+ llm: LLMClient;
281
+ store: Pick<MemoryStore, "store" | "update">;
282
+ scope: string;
283
+ /** Minimum cluster size to trigger consolidation (default: 3) */
284
+ minClusterSize?: number;
285
+ /** Similarity threshold for clustering (default: 0.75) */
286
+ clusterThreshold?: number;
287
+ /** Max clusters to process per run (default: 5) */
288
+ maxClusters?: number;
289
+ /** HP-5: Enable cross-memory pattern extraction after insight generation (default: false) */
290
+ extractPatterns?: boolean;
291
+ }): Promise<ClusterConsolidationResult> {
292
+ const {
293
+ entries,
294
+ llm,
295
+ store,
296
+ scope,
297
+ minClusterSize = 3,
298
+ clusterThreshold = 0.75,
299
+ maxClusters = 5,
300
+ extractPatterns = false,
301
+ } = params;
302
+
303
+ const result: ClusterConsolidationResult = {
304
+ clustersFound: 0,
305
+ clustersConsolidated: 0,
306
+ insightsGenerated: 0,
307
+ patternsExtracted: 0,
308
+ entriesLinked: 0,
309
+ };
310
+
311
+ // Filter to active entries with vectors
312
+ const active = entries.filter(e => isActiveMemory(e.metadata) && e.vector?.length > 0);
313
+ if (active.length === 0) return result;
314
+
315
+ // Step 1: Greedy clustering by embedding similarity
316
+ const clusters: MemoryEntry[][] = [];
317
+ const centroids: number[][] = [];
318
+ const assigned = new Set<string>();
319
+
320
+ for (const entry of active) {
321
+ if (assigned.has(entry.id)) continue;
322
+
323
+ let bestClusterIdx = -1;
324
+ let bestSim = -1;
325
+
326
+ for (let ci = 0; ci < centroids.length; ci++) {
327
+ const sim = cosineSimilarity(entry.vector, centroids[ci]);
328
+ if (sim > clusterThreshold && sim > bestSim) {
329
+ bestSim = sim;
330
+ bestClusterIdx = ci;
331
+ }
332
+ }
333
+
334
+ if (bestClusterIdx >= 0) {
335
+ clusters[bestClusterIdx].push(entry);
336
+ assigned.add(entry.id);
337
+ // Update centroid as running average
338
+ const members = clusters[bestClusterIdx];
339
+ const dim = centroids[bestClusterIdx].length;
340
+ const newCentroid = new Array<number>(dim);
341
+ for (let d = 0; d < dim; d++) {
342
+ let sum = 0;
343
+ for (const m of members) sum += m.vector[d];
344
+ newCentroid[d] = sum / members.length;
345
+ }
346
+ centroids[bestClusterIdx] = newCentroid;
347
+ } else {
348
+ // Start a new cluster
349
+ clusters.push([entry]);
350
+ centroids.push([...entry.vector]);
351
+ assigned.add(entry.id);
352
+ }
353
+ }
354
+
355
+ // Step 2: Filter to clusters meeting minClusterSize
356
+ const qualifiedClusters = clusters.filter(c => c.length >= minClusterSize);
357
+ result.clustersFound = qualifiedClusters.length;
358
+
359
+ if (qualifiedClusters.length === 0) return result;
360
+
361
+ // Step 3: Process up to maxClusters, with CC-9 diminishing returns detection
362
+ const toProcess = qualifiedClusters.slice(0, maxClusters);
363
+ let consecutiveLowYield = 0;
364
+
365
+ for (const cluster of toProcess) {
366
+ // CC-9: Check diminishing returns — 2 consecutive rounds with <= 1 insight
367
+ if (consecutiveLowYield >= 2) {
368
+ result.earlyStop = "diminishing_returns";
369
+ break;
370
+ }
371
+
372
+ // Generate insight via LLM from cluster member texts
373
+ const combinedText = cluster.map(m => m.text).join("\n---\n");
374
+ const insight = await llm.generateL0(combinedText);
375
+
376
+ if (!insight) {
377
+ // CC-9: No insight produced — this is a zero-yield round
378
+ consecutiveLowYield++;
379
+ result.clustersConsolidated++;
380
+ continue;
381
+ }
382
+
383
+ // Determine category (majority vote from cluster members)
384
+ const catCounts = new Map<string, number>();
385
+ for (const m of cluster) {
386
+ catCounts.set(m.category, (catCounts.get(m.category) ?? 0) + 1);
387
+ }
388
+ let bestCat = cluster[0].category;
389
+ let bestCount = 0;
390
+ for (const [cat, count] of catCounts) {
391
+ if (count > bestCount) {
392
+ bestCat = cat as MemoryEntry["category"];
393
+ bestCount = count;
394
+ }
395
+ }
396
+
397
+ // Importance = max of cluster members
398
+ const maxImportance = Math.max(...cluster.map(m => m.importance));
399
+
400
+ // Embed the insight text
401
+ const insightVector = await params.embedder.embedPassage(insight);
402
+
403
+ // Store the insight as a new memory
404
+ const insightEntry = await store.store({
405
+ text: insight,
406
+ vector: insightVector,
407
+ category: bestCat,
408
+ scope,
409
+ importance: maxImportance,
410
+ metadata: JSON.stringify({
411
+ evolution: {
412
+ status: "active",
413
+ version: 1,
414
+ accessCount: 0,
415
+ lastAccessedAt: null,
416
+ supersededBy: null,
417
+ consolidatedInto: null,
418
+ sourceMemories: cluster.map(m => m.id),
419
+ validFrom: Date.now(),
420
+ validUntil: null,
421
+ },
422
+ cluster_insight: true,
423
+ }),
424
+ });
425
+
426
+ result.insightsGenerated++;
427
+
428
+ // Mark source memories as consolidated_into (but keep them active)
429
+ for (const member of cluster) {
430
+ const patched = patchEvolution(member.metadata, {
431
+ consolidatedInto: insightEntry.id,
432
+ // Keep status active — still individually searchable
433
+ });
434
+ await store.update(member.id, { metadata: patched }, [scope]);
435
+ result.entriesLinked++;
436
+ }
437
+
438
+ result.clustersConsolidated++;
439
+
440
+ // HP-5: Cross-memory pattern extraction
441
+ if (extractPatterns && cluster.length >= 3) {
442
+ const patternText = await llm.extractPattern(cluster.map(m => m.text));
443
+ if (patternText) {
444
+ const patternImportance = Math.min(maxImportance + 0.1, 1.0);
445
+ const patternVector = await params.embedder.embedPassage(patternText);
446
+ const patternEntry = await store.store({
447
+ text: patternText,
448
+ vector: patternVector,
449
+ category: "patterns",
450
+ scope,
451
+ importance: patternImportance,
452
+ metadata: JSON.stringify({
453
+ evolution: {
454
+ status: "active",
455
+ version: 1,
456
+ accessCount: 0,
457
+ lastAccessedAt: null,
458
+ supersededBy: null,
459
+ consolidatedInto: null,
460
+ contributedToPattern: null,
461
+ sourceMemories: cluster.map(m => m.id),
462
+ validFrom: Date.now(),
463
+ validUntil: null,
464
+ },
465
+ cross_memory_pattern: true,
466
+ source_cluster_size: cluster.length,
467
+ }),
468
+ });
469
+
470
+ // Mark source memories with contributedToPattern
471
+ for (const member of cluster) {
472
+ const patched = patchEvolution(member.metadata, {
473
+ contributedToPattern: patternEntry.id,
474
+ });
475
+ await store.update(member.id, { metadata: patched }, [scope]);
476
+ }
477
+
478
+ result.patternsExtracted++;
479
+ }
480
+ }
481
+
482
+ // CC-9: Successful insight generated — reset diminishing returns counter
483
+ consecutiveLowYield = 0;
484
+ }
485
+
486
+ return result;
487
+ }
488
+
239
489
  /** Format a ConsolidationResult for display. */
240
490
  export function formatConsolidationResult(result: ConsolidationResult): string {
241
491
  const lines = [
@@ -254,3 +504,41 @@ export function formatConsolidationResult(result: ConsolidationResult): string {
254
504
  }
255
505
  return lines.join("\n");
256
506
  }
507
+
508
+ // ---------------------------------------------------------------------------
509
+ // LC-P2: Cluster-aware deduplication for retrieval results
510
+ // ---------------------------------------------------------------------------
511
+
512
+ /**
513
+ * LC-P2: When a cluster insight and its source memories both appear in
514
+ * retrieval results, keep only the cluster insight (it subsumes the
515
+ * individual entries). This saves token budget during context injection.
516
+ *
517
+ * Works with any result shape that has { entry: MemoryEntry; score: number }.
518
+ */
519
+ export function deduplicateByClusterInsight<T extends { entry: MemoryEntry; score: number }>(
520
+ results: T[],
521
+ ): T[] {
522
+ // Collect source memory IDs covered by cluster insights in this result set
523
+ const coveredByInsight = new Set<string>();
524
+
525
+ for (const r of results) {
526
+ if (!r.entry.metadata) continue;
527
+ try {
528
+ const meta = JSON.parse(r.entry.metadata);
529
+ if (meta.cluster_insight === true || meta.cross_memory_pattern === true) {
530
+ const sources: unknown[] = meta.evolution?.sourceMemories;
531
+ if (Array.isArray(sources)) {
532
+ for (const id of sources) {
533
+ if (typeof id === "string") coveredByInsight.add(id);
534
+ }
535
+ }
536
+ }
537
+ } catch { /* skip unparseable */ }
538
+ }
539
+
540
+ if (coveredByInsight.size === 0) return results;
541
+
542
+ // Filter out source memories that are subsumed by an insight
543
+ return results.filter(r => !coveredByInsight.has(r.entry.id));
544
+ }
@@ -0,0 +1,222 @@
1
+ /**
2
+ * CC-7: Context Collapse — dynamic granularity rendering layer.
3
+ *
4
+ * Given retrieval results + scores, renders each item at L0/L1/L2 granularity
5
+ * based on relevance score. High-relevance items get full content (L2),
6
+ * medium gets overview (L1), low gets one-liner (L0).
7
+ *
8
+ * Also includes CC-2 staleness hints: items older than 7 days get an age warning
9
+ * so the model can judge whether to trust the information.
10
+ *
11
+ * Reuses existing L0/L1/L2 text stored in metadata by the multi-vector pipeline.
12
+ */
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Types
16
+ // ---------------------------------------------------------------------------
17
+
18
+ export type RenderLevel = "L0" | "L1" | "L2";
19
+
20
+ export interface CollapseInput {
21
+ entryId: string;
22
+ text: string;
23
+ metadata?: string;
24
+ score: number;
25
+ timestamp: number;
26
+ }
27
+
28
+ export interface CollapseOutput {
29
+ entryId: string;
30
+ text: string;
31
+ renderLevel: RenderLevel;
32
+ stalenessHint?: string;
33
+ }
34
+
35
+ export interface CollapseConfig {
36
+ /** Total token budget for all rendered items (default: 8000) */
37
+ tokenBudget: number;
38
+ /** Score thresholds for each level */
39
+ thresholds: { l2: number; l1: number; l0: number };
40
+ /** Days before staleness hint is added (default: 7) */
41
+ stalenessThresholdDays: number;
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Defaults
46
+ // ---------------------------------------------------------------------------
47
+
48
+ export const DEFAULT_COLLAPSE_CONFIG: CollapseConfig = {
49
+ tokenBudget: 8000,
50
+ thresholds: { l2: 0.85, l1: 0.65, l0: 0.50 },
51
+ stalenessThresholdDays: 7,
52
+ };
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Text extraction at each level
56
+ // ---------------------------------------------------------------------------
57
+
58
+ function parseMetaJson(metadata?: string): Record<string, unknown> | null {
59
+ if (!metadata) return null;
60
+ try {
61
+ return JSON.parse(metadata);
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ /** Extract L0 text (one-liner, ≤80 chars). Falls back to first sentence of full text. */
68
+ function extractL0(text: string, metadata?: string): string {
69
+ const meta = parseMetaJson(metadata);
70
+ if (meta) {
71
+ if (typeof meta.l0_abstract === "string" && meta.l0_abstract.length > 5) return meta.l0_abstract;
72
+ if (typeof meta.anchor === "string" && meta.anchor.length > 5) return meta.anchor;
73
+ }
74
+ // Fallback: first sentence or first 80 chars
75
+ const firstSentence = text.match(/^[^.!?。!?\n]+[.!?。!?]?/)?.[0];
76
+ if (firstSentence && firstSentence.length <= 120) return firstSentence;
77
+ return text.slice(0, 80) + (text.length > 80 ? "…" : "");
78
+ }
79
+
80
+ /** Extract L1 text (structured overview, 2-5 lines). Falls back to L0. */
81
+ function extractL1(text: string, metadata?: string): string {
82
+ const meta = parseMetaJson(metadata);
83
+ if (meta) {
84
+ if (typeof meta.core_summary === "string" && meta.core_summary.length > 5) return meta.core_summary;
85
+ if (typeof meta.l1_overview === "string" && meta.l1_overview.length > 5) return meta.l1_overview;
86
+ }
87
+ // Fallback: truncated full text
88
+ if (text.length <= 300) return text;
89
+ return text.slice(0, 300) + "…";
90
+ }
91
+
92
+ /** Extract L2 text (full content). */
93
+ function extractL2(text: string): string {
94
+ return text;
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Token estimation
99
+ // ---------------------------------------------------------------------------
100
+
101
+ /** Rough token estimate: CJK chars ≈ 1.5 tokens each, others ≈ 0.25 per char. */
102
+ function estimateTokens(text: string): number {
103
+ let tokens = 0;
104
+ for (const char of text) {
105
+ tokens += /[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff]/.test(char) ? 1.5 : 0.25;
106
+ }
107
+ return Math.ceil(tokens);
108
+ }
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // Staleness hint (CC-2)
112
+ // ---------------------------------------------------------------------------
113
+
114
+ function buildStalenessHint(timestamp: number, thresholdDays: number, now?: number): string | undefined {
115
+ const ts = now ?? Date.now();
116
+ const daysSinceUpdate = Math.floor((ts - timestamp) / 86_400_000);
117
+ if (daysSinceUpdate <= thresholdDays) return undefined;
118
+ return `[Updated ${daysSinceUpdate} days ago — verify before acting on this]`;
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Core: collapse renderer
123
+ // ---------------------------------------------------------------------------
124
+
125
+ /**
126
+ * Render retrieval results at adaptive granularity based on relevance score.
127
+ *
128
+ * - score >= l2 threshold → full content (L2)
129
+ * - score >= l1 threshold → overview (L1)
130
+ * - score >= l0 threshold → one-liner (L0)
131
+ * - below l0 threshold → excluded
132
+ *
133
+ * Token budget is enforced: once budget is exhausted, remaining items
134
+ * are downgraded to L0 or excluded.
135
+ */
136
+ export function collapseResults(
137
+ items: CollapseInput[],
138
+ config?: Partial<CollapseConfig>,
139
+ ): CollapseOutput[] {
140
+ const cfg: CollapseConfig = { ...DEFAULT_COLLAPSE_CONFIG, ...config };
141
+ const { thresholds, tokenBudget, stalenessThresholdDays } = cfg;
142
+
143
+ // Sort by score descending (highest relevance first)
144
+ const sorted = [...items].sort((a, b) => b.score - a.score);
145
+
146
+ const output: CollapseOutput[] = [];
147
+ let tokensUsed = 0;
148
+
149
+ for (const item of sorted) {
150
+ // Below minimum threshold → skip
151
+ if (item.score < thresholds.l0) continue;
152
+
153
+ // Determine target level based on score
154
+ let targetLevel: RenderLevel;
155
+ if (item.score >= thresholds.l2) {
156
+ targetLevel = "L2";
157
+ } else if (item.score >= thresholds.l1) {
158
+ targetLevel = "L1";
159
+ } else {
160
+ targetLevel = "L0";
161
+ }
162
+
163
+ // Extract text at target level
164
+ let text: string;
165
+ let actualLevel: RenderLevel = targetLevel;
166
+
167
+ if (targetLevel === "L2") {
168
+ text = extractL2(item.text);
169
+ } else if (targetLevel === "L1") {
170
+ text = extractL1(item.text, item.metadata);
171
+ } else {
172
+ text = extractL0(item.text, item.metadata);
173
+ }
174
+
175
+ // Check token budget — downgrade if needed
176
+ const tokens = estimateTokens(text);
177
+ if (tokensUsed + tokens > tokenBudget) {
178
+ // Try downgrading to L1
179
+ if (actualLevel === "L2") {
180
+ text = extractL1(item.text, item.metadata);
181
+ actualLevel = "L1";
182
+ const l1Tokens = estimateTokens(text);
183
+ if (tokensUsed + l1Tokens > tokenBudget) {
184
+ // Further downgrade to L0
185
+ text = extractL0(item.text, item.metadata);
186
+ actualLevel = "L0";
187
+ const l0Tokens = estimateTokens(text);
188
+ if (tokensUsed + l0Tokens > tokenBudget) continue; // Skip entirely
189
+ tokensUsed += l0Tokens;
190
+ } else {
191
+ tokensUsed += l1Tokens;
192
+ }
193
+ } else if (actualLevel === "L1") {
194
+ text = extractL0(item.text, item.metadata);
195
+ actualLevel = "L0";
196
+ const l0Tokens = estimateTokens(text);
197
+ if (tokensUsed + l0Tokens > tokenBudget) continue;
198
+ tokensUsed += l0Tokens;
199
+ } else {
200
+ // Already L0 and still over budget → skip
201
+ continue;
202
+ }
203
+ } else {
204
+ tokensUsed += tokens;
205
+ }
206
+
207
+ // CC-2: Staleness hint
208
+ const stalenessHint = buildStalenessHint(item.timestamp, stalenessThresholdDays);
209
+
210
+ output.push({
211
+ entryId: item.entryId,
212
+ text,
213
+ renderLevel: actualLevel,
214
+ ...(stalenessHint ? { stalenessHint } : {}),
215
+ });
216
+ }
217
+
218
+ return output;
219
+ }
220
+
221
+ // Re-export helpers for testing
222
+ export { extractL0, extractL1, extractL2, estimateTokens, buildStalenessHint };
@@ -2,6 +2,7 @@ import { extractBoundaryMetadata, extractCanonicalKey, shouldUseStableMemoryResu
2
2
  import { buildProjectScopeCueTerms, normalizeScopedValue } from "./context-composer-scope.js";
3
3
  import { bestSummaryText, cleanText, dedupeText, stripConversationMarkers } from "./context-composer-text.js";
4
4
  import type { RetrievalResult } from "./retriever.js";
5
+ import { getConfidence } from "./confidence-tracker.js";
5
6
  import {
6
7
  GENERIC_ENTITY_TASK_TERMS,
7
8
  GENERIC_SCOPE_TERMS,
@@ -158,7 +159,10 @@ function hasUnsupportedPreferenceSpecificity(result: RetrievalResult, taskSeed?:
158
159
 
159
160
  function formatStableResult(category: StableCategory, result: RetrievalResult): string {
160
161
  const text = bestSummaryText(result.entry.text, result.entry.metadata);
161
- return cleanText(`${STABLE_CATEGORY_LABELS[category]}: ${text}`, 220);
162
+ // F1: Tag low-confidence memories in resume_context output
163
+ const conf = getConfidence(result.entry);
164
+ const tag = conf < 0.5 ? " [低置信]" : "";
165
+ return cleanText(`${STABLE_CATEGORY_LABELS[category]}: ${text}${tag}`, 230);
162
166
  }
163
167
 
164
168
  export function selectStableResults(