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.
- package/bin/cli.js +263 -414
- package/dist/error-log.d.ts +2 -0
- package/dist/error-log.d.ts.map +1 -1
- package/dist/error-log.js +31 -1
- package/dist/error-log.js.map +1 -1
- package/dist/register-tools.d.ts.map +1 -1
- package/dist/register-tools.js +4 -0
- package/dist/register-tools.js.map +1 -1
- package/dist/server.js +23 -426
- package/dist/server.js.map +1 -1
- package/dist/status.d.ts.map +1 -1
- package/dist/status.js +17 -0
- package/dist/status.js.map +1 -1
- package/dist/tools/context-status.d.ts.map +1 -1
- package/dist/tools/context-status.js +26 -1
- package/dist/tools/context-status.js.map +1 -1
- package/dist/tools/delete-context.d.ts +1 -1
- package/dist/tools/delete-context.d.ts.map +1 -1
- package/dist/tools/delete-context.js +15 -2
- package/dist/tools/delete-context.js.map +1 -1
- package/dist/tools/get-context.d.ts.map +1 -1
- package/dist/tools/get-context.js +3 -2
- package/dist/tools/get-context.js.map +1 -1
- package/dist/tools/list-context.d.ts +7 -15
- package/dist/tools/list-context.d.ts.map +1 -1
- package/dist/tools/list-context.js +570 -111
- package/dist/tools/list-context.js.map +1 -1
- package/dist/tools/publish-to-team.js +1 -1
- package/dist/tools/publish-to-team.js.map +1 -1
- package/dist/tools/save-context.js +2 -2
- package/dist/tools/save-context.js.map +1 -1
- package/dist/tools/session-start.d.ts +20 -7
- package/dist/tools/session-start.d.ts.map +1 -1
- package/dist/tools/session-start.js +406 -439
- package/dist/tools/session-start.js.map +1 -1
- package/node_modules/@context-vault/core/dist/capture.d.ts.map +1 -1
- package/node_modules/@context-vault/core/dist/capture.js +4 -0
- package/node_modules/@context-vault/core/dist/capture.js.map +1 -1
- package/node_modules/@context-vault/core/dist/categories.d.ts.map +1 -1
- package/node_modules/@context-vault/core/dist/categories.js +8 -0
- package/node_modules/@context-vault/core/dist/categories.js.map +1 -1
- package/node_modules/@context-vault/core/dist/compact.d.ts +38 -0
- package/node_modules/@context-vault/core/dist/compact.d.ts.map +1 -0
- package/node_modules/@context-vault/core/dist/compact.js +127 -0
- package/node_modules/@context-vault/core/dist/compact.js.map +1 -0
- package/node_modules/@context-vault/core/dist/config.d.ts.map +1 -1
- package/node_modules/@context-vault/core/dist/config.js +12 -0
- package/node_modules/@context-vault/core/dist/config.js.map +1 -1
- package/node_modules/@context-vault/core/dist/db.d.ts +1 -1
- package/node_modules/@context-vault/core/dist/db.d.ts.map +1 -1
- package/node_modules/@context-vault/core/dist/db.js +40 -4
- package/node_modules/@context-vault/core/dist/db.js.map +1 -1
- package/node_modules/@context-vault/core/dist/main.d.ts +6 -2
- package/node_modules/@context-vault/core/dist/main.d.ts.map +1 -1
- package/node_modules/@context-vault/core/dist/main.js +5 -1
- package/node_modules/@context-vault/core/dist/main.js.map +1 -1
- package/node_modules/@context-vault/core/dist/search.d.ts +13 -1
- package/node_modules/@context-vault/core/dist/search.d.ts.map +1 -1
- package/node_modules/@context-vault/core/dist/search.js +50 -5
- package/node_modules/@context-vault/core/dist/search.js.map +1 -1
- package/node_modules/@context-vault/core/dist/tier-analysis.d.ts +36 -0
- package/node_modules/@context-vault/core/dist/tier-analysis.d.ts.map +1 -0
- package/node_modules/@context-vault/core/dist/tier-analysis.js +227 -0
- package/node_modules/@context-vault/core/dist/tier-analysis.js.map +1 -0
- package/node_modules/@context-vault/core/dist/types.d.ts +12 -0
- package/node_modules/@context-vault/core/dist/types.d.ts.map +1 -1
- package/node_modules/@context-vault/core/dist/watch.d.ts +21 -0
- package/node_modules/@context-vault/core/dist/watch.d.ts.map +1 -0
- package/node_modules/@context-vault/core/dist/watch.js +230 -0
- package/node_modules/@context-vault/core/dist/watch.js.map +1 -0
- package/node_modules/@context-vault/core/package.json +13 -1
- package/node_modules/@context-vault/core/src/capture.ts +4 -0
- package/node_modules/@context-vault/core/src/categories.ts +8 -0
- package/node_modules/@context-vault/core/src/compact.ts +183 -0
- package/node_modules/@context-vault/core/src/config.ts +8 -0
- package/node_modules/@context-vault/core/src/db.ts +40 -4
- package/node_modules/@context-vault/core/src/main.ts +10 -0
- package/node_modules/@context-vault/core/src/search.ts +55 -4
- package/node_modules/@context-vault/core/src/tier-analysis.ts +299 -0
- package/node_modules/@context-vault/core/src/types.ts +10 -0
- package/node_modules/@context-vault/core/src/watch.ts +269 -0
- package/package.json +2 -2
- package/scripts/postinstall.js +26 -1
- package/src/error-log.ts +30 -0
- package/src/register-tools.ts +4 -0
- package/src/server.ts +23 -423
- package/src/status.ts +17 -0
- package/src/tools/context-status.ts +30 -1
- package/src/tools/delete-context.ts +10 -5
- package/src/tools/get-context.ts +3 -2
- package/src/tools/list-context.ts +620 -119
- package/src/tools/publish-to-team.ts +1 -1
- package/src/tools/save-context.ts +2 -2
- package/src/tools/session-start.ts +444 -484
|
@@ -1,567 +1,527 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
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
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
|
|
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
|
-
*
|
|
20
|
-
*
|
|
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
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
.
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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 = '
|
|
54
|
+
export const name = 'recall';
|
|
35
55
|
|
|
36
56
|
export const description =
|
|
37
|
-
'
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
|
179
|
+
// Remote recall: merge hints from hosted API
|
|
237
180
|
const remoteClient = getRemoteClient(ctx.config);
|
|
238
|
-
|
|
239
|
-
if (remoteClient && tokensUsed < tokenBudget) {
|
|
181
|
+
if (remoteClient && hints.length < limit) {
|
|
240
182
|
try {
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
if (
|
|
257
|
-
|
|
258
|
-
|
|
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
|
|
201
|
+
console.warn(`[context-vault] Remote recall failed: ${(e as Error).message}`);
|
|
274
202
|
}
|
|
275
203
|
}
|
|
276
204
|
|
|
277
|
-
// Team vault
|
|
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 &&
|
|
207
|
+
if (remoteClient && teamId && hints.length < limit) {
|
|
281
208
|
try {
|
|
282
|
-
const
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
if (
|
|
297
|
-
|
|
298
|
-
|
|
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
|
|
227
|
+
console.warn(`[context-vault] Team recall failed: ${(e as Error).message}`);
|
|
314
228
|
}
|
|
315
229
|
}
|
|
316
230
|
|
|
317
|
-
// Public vault
|
|
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 &&
|
|
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
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
|
261
|
+
console.warn(`[context-vault] Public vault recall failed: ${(e as Error).message}`);
|
|
358
262
|
}
|
|
359
263
|
}
|
|
360
264
|
|
|
361
|
-
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
.
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
.
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
-
//
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
|
|
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
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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
|
-
//
|
|
505
|
-
return rows;
|
|
520
|
+
// Non-fatal: co-retrieval recording is best-effort
|
|
506
521
|
}
|
|
507
522
|
}
|
|
508
523
|
|
|
509
|
-
/**
|
|
510
|
-
|
|
511
|
-
|
|
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
|
}
|