context-vault 3.13.0 → 3.16.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 (94) hide show
  1. package/bin/cli.js +263 -414
  2. package/dist/error-log.d.ts +2 -0
  3. package/dist/error-log.d.ts.map +1 -1
  4. package/dist/error-log.js +31 -1
  5. package/dist/error-log.js.map +1 -1
  6. package/dist/register-tools.d.ts.map +1 -1
  7. package/dist/register-tools.js +4 -0
  8. package/dist/register-tools.js.map +1 -1
  9. package/dist/server.js +23 -426
  10. package/dist/server.js.map +1 -1
  11. package/dist/status.d.ts.map +1 -1
  12. package/dist/status.js +17 -0
  13. package/dist/status.js.map +1 -1
  14. package/dist/tools/context-status.d.ts.map +1 -1
  15. package/dist/tools/context-status.js +26 -1
  16. package/dist/tools/context-status.js.map +1 -1
  17. package/dist/tools/delete-context.d.ts +1 -1
  18. package/dist/tools/delete-context.d.ts.map +1 -1
  19. package/dist/tools/delete-context.js +15 -2
  20. package/dist/tools/delete-context.js.map +1 -1
  21. package/dist/tools/get-context.d.ts.map +1 -1
  22. package/dist/tools/get-context.js +3 -2
  23. package/dist/tools/get-context.js.map +1 -1
  24. package/dist/tools/list-context.d.ts +7 -15
  25. package/dist/tools/list-context.d.ts.map +1 -1
  26. package/dist/tools/list-context.js +570 -111
  27. package/dist/tools/list-context.js.map +1 -1
  28. package/dist/tools/publish-to-team.js +1 -1
  29. package/dist/tools/publish-to-team.js.map +1 -1
  30. package/dist/tools/save-context.js +2 -2
  31. package/dist/tools/save-context.js.map +1 -1
  32. package/dist/tools/session-start.d.ts +20 -7
  33. package/dist/tools/session-start.d.ts.map +1 -1
  34. package/dist/tools/session-start.js +406 -439
  35. package/dist/tools/session-start.js.map +1 -1
  36. package/node_modules/@context-vault/core/dist/capture.d.ts.map +1 -1
  37. package/node_modules/@context-vault/core/dist/capture.js +4 -0
  38. package/node_modules/@context-vault/core/dist/capture.js.map +1 -1
  39. package/node_modules/@context-vault/core/dist/categories.d.ts.map +1 -1
  40. package/node_modules/@context-vault/core/dist/categories.js +8 -0
  41. package/node_modules/@context-vault/core/dist/categories.js.map +1 -1
  42. package/node_modules/@context-vault/core/dist/compact.d.ts +38 -0
  43. package/node_modules/@context-vault/core/dist/compact.d.ts.map +1 -0
  44. package/node_modules/@context-vault/core/dist/compact.js +127 -0
  45. package/node_modules/@context-vault/core/dist/compact.js.map +1 -0
  46. package/node_modules/@context-vault/core/dist/config.d.ts.map +1 -1
  47. package/node_modules/@context-vault/core/dist/config.js +12 -0
  48. package/node_modules/@context-vault/core/dist/config.js.map +1 -1
  49. package/node_modules/@context-vault/core/dist/db.d.ts +1 -1
  50. package/node_modules/@context-vault/core/dist/db.d.ts.map +1 -1
  51. package/node_modules/@context-vault/core/dist/db.js +40 -4
  52. package/node_modules/@context-vault/core/dist/db.js.map +1 -1
  53. package/node_modules/@context-vault/core/dist/main.d.ts +6 -2
  54. package/node_modules/@context-vault/core/dist/main.d.ts.map +1 -1
  55. package/node_modules/@context-vault/core/dist/main.js +5 -1
  56. package/node_modules/@context-vault/core/dist/main.js.map +1 -1
  57. package/node_modules/@context-vault/core/dist/search.d.ts +13 -1
  58. package/node_modules/@context-vault/core/dist/search.d.ts.map +1 -1
  59. package/node_modules/@context-vault/core/dist/search.js +50 -5
  60. package/node_modules/@context-vault/core/dist/search.js.map +1 -1
  61. package/node_modules/@context-vault/core/dist/tier-analysis.d.ts +36 -0
  62. package/node_modules/@context-vault/core/dist/tier-analysis.d.ts.map +1 -0
  63. package/node_modules/@context-vault/core/dist/tier-analysis.js +227 -0
  64. package/node_modules/@context-vault/core/dist/tier-analysis.js.map +1 -0
  65. package/node_modules/@context-vault/core/dist/types.d.ts +12 -0
  66. package/node_modules/@context-vault/core/dist/types.d.ts.map +1 -1
  67. package/node_modules/@context-vault/core/dist/watch.d.ts +21 -0
  68. package/node_modules/@context-vault/core/dist/watch.d.ts.map +1 -0
  69. package/node_modules/@context-vault/core/dist/watch.js +230 -0
  70. package/node_modules/@context-vault/core/dist/watch.js.map +1 -0
  71. package/node_modules/@context-vault/core/package.json +13 -1
  72. package/node_modules/@context-vault/core/src/capture.ts +4 -0
  73. package/node_modules/@context-vault/core/src/categories.ts +8 -0
  74. package/node_modules/@context-vault/core/src/compact.ts +183 -0
  75. package/node_modules/@context-vault/core/src/config.ts +8 -0
  76. package/node_modules/@context-vault/core/src/db.ts +40 -4
  77. package/node_modules/@context-vault/core/src/main.ts +10 -0
  78. package/node_modules/@context-vault/core/src/search.ts +55 -4
  79. package/node_modules/@context-vault/core/src/tier-analysis.ts +299 -0
  80. package/node_modules/@context-vault/core/src/types.ts +10 -0
  81. package/node_modules/@context-vault/core/src/watch.ts +269 -0
  82. package/package.json +2 -2
  83. package/scripts/postinstall.js +26 -1
  84. package/src/error-log.ts +30 -0
  85. package/src/register-tools.ts +4 -0
  86. package/src/server.ts +23 -423
  87. package/src/status.ts +17 -0
  88. package/src/tools/context-status.ts +30 -1
  89. package/src/tools/delete-context.ts +10 -5
  90. package/src/tools/get-context.ts +3 -2
  91. package/src/tools/list-context.ts +620 -119
  92. package/src/tools/publish-to-team.ts +1 -1
  93. package/src/tools/save-context.ts +2 -2
  94. package/src/tools/session-start.ts +444 -484
