context-vault 3.6.0 → 3.7.0

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.
@@ -1,6 +1,11 @@
1
1
  import { z } from 'zod';
2
- import { ok } from '../helpers.js';
3
- import type { ToolResult } from '../types.js';
2
+ import { ok, ensureVaultExists, kindIcon, fmtDate } from '../helpers.js';
3
+ import type { LocalCtx, SharedCtx, ToolResult } from '../types.js';
4
+
5
+ const DEFAULT_PRELOAD_TOKENS = 2000;
6
+ const MAX_BODY_PER_ENTRY = 300;
7
+ const RECENT_DAYS = 7;
8
+ const PRIORITY_KINDS = ['decision', 'insight', 'pattern'];
4
9
 
5
10
  export const name = 'clear_context';
6
11
 
@@ -14,28 +19,208 @@ export const inputSchema = {
14
19
  .describe(
15
20
  'Optional tag or project name to focus on going forward. When provided, treat subsequent get_context calls as if filtered to this tag.'
16
21
  ),
22
+ preload_bucket: z
23
+ .string()
24
+ .optional()
25
+ .describe(
26
+ 'Bucket name to preload context from after clearing. Loads recent decisions, insights, and patterns scoped to this bucket into the response so the agent has immediate context for the new project.'
27
+ ),
28
+ max_tokens: z
29
+ .number()
30
+ .optional()
31
+ .describe(
32
+ 'Token budget for preloaded context (rough estimate: 1 token ~ 4 chars). Default: 2000. Only applies when preload_bucket is set.'
33
+ ),
17
34
  };
18
35
 
