clawmem 0.6.0 → 0.7.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.
@@ -30,6 +30,7 @@ import { enrichResults } from "../search-utils.ts";
30
30
  import { sanitizeSnippet } from "../promptguard.ts";
31
31
  import { shouldSkipRetrieval, isRetrievedNoise } from "../retrieval-gate.ts";
32
32
  import { MAX_QUERY_LENGTH } from "../limits.ts";
33
+ import { writeRecallEvents, hashQuery } from "../recall-buffer.ts";
33
34
 
34
35
  // =============================================================================
35
36
  // Config
@@ -57,6 +58,17 @@ const NUDGE_INTERVAL = parseInt(process.env.CLAWMEM_NUDGE_INTERVAL || "15", 10);
57
58
  const LIFECYCLE_HOOK_NAMES = ["memory_pin", "memory_forget", "memory_snooze", "lifecycle-archive"];
58
59
  const NUDGE_TEXT = "You haven't managed memory recently. If vault-context is surfacing noise → snooze it. If a critical decision was just made → pin it. If stale knowledge appeared → forget it.";
59
60
 
61
+ // Ext 6a: Context instruction + relationship snippets
62
+ // The instruction is ALWAYS prepended when the hook emits context — it frames
63
+ // the surfaced facts as background knowledge the agent already holds, reducing
64
+ // prompt-level ambiguity. Relationship snippets are fetched from the vault
65
+ // knowledge graph for edges where BOTH endpoints are in the surfaced doc set.
66
+ const INSTRUCTION_TEXT = "Treat the following as background facts you already know unless the user corrects them.";
67
+ const INSTRUCTION_XML = `<instruction>${INSTRUCTION_TEXT}</instruction>`;
68
+ const INSTRUCTION_TOKEN_COST = estimateTokens(INSTRUCTION_XML);
69
+ const RELATIONSHIPS_XML_OVERHEAD_TOKENS = estimateTokens("<relationships>\n\n</relationships>");
70
+ const MAX_RELATION_SNIPPETS = 10;
71
+
60
72
  // File path patterns to extract from prompts (E13 replacement: file-aware UserPromptSubmit)
61
73
  const FILE_PATH_RE = /(?:^|\s)((?:\/[\w.@-]+)+(?:\.\w+)?|[\w.@-]+\.(?:ts|js|py|md|sh|yaml|yml|json|toml|rs|go|tsx|jsx|css|html))\b/g;
62
74
 