@@ -1,567 +1,527 @@
1
1
  import { z } from 'zod';
2
- import { execSync } from 'node:child_process';
3
- import { readFileSync, readdirSync, existsSync } from 'node:fs';
4
- import { join } from 'node:path';
5
- import { homedir } from 'node:os';
6
- import { ok, err, ensureVaultExists, kindIcon, fmtDate } from '../helpers.js';
7
- import { getAutoMemory } from '../auto-memory.js';
2
+ import { ok } from '../helpers.js';
3
+ import { isEmbedAvailable } from '@context-vault/core/embed';
4
+ import { getAutoMemory, findAutoMemoryOverlaps } from '../auto-memory.js';
8
5
  import { getRemoteClient, getTeamId, getPublicVaults } from '../remote.js';
9
- import type { AutoMemoryEntry, AutoMemoryResult } from '../auto-memory.js';
10
6
  import type { LocalCtx, SharedCtx, ToolResult } from '../types.js';
11
7
 
12
- const DEFAULT_MAX_TOKENS = 4000;
13
- const RECENT_DAYS = 7;
14
- const MAX_BODY_PER_ENTRY = 400;
15
- const PRIORITY_KINDS = ['decision', 'insight', 'pattern'];
16
- const SESSION_SUMMARY_KIND = 'session';
8
+ const SEMANTIC_SIMILARITY_THRESHOLD = 0.6;
9
+ const CO_RETRIEVAL_WEIGHT_CAP = 50;
10
+
11
+ const STOPWORDS = new Set([
12
+ 'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
13
+ 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could',
14
+ 'should', 'may', 'might', 'shall', 'can', 'need', 'must', 'ought',
15
+ 'and', 'but', 'or', 'nor', 'not', 'so', 'yet', 'for', 'with',
16
+ 'from', 'into', 'during', 'before', 'after', 'above', 'below',
17
+ 'to', 'of', 'in', 'on', 'at', 'by', 'about', 'between', 'through',
18
+ 'this', 'that', 'these', 'those', 'it', 'its', 'they', 'them',
19
+ 'their', 'we', 'our', 'you', 'your', 'he', 'she', 'him', 'her',
20
+ 'all', 'each', 'every', 'both', 'few', 'more', 'most', 'other',
21
+ 'some', 'such', 'no', 'only', 'own', 'same', 'than', 'too', 'very',
22
+ 'just', 'also', 'now', 'then', 'here', 'there', 'when', 'where',
23
+ 'how', 'what', 'which', 'who', 'whom', 'why', 'if', 'because',
24
+ 'as', 'until', 'while', 'use', 'using', 'used',
25
+ ]);
26
+
27
+ const DEFAULT_MAX_HINTS = 3;
28
+
29
+ /** Module-level session dedup map: session_id -> Set of surfaced entry IDs */
30
+ const sessionSurfaced = new Map<string, Set<string>>();
17
31
 
18
32
  /**
19
- * Build a search context string from auto-memory entries.
20
- * Used to boost vault retrieval relevance.
33
+ * Extract keywords from a signal string.
34
+ * Split on whitespace, filter stopwords and words under 4 chars, keep top 10.
21
35
  */
