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.
- package/AGENTS.md +10 -5
- package/CLAUDE.md +10 -5
- package/README.md +34 -4
- package/SKILL.md +15 -1
- package/package.json +1 -1
- package/src/consolidation.ts +525 -40
- package/src/deductive-guardrails.ts +481 -0
- package/src/hooks/context-surfacing.ts +285 -16
- package/src/hooks/feedback-loop.ts +40 -0
- package/src/hooks.ts +8 -3
- package/src/mcp.ts +32 -1
- package/src/merge-guards.ts +266 -0
- package/src/recall-attribution.ts +182 -0
- package/src/recall-buffer.ts +85 -0
- package/src/store.ts +271 -12
- package/src/text-similarity.ts +364 -0
|
@@ -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
|
-
|
|
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("/"))
|
|
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))
|
|
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
|
-
|
|
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)
|
|
371
|
+
if (!context) {
|
|
372
|
+
logEmptyTurn(store, input);
|
|
373
|
+
return makeEmptyOutput("context-surfacing");
|
|
374
|
+
}
|
|
329
375
|
|
|
330
|
-
//
|
|
376
|
+
// Use pre-computed turn_index from top of function
|
|
331
377
|
if (input.sessionId) {
|
|
332
|
-
|
|
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${
|
|
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
|
-
|
|
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
|
-
|
|
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));
|