@@ -69,18 +81,44 @@ export async function contextSurfacing(
69
81
  input: HookInput
70
82
  ): Promise<HookOutput> {
71
83
  let prompt = input.prompt?.trim();
72
- if (!prompt || prompt.length < MIN_PROMPT_LENGTH) return makeEmptyOutput("context-surfacing");
84
+
85
+ // Compute turn_index FIRST, before any early returns.
86
+ // Every transcript-visible early return must log an empty context_usage row
87
+ // to keep turn_index aligned with transcript turns for per-turn attribution.
88
+ if (input.sessionId) {
89
+ try {
90
+ let turnIndex = 0;
91
+ try {
92
+ const existing = store.db.prepare(
93
+ `SELECT COUNT(*) as cnt FROM context_usage WHERE session_id = ? AND hook_name = 'context-surfacing'`
94
+ ).get(input.sessionId) as { cnt: number };
95
+ turnIndex = existing.cnt;
96
+ } catch { /* fallback to 0 */ }
97
+ (input as any)._turnIndex = turnIndex;
98
+ } catch { /* non-fatal */ }
99
+ }
100
+
101
+ if (!prompt || prompt.length < MIN_PROMPT_LENGTH) {
102
+ logEmptyTurn(store, input);
103
+ return makeEmptyOutput("context-surfacing");
104
+ }
73
105
 
74
106
  // Bound query length to prevent DoS on search indices
75
107
  if (prompt.length > MAX_QUERY_LENGTH) prompt = prompt.slice(0, MAX_QUERY_LENGTH);
76
108
 
77
- // Skip slash commands
78
- if (prompt.startsWith("/")) return makeEmptyOutput("context-surfacing");
109
+ // Skip slash commands — log empty turn for alignment
110
+ if (prompt.startsWith("/")) {
111
+ logEmptyTurn(store, input);
112
+ return makeEmptyOutput("context-surfacing");
113
+ }
79
114
 
80
115
  // Adaptive retrieval gate: skip greetings, shell commands, affirmations, etc.
81
- if (shouldSkipRetrieval(prompt)) return makeEmptyOutput("context-surfacing");
116
+ if (shouldSkipRetrieval(prompt)) {
117
+ logEmptyTurn(store, input);
118
+ return makeEmptyOutput("context-surfacing");
119
+ }
82
120
 
83
- // Heartbeat / duplicate suppression (IO4)
121
+ // Heartbeat / duplicate suppression (IO4) — NOT transcript-visible user turns
84
122
  if (isHeartbeatPrompt(prompt)) return makeEmptyOutput("context-surfacing");
85
123
  if (wasPromptSeenRecently(store, "context-surfacing", prompt)) {
86
124
  return makeEmptyOutput("context-surfacing");
@@ -157,7 +195,7 @@ export async function contextSurfacing(
157
195
  }
158
196
  }
159
197
 
160
- if (results.length === 0) return makeEmptyOutput("context-surfacing");
198
+ if (results.length === 0) { logEmptyTurn(store, input); return makeEmptyOutput("context-surfacing"); }
161
199
 
162
200
  // Budget-aware deep escalation (deep profile only):
163
201
  // If the fast path finished quickly and found results, spend remaining time budget
@@ -215,7 +253,7 @@ export async function contextSurfacing(
215
253
  !FILTERED_PATHS.some(p => r.displayPath.includes(p))
216
254
  );
217
255
 
218
- if (results.length === 0) return makeEmptyOutput("context-surfacing");
256
+ if (results.length === 0) { logEmptyTurn(store, input); return makeEmptyOutput("context-surfacing"); }
219
257
 
220
258
  // Filter out snoozed documents
221
259
  const now = new Date();
@@ -231,7 +269,7 @@ export async function contextSurfacing(
231
269
  return true;
232
270
  });
233
271
 
234
- if (results.length === 0) return makeEmptyOutput("context-surfacing");
272
+ if (results.length === 0) { logEmptyTurn(store, input); return makeEmptyOutput("context-surfacing"); }
235
273
 
236
274
  // Deduplicate by filepath (keep best score per path)
237
275
  const deduped = new Map<string, SearchResult>();