22
- function buildAutoMemoryContext(entries: AutoMemoryEntry[]): string {
23
- if (entries.length === 0) return '';
24
- const parts = entries
25
- .map((e) => {
26
- const desc = e.description ? `: ${e.description}` : '';
27
- // Include a snippet of the body for richer context
28
- const bodySnippet = e.body ? ` -- ${e.body.slice(0, 200)}` : '';
29
- return `${e.name}${desc}${bodySnippet}`;
30
- });
31
- return parts.join('. ');
36
+ export function extractKeywords(signal: string): string[] {
37
+ const words = signal
38
+ .toLowerCase()
39
+ .replace(/[^a-z0-9\s_-]/g, ' ')
40
+ .split(/\s+/)
41
+ .filter((w) => w.length >= 4 && !STOPWORDS.has(w));
42
+
43
+ const seen = new Set<string>();
44
+ const unique: string[] = [];
45
+ for (const w of words) {
46
+ if (seen.has(w)) continue;
47
+ seen.add(w);
48
+ unique.push(w);
49
+ if (unique.length >= 10) break;
50
+ }
51
+ return unique;
32
52
  }
33
53
 
34
- export const name = 'session_start';
54
+ export const name = 'recall';
35
55
 
36
56
  export const description =
37
- 'Auto-assemble a context brief for the current project on session start. Pulls recent entries, last session summary, and active decisions/blockers into a token-budgeted capsule formatted for agent consumption.';
57
+ '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.';
38
58
 
39
59
  export const inputSchema = {
40
- project: z
60
+ signal: z
61
+ .string()
62
+ .describe('Raw text: prompt, error message, file path, or combined signal.'),
63
+ signal_type: z
64
+ .enum(['prompt', 'error', 'file', 'task'])
65
+ .describe('Type of signal, used to weight results.'),
66
+ bucket: z
41
67
  .string()
42
68
  .optional()
43
- .describe(
44
- 'Project name or tag to scope the brief. Auto-detected from cwd/git remote if not provided.'
45
- ),
46
- max_tokens: z
47
- .number()
48
- .optional()
49
- .describe('Token budget for the capsule (rough estimate: 1 token ~ 4 chars). Default: 4000.'),
50
- buckets: z
51
- .array(z.string())
52
- .optional()
53
- .describe(
54
- "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."
55
- ),
56
- auto_memory_path: z
69
+ .describe('Scope results to a project bucket.'),
70
+ session_id: z
57
71
  .string()
58
72
  .optional()
59
- .describe(
60
- "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."
61
- ),
73
+ .describe('Session identifier for dedup. Entries already surfaced this session are suppressed.'),
74
+ max_hints: z
75
+ .number()
76
+ .optional()
77
+ .describe('Maximum hints to return. Default: 3.'),
62
78
  };
63
79
 
64
- function detectProject() {
65
- try {
66
- const remote = execSync('git remote get-url origin 2>/dev/null', {
67
- encoding: 'utf-8',
68
- timeout: 3000,
69
- stdio: ['pipe', 'pipe', 'pipe'],
70
- }).trim();
71
- if (remote) {
72
- const match = remote.match(/\/([^/]+?)(?:\.git)?$/);
73
- if (match) return match[1];
74
- }
75
- } catch {}
76
-
77
- try {
78
- const cwd = process.cwd();
79
- const parts = cwd.split(/[/\\]/);
80
- return parts[parts.length - 1];
81
- } catch {}
82
-
83
- return null;
84
- }
85
-
86
- function truncateBody(body: string | null | undefined, maxLen = MAX_BODY_PER_ENTRY): string {
87
- if (!body) return '(no body)';
88
- if (body.length <= maxLen) return body;
89
- return body.slice(0, maxLen) + '...';
90
- }
91
-
92
- function estimateTokens(text: string | null | undefined): number {
93
- return Math.ceil((text || '').length / 4);
94
- }
95
-
96
- function formatEntry(entry: any): string {
97
- const tags = entry.tags ? JSON.parse(entry.tags) : [];
98
- const tagStr = tags.length ? tags.join(', ') : '';
99
- const date = fmtDate(entry.updated_at || entry.created_at);
100
- const icon = kindIcon(entry.kind);
101
- const meta = [`\`${entry.kind}\``, tagStr, date].filter(Boolean).join(' · ');
102
- return [
103
- `- ${icon} **${entry.title || '(untitled)'}**`,
104
- ` ${meta} · \`${entry.id}\``,
105
- ` ${truncateBody(entry.body).replace(/\n+/g, ' ').trim()}`,
106
- ].join('\n');
107
- }
108
-
109
80
  export async function handler(
110
- { project, max_tokens, buckets, auto_memory_path }: Record<string, any>,
81
+ { signal, signal_type, bucket, session_id, max_hints }: Record<string, any>,
111
82
  ctx: LocalCtx,
112
83
  { ensureIndexed }: SharedCtx
113
84
  ): Promise<ToolResult> {
114
- const { config } = ctx;
115
-
116
- const vaultErr = ensureVaultExists(config);
117
- if (vaultErr) return vaultErr;
85
+ const start = Date.now();
118
86
 
119
87
  await ensureIndexed();
120
88
 
121
- // Sanity check: compare DB entries vs disk files
122
- let indexWarning = '';
123
- try {
124
- const dbCount = (ctx.db.prepare('SELECT COUNT(*) as cnt FROM vault').get() as any)?.cnt ?? 0;
125
- let diskCount = 0;
126
- const walk = (dir: string, depth = 0) => {
127
- if (depth > 3 || diskCount >= 100) return;
128
- try {
129
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
130
- if (diskCount >= 100) return;
131
- if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== '_archive') {
132
- walk(`${dir}/${entry.name}`, depth + 1);
133
- } else if (entry.name.endsWith('.md')) {
134
- diskCount++;
135
- }
136
- }
137
- } catch {}
89
+ const keywords = extractKeywords(signal || '');
90
+ const limit = max_hints ?? DEFAULT_MAX_HINTS;
91
+
92
+ if (keywords.length === 0) {
93
+ const result = ok('No relevant entries found.');
94
+ result._meta = {
95
+ latency_ms: Date.now() - start,
96
+ method: 'none' as const,
97
+ signal_keywords: [],
98
+ suppressed: 0,
138
99
  };
139
- walk(config.vaultDir);
140
- if (diskCount >= 100 && dbCount < diskCount / 10) {
141
- indexWarning = `\n> **WARNING:** Vault has significantly more files on disk (~${diskCount}+) than indexed entries (${dbCount}). The search index may be out of sync. Run \`context-vault reconnect\` to fix.\n`;
142
- }
143
- } catch {}
144
-
145
- const effectiveProject = project?.trim() || detectProject();
146
- const tokenBudget = max_tokens || DEFAULT_MAX_TOKENS;
147
-
148
- const bucketTags = buckets?.length ? buckets.map((b: string) => `bucket:${b}`) : [];
149
- const effectiveTags = bucketTags.length ? bucketTags : effectiveProject ? [effectiveProject] : [];
150
-
151
- const sinceDate = new Date(Date.now() - RECENT_DAYS * 86400000).toISOString();
152
-
153
- // Auto-detect Claude Code auto-memory (explicit path overrides auto-detection)
154
- const autoMemory: AutoMemoryResult = getAutoMemory(auto_memory_path);
155
- const autoMemoryContext = buildAutoMemoryContext(autoMemory.entries);
156
- const topicsExtracted = autoMemory.entries.length > 0
157
- ? extractKeywords(autoMemoryContext).slice(0, 20)
158
- : [];
159
-
160
- const sections = [];
161
- let tokensUsed = 0;
162
-
163
- sections.push(`# Session Brief${effectiveProject ? ` -- ${effectiveProject}` : ''}`);
164
- const bucketsLabel = buckets?.length ? ` | buckets: ${buckets.join(', ')}` : '';
165
- const autoMemoryLabel = autoMemory.detected
166
- ? ` | auto-memory: ${autoMemory.entries.length} entries detected, used as search context`
167
- : '';
168
- sections.push(
169
- `_Generated ${new Date().toISOString().slice(0, 10)} | budget: ${tokenBudget} tokens${bucketsLabel}${autoMemoryLabel}_\n`
170
- );
171
- tokensUsed += estimateTokens(sections.join('\n'));
172
-
173
- const lastSession = queryLastSession(ctx, effectiveTags);
174
- if (lastSession) {
175
- const sessionBlock = ['## Last Session Summary', truncateBody(lastSession.body, 600), ''].join(
176
- '\n'
177
- );
178
- const sessionTokens = estimateTokens(sessionBlock);
179
- if (tokensUsed + sessionTokens <= tokenBudget) {
180
- sections.push(sessionBlock);
181
- tokensUsed += sessionTokens;
182
- }
100
+ return result;
183
101
  }
184
102
 
185
- // When auto-memory context is available, boost decisions query with FTS
186
- const decisions = queryByKinds(
187
- ctx,
188
- PRIORITY_KINDS,
189
- sinceDate,
190
- effectiveTags,
191
- autoMemoryContext
192
- );
193
- if (decisions.length > 0) {
194
- const header = '## Active Decisions, Insights & Patterns\n';
195
- const headerTokens = estimateTokens(header);
196
- if (tokensUsed + headerTokens <= tokenBudget) {
197
- const entryLines = [];
198
- tokensUsed += headerTokens;
199
- for (const entry of decisions) {
200
- const line = formatEntry(entry);
201
- const lineTokens = estimateTokens(line);
202
- if (tokensUsed + lineTokens > tokenBudget) break;
203
- entryLines.push(line);
204
- tokensUsed += lineTokens;
205
- }
206
- if (entryLines.length > 0) {
207
- sections.push(header + entryLines.join('\n') + '\n');
208
- }
209
- }
103
+ // Build fast-path query: tag/title LIKE match for each keyword
104
+ const conditions: string[] = [];
105
+ const params: string[] = [];
106
+ for (const kw of keywords) {
107
+ conditions.push('(title LIKE ? OR tags LIKE ?)');
108
+ params.push(`%${kw}%`, `%${kw}%`);
210
109
  }
211
110
 
212
- const recent = queryRecent(ctx, sinceDate, effectiveTags);
213
- const seenIds = new Set(decisions.map((d: any) => d.id));
214
- if (lastSession) seenIds.add(lastSession.id);
215
- const deduped = recent.filter((r: any) => !seenIds.has(r.id));
216
-
217
- if (deduped.length > 0) {
218
- const header = `## Recent Entries (last ${RECENT_DAYS} days)\n`;
219
- const headerTokens = estimateTokens(header);
220
- if (tokensUsed + headerTokens <= tokenBudget) {
221
- const entryLines = [];
222
- tokensUsed += headerTokens;
223
- for (const entry of deduped) {
224
- const line = formatEntry(entry);
225
- const lineTokens = estimateTokens(line);
226
- if (tokensUsed + lineTokens > tokenBudget) break;
227
- entryLines.push(line);
228
- tokensUsed += lineTokens;
229
- }
230
- if (entryLines.length > 0) {
231
- sections.push(header + entryLines.join('\n') + '\n');
232
- }
111
+ const bucketClause = bucket ? ' AND tags LIKE ?' : '';
112
+ if (bucket) params.push(`%"bucket:${bucket}"%`);
113
+
114
+ const sql = `SELECT id, title, substr(body, 1, 100) as summary, kind, tags, tier
115
+ FROM vault
116
+ WHERE indexed = 1
117
+ AND (expires_at IS NULL OR expires_at > datetime('now'))
118
+ AND superseded_by IS NULL
119
+ AND (${conditions.join(' OR ')})${bucketClause}
120
+ LIMIT 20`;
121
+
122
+ let rows: any[];
123
+ try {
124
+ rows = ctx.db.prepare(sql).all(...params);
125
+ } catch {
126
+ rows = [];
127
+ }
128
+
129
+ // Session dedup
130
+ let suppressed = 0;
131
+ const bypassDedup = signal_type === 'error';
132
+ const sessionSet = session_id
133
+ ? (sessionSurfaced.get(session_id) ?? (() => { const s = new Set<string>(); sessionSurfaced.set(session_id, s); return s; })())
134
+ : null;
135
+
136
+ const hints: Array<{
137
+ id: string;
138
+ title: string;
139
+ summary: string;
140
+ relevance: 'high' | 'medium';
141
+ kind: string;
142
+ tags: string[];
143
+ }> = [];
144
+
145
+ for (const row of rows) {
146
+ if (hints.length >= limit) break;
147
+
148
+ // Dedup check
149
+ if (sessionSet && !bypassDedup && sessionSet.has(row.id)) {
150
+ suppressed++;
151
+ continue;
233
152
  }
153
+
154
+ const entryTags: string[] = row.tags ? JSON.parse(row.tags) : [];
155
+
156
+ // Score relevance: count how many keywords match title or tags
157
+ let matchCount = 0;
158
+ const titleLower = (row.title || '').toLowerCase();
159
+ const tagsLower = (row.tags || '').toLowerCase();
160
+ for (const kw of keywords) {
161
+ if (titleLower.includes(kw) || tagsLower.includes(kw)) matchCount++;
162
+ }
163
+ const isDurable = row.tier === 'durable';
164
+ const relevance: 'high' | 'medium' = (matchCount >= 2 || (isDurable && matchCount >= 1)) ? 'high' : 'medium';
165
+
166
+ hints.push({
167
+ id: row.id,
168
+ title: row.title || '(untitled)',
169
+ summary: row.summary || '',
170
+ relevance,
171
+ kind: row.kind || 'knowledge',
172
+ tags: entryTags,
173
+ });
174
+
175
+ // Track surfaced
176
+ if (sessionSet) sessionSet.add(row.id);
234
177
  }
235
178
 
236
- // Remote entries: pull recent from hosted API if configured
179
+ // Remote recall: merge hints from hosted API
237
180
  const remoteClient = getRemoteClient(ctx.config);
238
- let remoteCount = 0;
239
- if (remoteClient && tokensUsed < tokenBudget) {
181
+ if (remoteClient && hints.length < limit) {
240
182
  try {
241
- const seenIds = new Set([
242
- ...decisions.map((d: any) => d.id),
243
- ...deduped.map((d: any) => d.id),
244
- ...(lastSession ? [lastSession.id] : []),
245
- ]);
246
- const remoteTags = effectiveTags.length ? effectiveTags : undefined;
247
- const remoteResults = await remoteClient.search({
248
- tags: remoteTags,
249
- limit: 10,
250
- since: sinceDate,
183
+ const remoteHints = await remoteClient.recall({
184
+ signal,
185
+ signal_type,
186
+ bucket,
187
+ max_hints: limit - hints.length,
251
188
  });
252
- const uniqueRemote = remoteResults.filter((r: any) => !seenIds.has(r.id));
253
- if (uniqueRemote.length > 0) {
254
- const header = '## Remote Entries\n';
255
- const headerTokens = estimateTokens(header);
256
- if (tokensUsed + headerTokens <= tokenBudget) {
257
- const entryLines: string[] = [];
258
- tokensUsed += headerTokens;
259
- for (const entry of uniqueRemote) {
260
- const line = formatEntry(entry);
261
- const lineTokens = estimateTokens(line);
262
- if (tokensUsed + lineTokens > tokenBudget) break;
263
- entryLines.push(line);
264
- tokensUsed += lineTokens;
265
- remoteCount++;
266
- }
267
- if (entryLines.length > 0) {
268
- sections.push(header + entryLines.join('\n') + '\n');
269
- }
189
+ const localIds = new Set(hints.map(h => h.id));
190
+ for (const rh of remoteHints) {
191
+ if (hints.length >= limit) break;
192
+ if (localIds.has(rh.id)) continue;
193
+ if (sessionSet && !bypassDedup && sessionSet.has(rh.id)) {
194
+ suppressed++;
195
+ continue;
270
196
  }
197
+ hints.push(rh);
198
+ if (sessionSet) sessionSet.add(rh.id);
271
199
  }
272
200
  } catch (e) {
273
- console.warn(`[context-vault] Remote session_start failed: ${(e as Error).message}`);
201
+ console.warn(`[context-vault] Remote recall failed: ${(e as Error).message}`);
274
202
  }
275
203
  }
276
204
 
277
- // Team vault entries: include team knowledge in brief if teamId is configured
278
- let teamCount = 0;
205
+ // Team vault recall: include team results if teamId is configured
279
206
  const teamId = getTeamId(ctx.config);
280
- if (remoteClient && teamId && tokensUsed < tokenBudget) {
207
+ if (remoteClient && teamId && hints.length < limit) {
281
208
  try {
282
- const allSeenIds = new Set([
283
- ...decisions.map((d: any) => d.id),
284
- ...deduped.map((d: any) => d.id),
285
- ...(lastSession ? [lastSession.id] : []),
286
- ]);
287
- const teamResults = await remoteClient.teamSearch(teamId, {
288
- tags: effectiveTags.length ? effectiveTags : undefined,
289
- limit: 10,
290
- since: sinceDate,
209
+ const teamHints = await remoteClient.teamRecall(teamId, {
210
+ signal,
211
+ signal_type,
212
+ bucket,
213
+ max_hints: limit - hints.length,
291
214
  });
292
- const uniqueTeam = teamResults.filter((r: any) => !allSeenIds.has(r.id));
293
- if (uniqueTeam.length > 0) {
294
- const header = '## Team Knowledge\n';
295
- const headerTokens = estimateTokens(header);
296
- if (tokensUsed + headerTokens <= tokenBudget) {
297
- const entryLines: string[] = [];
298
- tokensUsed += headerTokens;
299
- for (const entry of uniqueTeam) {
300
- const line = formatEntry(entry) + ' `[team]`';
301
- const lineTokens = estimateTokens(line);
302
- if (tokensUsed + lineTokens > tokenBudget) break;
303
- entryLines.push(line);
304
- tokensUsed += lineTokens;
305
- teamCount++;
306
- }
307
- if (entryLines.length > 0) {
308
- sections.push(header + entryLines.join('\n') + '\n');
309
- }
215
+ const existingIds = new Set(hints.map(h => h.id));
216
+ for (const th of teamHints) {
217
+ if (hints.length >= limit) break;
218
+ if (existingIds.has(th.id)) continue;
219
+ if (sessionSet && !bypassDedup && sessionSet.has(th.id)) {
220
+ suppressed++;
221
+ continue;
310
222
  }
223
+ hints.push({ ...th, tags: [...(th.tags || []), '[team]'] });
224
+ if (sessionSet) sessionSet.add(th.id);
311
225
  }
312
226
  } catch (e) {
313
- console.warn(`[context-vault] Team session_start failed: ${(e as Error).message}`);
227
+ console.warn(`[context-vault] Team recall failed: ${(e as Error).message}`);
314
228
  }
315
229
  }
316
230
 
317
- // Public vault entries: include public knowledge if publicVaults are configured
318
- let publicCount = 0;
231
+ // Public vault recall: query each configured public vault
319
232
  const publicVaultSlugs = getPublicVaults(ctx.config);
320
- if (remoteClient && publicVaultSlugs.length > 0 && tokensUsed < tokenBudget) {
233
+ if (remoteClient && publicVaultSlugs.length > 0 && hints.length < limit) {
234
+ const publicRecalls = publicVaultSlugs.map(slug =>
235
+ remoteClient.publicRecall(slug, {
236
+ signal,
237
+ signal_type,
238
+ bucket,
239
+ max_hints: limit - hints.length,
240
+ }).catch(e => {
241
+ console.warn(`[context-vault] Public vault "${slug}" recall failed: ${(e as Error).message}`);
242
+ return [];
243
+ })
244
+ );
321
245
  try {
322
- const allPublicSeenIds = new Set([
323
- ...decisions.map((d: any) => d.id),
324
- ...deduped.map((d: any) => d.id),
325
- ...(lastSession ? [lastSession.id] : []),
326
- ]);
327
- const publicSearches = publicVaultSlugs.map(slug =>
328
- remoteClient.publicSearch(slug, {
329
- tags: effectiveTags.length ? effectiveTags : undefined,
330
- limit: 5,
331
- since: sinceDate,
332
- }).catch(() => [])
333
- );
334
- const allPublicResults = await Promise.all(publicSearches);
335
- const flatPublic = allPublicResults.flat().filter((r: any) => !allPublicSeenIds.has(r.id));
336
- if (flatPublic.length > 0) {
337
- const header = '## Public Knowledge\n';
338
- const headerTokens = estimateTokens(header);
339
- if (tokensUsed + headerTokens <= tokenBudget) {
340
- const entryLines: string[] = [];
341
- tokensUsed += headerTokens;
342
- for (const entry of flatPublic) {
343
- const slug = (entry as any).vault_slug || 'public';
344
- const line = formatEntry(entry) + ` \`[public:${slug}]\``;
345
- const lineTokens = estimateTokens(line);
346
- if (tokensUsed + lineTokens > tokenBudget) break;
347
- entryLines.push(line);
348
- tokensUsed += lineTokens;
349
- publicCount++;
350
- }
351
- if (entryLines.length > 0) {
352
- sections.push(header + entryLines.join('\n') + '\n');
246
+ const allPublicHints = await Promise.all(publicRecalls);
247
+ const existingIds = new Set(hints.map(h => h.id));
248
+ for (const publicHints of allPublicHints) {
249
+ for (const ph of publicHints) {
250
+ if (hints.length >= limit) break;
251
+ if (existingIds.has(ph.id)) continue;
252
+ if (sessionSet && !bypassDedup && sessionSet.has(ph.id)) {
253
+ suppressed++;
254
+ continue;
353
255
  }
256
+ hints.push({ ...ph, tags: [...(ph.tags || []), '[public]'] });
257
+ if (sessionSet) sessionSet.add(ph.id);
354
258
  }
355
259
  }
356
260
  } catch (e) {
357
- console.warn(`[context-vault] Public vault session_start failed: ${(e as Error).message}`);
261
+ console.warn(`[context-vault] Public vault recall failed: ${(e as Error).message}`);
358
262
  }
359
263
  }
360
264
 
361
- const totalEntries =
362
- (lastSession ? 1 : 0) +
363
- decisions.length +
364
- deduped.filter((_d: any) => {
365
- return true;
366
- }).length +
367
- remoteCount +
368
- teamCount +
369
- publicCount;
370
-
371
- if (indexWarning) {
372
- sections.push(indexWarning);
373
- }
265
+ let method: 'tag_match' | 'semantic' | 'durable_semantic' | 'none' = hints.length > 0 ? 'tag_match' : 'none';
374
266
 
375
- sections.push('---');
376
- sections.push(
377
- `_${tokensUsed} / ${tokenBudget} tokens used | project: ${effectiveProject || 'unscoped'}_`
378
- );
267
+ // Associative recall: always search durables semantically (regardless of keyword hits)
268
+ if (signal_type !== 'file' && isEmbedAvailable()) {
269
+ try {
270
+ const durableCount = (
271
+ ctx.db.prepare("SELECT COUNT(*) as c FROM vault_vec v JOIN vault e ON e.rowid = v.rowid WHERE e.tier = 'durable'").get() as { c: number }
272
+ ).c;
273
+
274
+ if (durableCount > 0) {
275
+ const queryVec = await ctx.embed(signal);
276
+ if (queryVec) {
277
+ // KNN against all vectors, then filter to durables in hydration
278
+ const vecRows = ctx.db
279
+ .prepare(
280
+ 'SELECT v.rowid, v.distance FROM vault_vec v WHERE embedding MATCH ? ORDER BY distance LIMIT 15'
281
+ )
282
+ .all(queryVec) as { rowid: number; distance: number }[];
283
+
284
+ // Merge content vectors (vault_vec) and context vectors (vault_ctx_vec)
285
+ // Content vectors match on what the entry says; context vectors match on
286
+ // when/where the decision applies (encoding_context), bridging vocabulary gaps.
287
+ let ctxVecRows: { rowid: number; distance: number }[] = [];
288
+ try {
289
+ const ctxVecCount = (
290
+ ctx.db.prepare('SELECT COUNT(*) as c FROM vault_ctx_vec').get() as { c: number }
291
+ ).c;
292
+ if (ctxVecCount > 0) {
293
+ ctxVecRows = ctx.db
294
+ .prepare(
295
+ 'SELECT v.rowid, v.distance FROM vault_ctx_vec v WHERE embedding MATCH ? ORDER BY distance LIMIT 15'
296
+ )
297
+ .all(queryVec) as { rowid: number; distance: number }[];
298
+ }
299
+ } catch {
300
+ // vault_ctx_vec may not exist or be empty
301
+ }
379
302
 
380
- const result: ToolResult = ok(sections.join('\n'));
381
- result._meta = {
382
- project: effectiveProject || null,
383
- buckets: buckets || null,
384
- tokens_used: tokensUsed,
385
- tokens_budget: tokenBudget,
386
- sections: {
387
- last_session: lastSession ? 1 : 0,
388
- decisions: decisions.length,
389
- recent: deduped.length,
390
- },
391
- auto_memory: {
392
- detected: autoMemory.detected,
393
- path: autoMemory.path,
394
- entries: autoMemory.entries.length,
395
- lines_used: autoMemory.linesUsed,
396
- topics_extracted: topicsExtracted,
397
- },
398
- };
399
- return result;
400
- }
303
+ // Combine both vector result sets, keeping best distance per rowid
304
+ const mergedDistMap = new Map<number, number>();
305
+ for (const vr of vecRows) {
306
+ mergedDistMap.set(vr.rowid, vr.distance);
307
+ }
308
+ for (const cr of ctxVecRows) {
309
+ const existing = mergedDistMap.get(cr.rowid);
310
+ if (existing === undefined || cr.distance < existing) {
311
+ mergedDistMap.set(cr.rowid, cr.distance);
312
+ }
313
+ }
401
314
 
402
- function queryLastSession(ctx: LocalCtx, effectiveTags: string[]): any {
403
- const clauses = [`kind = '${SESSION_SUMMARY_KIND}'`];
404
- const params: any[] = [];
315
+ const allRowids = [...mergedDistMap.keys()];
316
+ if (allRowids.length) {
317
+ const placeholders = allRowids.map(() => '?').join(',');
318
+
319
+ const hydrated = ctx.db
320
+ .prepare(
321
+ `SELECT rowid, id, title, substr(body, 1, 150) as summary, kind, tags, tier FROM vault
322
+ WHERE rowid IN (${placeholders})
323
+ AND tier = 'durable'
324
+ AND indexed = 1
325
+ AND (expires_at IS NULL OR expires_at > datetime('now'))
326
+ AND superseded_by IS NULL`
327
+ )
328
+ .all(...allRowids) as any[];
329
+
330
+ const byRowid = new Map<number, any>();
331
+ for (const row of hydrated) byRowid.set(row.rowid, row);
332
+ const existingIds = new Set(hints.map(h => h.id));
333
+
334
+ // Sort by distance (best first)
335
+ const sorted = allRowids
336
+ .map(rowid => ({ rowid, distance: mergedDistMap.get(rowid)! }))
337
+ .sort((a, b) => a.distance - b.distance);
338
+
339
+ for (const { rowid, distance } of sorted) {
340
+ if (hints.length >= limit + 2) break;
341
+ const row = byRowid.get(rowid);
342
+ if (!row) continue;
343
+ if (existingIds.has(row.id)) continue;
344
+
345
+ const similarity = Math.max(0, 1 - distance / 2);
346
+ if (similarity < 0.45) continue;
347
+
348
+ if (sessionSet && !bypassDedup && sessionSet.has(row.id)) {
349
+ suppressed++;
350
+ continue;
351
+ }
352
+
353
+ hints.push({
354
+ id: row.id,
355
+ title: row.title || '(untitled)',
356
+ summary: row.summary || '',
357
+ relevance: similarity >= 0.6 ? 'high' : 'medium',
358
+ kind: row.kind || 'knowledge',
359
+ tags: row.tags ? JSON.parse(row.tags) : [],
360
+ });
361
+
362
+ if (sessionSet) sessionSet.add(row.id);
363
+ }
364
+
365
+ if (method === 'none' && hints.length > 0) method = 'durable_semantic';
366
+ }
367
+ }
368
+ }
369
+ } catch {
370
+ // Associative recall is best-effort
371
+ }
372
+ }
405
373
 
406
- if (false) {
374
+ // Semantic fallback: when fast-path returns 0 results and signal is not file-based
375
+ if (hints.length === 0 && signal_type !== 'file' && isEmbedAvailable()) {
376
+ try {
377
+ const vecCount = (
378
+ ctx.db.prepare('SELECT COUNT(*) as c FROM vault_vec').get() as { c: number }
379
+ ).c;
380
+
381
+ if (vecCount > 0) {
382
+ const queryVec = await ctx.embed(signal);
383
+ if (queryVec) {
384
+ const vecRows = ctx.db
385
+ .prepare(
386
+ 'SELECT v.rowid, v.distance FROM vault_vec v WHERE embedding MATCH ? ORDER BY distance LIMIT 5'
387
+ )
388
+ .all(queryVec) as { rowid: number; distance: number }[];
389
+
390
+ if (vecRows.length) {
391
+ const rowids = vecRows.map((vr) => vr.rowid);
392
+ const placeholders = rowids.map(() => '?').join(',');
393
+
394
+ let bucketFilter = '';
395
+ const hydrateParams: any[] = [...rowids];
396
+ if (bucket) {
397
+ bucketFilter = ' AND tags LIKE ?';
398
+ hydrateParams.push(`%"bucket:${bucket}"%`);
399
+ }
400
+
401
+ const hydrated = ctx.db
402
+ .prepare(
403
+ `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}`
404
+ )
405
+ .all(...hydrateParams) as any[];
406
+
407
+ const byRowid = new Map<number, any>();
408
+ for (const row of hydrated) byRowid.set(row.rowid, row);
409
+
410
+ for (const vr of vecRows) {
411
+ if (hints.length >= limit) break;
412
+ const row = byRowid.get(vr.rowid);
413
+ if (!row) continue;
414
+
415
+ const similarity = Math.max(0, 1 - vr.distance / 2);
416
+ if (similarity < SEMANTIC_SIMILARITY_THRESHOLD) continue;
417
+
418
+ // Session dedup
419
+ if (sessionSet && !bypassDedup && sessionSet.has(row.id)) {
420
+ suppressed++;
421
+ continue;
422
+ }
423
+
424
+ const entryTags: string[] = row.tags ? JSON.parse(row.tags) : [];
425
+ hints.push({
426
+ id: row.id,
427
+ title: row.title || '(untitled)',
428
+ summary: row.summary || '',
429
+ relevance: similarity >= 0.8 ? 'high' : 'medium',
430
+ kind: row.kind || 'knowledge',
431
+ tags: entryTags,
432
+ });
433
+
434
+ if (sessionSet) sessionSet.add(row.id);
435
+ }
436
+
437
+ if (hints.length > 0) method = 'semantic';
438
+ }
439
+ }
440
+ }
441
+ } catch {
442
+ // Semantic fallback is best-effort; fast path already ran
443
+ }
407
444
  }
408
- clauses.push("(expires_at IS NULL OR expires_at > datetime('now'))");
409
- clauses.push('superseded_by IS NULL');
410
-
411
- const where = `WHERE ${clauses.join(' AND ')}`;
412
- const rows = ctx.db
413
- .prepare(`SELECT * FROM vault ${where} ORDER BY created_at DESC LIMIT 5`)
414
- .all(...params);
415
-
416
- if (effectiveTags.length) {
417
- const match = rows.find((r: any) => {
418
- const tags = r.tags ? JSON.parse(r.tags) : [];
419
- return effectiveTags.some((t: string) => tags.includes(t));
420
- });
421
- if (match) return match;
445
+
446
+ const latency = Date.now() - start;
447
+
448
+ if (hints.length === 0) {
449
+ const result = ok('No relevant entries found.');
450
+ result._meta = {
451
+ latency_ms: latency,
452
+ method,
453
+ signal_keywords: keywords,
454
+ suppressed,
455
+ };
456
+ return result;
422
457
  }
423
- return rows[0] || null;
424
- }
425
458
 
426
- function queryByKinds(
427
- ctx: LocalCtx,
428
- kinds: string[],
429
- since: string,
430
- effectiveTags: string[],
431
- autoMemoryContext = ''
432
- ): any[] {
433
- const kindPlaceholders = kinds.map(() => '?').join(',');
434
- const clauses = [`kind IN (${kindPlaceholders})`];
435
- const params = [...kinds];
436
-
437
- clauses.push('created_at >= ?');
438
- params.push(since);
439
-
440
- if (false) {
459
+ // Record co-retrieval pairs (fire and forget, non-blocking)
460
+ if (hints.length >= 2) {
461
+ recordCoRetrieval(ctx, hints.map((h) => h.id));
441
462
  }
442
- clauses.push("(expires_at IS NULL OR expires_at > datetime('now'))");
443
- clauses.push('superseded_by IS NULL');
444
-
445
- const where = `WHERE ${clauses.join(' AND ')}`;
446
- let rows = ctx.db
447
- .prepare(`SELECT * FROM vault ${where} ORDER BY created_at DESC LIMIT 50`)
448
- .all(...params) as any[];
449
-
450
- if (effectiveTags.length) {
451
- const tagged = rows.filter((r: any) => {
452
- const tags = r.tags ? JSON.parse(r.tags) : [];
453
- return effectiveTags.some((t: string) => tags.includes(t));
454
- });
455
- if (tagged.length > 0) rows = tagged;
463
+
464
+ // Check for auto-memory overlap to avoid redundant surfacing
465
+ let autoMemoryOverlaps: Array<{ hint_id: string; memory_file: string; memory_name: string }> = [];
466
+ try {
467
+ const autoMemory = getAutoMemory();
468
+ if (autoMemory.detected && autoMemory.entries.length > 0) {
469
+ for (const h of hints) {
470
+ const searchText = [h.title, h.summary].filter(Boolean).join(' ');
471
+ const overlaps = findAutoMemoryOverlaps(autoMemory, searchText, 0.3);
472
+ if (overlaps.length > 0) {
473
+ autoMemoryOverlaps.push({
474
+ hint_id: h.id,
475
+ memory_file: overlaps[0].file,
476
+ memory_name: overlaps[0].name,
477
+ });
478
+ }
479
+ }
480
+ }
481
+ } catch {
482
+ // Non-fatal
456
483
  }
457
484
 
458
- // When auto-memory context is available, boost results by FTS relevance
459
- // to surface vault entries that match what the user's auto-memory says they care about
460
- if (autoMemoryContext && rows.length > 1) {
461
- rows = boostByAutoMemory(ctx, rows, autoMemoryContext);
485
+ // Format output
486
+ const lines = [`[Vault: ${hints.length} ${hints.length === 1 ? 'entry' : 'entries'} may be relevant]`];
487
+ for (const h of hints) {
488
+ const overlap = autoMemoryOverlaps.find(o => o.hint_id === h.id);
489
+ const overlapNote = overlap ? ` [also in auto-memory: ${overlap.memory_name}]` : '';
490
+ lines.push(`- "${h.title}" (${h.kind}, ${h.relevance})${overlapNote}`);
462
491
  }
492
+ lines.push('Use get_context to retrieve full details.');
463
493
 
464
- return rows;
494
+ const result = ok(lines.join('\n'));
495
+ result._meta = {
496
+ latency_ms: latency,
497
+ method,
498
+ signal_keywords: keywords,
499
+ suppressed,
500
+ hints,
501
+ auto_memory_overlaps: autoMemoryOverlaps.length > 0 ? autoMemoryOverlaps : undefined,
502
+ };
503
+ return result;
465
504
  }
466
505
 
467
- /**
468
- * Re-rank vault entries by FTS relevance to auto-memory context.
469
- * Entries matching auto-memory topics float to the top while preserving
470
- * all original entries (non-matching ones keep their original order at the end).
471
- */
472
- function boostByAutoMemory(ctx: LocalCtx, rows: any[], context: string): any[] {
473
- // Extract meaningful keywords from auto-memory context for FTS
474
- const keywords = extractKeywords(context);
475
- if (keywords.length === 0) return rows;
476
-
477
- // Build FTS query from auto-memory keywords
478
- const ftsTerms = keywords.slice(0, 10).map((k) => `"${k}"`).join(' OR ');
479
- const rowIds = new Set(rows.map((r: any) => r.id));
480
-
506
+ function recordCoRetrieval(ctx: LocalCtx, ids: string[]): void {
481
507
  try {
482
- const ftsRows = ctx.db
483
- .prepare(
484
- `SELECT e.id, rank FROM vault_fts f JOIN vault e ON f.rowid = e.rowid WHERE vault_fts MATCH ? ORDER BY rank LIMIT 50`
485
- )
486
- .all(ftsTerms) as { id: string; rank: number }[];
487
-
488
- // Build a boost map: entries matching auto-memory context get priority
489
- const boostMap = new Map<string, number>();
490
- for (const fr of ftsRows) {
491
- if (rowIds.has(fr.id)) {
492
- boostMap.set(fr.id, fr.rank);
508
+ const now = new Date().toISOString();
509
+ const upsert = ctx.db.prepare(
510
+ `INSERT INTO co_retrievals (entry_a, entry_b, count, last_at) VALUES (?, ?, 1, ?)
511
+ ON CONFLICT(entry_a, entry_b) DO UPDATE SET count = MIN(count + 1, ?), last_at = ?`
512
+ );
513
+ for (let i = 0; i < ids.length; i++) {
514
+ for (let j = i + 1; j < ids.length; j++) {
515
+ const [a, b] = ids[i] < ids[j] ? [ids[i], ids[j]] : [ids[j], ids[i]];
516
+ upsert.run(a, b, now, CO_RETRIEVAL_WEIGHT_CAP, now);
493
517
  }
494
518
  }
495
-
496
- if (boostMap.size === 0) return rows;
497
-
498
- // Sort: boosted entries first (by FTS rank), then unboosted in original order
499
- const boosted = rows.filter((r: any) => boostMap.has(r.id));
500
- const unboosted = rows.filter((r: any) => !boostMap.has(r.id));
501
- boosted.sort((a: any, b: any) => (boostMap.get(a.id) ?? 0) - (boostMap.get(b.id) ?? 0));
502
- return [...boosted, ...unboosted];
503
519
  } catch {
504
- // FTS errors are non-fatal; return original order
505
- return rows;
520
+ // Non-fatal: co-retrieval recording is best-effort
506
521
  }
507
522
  }
508
523
 
509
- /**
510
- * Extract meaningful keywords from auto-memory context text.
511
- * Filters out common stop words and short tokens.
512
- */
513
- function extractKeywords(text: string): string[] {
514
- const stopWords = new Set([
515
- 'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
516
- 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could',
517
- 'should', 'may', 'might', 'shall', 'can', 'need', 'must', 'ought',
518
- 'and', 'but', 'or', 'nor', 'not', 'so', 'yet', 'for', 'with',
519
- 'from', 'into', 'during', 'before', 'after', 'above', 'below',
520
- 'to', 'of', 'in', 'on', 'at', 'by', 'about', 'between', 'through',
521
- 'this', 'that', 'these', 'those', 'it', 'its', 'they', 'them',
522
- 'their', 'we', 'our', 'you', 'your', 'he', 'she', 'him', 'her',
523
- 'all', 'each', 'every', 'both', 'few', 'more', 'most', 'other',
524
- 'some', 'such', 'no', 'only', 'own', 'same', 'than', 'too', 'very',
525
- 'just', 'also', 'now', 'then', 'here', 'there', 'when', 'where',
526
- 'how', 'what', 'which', 'who', 'whom', 'why', 'if', 'because',
527
- 'as', 'until', 'while', 'use', 'using', 'used',
528
- ]);
529
-
530
- const words = text
531
- .toLowerCase()
532
- .replace(/[^a-z0-9\s-]/g, ' ')
533
- .split(/\s+/)
534
- .filter((w) => w.length >= 3 && !stopWords.has(w));
535
-
536
- // Deduplicate while preserving order
537
- const seen = new Set<string>();
538
- return words.filter((w) => {
539
- if (seen.has(w)) return false;
540
- seen.add(w);
541
- return true;
542
- });
543
- }
544
-
545
- function queryRecent(ctx: LocalCtx, since: string, effectiveTags: string[]): any[] {
546
- const clauses = ['created_at >= ?'];
547
- const params = [since];
548
-
549
- if (false) {
550
- }
551
- clauses.push("(expires_at IS NULL OR expires_at > datetime('now'))");
552
- clauses.push('superseded_by IS NULL');
553
-
554
- const where = `WHERE ${clauses.join(' AND ')}`;
555
- const rows = ctx.db
556
- .prepare(`SELECT * FROM vault ${where} ORDER BY created_at DESC LIMIT 50`)
557
- .all(...params);
558
-
559
- if (effectiveTags.length) {
560
- const tagged = rows.filter((r: any) => {
561
- const tags = r.tags ? JSON.parse(r.tags) : [];
562
- return effectiveTags.some((t: string) => tags.includes(t));
563
- });
564
- if (tagged.length > 0) return tagged;
565
- }
566
- return rows;
524
+ /** Reset session dedup state (for testing) */
525
+ export function _resetSessionState(): void {
526
+ sessionSurfaced.clear();
567
527
  }