19
- export function handler({ scope }: { scope?: string } = {}): ToolResult {
36
+ function estimateTokens(text: string | null | undefined): number {
37
+ return Math.ceil((text || '').length / 4);
38
+ }
39
+
40
+ function truncateBody(body: string | null | undefined, maxLen = MAX_BODY_PER_ENTRY): string {
41
+ if (!body) return '(no body)';
42
+ if (body.length <= maxLen) return body;
43
+ return body.slice(0, maxLen) + '...';
44
+ }
45
+
46
+ function formatEntry(entry: any): string {
47
+ const tags = entry.tags ? JSON.parse(entry.tags) : [];
48
+ const tagStr = tags.length ? tags.join(', ') : '';
49
+ const date = fmtDate(entry.updated_at || entry.created_at);
50
+ const icon = kindIcon(entry.kind);
51
+ const meta = [`\`${entry.kind}\``, tagStr, date].filter(Boolean).join(' · ');
52
+ return [
53
+ `- ${icon} **${entry.title || '(untitled)'}**`,
54
+ ` ${meta} · \`${entry.id}\``,
55
+ ` ${truncateBody(entry.body).replace(/\n+/g, ' ').trim()}`,
56
+ ].join('\n');
57
+ }
58
+
59
+ function preloadBucketContext(
60
+ ctx: LocalCtx,
61
+ bucket: string,
62
+ tokenBudget: number
63
+ ): { sections: string[]; tokensUsed: number; entryCount: number } {
64
+ const sections: string[] = [];
65
+ let tokensUsed = 0;
66
+ let entryCount = 0;
67
+
68
+ const bucketTag = `bucket:${bucket}`;
69
+ const sinceDate = new Date(Date.now() - RECENT_DAYS * 86400000).toISOString();
70
+
71
+ // Query priority kinds (decisions, insights, patterns)
72
+ const kindPlaceholders = PRIORITY_KINDS.map(() => '?').join(',');
73
+ const priorityRows = ctx.db
74
+ .prepare(
75
+ `SELECT * FROM vault
76
+ WHERE kind IN (${kindPlaceholders})
77
+ AND created_at >= ?
78
+ AND (expires_at IS NULL OR expires_at > datetime('now'))
79
+ AND superseded_by IS NULL
80
+ ORDER BY created_at DESC
81
+ LIMIT 30`
82
+ )
83
+ .all(...PRIORITY_KINDS, sinceDate) as any[];
84
+
85
+ // Filter to bucket-tagged entries
86
+ const taggedPriority = priorityRows.filter((r: any) => {
87
+ const tags = r.tags ? JSON.parse(r.tags) : [];
88
+ return tags.includes(bucketTag);
89
+ });
90
+
91
+ if (taggedPriority.length > 0) {
92
+ const header = '### Active Decisions, Insights & Patterns\n';
93
+ const headerTokens = estimateTokens(header);
94
+ if (tokensUsed + headerTokens <= tokenBudget) {
95
+ const entryLines: string[] = [];
96
+ tokensUsed += headerTokens;
97
+ for (const entry of taggedPriority) {
98
+ const line = formatEntry(entry);
99
+ const lineTokens = estimateTokens(line);
100
+ if (tokensUsed + lineTokens > tokenBudget) break;
101
+ entryLines.push(line);
102
+ tokensUsed += lineTokens;
103
+ entryCount++;
104
+ }
105
+ if (entryLines.length > 0) {
106
+ sections.push(header + entryLines.join('\n'));
107
+ }
108
+ }
109
+ }
110
+
111
+ // Query recent entries (any kind)
112
+ const recentRows = ctx.db
113
+ .prepare(
114
+ `SELECT * FROM vault
115
+ WHERE created_at >= ?
116
+ AND (expires_at IS NULL OR expires_at > datetime('now'))
117
+ AND superseded_by IS NULL
118
+ ORDER BY created_at DESC
119
+ LIMIT 30`
120
+ )
121
+ .all(sinceDate) as any[];
122
+
123
+ const seenIds = new Set(taggedPriority.map((r: any) => r.id));
124
+ const taggedRecent = recentRows.filter((r: any) => {
125
+ if (seenIds.has(r.id)) return false;
126
+ const tags = r.tags ? JSON.parse(r.tags) : [];
127
+ return tags.includes(bucketTag);
128
+ });
129
+
130
+ if (taggedRecent.length > 0) {
131
+ const header = `\n### Recent Entries (last ${RECENT_DAYS} days)\n`;
132
+ const headerTokens = estimateTokens(header);
133
+ if (tokensUsed + headerTokens <= tokenBudget) {
134
+ const entryLines: string[] = [];
135
+ tokensUsed += headerTokens;
136
+ for (const entry of taggedRecent) {
137
+ const line = formatEntry(entry);
138
+ const lineTokens = estimateTokens(line);
139
+ if (tokensUsed + lineTokens > tokenBudget) break;
140
+ entryLines.push(line);
141
+ tokensUsed += lineTokens;
142
+ entryCount++;
143
+ }
144
+ if (entryLines.length > 0) {
145
+ sections.push(header + entryLines.join('\n'));
146
+ }
147
+ }
148
+ }
149
+
150
+ return { sections, tokensUsed, entryCount };
151
+ }
152
+
153
+ export async function handler(
154
+ { scope, preload_bucket, max_tokens }: { scope?: string; preload_bucket?: string; max_tokens?: number } = {},
155
+ ctx?: LocalCtx,
156
+ shared?: SharedCtx
157
+ ): Promise<ToolResult> {
20
158
  const lines = [
21
159
  '## Context Reset',
22
160
  '',
23
161
  'Active session context has been cleared. All previous context from this session should be disregarded.',
24
162
  '',
25
- 'Vault entries are unchanged no data was deleted.',
163
+ 'Vault entries are unchanged -- no data was deleted.',
26
164
  ];
27
165
 
28
- if (scope?.trim()) {
29
- const trimmed = scope.trim();
166
+ const trimmedScope = scope?.trim() || '';
167
+ // If preload_bucket is set but scope is not, infer scope from the bucket
168
+ const effectiveScope = trimmedScope || preload_bucket?.trim() || '';
169
+
170
+ if (effectiveScope) {
30
171
  lines.push(
31
172
  '',
32
- `### Active Scope: \`${trimmed}\``,
173
+ `### Active Scope: \`${effectiveScope}\``,
33
174
  '',
34
- `Going forward, treat \`get_context\` calls as scoped to the tag or project **"${trimmed}"** unless the user explicitly requests a different scope or passes their own tag filters.`
175
+ `Going forward, treat \`get_context\` calls as scoped to the tag or project **"${effectiveScope}"** unless the user explicitly requests a different scope or passes their own tag filters.`
35
176
  );
36
177
  } else {
37
- lines.push('', 'No scope set. Use `get_context` normally all vault entries are accessible.');
178
+ lines.push('', 'No scope set. Use `get_context` normally -- all vault entries are accessible.');
179
+ }
180
+
181
+ // Preload bucket context if requested and ctx is available
182
+ const bucket = preload_bucket?.trim();
183
+ let preloadMeta: Record<string, unknown> = {};
184
+ if (bucket && ctx) {
185
+ const vaultErr = ensureVaultExists(ctx.config);
186
+ if (vaultErr) {
187
+ lines.push('', `> **Warning:** Could not preload bucket context: vault not found.`);
188
+ } else {
189
+ if (shared?.ensureIndexed) {
190
+ await shared.ensureIndexed({ blocking: false });
191
+ }
192
+
193
+ const tokenBudget = max_tokens || DEFAULT_PRELOAD_TOKENS;
194
+ const { sections, tokensUsed, entryCount } = preloadBucketContext(ctx, bucket, tokenBudget);
195
+
196
+ if (sections.length > 0) {
197
+ lines.push(
198
+ '',
199
+ `## Preloaded Context: \`${bucket}\``,
200
+ `_${entryCount} entries | ${tokensUsed} / ${tokenBudget} tokens used_`,
201
+ '',
202
+ ...sections
203
+ );
204
+ } else {
205
+ lines.push(
206
+ '',
207
+ `_No recent entries found in bucket \`${bucket}\`. Use \`get_context\` to search across all entries._`
208
+ );
209
+ }
210
+
211
+ preloadMeta = {
212
+ preload_bucket: bucket,
213
+ preload_entries: entryCount,
214
+ preload_tokens_used: tokensUsed,
215
+ preload_tokens_budget: tokenBudget,
216
+ };
217
+ }
38
218
  }
39
219
 
40
- return ok(lines.join('\n'));
220
+ const result: ToolResult = ok(lines.join('\n'));
221
+ result._meta = {
222
+ scope: effectiveScope || null,
223
+ ...preloadMeta,
224
+ };
225
+ return result;
41
226
  }
@@ -695,7 +695,7 @@ export async function handler(
695
695
  }
696
696
  }
697
697
 
698
- // Graph traversal: follow related_to links bidirectionally
698
+ // Graph traversal: follow related_to links bidirectionally + co-retrieval edges
699
699
  if (follow_links) {
700
700
  const { forward, backward } = collectLinkedEntries(ctx.db, filtered as any);
701
701
  const allLinked: any[] = [...(forward as any[]), ...(backward as any[])];
@@ -722,6 +722,69 @@ export async function handler(
722
722
  } else {
723
723
  lines.push(`## Linked Entries\n\nNo related entries found.\n`);
724
724
  }
725
+
726
+ // Co-retrieval graph traversal: edges with count > 3
727
+ const primaryIds = filtered.map((e: any) => e.id);
728
+ const primaryIdSet = new Set([...primaryIds, ...Array.from(seen)]);
729
+ const coRetrieved: any[] = [];
730
+
731
+ try {
732
+ const CO_RETRIEVAL_THRESHOLD = 3;
733
+ const placeholders = primaryIds.map(() => '?').join(',');
734
+ const coRows = ctx.db
735
+ .prepare(
736
+ `SELECT entry_a, entry_b, count FROM co_retrievals
737
+ WHERE count > ? AND (entry_a IN (${placeholders}) OR entry_b IN (${placeholders}))`
738
+ )
739
+ .all(CO_RETRIEVAL_THRESHOLD, ...primaryIds, ...primaryIds) as {
740
+ entry_a: string;
741
+ entry_b: string;
742
+ count: number;
743
+ }[];
744
+
745
+ const linkedIds = new Map<string, number>();
746
+ for (const row of coRows) {
747
+ for (const candidate of [row.entry_a, row.entry_b]) {
748
+ if (!primaryIdSet.has(candidate)) {
749
+ const existing = linkedIds.get(candidate) ?? 0;
750
+ if (row.count > existing) linkedIds.set(candidate, row.count);
751
+ }
752
+ }
753
+ }
754
+
755
+ if (linkedIds.size > 0) {
756
+ const coIds = Array.from(linkedIds.keys());
757
+ const coPlaceholders = coIds.map(() => '?').join(',');
758
+ const coEntries = ctx.db
759
+ .prepare(
760
+ `SELECT * FROM vault WHERE id IN (${coPlaceholders}) AND (expires_at IS NULL OR expires_at > datetime('now')) AND superseded_by IS NULL`
761
+ )
762
+ .all(...coIds) as any[];
763
+
764
+ for (const entry of coEntries) {
765
+ coRetrieved.push({ ...entry, co_retrieval_weight: linkedIds.get(entry.id) ?? 0 });
766
+ }
767
+ }
768
+ } catch {
769
+ // Non-fatal: co-retrieval traversal is best-effort
770
+ }
771
+
772
+ if (coRetrieved.length > 0) {
773
+ coRetrieved.sort((a: any, b: any) => b.co_retrieval_weight - a.co_retrieval_weight);
774
+ lines.push(
775
+ `## Co-Retrieved Entries (${coRetrieved.length} via usage patterns)\n`
776
+ );
777
+ for (const r of coRetrieved) {
778
+ const entryTags = r.tags ? JSON.parse(r.tags) : [];
779
+ const tagStr = entryTags.length ? entryTags.join(', ') : '';
780
+ const icon = kindIcon(r.kind);
781
+ lines.push(`### ${icon} ${r.title || '(untitled)'} (weight: ${r.co_retrieval_weight})`);
782
+ const meta = [`\`${r.kind}\``, tagStr].filter(Boolean).join(' · ');
783
+ lines.push(`${meta} \nid: \`${r.id}\``);
784
+ lines.push(truncateBody(r.body, body_limit ?? 300));
785
+ lines.push('');
786
+ }
787
+ }
725
788
  }
726
789
 
727
790
  // Consolidation suggestion detection — lazy, opportunistic, vault-wide
@@ -0,0 +1,307 @@
1
+ import { z } from 'zod';
2
+ import { ok } from '../helpers.js';
3
+ import { isEmbedAvailable } from '@context-vault/core/embed';
4
+ import type { LocalCtx, SharedCtx, ToolResult } from '../types.js';
5
+
6
+ const SEMANTIC_SIMILARITY_THRESHOLD = 0.6;
7
+ const CO_RETRIEVAL_WEIGHT_CAP = 50;
8
+
9
+ const STOPWORDS = new Set([
10
+ 'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
11
+ 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could',
12
+ 'should', 'may', 'might', 'shall', 'can', 'need', 'must', 'ought',
13
+ 'and', 'but', 'or', 'nor', 'not', 'so', 'yet', 'for', 'with',
14
+ 'from', 'into', 'during', 'before', 'after', 'above', 'below',
15
+ 'to', 'of', 'in', 'on', 'at', 'by', 'about', 'between', 'through',
16
+ 'this', 'that', 'these', 'those', 'it', 'its', 'they', 'them',
17
+ 'their', 'we', 'our', 'you', 'your', 'he', 'she', 'him', 'her',
18
+ 'all', 'each', 'every', 'both', 'few', 'more', 'most', 'other',
19
+ 'some', 'such', 'no', 'only', 'own', 'same', 'than', 'too', 'very',
20
+ 'just', 'also', 'now', 'then', 'here', 'there', 'when', 'where',
21
+ 'how', 'what', 'which', 'who', 'whom', 'why', 'if', 'because',
22
+ 'as', 'until', 'while', 'use', 'using', 'used',
23
+ ]);
24
+
25
+ const DEFAULT_MAX_HINTS = 3;
26
+
27
+ /** Module-level session dedup map: session_id -> Set of surfaced entry IDs */
28
+ const sessionSurfaced = new Map<string, Set<string>>();
29
+
30
+ /**
31
+ * Extract keywords from a signal string.
32
+ * Split on whitespace, filter stopwords and words under 4 chars, keep top 10.
33
+ */
34
+ export function extractKeywords(signal: string): string[] {
35
+ const words = signal
36
+ .toLowerCase()
37
+ .replace(/[^a-z0-9\s_-]/g, ' ')
38
+ .split(/\s+/)
39
+ .filter((w) => w.length >= 4 && !STOPWORDS.has(w));
40
+
41
+ const seen = new Set<string>();
42
+ const unique: string[] = [];
43
+ for (const w of words) {
44
+ if (seen.has(w)) continue;
45
+ seen.add(w);
46
+ unique.push(w);
47
+ if (unique.length >= 10) break;
48
+ }
49
+ return unique;
50
+ }
51
+
52
+ export const name = 'recall';
53
+
54
+ export const description =
55
+ 'Search the vault using a raw signal (prompt text, error message, file path, or task context). Returns lightweight hints for proactive surfacing. Designed for runtime hooks, not direct user interaction.';
56
+
57
+ export const inputSchema = {
58
+ signal: z
59
+ .string()
60
+ .describe('Raw text: prompt, error message, file path, or combined signal.'),
61
+ signal_type: z
62
+ .enum(['prompt', 'error', 'file', 'task'])
63
+ .describe('Type of signal, used to weight results.'),
64
+ bucket: z
65
+ .string()
66
+ .optional()
67
+ .describe('Scope results to a project bucket.'),
68
+ session_id: z
69
+ .string()
70
+ .optional()
71
+ .describe('Session identifier for dedup. Entries already surfaced this session are suppressed.'),
72
+ max_hints: z
73
+ .number()
74
+ .optional()
75
+ .describe('Maximum hints to return. Default: 3.'),
76
+ };
77
+
78
+ export async function handler(
79
+ { signal, signal_type, bucket, session_id, max_hints }: Record<string, any>,
80
+ ctx: LocalCtx,
81
+ { ensureIndexed }: SharedCtx
82
+ ): Promise<ToolResult> {
83
+ const start = Date.now();
84
+
85
+ await ensureIndexed();
86
+
87
+ const keywords = extractKeywords(signal || '');
88
+ const limit = max_hints ?? DEFAULT_MAX_HINTS;
89
+
90
+ if (keywords.length === 0) {
91
+ const result = ok('No relevant entries found.');
92
+ result._meta = {
93
+ latency_ms: Date.now() - start,
94
+ method: 'none' as const,
95
+ signal_keywords: [],
96
+ suppressed: 0,
97
+ };
98
+ return result;
99
+ }
100
+
101
+ // Build fast-path query: tag/title LIKE match for each keyword
102
+ const conditions: string[] = [];
103
+ const params: string[] = [];
104
+ for (const kw of keywords) {
105
+ conditions.push('(title LIKE ? OR tags LIKE ?)');
106
+ params.push(`%${kw}%`, `%${kw}%`);
107
+ }
108
+
109
+ const bucketClause = bucket ? ' AND tags LIKE ?' : '';
110
+ if (bucket) params.push(`%"bucket:${bucket}"%`);
111
+
112
+ const sql = `SELECT id, title, substr(body, 1, 100) as summary, kind, tags
113
+ FROM vault
114
+ WHERE indexed = 1
115
+ AND (expires_at IS NULL OR expires_at > datetime('now'))
116
+ AND superseded_by IS NULL
117
+ AND (${conditions.join(' OR ')})${bucketClause}
118
+ LIMIT 20`;
119
+
120
+ let rows: any[];
121
+ try {
122
+ rows = ctx.db.prepare(sql).all(...params);
123
+ } catch {
124
+ rows = [];
125
+ }
126
+
127
+ // Session dedup
128
+ let suppressed = 0;
129
+ const bypassDedup = signal_type === 'error';
130
+ const sessionSet = session_id
131
+ ? (sessionSurfaced.get(session_id) ?? (() => { const s = new Set<string>(); sessionSurfaced.set(session_id, s); return s; })())
132
+ : null;
133
+
134
+ const hints: Array<{
135
+ id: string;
136
+ title: string;
137
+ summary: string;
138
+ relevance: 'high' | 'medium';
139
+ kind: string;
140
+ tags: string[];
141
+ }> = [];
142
+
143
+ for (const row of rows) {
144
+ if (hints.length >= limit) break;
145
+
146
+ // Dedup check
147
+ if (sessionSet && !bypassDedup && sessionSet.has(row.id)) {
148
+ suppressed++;
149
+ continue;
150
+ }
151
+
152
+ const entryTags: string[] = row.tags ? JSON.parse(row.tags) : [];
153
+
154
+ // Score relevance: count how many keywords match title or tags
155
+ let matchCount = 0;
156
+ const titleLower = (row.title || '').toLowerCase();
157
+ const tagsLower = (row.tags || '').toLowerCase();
158
+ for (const kw of keywords) {
159
+ if (titleLower.includes(kw) || tagsLower.includes(kw)) matchCount++;
160
+ }
161
+ const relevance: 'high' | 'medium' = matchCount >= 2 ? 'high' : 'medium';
162
+
163
+ hints.push({
164
+ id: row.id,
165
+ title: row.title || '(untitled)',
166
+ summary: row.summary || '',
167
+ relevance,
168
+ kind: row.kind || 'knowledge',
169
+ tags: entryTags,
170
+ });
171
+
172
+ // Track surfaced
173
+ if (sessionSet) sessionSet.add(row.id);
174
+ }
175
+
176
+ let method: 'tag_match' | 'semantic' | 'none' = hints.length > 0 ? 'tag_match' : 'none';
177
+
178
+ // Semantic fallback: when fast-path returns 0 results and signal is not file-based
179
+ if (hints.length === 0 && signal_type !== 'file' && isEmbedAvailable()) {
180
+ try {
181
+ const vecCount = (
182
+ ctx.db.prepare('SELECT COUNT(*) as c FROM vault_vec').get() as { c: number }
183
+ ).c;
184
+
185
+ if (vecCount > 0) {
186
+ const queryVec = await ctx.embed(signal);
187
+ if (queryVec) {
188
+ const vecRows = ctx.db
189
+ .prepare(
190
+ 'SELECT v.rowid, v.distance FROM vault_vec v WHERE embedding MATCH ? ORDER BY distance LIMIT 5'
191
+ )
192
+ .all(queryVec) as { rowid: number; distance: number }[];
193
+
194
+ if (vecRows.length) {
195
+ const rowids = vecRows.map((vr) => vr.rowid);
196
+ const placeholders = rowids.map(() => '?').join(',');
197
+
198
+ let bucketFilter = '';
199
+ const hydrateParams: any[] = [...rowids];
200
+ if (bucket) {
201
+ bucketFilter = ' AND tags LIKE ?';
202
+ hydrateParams.push(`%"bucket:${bucket}"%`);
203
+ }
204
+
205
+ const hydrated = ctx.db
206
+ .prepare(
207
+ `SELECT rowid, id, title, substr(body, 1, 100) as summary, kind, tags FROM vault WHERE rowid IN (${placeholders}) AND indexed = 1 AND (expires_at IS NULL OR expires_at > datetime('now')) AND superseded_by IS NULL${bucketFilter}`
208
+ )
209
+ .all(...hydrateParams) as any[];
210
+
211
+ const byRowid = new Map<number, any>();
212
+ for (const row of hydrated) byRowid.set(row.rowid, row);
213
+
214
+ for (const vr of vecRows) {
215
+ if (hints.length >= limit) break;
216
+ const row = byRowid.get(vr.rowid);
217
+ if (!row) continue;
218
+
219
+ const similarity = Math.max(0, 1 - vr.distance / 2);
220
+ if (similarity < SEMANTIC_SIMILARITY_THRESHOLD) continue;
221
+
222
+ // Session dedup
223
+ if (sessionSet && !bypassDedup && sessionSet.has(row.id)) {
224
+ suppressed++;
225
+ continue;
226
+ }
227
+
228
+ const entryTags: string[] = row.tags ? JSON.parse(row.tags) : [];
229
+ hints.push({
230
+ id: row.id,
231
+ title: row.title || '(untitled)',
232
+ summary: row.summary || '',
233
+ relevance: similarity >= 0.8 ? 'high' : 'medium',
234
+ kind: row.kind || 'knowledge',
235
+ tags: entryTags,
236
+ });
237
+
238
+ if (sessionSet) sessionSet.add(row.id);
239
+ }
240
+
241
+ if (hints.length > 0) method = 'semantic';
242
+ }
243
+ }
244
+ }
245
+ } catch {
246
+ // Semantic fallback is best-effort; fast path already ran
247
+ }
248
+ }
249
+
250
+ const latency = Date.now() - start;
251
+
252
+ if (hints.length === 0) {
253
+ const result = ok('No relevant entries found.');
254
+ result._meta = {
255
+ latency_ms: latency,
256
+ method,
257
+ signal_keywords: keywords,
258
+ suppressed,
259
+ };
260
+ return result;
261
+ }
262
+
263
+ // Record co-retrieval pairs (fire and forget, non-blocking)
264
+ if (hints.length >= 2) {
265
+ recordCoRetrieval(ctx, hints.map((h) => h.id));
266
+ }
267
+
268
+ // Format output
269
+ const lines = [`[Vault: ${hints.length} ${hints.length === 1 ? 'entry' : 'entries'} may be relevant]`];
270
+ for (const h of hints) {
271
+ lines.push(`- "${h.title}" (${h.kind}, ${h.relevance})`);
272
+ }
273
+ lines.push('Use get_context to retrieve full details.');
274
+
275
+ const result = ok(lines.join('\n'));
276
+ result._meta = {
277
+ latency_ms: latency,
278
+ method,
279
+ signal_keywords: keywords,
280
+ suppressed,
281
+ hints,
282
+ };
283
+ return result;
284
+ }
285
+
286
+ function recordCoRetrieval(ctx: LocalCtx, ids: string[]): void {
287
+ try {
288
+ const now = new Date().toISOString();
289
+ const upsert = ctx.db.prepare(
290
+ `INSERT INTO co_retrievals (entry_a, entry_b, count, last_at) VALUES (?, ?, 1, ?)
291
+ ON CONFLICT(entry_a, entry_b) DO UPDATE SET count = MIN(count + 1, ?), last_at = ?`
292
+ );
293
+ for (let i = 0; i < ids.length; i++) {
294
+ for (let j = i + 1; j < ids.length; j++) {
295
+ const [a, b] = ids[i] < ids[j] ? [ids[i], ids[j]] : [ids[j], ids[i]];
296
+ upsert.run(a, b, now, CO_RETRIEVAL_WEIGHT_CAP, now);
297
+ }
298
+ }
299
+ } catch {
300
+ // Non-fatal: co-retrieval recording is best-effort
301
+ }
302
+ }
303
+
304
+ /** Reset session dedup state (for testing) */
305
+ export function _resetSessionState(): void {
306
+ sessionSurfaced.clear();
307
+ }
@@ -143,6 +143,12 @@ export const inputSchema = {
143
143
  .describe(
144
144
  "Bucket names to scope the session brief. Each name expands to a 'bucket:<name>' tag filter. When provided, the brief only includes entries from these buckets."
145
145
  ),
146
+ auto_memory_path: z
147
+ .string()
148
+ .optional()
149
+ .describe(
150
+ "Explicit path to the Claude Code auto-memory directory. Overrides auto-detection. If not provided, session_start attempts to detect ~/.claude/projects/-<project-key>/memory/ from cwd."
151
+ ),
146
152
  };
147
153
 
148
154
  function detectProject() {
@@ -191,7 +197,7 @@ function formatEntry(entry: any): string {
191
197
  }
192
198
 
193
199
  export async function handler(
194
- { project, max_tokens, buckets }: Record<string, any>,
200
+ { project, max_tokens, buckets, auto_memory_path }: Record<string, any>,
195
201
  ctx: LocalCtx,
196
202
  { ensureIndexed }: SharedCtx
197
203
  ): Promise<ToolResult> {
@@ -234,12 +240,17 @@ export async function handler(
234
240
 
235
241
  const sinceDate = new Date(Date.now() - RECENT_DAYS * 86400000).toISOString();
236
242
 
237
- // Auto-detect Claude Code auto-memory
238
- const autoMemoryPath = detectAutoMemoryPath();
239
- const autoMemory: AutoMemoryResult = autoMemoryPath
240
- ? readAutoMemory(autoMemoryPath)
243
+ // Auto-detect Claude Code auto-memory (explicit path overrides auto-detection)
244
+ const resolvedMemoryPath = auto_memory_path?.trim()
245
+ ? (existsSync(join(auto_memory_path.trim(), 'MEMORY.md')) ? auto_memory_path.trim() : null)
246
+ : detectAutoMemoryPath();
247
+ const autoMemory: AutoMemoryResult = resolvedMemoryPath
248
+ ? readAutoMemory(resolvedMemoryPath)
241
249
  : { detected: false, path: null, entries: [], linesUsed: 0 };
242
250
  const autoMemoryContext = buildAutoMemoryContext(autoMemory.entries);
251
+ const topicsExtracted = autoMemory.entries.length > 0
252
+ ? extractKeywords(autoMemoryContext).slice(0, 20)
253
+ : [];
243
254
 
244
255
  const sections = [];
245
256
  let tokensUsed = 0;
@@ -346,8 +357,10 @@ export async function handler(
346
357
  },
347
358
  auto_memory: {
348
359
  detected: autoMemory.detected,
360
+ path: autoMemory.path,
349
361
  entries: autoMemory.entries.length,
350
362
  lines_used: autoMemory.linesUsed,
363
+ topics_extracted: topicsExtracted,
351
364
  },
352
365
  };
353
366
  return result;