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.
- package/assets/vault-error-hook.mjs +106 -0
- package/assets/vault-recall-hook.mjs +67 -0
- package/bin/cli.js +607 -2
- package/dist/register-tools.d.ts.map +1 -1
- package/dist/register-tools.js +2 -0
- package/dist/register-tools.js.map +1 -1
- package/dist/tools/clear-context.d.ts +7 -3
- package/dist/tools/clear-context.d.ts.map +1 -1
- package/dist/tools/clear-context.js +157 -8
- package/dist/tools/clear-context.js.map +1 -1
- package/dist/tools/get-context.d.ts.map +1 -1
- package/dist/tools/get-context.js +50 -1
- package/dist/tools/get-context.js.map +1 -1
- package/dist/tools/recall.d.ts +25 -0
- package/dist/tools/recall.d.ts.map +1 -0
- package/dist/tools/recall.js +257 -0
- package/dist/tools/recall.js.map +1 -0
- package/dist/tools/session-start.d.ts +2 -1
- package/dist/tools/session-start.d.ts.map +1 -1
- package/dist/tools/session-start.js +16 -5
- package/dist/tools/session-start.js.map +1 -1
- package/node_modules/@context-vault/core/package.json +1 -1
- package/package.json +2 -2
- package/src/register-tools.ts +2 -0
- package/src/tools/clear-context.ts +195 -10
- package/src/tools/get-context.ts +64 -1
- package/src/tools/recall.ts +307 -0
- package/src/tools/session-start.ts +18 -5
|
@@ -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
|
-
|
|
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
|
|
163
|
+
'Vault entries are unchanged -- no data was deleted.',
|
|
26
164
|
];
|
|
27
165
|
|
|
28
|
-
|
|
29
|
-
|
|
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: \`${
|
|
173
|
+
`### Active Scope: \`${effectiveScope}\``,
|
|
33
174
|
'',
|
|
34
|
-
`Going forward, treat \`get_context\` calls as scoped to the tag or project **"${
|
|
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
|
|
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
|
-
|
|
220
|
+
const result: ToolResult = ok(lines.join('\n'));
|
|
221
|
+
result._meta = {
|
|
222
|
+
scope: effectiveScope || null,
|
|
223
|
+
...preloadMeta,
|
|
224
|
+
};
|
|
225
|
+
return result;
|
|
41
226
|
}
|
package/src/tools/get-context.ts
CHANGED
|
@@ -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
|
|
239
|
-
|
|
240
|
-
|
|
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;
|