context-vault 3.4.5 → 3.5.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 +489 -299
- package/dist/server.js +2 -0
- package/dist/server.js.map +1 -1
- package/dist/status.d.ts.map +1 -1
- package/dist/status.js +29 -0
- package/dist/status.js.map +1 -1
- package/dist/tools/context-status.d.ts.map +1 -1
- package/dist/tools/context-status.js +39 -5
- package/dist/tools/context-status.js.map +1 -1
- package/dist/tools/get-context.d.ts.map +1 -1
- package/dist/tools/get-context.js +1 -0
- package/dist/tools/get-context.js.map +1 -1
- package/dist/tools/list-context.d.ts +2 -1
- package/dist/tools/list-context.d.ts.map +1 -1
- package/dist/tools/list-context.js +22 -5
- package/dist/tools/list-context.js.map +1 -1
- package/dist/tools/save-context.d.ts +2 -1
- package/dist/tools/save-context.d.ts.map +1 -1
- package/dist/tools/save-context.js +58 -4
- package/dist/tools/save-context.js.map +1 -1
- package/dist/tools/session-start.d.ts.map +1 -1
- package/dist/tools/session-start.js +192 -7
- 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 +2 -0
- package/node_modules/@context-vault/core/dist/capture.js.map +1 -1
- package/node_modules/@context-vault/core/dist/config.d.ts.map +1 -1
- package/node_modules/@context-vault/core/dist/config.js +27 -1
- package/node_modules/@context-vault/core/dist/config.js.map +1 -1
- package/node_modules/@context-vault/core/dist/constants.d.ts +13 -0
- package/node_modules/@context-vault/core/dist/constants.d.ts.map +1 -1
- package/node_modules/@context-vault/core/dist/constants.js +13 -0
- package/node_modules/@context-vault/core/dist/constants.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 +73 -9
- package/node_modules/@context-vault/core/dist/db.js.map +1 -1
- package/node_modules/@context-vault/core/dist/index.d.ts +4 -1
- package/node_modules/@context-vault/core/dist/index.d.ts.map +1 -1
- package/node_modules/@context-vault/core/dist/index.js +58 -10
- package/node_modules/@context-vault/core/dist/index.js.map +1 -1
- package/node_modules/@context-vault/core/dist/indexing.d.ts +8 -0
- package/node_modules/@context-vault/core/dist/indexing.d.ts.map +1 -0
- package/node_modules/@context-vault/core/dist/indexing.js +22 -0
- package/node_modules/@context-vault/core/dist/indexing.js.map +1 -0
- package/node_modules/@context-vault/core/dist/main.d.ts +3 -2
- package/node_modules/@context-vault/core/dist/main.d.ts.map +1 -1
- package/node_modules/@context-vault/core/dist/main.js +3 -1
- package/node_modules/@context-vault/core/dist/main.js.map +1 -1
- package/node_modules/@context-vault/core/dist/search.d.ts +2 -0
- package/node_modules/@context-vault/core/dist/search.d.ts.map +1 -1
- package/node_modules/@context-vault/core/dist/search.js +82 -6
- package/node_modules/@context-vault/core/dist/search.js.map +1 -1
- package/node_modules/@context-vault/core/dist/types.d.ts +24 -0
- package/node_modules/@context-vault/core/dist/types.d.ts.map +1 -1
- package/node_modules/@context-vault/core/package.json +5 -1
- package/node_modules/@context-vault/core/src/capture.ts +2 -0
- package/node_modules/@context-vault/core/src/config.ts +18 -1
- package/node_modules/@context-vault/core/src/constants.ts +15 -0
- package/node_modules/@context-vault/core/src/db.ts +73 -9
- package/node_modules/@context-vault/core/src/index.ts +65 -11
- package/node_modules/@context-vault/core/src/indexing.ts +35 -0
- package/node_modules/@context-vault/core/src/main.ts +5 -0
- package/node_modules/@context-vault/core/src/search.ts +96 -6
- package/node_modules/@context-vault/core/src/types.ts +26 -0
- package/package.json +2 -2
- package/src/server.ts +3 -0
- package/src/status.ts +35 -0
- package/src/tools/context-status.ts +40 -5
- package/src/tools/get-context.ts +1 -0
- package/src/tools/list-context.ts +20 -5
- package/src/tools/save-context.ts +67 -4
- package/src/tools/session-start.ts +222 -9
package/src/status.ts
CHANGED
|
@@ -148,6 +148,20 @@ export function gatherVaultStatus(ctx: LocalCtx, opts: Record<string, unknown> =
|
|
|
148
148
|
errors.push(`Archived count failed: ${(e as Error).message}`);
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
let indexingStats: { indexed: number; unindexed: number; total: number; byKind: Array<{ kind: string; indexed: number; total: number }> } | null = null;
|
|
152
|
+
try {
|
|
153
|
+
const totalRow = db.prepare('SELECT COUNT(*) as c FROM vault').get() as { c: number } | undefined;
|
|
154
|
+
const indexedRow = db.prepare('SELECT COUNT(*) as c FROM vault WHERE indexed = 1').get() as { c: number } | undefined;
|
|
155
|
+
const total = totalRow?.c ?? 0;
|
|
156
|
+
const indexed = indexedRow?.c ?? 0;
|
|
157
|
+
const byKind = db.prepare(
|
|
158
|
+
'SELECT kind, COUNT(*) as total, SUM(CASE WHEN indexed = 1 THEN 1 ELSE 0 END) as indexed FROM vault GROUP BY kind'
|
|
159
|
+
).all() as Array<{ kind: string; total: number; indexed: number }>;
|
|
160
|
+
indexingStats = { indexed, unindexed: total - indexed, total, byKind };
|
|
161
|
+
} catch (e) {
|
|
162
|
+
errors.push(`Indexing stats failed: ${(e as Error).message}`);
|
|
163
|
+
}
|
|
164
|
+
|
|
151
165
|
let staleKnowledge: unknown[] = [];
|
|
152
166
|
try {
|
|
153
167
|
const stalenessKinds = Object.entries(KIND_STALENESS_DAYS);
|
|
@@ -168,6 +182,25 @@ export function gatherVaultStatus(ctx: LocalCtx, opts: Record<string, unknown> =
|
|
|
168
182
|
errors.push(`Stale knowledge check failed: ${(e as Error).message}`);
|
|
169
183
|
}
|
|
170
184
|
|
|
185
|
+
let recallStats: { totalRecalls: number; entriesRecalled: number; avgRecallCount: number; maxRecallCount: number; topRecalled: Array<{ title: string; kind: string; recall_count: number; recall_sessions: number }> } | null = null;
|
|
186
|
+
try {
|
|
187
|
+
const totals = db.prepare(
|
|
188
|
+
`SELECT SUM(recall_count) as total_recalls, COUNT(CASE WHEN recall_count > 0 THEN 1 END) as entries_recalled, AVG(CASE WHEN recall_count > 0 THEN recall_count END) as avg_recall, MAX(recall_count) as max_recall FROM vault`
|
|
189
|
+
).get() as any;
|
|
190
|
+
const topRecalled = db.prepare(
|
|
191
|
+
`SELECT title, kind, recall_count, recall_sessions FROM vault WHERE recall_count > 0 ORDER BY recall_count DESC LIMIT 5`
|
|
192
|
+
).all() as any[];
|
|
193
|
+
recallStats = {
|
|
194
|
+
totalRecalls: totals?.total_recalls ?? 0,
|
|
195
|
+
entriesRecalled: totals?.entries_recalled ?? 0,
|
|
196
|
+
avgRecallCount: Math.round((totals?.avg_recall ?? 0) * 10) / 10,
|
|
197
|
+
maxRecallCount: totals?.max_recall ?? 0,
|
|
198
|
+
topRecalled,
|
|
199
|
+
};
|
|
200
|
+
} catch (e) {
|
|
201
|
+
errors.push(`Recall stats failed: ${(e as Error).message}`);
|
|
202
|
+
}
|
|
203
|
+
|
|
171
204
|
return {
|
|
172
205
|
fileCount,
|
|
173
206
|
subdirs,
|
|
@@ -185,6 +218,8 @@ export function gatherVaultStatus(ctx: LocalCtx, opts: Record<string, unknown> =
|
|
|
185
218
|
autoCapturedFeedbackCount,
|
|
186
219
|
archivedCount,
|
|
187
220
|
staleKnowledge,
|
|
221
|
+
indexingStats,
|
|
222
|
+
recallStats,
|
|
188
223
|
resolvedFrom: config.resolvedFrom,
|
|
189
224
|
errors,
|
|
190
225
|
};
|
|
@@ -55,13 +55,31 @@ export function handler(_args: Record<string, any>, ctx: LocalCtx): ToolResult {
|
|
|
55
55
|
lines.push(`| **Expired** | ${status.expiredCount} pending prune |`);
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
// Selective indexing stats
|
|
59
|
+
if (status.indexingStats) {
|
|
60
|
+
const ix = status.indexingStats;
|
|
61
|
+
const pct = ix.total > 0 ? Math.round((ix.indexed / ix.total) * 100) : 100;
|
|
62
|
+
lines.push(`| **Indexed entries** | ${ix.indexed}/${ix.total} (${pct}%) |`);
|
|
63
|
+
}
|
|
64
|
+
|
|
58
65
|
// Indexed kinds as compact table
|
|
59
|
-
lines.push(``, `###
|
|
66
|
+
lines.push(``, `### Entries by Kind`);
|
|
60
67
|
if (status.kindCounts.length) {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
68
|
+
if (status.indexingStats?.byKind?.length) {
|
|
69
|
+
lines.push('| Kind | Total | Indexed |');
|
|
70
|
+
lines.push('|---|---|---|');
|
|
71
|
+
const byKindMap = new Map(status.indexingStats.byKind.map((k: any) => [k.kind, k]));
|
|
72
|
+
for (const { kind, c } of status.kindCounts) {
|
|
73
|
+
const kStats = byKindMap.get(kind) as { indexed?: number } | undefined;
|
|
74
|
+
const idxCount = kStats?.indexed ?? c;
|
|
75
|
+
lines.push(`| ${kindIcon(kind)} \`${kind}\` | ${c} | ${idxCount} |`);
|
|
76
|
+
}
|
|
77
|
+
} else {
|
|
78
|
+
lines.push('| Kind | Count |');
|
|
79
|
+
lines.push('|---|---|');
|
|
80
|
+
for (const { kind, c } of status.kindCounts) {
|
|
81
|
+
lines.push(`| ${kindIcon(kind)} \`${kind}\` | ${c} |`);
|
|
82
|
+
}
|
|
65
83
|
}
|
|
66
84
|
} else {
|
|
67
85
|
lines.push(`_(empty vault)_`);
|
|
@@ -79,6 +97,23 @@ export function handler(_args: Record<string, any>, ctx: LocalCtx): ToolResult {
|
|
|
79
97
|
for (const { name, count } of status.subdirs) lines.push(`- \`${name}/\` ${count} files`);
|
|
80
98
|
}
|
|
81
99
|
|
|
100
|
+
if (status.recallStats && status.recallStats.totalRecalls > 0) {
|
|
101
|
+
const rs = status.recallStats;
|
|
102
|
+
lines.push(``, `### Recall Frequency`);
|
|
103
|
+
lines.push(`| Metric | Value |`);
|
|
104
|
+
lines.push(`|---|---|`);
|
|
105
|
+
lines.push(`| **Total recalls** | ${rs.totalRecalls} |`);
|
|
106
|
+
lines.push(`| **Entries recalled** | ${rs.entriesRecalled} |`);
|
|
107
|
+
lines.push(`| **Avg recalls/entry** | ${rs.avgRecallCount} |`);
|
|
108
|
+
lines.push(`| **Max recalls** | ${rs.maxRecallCount} |`);
|
|
109
|
+
if (rs.topRecalled?.length) {
|
|
110
|
+
lines.push(``, `**Top recalled:**`);
|
|
111
|
+
for (const t of rs.topRecalled) {
|
|
112
|
+
lines.push(`- "${t.title || '(untitled)'}" (\`${t.kind}\`): ${t.recall_count} recalls, ${t.recall_sessions} sessions`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
82
117
|
if (status.stalePaths) {
|
|
83
118
|
lines.push(``);
|
|
84
119
|
lines.push(`### ⚠ Stale Paths`);
|
package/src/tools/get-context.ts
CHANGED
|
@@ -526,6 +526,7 @@ export async function handler(
|
|
|
526
526
|
if (!include_superseded) {
|
|
527
527
|
clauses.push('superseded_by IS NULL');
|
|
528
528
|
}
|
|
529
|
+
clauses.push('indexed = 1');
|
|
529
530
|
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
|
|
530
531
|
params.push(fetchLimit);
|
|
531
532
|
let rows;
|
|
@@ -18,10 +18,11 @@ export const inputSchema = {
|
|
|
18
18
|
until: z.string().optional().describe('ISO date, return entries created before this'),
|
|
19
19
|
limit: z.number().optional().describe('Max results to return (default 20, max 100)'),
|
|
20
20
|
offset: z.number().optional().describe('Skip first N results for pagination'),
|
|
21
|
+
include_unindexed: z.boolean().optional().describe('If true, include entries that are stored but not indexed for search. Default: false.'),
|
|
21
22
|
};
|
|
22
23
|
|
|
23
24
|
export async function handler(
|
|
24
|
-
{ kind, category, tags, since, until, limit, offset }: Record<string, any>,
|
|
25
|
+
{ kind, category, tags, since, until, limit, offset, include_unindexed }: Record<string, any>,
|
|
25
26
|
ctx: LocalCtx,
|
|
26
27
|
{ ensureIndexed, reindexFailed }: SharedCtx
|
|
27
28
|
): Promise<ToolResult> {
|
|
@@ -63,6 +64,9 @@ export async function handler(
|
|
|
63
64
|
params.push(until);
|
|
64
65
|
}
|
|
65
66
|
clauses.push("(expires_at IS NULL OR expires_at > datetime('now'))");
|
|
67
|
+
if (!include_unindexed) {
|
|
68
|
+
clauses.push('indexed = 1');
|
|
69
|
+
}
|
|
66
70
|
|
|
67
71
|
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
|
|
68
72
|
const effectiveLimit = Math.min(limit || 20, 100);
|
|
@@ -81,7 +85,7 @@ export async function handler(
|
|
|
81
85
|
params.push(fetchLimit, effectiveOffset);
|
|
82
86
|
rows = ctx.db
|
|
83
87
|
.prepare(
|
|
84
|
-
`SELECT id, title, kind, category, tags, created_at, updated_at, SUBSTR(body, 1, 120) as preview FROM vault ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`
|
|
88
|
+
`SELECT id, title, kind, category, tags, created_at, updated_at, indexed, recall_count, SUBSTR(body, 1, 120) as preview FROM vault ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`
|
|
85
89
|
)
|
|
86
90
|
.all(...params) as any[];
|
|
87
91
|
} catch (e) {
|
|
@@ -124,15 +128,26 @@ export async function handler(
|
|
|
124
128
|
`> ℹ Event search limited to last ${days} days. Use \`since\` parameter for older results.\n`
|
|
125
129
|
);
|
|
126
130
|
}
|
|
127
|
-
|
|
128
|
-
|
|
131
|
+
if (include_unindexed) {
|
|
132
|
+
lines.push('| | Title | Kind | Tags | Recalls | Idx | Date | ID |');
|
|
133
|
+
lines.push('|---|---|---|---|---|---|---|---|');
|
|
134
|
+
} else {
|
|
135
|
+
lines.push('| | Title | Kind | Tags | Recalls | Date | ID |');
|
|
136
|
+
lines.push('|---|---|---|---|---|---|---|');
|
|
137
|
+
}
|
|
129
138
|
for (const r of filtered) {
|
|
130
139
|
const entryTags = r.tags ? JSON.parse(r.tags) : [];
|
|
131
140
|
const tagStr = entryTags.length ? entryTags.join(', ') : '';
|
|
132
141
|
const date = fmtDate(r.updated_at && r.updated_at !== r.created_at ? r.updated_at : r.created_at);
|
|
133
142
|
const icon = kindIcon(r.kind);
|
|
134
143
|
const title = (r.title || '(untitled)').replace(/\|/g, '\\|');
|
|
135
|
-
|
|
144
|
+
const recalls = r.recall_count ?? 0;
|
|
145
|
+
if (include_unindexed) {
|
|
146
|
+
const idxStr = r.indexed ? 'Y' : 'N';
|
|
147
|
+
lines.push(`| ${icon} | **${title}** | \`${r.kind}\` | ${tagStr} | ${recalls} | ${idxStr} | ${date} | \`${r.id}\` |`);
|
|
148
|
+
} else {
|
|
149
|
+
lines.push(`| ${icon} | **${title}** | \`${r.kind}\` | ${tagStr} | ${recalls} | ${date} | \`${r.id}\` |`);
|
|
150
|
+
}
|
|
136
151
|
}
|
|
137
152
|
|
|
138
153
|
if (effectiveOffset + effectiveLimit < total) {
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { resolve, basename, join } from 'node:path';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
2
5
|
import { captureAndIndex, updateEntryFile } from '@context-vault/core/capture';
|
|
3
6
|
import { indexEntry } from '@context-vault/core/index';
|
|
4
7
|
import { categoryFor, defaultTierFor } from '@context-vault/core/categories';
|
|
5
|
-
import { normalizeKind } from '@context-vault/core/files';
|
|
8
|
+
import { normalizeKind, kindToPath } from '@context-vault/core/files';
|
|
6
9
|
import { parseContextParam } from '@context-vault/core/context';
|
|
10
|
+
import { shouldIndex } from '@context-vault/core/indexing';
|
|
7
11
|
import { ok, err, errWithHint, ensureVaultExists, ensureValidKind, kindIcon } from '../helpers.js';
|
|
8
12
|
import { maybeShowFeedbackPrompt } from '../telemetry.js';
|
|
9
13
|
import { validateRelatedTo } from '../linking.js';
|
|
@@ -23,6 +27,34 @@ const DEFAULT_SIMILARITY_THRESHOLD = 0.85;
|
|
|
23
27
|
const SKIP_THRESHOLD = 0.95;
|
|
24
28
|
const UPDATE_THRESHOLD = 0.85;
|
|
25
29
|
|
|
30
|
+
function isDualWriteEnabled(config: { dataDir: string }): boolean {
|
|
31
|
+
try {
|
|
32
|
+
const configPath = join(config.dataDir, 'config.json');
|
|
33
|
+
if (!existsSync(configPath)) return true;
|
|
34
|
+
const fc = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
35
|
+
if (fc.dualWrite && fc.dualWrite.enabled === false) return false;
|
|
36
|
+
return true;
|
|
37
|
+
} catch {
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function dualWriteLocal(entryFilePath: string, kind: string): void {
|
|
43
|
+
const cwd = process.cwd();
|
|
44
|
+
const home = homedir();
|
|
45
|
+
if (!cwd || cwd === '/' || cwd === home) return;
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const content = readFileSync(entryFilePath, 'utf-8');
|
|
49
|
+
const localDir = resolve(cwd, '.context', kindToPath(kind));
|
|
50
|
+
mkdirSync(localDir, { recursive: true });
|
|
51
|
+
const filename = basename(entryFilePath);
|
|
52
|
+
writeFileSync(join(localDir, filename), content);
|
|
53
|
+
} catch (e) {
|
|
54
|
+
console.warn(`[context-vault] Dual-write to .context/ failed: ${(e as Error).message}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
26
58
|
async function findSimilar(
|
|
27
59
|
ctx: LocalCtx,
|
|
28
60
|
embedding: any,
|
|
@@ -316,6 +348,12 @@ export const inputSchema = {
|
|
|
316
348
|
.describe(
|
|
317
349
|
'Encoding context for contextual reinstatement. Captures the situation when this entry was created, enabling context-aware retrieval boosting. Pass a structured object (e.g. { project: "myapp", arc: "auth-rewrite", task: "implementing JWT" }) or a free-text string describing the current context.'
|
|
318
350
|
),
|
|
351
|
+
indexed: z
|
|
352
|
+
.boolean()
|
|
353
|
+
.optional()
|
|
354
|
+
.describe(
|
|
355
|
+
'Whether to index this entry for search (generate embeddings + FTS). Default: auto-determined by indexing config. Set to false to store file + metadata only, skipping embedding generation. Set to true to force indexing regardless of config rules.'
|
|
356
|
+
),
|
|
319
357
|
};
|
|
320
358
|
|
|
321
359
|
export async function handler(
|
|
@@ -338,6 +376,7 @@ export async function handler(
|
|
|
338
376
|
tier,
|
|
339
377
|
conflict_resolution,
|
|
340
378
|
encoding_context,
|
|
379
|
+
indexed,
|
|
341
380
|
}: Record<string, any>,
|
|
342
381
|
ctx: LocalCtx,
|
|
343
382
|
{ ensureIndexed }: SharedCtx
|
|
@@ -447,6 +486,10 @@ export async function handler(
|
|
|
447
486
|
} else if (entry.related_to === null && ctx.stmts.updateRelatedTo) {
|
|
448
487
|
ctx.stmts.updateRelatedTo.run(null, entry.id);
|
|
449
488
|
}
|
|
489
|
+
if (isDualWriteEnabled(config) && entry.filePath) {
|
|
490
|
+
dualWriteLocal(entry.filePath, entry.kind);
|
|
491
|
+
}
|
|
492
|
+
|
|
450
493
|
const relPath = entry.filePath
|
|
451
494
|
? entry.filePath.replace(config.vaultDir + '/', '')
|
|
452
495
|
: entry.filePath;
|
|
@@ -548,7 +591,17 @@ export async function handler(
|
|
|
548
591
|
|
|
549
592
|
const effectiveTier = tier ?? defaultTierFor(normalizedKind);
|
|
550
593
|
|
|
551
|
-
const
|
|
594
|
+
const effectiveIndexed = shouldIndex(
|
|
595
|
+
{
|
|
596
|
+
kind: normalizedKind,
|
|
597
|
+
category,
|
|
598
|
+
bodyLength: body?.length ?? 0,
|
|
599
|
+
explicitIndexed: indexed,
|
|
600
|
+
},
|
|
601
|
+
config.indexing
|
|
602
|
+
);
|
|
603
|
+
|
|
604
|
+
const embeddingToReuse = category === 'knowledge' && effectiveIndexed ? queryEmbedding : null;
|
|
552
605
|
|
|
553
606
|
let entry;
|
|
554
607
|
try {
|
|
@@ -567,8 +620,8 @@ export async function handler(
|
|
|
567
620
|
supersedes,
|
|
568
621
|
related_to,
|
|
569
622
|
source_files,
|
|
570
|
-
|
|
571
623
|
tier: effectiveTier,
|
|
624
|
+
indexed: effectiveIndexed,
|
|
572
625
|
},
|
|
573
626
|
embeddingToReuse
|
|
574
627
|
);
|
|
@@ -581,7 +634,7 @@ export async function handler(
|
|
|
581
634
|
}
|
|
582
635
|
|
|
583
636
|
// Store context embedding in vault_ctx_vec for contextual reinstatement
|
|
584
|
-
if (parsedCtx?.text && entry) {
|
|
637
|
+
if (parsedCtx?.text && entry && effectiveIndexed) {
|
|
585
638
|
try {
|
|
586
639
|
const ctxEmbedding = await ctx.embed(parsedCtx.text);
|
|
587
640
|
if (ctxEmbedding) {
|
|
@@ -598,6 +651,10 @@ export async function handler(
|
|
|
598
651
|
}
|
|
599
652
|
}
|
|
600
653
|
|
|
654
|
+
if (isDualWriteEnabled(config) && entry.filePath) {
|
|
655
|
+
dualWriteLocal(entry.filePath, normalizedKind);
|
|
656
|
+
}
|
|
657
|
+
|
|
601
658
|
if (ctx.config?.dataDir) {
|
|
602
659
|
maybeShowFeedbackPrompt(ctx.config.dataDir);
|
|
603
660
|
}
|
|
@@ -612,6 +669,12 @@ export async function handler(
|
|
|
612
669
|
`\`${normalizedKind}\` · **${effectiveTier}**${tags?.length ? ` · ${tags.join(', ')}` : ''}`,
|
|
613
670
|
`\`${entry.id}\` → ${relPath}`,
|
|
614
671
|
];
|
|
672
|
+
if (!effectiveIndexed) {
|
|
673
|
+
parts.push(
|
|
674
|
+
'',
|
|
675
|
+
'_Note: this entry is stored but not indexed (no embeddings/FTS). It will not appear in search results. Use `include_unindexed: true` in list_context to browse unindexed entries._'
|
|
676
|
+
);
|
|
677
|
+
}
|
|
615
678
|
if (effectiveTier === 'ephemeral') {
|
|
616
679
|
parts.push(
|
|
617
680
|
'',
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { execSync } from 'node:child_process';
|
|
3
|
-
import { readdirSync } from 'node:fs';
|
|
3
|
+
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
4
6
|
import { ok, err, ensureVaultExists, kindIcon, fmtDate } from '../helpers.js';
|
|
5
7
|
import type { LocalCtx, SharedCtx, ToolResult } from '../types.js';
|
|
6
8
|
|
|
@@ -10,6 +12,115 @@ const MAX_BODY_PER_ENTRY = 400;
|
|
|
10
12
|
const PRIORITY_KINDS = ['decision', 'insight', 'pattern'];
|
|
11
13
|
const SESSION_SUMMARY_KIND = 'session';
|
|
12
14
|
|
|
15
|
+
interface AutoMemoryEntry {
|
|
16
|
+
file: string;
|
|
17
|
+
name: string;
|
|
18
|
+
description: string;
|
|
19
|
+
type: string;
|
|
20
|
+
body: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface AutoMemoryResult {
|
|
24
|
+
detected: boolean;
|
|
25
|
+
path: string | null;
|
|
26
|
+
entries: AutoMemoryEntry[];
|
|
27
|
+
linesUsed: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Detect the Claude Code auto-memory directory for the current project.
|
|
32
|
+
* Convention: ~/.claude/projects/-<cwd-with-slashes-replaced-by-dashes>/memory/
|
|
33
|
+
*/
|
|
34
|
+
function detectAutoMemoryPath(): string | null {
|
|
35
|
+
try {
|
|
36
|
+
const cwd = process.cwd();
|
|
37
|
+
// Claude Code project key: absolute path with / replaced by -, leading - kept
|
|
38
|
+
const projectKey = cwd.replace(/\//g, '-');
|
|
39
|
+
const memoryDir = join(homedir(), '.claude', 'projects', projectKey, 'memory');
|
|
40
|
+
const memoryIndex = join(memoryDir, 'MEMORY.md');
|
|
41
|
+
if (existsSync(memoryIndex)) return memoryDir;
|
|
42
|
+
} catch {}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Parse YAML-ish frontmatter from a memory file.
|
|
48
|
+
* Returns { name, description, type } and the body after frontmatter.
|
|
49
|
+
*/
|
|
50
|
+
function parseMemoryFile(content: string): { name: string; description: string; type: string; body: string } {
|
|
51
|
+
const result = { name: '', description: '', type: '', body: content };
|
|
52
|
+
const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/);
|
|
53
|
+
if (!fmMatch) return result;
|
|
54
|
+
|
|
55
|
+
const frontmatter = fmMatch[1];
|
|
56
|
+
result.body = fmMatch[2].trim();
|
|
57
|
+
|
|
58
|
+
for (const line of frontmatter.split('\n')) {
|
|
59
|
+
const kv = line.match(/^(\w+)\s*:\s*(.+)$/);
|
|
60
|
+
if (!kv) continue;
|
|
61
|
+
const [, key, val] = kv;
|
|
62
|
+
if (key === 'name') result.name = val.trim();
|
|
63
|
+
else if (key === 'description') result.description = val.trim();
|
|
64
|
+
else if (key === 'type') result.type = val.trim();
|
|
65
|
+
}
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Read and parse all auto-memory entries from a memory directory.
|
|
71
|
+
*/
|
|
72
|
+
function readAutoMemory(memoryDir: string): AutoMemoryResult {
|
|
73
|
+
const indexPath = join(memoryDir, 'MEMORY.md');
|
|
74
|
+
let linesUsed = 0;
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const indexContent = readFileSync(indexPath, 'utf-8');
|
|
78
|
+
linesUsed = indexContent.split('\n').length;
|
|
79
|
+
} catch {
|
|
80
|
+
return { detected: true, path: memoryDir, entries: [], linesUsed: 0 };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const entries: AutoMemoryEntry[] = [];
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
const files = readdirSync(memoryDir).filter(
|
|
87
|
+
(f) => f.endsWith('.md') && f !== 'MEMORY.md'
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
for (const file of files) {
|
|
91
|
+
try {
|
|
92
|
+
const content = readFileSync(join(memoryDir, file), 'utf-8');
|
|
93
|
+
const parsed = parseMemoryFile(content);
|
|
94
|
+
entries.push({
|
|
95
|
+
file,
|
|
96
|
+
name: parsed.name || file.replace('.md', ''),
|
|
97
|
+
description: parsed.description,
|
|
98
|
+
type: parsed.type,
|
|
99
|
+
body: parsed.body,
|
|
100
|
+
});
|
|
101
|
+
} catch {}
|
|
102
|
+
}
|
|
103
|
+
} catch {}
|
|
104
|
+
|
|
105
|
+
return { detected: true, path: memoryDir, entries, linesUsed };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Build a search context string from auto-memory entries.
|
|
110
|
+
* Used to boost vault retrieval relevance.
|
|
111
|
+
*/
|
|
112
|
+
function buildAutoMemoryContext(entries: AutoMemoryEntry[]): string {
|
|
113
|
+
if (entries.length === 0) return '';
|
|
114
|
+
const parts = entries
|
|
115
|
+
.map((e) => {
|
|
116
|
+
const desc = e.description ? `: ${e.description}` : '';
|
|
117
|
+
// Include a snippet of the body for richer context
|
|
118
|
+
const bodySnippet = e.body ? ` -- ${e.body.slice(0, 200)}` : '';
|
|
119
|
+
return `${e.name}${desc}${bodySnippet}`;
|
|
120
|
+
});
|
|
121
|
+
return parts.join('. ');
|
|
122
|
+
}
|
|
123
|
+
|
|
13
124
|
export const name = 'session_start';
|
|
14
125
|
|
|
15
126
|
export const description =
|
|
@@ -123,13 +234,23 @@ export async function handler(
|
|
|
123
234
|
|
|
124
235
|
const sinceDate = new Date(Date.now() - RECENT_DAYS * 86400000).toISOString();
|
|
125
236
|
|
|
237
|
+
// Auto-detect Claude Code auto-memory
|
|
238
|
+
const autoMemoryPath = detectAutoMemoryPath();
|
|
239
|
+
const autoMemory: AutoMemoryResult = autoMemoryPath
|
|
240
|
+
? readAutoMemory(autoMemoryPath)
|
|
241
|
+
: { detected: false, path: null, entries: [], linesUsed: 0 };
|
|
242
|
+
const autoMemoryContext = buildAutoMemoryContext(autoMemory.entries);
|
|
243
|
+
|
|
126
244
|
const sections = [];
|
|
127
245
|
let tokensUsed = 0;
|
|
128
246
|
|
|
129
|
-
sections.push(`# Session Brief${effectiveProject ? `
|
|
247
|
+
sections.push(`# Session Brief${effectiveProject ? ` -- ${effectiveProject}` : ''}`);
|
|
130
248
|
const bucketsLabel = buckets?.length ? ` | buckets: ${buckets.join(', ')}` : '';
|
|
249
|
+
const autoMemoryLabel = autoMemory.detected
|
|
250
|
+
? ` | auto-memory: ${autoMemory.entries.length} entries detected, used as search context`
|
|
251
|
+
: '';
|
|
131
252
|
sections.push(
|
|
132
|
-
`_Generated ${new Date().toISOString().slice(0, 10)} | budget: ${tokenBudget} tokens${bucketsLabel}_\n`
|
|
253
|
+
`_Generated ${new Date().toISOString().slice(0, 10)} | budget: ${tokenBudget} tokens${bucketsLabel}${autoMemoryLabel}_\n`
|
|
133
254
|
);
|
|
134
255
|
tokensUsed += estimateTokens(sections.join('\n'));
|
|
135
256
|
|
|
@@ -145,12 +266,13 @@ export async function handler(
|
|
|
145
266
|
}
|
|
146
267
|
}
|
|
147
268
|
|
|
269
|
+
// When auto-memory context is available, boost decisions query with FTS
|
|
148
270
|
const decisions = queryByKinds(
|
|
149
271
|
ctx,
|
|
150
272
|
PRIORITY_KINDS,
|
|
151
273
|
sinceDate,
|
|
152
|
-
|
|
153
|
-
|
|
274
|
+
effectiveTags,
|
|
275
|
+
autoMemoryContext
|
|
154
276
|
);
|
|
155
277
|
if (decisions.length > 0) {
|
|
156
278
|
const header = '## Active Decisions, Insights & Patterns\n';
|
|
@@ -222,6 +344,11 @@ export async function handler(
|
|
|
222
344
|
decisions: decisions.length,
|
|
223
345
|
recent: deduped.length,
|
|
224
346
|
},
|
|
347
|
+
auto_memory: {
|
|
348
|
+
detected: autoMemory.detected,
|
|
349
|
+
entries: autoMemory.entries.length,
|
|
350
|
+
lines_used: autoMemory.linesUsed,
|
|
351
|
+
},
|
|
225
352
|
};
|
|
226
353
|
return result;
|
|
227
354
|
}
|
|
@@ -254,7 +381,8 @@ function queryByKinds(
|
|
|
254
381
|
ctx: LocalCtx,
|
|
255
382
|
kinds: string[],
|
|
256
383
|
since: string,
|
|
257
|
-
effectiveTags: string[]
|
|
384
|
+
effectiveTags: string[],
|
|
385
|
+
autoMemoryContext = ''
|
|
258
386
|
): any[] {
|
|
259
387
|
const kindPlaceholders = kinds.map(() => '?').join(',');
|
|
260
388
|
const clauses = [`kind IN (${kindPlaceholders})`];
|
|
@@ -269,20 +397,105 @@ function queryByKinds(
|
|
|
269
397
|
clauses.push('superseded_by IS NULL');
|
|
270
398
|
|
|
271
399
|
const where = `WHERE ${clauses.join(' AND ')}`;
|
|
272
|
-
|
|
400
|
+
let rows = ctx.db
|
|
273
401
|
.prepare(`SELECT * FROM vault ${where} ORDER BY created_at DESC LIMIT 50`)
|
|
274
|
-
.all(...params);
|
|
402
|
+
.all(...params) as any[];
|
|
275
403
|
|
|
276
404
|
if (effectiveTags.length) {
|
|
277
405
|
const tagged = rows.filter((r: any) => {
|
|
278
406
|
const tags = r.tags ? JSON.parse(r.tags) : [];
|
|
279
407
|
return effectiveTags.some((t: string) => tags.includes(t));
|
|
280
408
|
});
|
|
281
|
-
if (tagged.length > 0)
|
|
409
|
+
if (tagged.length > 0) rows = tagged;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// When auto-memory context is available, boost results by FTS relevance
|
|
413
|
+
// to surface vault entries that match what the user's auto-memory says they care about
|
|
414
|
+
if (autoMemoryContext && rows.length > 1) {
|
|
415
|
+
rows = boostByAutoMemory(ctx, rows, autoMemoryContext);
|
|
282
416
|
}
|
|
417
|
+
|
|
283
418
|
return rows;
|
|
284
419
|
}
|
|
285
420
|
|
|
421
|
+
/**
|
|
422
|
+
* Re-rank vault entries by FTS relevance to auto-memory context.
|
|
423
|
+
* Entries matching auto-memory topics float to the top while preserving
|
|
424
|
+
* all original entries (non-matching ones keep their original order at the end).
|
|
425
|
+
*/
|
|
426
|
+
function boostByAutoMemory(ctx: LocalCtx, rows: any[], context: string): any[] {
|
|
427
|
+
// Extract meaningful keywords from auto-memory context for FTS
|
|
428
|
+
const keywords = extractKeywords(context);
|
|
429
|
+
if (keywords.length === 0) return rows;
|
|
430
|
+
|
|
431
|
+
// Build FTS query from auto-memory keywords
|
|
432
|
+
const ftsTerms = keywords.slice(0, 10).map((k) => `"${k}"`).join(' OR ');
|
|
433
|
+
const rowIds = new Set(rows.map((r: any) => r.id));
|
|
434
|
+
|
|
435
|
+
try {
|
|
436
|
+
const ftsRows = ctx.db
|
|
437
|
+
.prepare(
|
|
438
|
+
`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`
|
|
439
|
+
)
|
|
440
|
+
.all(ftsTerms) as { id: string; rank: number }[];
|
|
441
|
+
|
|
442
|
+
// Build a boost map: entries matching auto-memory context get priority
|
|
443
|
+
const boostMap = new Map<string, number>();
|
|
444
|
+
for (const fr of ftsRows) {
|
|
445
|
+
if (rowIds.has(fr.id)) {
|
|
446
|
+
boostMap.set(fr.id, fr.rank);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (boostMap.size === 0) return rows;
|
|
451
|
+
|
|
452
|
+
// Sort: boosted entries first (by FTS rank), then unboosted in original order
|
|
453
|
+
const boosted = rows.filter((r: any) => boostMap.has(r.id));
|
|
454
|
+
const unboosted = rows.filter((r: any) => !boostMap.has(r.id));
|
|
455
|
+
boosted.sort((a: any, b: any) => (boostMap.get(a.id) ?? 0) - (boostMap.get(b.id) ?? 0));
|
|
456
|
+
return [...boosted, ...unboosted];
|
|
457
|
+
} catch {
|
|
458
|
+
// FTS errors are non-fatal; return original order
|
|
459
|
+
return rows;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Extract meaningful keywords from auto-memory context text.
|
|
465
|
+
* Filters out common stop words and short tokens.
|
|
466
|
+
*/
|
|
467
|
+
function extractKeywords(text: string): string[] {
|
|
468
|
+
const stopWords = new Set([
|
|
469
|
+
'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
|
|
470
|
+
'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could',
|
|
471
|
+
'should', 'may', 'might', 'shall', 'can', 'need', 'must', 'ought',
|
|
472
|
+
'and', 'but', 'or', 'nor', 'not', 'so', 'yet', 'for', 'with',
|
|
473
|
+
'from', 'into', 'during', 'before', 'after', 'above', 'below',
|
|
474
|
+
'to', 'of', 'in', 'on', 'at', 'by', 'about', 'between', 'through',
|
|
475
|
+
'this', 'that', 'these', 'those', 'it', 'its', 'they', 'them',
|
|
476
|
+
'their', 'we', 'our', 'you', 'your', 'he', 'she', 'him', 'her',
|
|
477
|
+
'all', 'each', 'every', 'both', 'few', 'more', 'most', 'other',
|
|
478
|
+
'some', 'such', 'no', 'only', 'own', 'same', 'than', 'too', 'very',
|
|
479
|
+
'just', 'also', 'now', 'then', 'here', 'there', 'when', 'where',
|
|
480
|
+
'how', 'what', 'which', 'who', 'whom', 'why', 'if', 'because',
|
|
481
|
+
'as', 'until', 'while', 'use', 'using', 'used',
|
|
482
|
+
]);
|
|
483
|
+
|
|
484
|
+
const words = text
|
|
485
|
+
.toLowerCase()
|
|
486
|
+
.replace(/[^a-z0-9\s-]/g, ' ')
|
|
487
|
+
.split(/\s+/)
|
|
488
|
+
.filter((w) => w.length >= 3 && !stopWords.has(w));
|
|
489
|
+
|
|
490
|
+
// Deduplicate while preserving order
|
|
491
|
+
const seen = new Set<string>();
|
|
492
|
+
return words.filter((w) => {
|
|
493
|
+
if (seen.has(w)) return false;
|
|
494
|
+
seen.add(w);
|
|
495
|
+
return true;
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
|
|
286
499
|
function queryRecent(ctx: LocalCtx, since: string, effectiveTags: string[]): any[] {
|
|
287
500
|
const clauses = ['created_at >= ?'];
|
|
288
501
|
const params = [since];
|