@@ -273,7 +311,7 @@ export async function contextSurfacing(
273
311
  : 0;
274
312
 
275
313
  // Activation floor: if even the best result is too weak, bail entirely
276
- if (bestScore < profile.activationFloor) return makeEmptyOutput("context-surfacing");
314
+ if (bestScore < profile.activationFloor) { logEmptyTurn(store, input); return makeEmptyOutput("context-surfacing"); }
277
315
 
278
316
  const adaptiveMin = Math.max(bestScore * profile.minScoreRatio, profile.absoluteFloor);
279
317
  scored = allScored.filter(r => r.compositeScore >= adaptiveMin);
@@ -282,7 +320,7 @@ export async function contextSurfacing(
282
320
  scored = allScored.filter(r => r.compositeScore >= minScore);
283
321
  }
284
322
 
285
- if (scored.length === 0) return makeEmptyOutput("context-surfacing");
323
+ if (scored.length === 0) { logEmptyTurn(store, input); return makeEmptyOutput("context-surfacing"); }
286
324
 
287
325
  // Spreading activation (E11): boost results co-activated with top HOT results
288
326
  if (scored.length > 3) {
@@ -322,14 +360,70 @@ export async function contextSurfacing(
322
360
  }
323
361
  }
324
362
 
325
- // Build context within token budget (profile-driven)
326
- const { context, paths, tokens } = buildContext(scored, prompt, tokenBudget);
363
+ // Build context within token budget (profile-driven).
364
+ // Ext 6a: Reserve budget for the always-on instruction line so the final
365
+ // vault-context payload stays within `tokenBudget`. Relations are layered
366
+ // in afterward using whatever budget remains and are the first thing
367
+ // truncated when the payload would overflow.
368
+ const factsBudget = Math.max(0, tokenBudget - INSTRUCTION_TOKEN_COST);
369
+ const { context, paths, tokens } = buildContext(scored, prompt, factsBudget);
327
370
 
328
- if (!context) return makeEmptyOutput("context-surfacing");
371
+ if (!context) {
372
+ logEmptyTurn(store, input);
373
+ return makeEmptyOutput("context-surfacing");
374
+ }
329
375
 
330
- // Log the injection
376
+ // Use pre-computed turn_index from top of function
331
377
  if (input.sessionId) {
332
- logInjection(store, input.sessionId, "context-surfacing", paths, tokens);
378
+ const turnIndex = (input as any)._turnIndex ?? 0;
379
+
380
+ // Log the injection — returns usage_id for recall event linkage
381
+ const usageId = logInjection(store, input.sessionId, "context-surfacing", paths, tokens, turnIndex);
382
+
383
+ // Record recall events ONLY for docs that made it into the injected context
384
+ // (post-budget). Docs trimmed by token budget were never seen by the model.
385
+ // Each event links to its context_usage row via usage_id + turn_index.
386
+ // Multi-vault: route docs to origin vault's store. Mirror context_usage there too.
387
+ try {
388
+ const qHash = hashQuery(prompt);
389
+ const injectedSet = new Set(paths);
390
+ const injectedScored = scored.filter(r => injectedSet.has(r.displayPath));
391
+
392
+ // Group by vault origin (undefined = general vault)
393
+ const byVault = new Map<string | undefined, typeof injectedScored>();
394
+ for (const r of injectedScored) {
395
+ const vault = (r as any)._fromVault as string | undefined;
396
+ let group = byVault.get(vault);
397
+ if (!group) { group = []; byVault.set(vault, group); }
398
+ group.push(r);
399
+ }
400
+
401
+ const validUsageId = usageId > 0 ? usageId : undefined;
402
+ for (const [vault, docs] of byVault) {
403
+ const mappedDocs = docs.map(r => ({ displayPath: r.displayPath, searchScore: r.compositeScore }));
404
+ if (!vault) {
405
+ writeRecallEvents(store, input.sessionId, qHash, mappedDocs, validUsageId, turnIndex);
406
+ } else {
407
+ try {
408
+ const vaultStore = resolveStore(vault);
409
+ // Mirror context_usage row into named vault for correct FK + attribution
410
+ const vaultPaths = docs.map(r => r.displayPath);
411
+ const vaultUsageId = vaultStore.insertUsage({
412
+ sessionId: input.sessionId,
413
+ timestamp: new Date().toISOString(),
414
+ hookName: "context-surfacing",
415
+ injectedPaths: vaultPaths,
416
+ estimatedTokens: 0,
417
+ wasReferenced: 0,
418
+ turnIndex,
419
+ });
420
+ writeRecallEvents(vaultStore, input.sessionId, qHash, mappedDocs, vaultUsageId > 0 ? vaultUsageId : undefined, turnIndex);
421
+ } catch { /* vault unavailable — skip */ }
422
+ }
423
+ }
424
+ } catch {
425
+ // Non-critical — don't block context surfacing on recall tracking errors
426
+ }
333
427
  }
334
428
 
335
429
  // Routing hint: detect query intent signals and prepend a tool routing directive
@@ -339,9 +433,29 @@ export async function contextSurfacing(
339
433
  // Memory nudge: periodically remind agent to use lifecycle tools
340
434
  const nudge = NUDGE_INTERVAL > 0 ? shouldNudge(store) : null;
341
435
 
436
+ // Ext 6a: Enrich vault-context with instruction framing + optional
437
+ // relationship snippets sourced from memory_relations. Only edges where
438
+ // BOTH endpoints are in the surfaced doc set are included. The relations
439
+ // block is the first thing dropped when the payload would overflow budget.
440
+ //
441
+ // Budget accounting (Turn 11 fix): `tokens` from buildContext only sums per-
442
+ // entry bodies and misses both the `<facts>...</facts>` wrapper and the
443
+ // `\n\n---\n\n` separators between entries. Compute the wrapped-facts cost
444
+ // directly from the rendered string so the relationships block can never
445
+ // push the final `<vault-context>` inner payload past `tokenBudget`.
446
+ const surfacedDocIds = lookupSurfacedDocIds(store, paths);
447
+ const relationSnippets = fetchRelationSnippets(store, surfacedDocIds);
448
+ const factsBlockXml = `<facts>\n${context}\n</facts>`;
449
+ const factsWrappedTokens = estimateTokens(factsBlockXml);
450
+ const relationBudget = Math.max(
451
+ 0,
452
+ tokenBudget - INSTRUCTION_TOKEN_COST - factsWrappedTokens
453
+ );
454
+ const vaultInner = buildVaultContextInner(context, relationSnippets, relationBudget);
455
+
342
456
  const parts: string[] = [];
343
457
  if (routingHint) parts.push(`<vault-routing>${routingHint}</vault-routing>`);
344
- parts.push(`<vault-context>\n${context}\n</vault-context>`);
458
+ parts.push(`<vault-context>\n${vaultInner}\n</vault-context>`);
345
459
  if (nudge) parts.push(`<vault-nudge>${NUDGE_TEXT}</vault-nudge>`);
346
460
 
347
461
  return makeContextOutput("context-surfacing", parts.join("\n"));
@@ -351,6 +465,19 @@ export async function contextSurfacing(
351
465
  // Helpers
352
466
  // =============================================================================
353
467
 
468
+ /**
469
+ * Log an empty context_usage row for a skipped turn.
470
+ * Keeps turn_index aligned with transcript turns so per-turn recall
471
+ * attribution doesn't drift when some prompts are gated.
472
+ */
473
+ function logEmptyTurn(store: Store, input: HookInput): void {
474
+ if (!input.sessionId) return;
475
+ try {
476
+ const turnIndex = (input as any)._turnIndex ?? 0;
477
+ logInjection(store, input.sessionId, "context-surfacing", [], 0, turnIndex);
478
+ } catch { /* non-fatal */ }
479
+ }
480
+
354
481
  /**
355
482
  * Detect causal/temporal/discovery signals in the prompt and return a
356
483
  * routing hint that makes the correct tool choice salient at the moment
@@ -431,6 +558,148 @@ function buildContext(
431
558
  };
432
559
  }
433
560
 
561
+ // =============================================================================
562
+ // Ext 6a: Relationship snippets + instruction framing
563
+ // =============================================================================
564
+
565
+ /**
566
+ * Relationship snippet derived from a memory_relations edge whose source and
567
+ * target are both active documents currently surfaced by the context hook.
568
+ */
569
+ export interface RelationSnippet {
570
+ sourceTitle: string;
571
+ targetTitle: string;
572
+ relationType: string;
573
+ }
574
+
575
+ /**
576
+ * Resolve surfaced display paths back to document ids so the relation query
577
+ * can filter memory_relations edges to the surfaced set. Silently drops paths
578
+ * that don't match an active row in the general vault (e.g. skill-vault paths
579
+ * or deactivated docs) — fail-open, never throws.
580
+ */
581
+ export function lookupSurfacedDocIds(
582
+ store: Store,
583
+ displayPaths: string[]
584
+ ): number[] {
585
+ if (displayPaths.length === 0) return [];
586
+ try {
587
+ const placeholders = displayPaths.map(() => "?").join(",");
588
+ const rows = store.db
589
+ .prepare(
590
+ `SELECT id FROM documents
591
+ WHERE active = 1
592
+ AND (collection || '/' || path) IN (${placeholders})`
593
+ )
594
+ .all(...displayPaths) as Array<{ id: number }>;
595
+ return rows.map((r) => r.id);
596
+ } catch {
597
+ return [];
598
+ }
599
+ }
600
+
601
+ /**
602
+ * Fetch relationship snippets for edges where BOTH endpoints are in the
603
+ * surfaced doc set. Returns an empty list on empty input, zero/one surfaced
604
+ * docs, self-loops, or any DB error (fail-open, never throws). Results are
605
+ * ordered by relation weight DESC then recency so the most salient edges
606
+ * survive budget truncation.
607
+ */
608
+ export function fetchRelationSnippets(
609
+ store: Store,
610
+ surfacedDocIds: number[],
611
+ limit: number = MAX_RELATION_SNIPPETS
612
+ ): RelationSnippet[] {
613
+ if (surfacedDocIds.length < 2) return [];
614
+ try {
615
+ const placeholders = surfacedDocIds.map(() => "?").join(",");
616
+ const rows = store.db
617
+ .prepare(
618
+ `SELECT mr.relation_type,
619
+ ds.title AS source_title,
620
+ dt.title AS target_title
621
+ FROM memory_relations mr
622
+ JOIN documents ds ON ds.id = mr.source_id AND ds.active = 1
623
+ JOIN documents dt ON dt.id = mr.target_id AND dt.active = 1
624
+ WHERE mr.source_id IN (${placeholders})
625
+ AND mr.target_id IN (${placeholders})
626
+ AND mr.source_id != mr.target_id
627
+ ORDER BY mr.weight DESC, mr.created_at DESC
628
+ LIMIT ?`
629
+ )
630
+ .all(...surfacedDocIds, ...surfacedDocIds, limit) as Array<{
631
+ relation_type: string;
632
+ source_title: string;
633
+ target_title: string;
634
+ }>;
635
+ return rows.map((r) => ({
636
+ sourceTitle: r.source_title,
637
+ targetTitle: r.target_title,
638
+ relationType: r.relation_type,
639
+ }));
640
+ } catch {
641
+ return [];
642
+ }
643
+ }
644
+
645
+ /**
646
+ * Render relationship snippets as bullet lines, sanitizing titles to block
647
+ * prompt-injection via metadata fields. Lines that become filtered-content
648
+ * markers after sanitization are dropped.
649
+ */
650
+ export function renderRelationshipLines(
651
+ relations: RelationSnippet[]
652
+ ): string[] {
653
+ const FILTERED = "[content filtered for security]";
654
+ const out: string[] = [];
655
+ for (const r of relations) {
656
+ const src = sanitizeSnippet(r.sourceTitle);
657
+ const tgt = sanitizeSnippet(r.targetTitle);
658
+ if (src === FILTERED || tgt === FILTERED) continue;
659
+ out.push(`- ${src} --[${r.relationType}]--> ${tgt}`);
660
+ }
661
+ return out;
662
+ }
663
+
664
+ /**
665
+ * Assemble the inner body of <vault-context>: always instruction + facts,
666
+ * optionally relationships when at least one line fits in the remaining
667
+ * budget. Relationships are the first thing dropped — if the relationships
668
+ * XML wrapper alone would exceed `remainingBudgetTokens`, the whole block
669
+ * is omitted rather than emitting an empty wrapper.
670
+ */
671
+ export function buildVaultContextInner(
672
+ factsBlock: string,
673
+ relations: RelationSnippet[],
674
+ remainingBudgetTokens: number
675
+ ): string {
676
+ const lines: string[] = [];
677
+ lines.push(INSTRUCTION_XML);
678
+ lines.push(`<facts>\n${factsBlock}\n</facts>`);
679
+
680
+ if (relations.length === 0 || remainingBudgetTokens <= 0) {
681
+ return lines.join("\n");
682
+ }
683
+
684
+ const relationLines = renderRelationshipLines(relations);
685
+ if (relationLines.length === 0) return lines.join("\n");
686
+
687
+ // The XML wrapper itself consumes tokens — if there's no room for even one
688
+ // line on top of the wrapper, drop the block entirely.
689
+ const fittedLines: string[] = [];
690
+ let used = RELATIONSHIPS_XML_OVERHEAD_TOKENS;
691
+ for (const line of relationLines) {
692
+ const lineTokens = estimateTokens(line + "\n");
693
+ if (used + lineTokens > remainingBudgetTokens) break;
694
+ fittedLines.push(line);
695
+ used += lineTokens;
696
+ }
697
+ if (fittedLines.length === 0) return lines.join("\n");
698
+
699
+ lines.push(`<relationships>\n${fittedLines.join("\n")}\n</relationships>`);
700
+ return lines.join("\n");
701
+ }
702
+
434
703
  /**
435
704
  * Check if the agent should be nudged to use lifecycle tools.
436
705
  * Returns true if N+ context-surfacing invocations have occurred since the
@@ -10,12 +10,18 @@
10
10
  */
11
11
 
12
12
  import type { Store } from "../store.ts";
13
+ import { resolveStore } from "../store.ts";
14
+ import { listVaults } from "../config.ts";
13
15
  import type { HookInput, HookOutput } from "../hooks.ts";
14
16
  import {
15
17
  makeEmptyOutput,
16
18
  readTranscript,
17
19
  validateTranscriptPath,
18
20
  } from "../hooks.ts";
21
+ import {
22
+ segmentTranscriptIntoTurns,
23
+ attributeRecallReferences,
24
+ } from "../recall-attribution.ts";
19
25
 
20
26
  // =============================================================================
21
27
  // Handler
@@ -129,6 +135,33 @@ export async function feedbackLoop(
129
135
  // Non-critical — don't block feedback loop on utility tracking errors
130
136
  }
131
137
 
138
+ // Recall tracking: per-turn attribution using transcript segmentation.
139
+ // Reads full transcript, segments into turns, zips with context_usage rows,
140
+ // checks references per-turn rather than session-globally.
141
+ try {
142
+ const allMessages = readTranscript(transcriptPath, 500);
143
+ const turns = segmentTranscriptIntoTurns(allMessages);
144
+ const usages = store.getUsageForSession(sessionId);
145
+
146
+ // General vault attribution
147
+ attributeRecallReferences(store, sessionId, usages, turns);
148
+
149
+ // Cross-vault: attribute recall events in any configured named vaults.
150
+ // Each vault has its own context_usage rows (mirrored during context-surfacing).
151
+ const vaultNames = listVaults();
152
+ for (const vaultName of vaultNames) {
153
+ try {
154
+ const vaultStore = resolveStore(vaultName);
155
+ const vaultUsages = vaultStore.getUsageForSession(sessionId);
156
+ if (vaultUsages.length > 0) {
157
+ attributeRecallReferences(vaultStore, sessionId, vaultUsages, turns);
158
+ }
159
+ } catch { /* vault unavailable — skip */ }
160
+ }
161
+ } catch {
162
+ // Non-critical — don't block feedback loop on recall tracking errors
163
+ }
164
+
132
165
  // Silent return — feedback loop doesn't inject context
133
166
  return makeEmptyOutput("feedback-loop");
134
167
  }
@@ -195,6 +228,13 @@ function trackUtilitySignals(
195
228
  // Reference Detection
196
229
  // =============================================================================
197
230
 
231
+ // Recall attribution logic is in src/recall-attribution.ts
232
+ // (attributeRecallReferences, segmentTranscriptIntoTurns)
233
+
234
+ // =============================================================================
235
+ // Reference Detection
236
+ // =============================================================================
237
+
198
238
  function checkTitleReference(store: Store, path: string, text: string): boolean {
199
239
  try {
200
240
  const parts = path.split("/");
package/src/hooks.ts CHANGED
@@ -385,23 +385,28 @@ export function logInjection(
385
385
  sessionId: string,
386
386
  hookName: string,
387
387
  injectedPaths: string[],
388
- estimatedTokens: number
389
- ): void {
388
+ estimatedTokens: number,
389
+ turnIndex?: number
390
+ ): number {
390
391
  try {
391
- store.insertUsage({
392
+ const usageId = store.insertUsage({
392
393
  sessionId,
393
394
  timestamp: new Date().toISOString(),
394
395
  hookName,
395
396
  injectedPaths,
396
397
  estimatedTokens,
397
398
  wasReferenced: 0,
399
+ turnIndex,
398
400
  });
399
401
 
400
402
  // Record co-activation for all injected paths (E3)
401
403
  if (injectedPaths.length >= 2) {
402
404
  store.recordCoActivation(injectedPaths);
403
405
  }
406
+
407
+ return usageId;
404
408
  } catch {
405
409
  // Non-fatal: don't crash hook if usage logging fails
410
+ return -1;
406
411
  }
407
412
  }
package/src/mcp.ts CHANGED
@@ -2277,6 +2277,11 @@ This is the recommended entry point for ALL memory queries.`,
2277
2277
  const config = loadConfig();
2278
2278
  const policy = config.lifecycle;
2279
2279
 
2280
+ // Recall tracking summary
2281
+ const recallStats = store.getRecallStatsAll(1);
2282
+ const highDiversity = recallStats.filter(r => r.diversityScore >= 0.4 && r.spacingScore >= 0.5 && r.recallCount >= 3);
2283
+ const highNoise = recallStats.filter(r => r.recallCount >= 5 && r.negativeCount > r.recallCount * 0.8);
2284
+
2280
2285
  const lines = [
2281
2286
  `Active: ${stats.active}`,
2282
2287
  `Archived (auto): ${stats.archived}`,
@@ -2286,6 +2291,10 @@ This is the recommended entry point for ALL memory queries.`,
2286
2291
  `Never accessed: ${stats.neverAccessed}`,
2287
2292
  `Oldest access: ${stats.oldestAccess?.slice(0, 10) || "n/a"}`,
2288
2293
  "",
2294
+ `Recall tracking: ${recallStats.length} docs tracked`,
2295
+ ` Pin candidates (high diversity+spacing): ${highDiversity.length}`,
2296
+ ` Snooze candidates (surfaced often, rarely referenced): ${highNoise.length}`,
2297
+ "",
2289
2298
  `Policy: ${policy ? `archive after ${policy.archive_after_days}d, purge after ${policy.purge_after_days ?? "never"}, dry_run=${policy.dry_run}` : "none configured"}`,
2290
2299
  ];
2291
2300
 
@@ -2322,7 +2331,29 @@ This is the recommended entry point for ALL memory queries.`,
2322
2331
  const lines = candidates.map(c =>
2323
2332
  `- ${c.collection}/${c.path} (${c.content_type}, modified ${c.modified_at.slice(0, 10)}, accessed ${c.last_accessed_at?.slice(0, 10) || "never"})`
2324
2333
  );
2325
- return { content: [{ type: "text", text: `Would archive ${candidates.length} document(s):\n${lines.join("\n") || "(none)"}` }] };
2334
+
2335
+ // Recall-based recommendations
2336
+ const recallStats = store.getRecallStatsAll(3);
2337
+ const pinCandidates = recallStats.filter(r => r.diversityScore >= 0.4 && r.spacingScore >= 0.5 && r.recallCount >= 3);
2338
+ const snoozeCandidates = recallStats.filter(r => r.recallCount >= 5 && r.negativeCount > r.recallCount * 0.8);
2339
+
2340
+ const recallLines: string[] = [];
2341
+ if (pinCandidates.length > 0) {
2342
+ recallLines.push("", "Pin candidates (high diversity, multi-day spread, recall≥3):");
2343
+ for (const r of pinCandidates.slice(0, 5)) {
2344
+ const label = r.collection && r.path ? `${r.collection}/${r.path}` : `doc#${r.docId}`;
2345
+ recallLines.push(` - ${label} (recalls=${r.recallCount}, queries=${r.uniqueQueries}, days=${r.recallDays}, diversity=${r.diversityScore.toFixed(2)}, spacing=${r.spacingScore.toFixed(2)})`);
2346
+ }
2347
+ }
2348
+ if (snoozeCandidates.length > 0) {
2349
+ recallLines.push("", "Snooze candidates (surfaced often, rarely referenced):");
2350
+ for (const r of snoozeCandidates.slice(0, 5)) {
2351
+ const label = r.collection && r.path ? `${r.collection}/${r.path}` : `doc#${r.docId}`;
2352
+ recallLines.push(` - ${label} (recalls=${r.recallCount}, referenced=${r.recallCount - r.negativeCount}, noise_ratio=${(r.negativeCount / r.recallCount * 100).toFixed(0)}%)`);
2353
+ }
2354
+ }
2355
+
2356
+ return { content: [{ type: "text", text: `Would archive ${candidates.length} document(s):\n${lines.join("\n") || "(none)"}${recallLines.join("\n")}` }] };
2326
2357
  }
2327
2358
 
2328
2359
  const archived = store.archiveDocuments(candidates.map(c => c.id));