context-vault 3.4.5 → 3.5.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.
Files changed (73) hide show
  1. package/bin/cli.js +299 -194
  2. package/dist/server.js +2 -0
  3. package/dist/server.js.map +1 -1
  4. package/dist/status.d.ts.map +1 -1
  5. package/dist/status.js +29 -0
  6. package/dist/status.js.map +1 -1
  7. package/dist/tools/context-status.d.ts.map +1 -1
  8. package/dist/tools/context-status.js +39 -5
  9. package/dist/tools/context-status.js.map +1 -1
  10. package/dist/tools/get-context.d.ts.map +1 -1
  11. package/dist/tools/get-context.js +1 -0
  12. package/dist/tools/get-context.js.map +1 -1
  13. package/dist/tools/list-context.d.ts +2 -1
  14. package/dist/tools/list-context.d.ts.map +1 -1
  15. package/dist/tools/list-context.js +22 -5
  16. package/dist/tools/list-context.js.map +1 -1
  17. package/dist/tools/save-context.d.ts +2 -1
  18. package/dist/tools/save-context.d.ts.map +1 -1
  19. package/dist/tools/save-context.js +58 -4
  20. package/dist/tools/save-context.js.map +1 -1
  21. package/dist/tools/session-start.d.ts.map +1 -1
  22. package/dist/tools/session-start.js +192 -7
  23. package/dist/tools/session-start.js.map +1 -1
  24. package/node_modules/@context-vault/core/dist/capture.d.ts.map +1 -1
  25. package/node_modules/@context-vault/core/dist/capture.js +2 -0
  26. package/node_modules/@context-vault/core/dist/capture.js.map +1 -1
  27. package/node_modules/@context-vault/core/dist/config.d.ts.map +1 -1
  28. package/node_modules/@context-vault/core/dist/config.js +27 -1
  29. package/node_modules/@context-vault/core/dist/config.js.map +1 -1
  30. package/node_modules/@context-vault/core/dist/constants.d.ts +13 -0
  31. package/node_modules/@context-vault/core/dist/constants.d.ts.map +1 -1
  32. package/node_modules/@context-vault/core/dist/constants.js +13 -0
  33. package/node_modules/@context-vault/core/dist/constants.js.map +1 -1
  34. package/node_modules/@context-vault/core/dist/db.d.ts +1 -1
  35. package/node_modules/@context-vault/core/dist/db.d.ts.map +1 -1
  36. package/node_modules/@context-vault/core/dist/db.js +73 -9
  37. package/node_modules/@context-vault/core/dist/db.js.map +1 -1
  38. package/node_modules/@context-vault/core/dist/index.d.ts +4 -1
  39. package/node_modules/@context-vault/core/dist/index.d.ts.map +1 -1
  40. package/node_modules/@context-vault/core/dist/index.js +58 -10
  41. package/node_modules/@context-vault/core/dist/index.js.map +1 -1
  42. package/node_modules/@context-vault/core/dist/indexing.d.ts +8 -0
  43. package/node_modules/@context-vault/core/dist/indexing.d.ts.map +1 -0
  44. package/node_modules/@context-vault/core/dist/indexing.js +22 -0
  45. package/node_modules/@context-vault/core/dist/indexing.js.map +1 -0
  46. package/node_modules/@context-vault/core/dist/main.d.ts +3 -2
  47. package/node_modules/@context-vault/core/dist/main.d.ts.map +1 -1
  48. package/node_modules/@context-vault/core/dist/main.js +3 -1
  49. package/node_modules/@context-vault/core/dist/main.js.map +1 -1
  50. package/node_modules/@context-vault/core/dist/search.d.ts +2 -0
  51. package/node_modules/@context-vault/core/dist/search.d.ts.map +1 -1
  52. package/node_modules/@context-vault/core/dist/search.js +82 -6
  53. package/node_modules/@context-vault/core/dist/search.js.map +1 -1
  54. package/node_modules/@context-vault/core/dist/types.d.ts +24 -0
  55. package/node_modules/@context-vault/core/dist/types.d.ts.map +1 -1
  56. package/node_modules/@context-vault/core/package.json +5 -1
  57. package/node_modules/@context-vault/core/src/capture.ts +2 -0
  58. package/node_modules/@context-vault/core/src/config.ts +18 -1
  59. package/node_modules/@context-vault/core/src/constants.ts +15 -0
  60. package/node_modules/@context-vault/core/src/db.ts +73 -9
  61. package/node_modules/@context-vault/core/src/index.ts +65 -11
  62. package/node_modules/@context-vault/core/src/indexing.ts +35 -0
  63. package/node_modules/@context-vault/core/src/main.ts +5 -0
  64. package/node_modules/@context-vault/core/src/search.ts +96 -6
  65. package/node_modules/@context-vault/core/src/types.ts +26 -0
  66. package/package.json +2 -2
  67. package/src/server.ts +3 -0
  68. package/src/status.ts +35 -0
  69. package/src/tools/context-status.ts +40 -5
  70. package/src/tools/get-context.ts +1 -0
  71. package/src/tools/list-context.ts +20 -5
  72. package/src/tools/save-context.ts +67 -4
  73. package/src/tools/session-start.ts +222 -9
@@ -143,7 +143,11 @@ export const SCHEMA_DDL = `
143
143
  last_accessed_at TEXT,
144
144
  source_files TEXT,
145
145
  tier TEXT DEFAULT 'working' CHECK(tier IN ('ephemeral', 'working', 'durable')),
146
- related_to TEXT
146
+ related_to TEXT,
147
+ indexed INTEGER DEFAULT 1,
148
+ recall_count INTEGER DEFAULT 0,
149
+ recall_sessions INTEGER DEFAULT 0,
150
+ last_recalled_at TEXT
147
151
  );
148
152
 
149
153
  CREATE INDEX IF NOT EXISTS idx_vault_kind ON vault(kind);
@@ -153,33 +157,42 @@ export const SCHEMA_DDL = `
153
157
  CREATE UNIQUE INDEX IF NOT EXISTS idx_vault_identity ON vault(kind, identity_key) WHERE identity_key IS NOT NULL AND category = 'entity';
154
158
  CREATE INDEX IF NOT EXISTS idx_vault_superseded ON vault(superseded_by) WHERE superseded_by IS NOT NULL;
155
159
  CREATE INDEX IF NOT EXISTS idx_vault_tier ON vault(tier);
160
+ CREATE INDEX IF NOT EXISTS idx_vault_indexed ON vault(indexed);
156
161
 
157
162
  CREATE VIRTUAL TABLE IF NOT EXISTS vault_fts USING fts5(
158
163
  title, body, tags, kind,
159
164
  content='vault', content_rowid='rowid'
160
165
  );
161
166
 
162
- CREATE TRIGGER IF NOT EXISTS vault_ai AFTER INSERT ON vault BEGIN
167
+ CREATE TRIGGER IF NOT EXISTS vault_ai AFTER INSERT ON vault WHEN new.indexed = 1 BEGIN
163
168
  INSERT INTO vault_fts(rowid, title, body, tags, kind)
164
169
  VALUES (new.rowid, new.title, new.body, new.tags, new.kind);
165
170
  END;
166
- CREATE TRIGGER IF NOT EXISTS vault_ad AFTER DELETE ON vault BEGIN
171
+ CREATE TRIGGER IF NOT EXISTS vault_ad AFTER DELETE ON vault WHEN old.indexed = 1 BEGIN
167
172
  INSERT INTO vault_fts(vault_fts, rowid, title, body, tags, kind)
168
173
  VALUES ('delete', old.rowid, old.title, old.body, old.tags, old.kind);
169
174
  END;
170
- CREATE TRIGGER IF NOT EXISTS vault_au AFTER UPDATE ON vault BEGIN
175
+ CREATE TRIGGER IF NOT EXISTS vault_au AFTER UPDATE ON vault WHEN old.indexed = 1 OR new.indexed = 1 BEGIN
171
176
  INSERT INTO vault_fts(vault_fts, rowid, title, body, tags, kind)
172
177
  VALUES ('delete', old.rowid, old.title, old.body, old.tags, old.kind);
173
178
  INSERT INTO vault_fts(rowid, title, body, tags, kind)
174
- VALUES (new.rowid, new.title, new.body, new.tags, new.kind);
179
+ SELECT new.rowid, new.title, new.body, new.tags, new.kind WHERE new.indexed = 1;
175
180
  END;
176
181
 
177
182
  CREATE VIRTUAL TABLE IF NOT EXISTS vault_vec USING vec0(embedding float[384]);
178
183
 
179
184
  CREATE VIRTUAL TABLE IF NOT EXISTS vault_ctx_vec USING vec0(embedding float[384]);
185
+
186
+ CREATE TABLE IF NOT EXISTS co_retrievals (
187
+ entry_a TEXT NOT NULL,
188
+ entry_b TEXT NOT NULL,
189
+ count INTEGER DEFAULT 1,
190
+ last_at TEXT NOT NULL,
191
+ PRIMARY KEY (entry_a, entry_b)
192
+ );
180
193
  `;
181
194
 
182
- const CURRENT_VERSION = 16;
195
+ const CURRENT_VERSION = 18;
183
196
 
184
197
  export async function initDatabase(dbPath: string): Promise<DatabaseSync> {
185
198
  const sqliteVec = await loadSqliteVec();
@@ -208,9 +221,60 @@ export async function initDatabase(dbPath: string): Promise<DatabaseSync> {
208
221
  if (version === 15) {
209
222
  try {
210
223
  db.exec('CREATE VIRTUAL TABLE IF NOT EXISTS vault_ctx_vec USING vec0(embedding float[384])');
211
- db.exec(`PRAGMA user_version = ${CURRENT_VERSION}`);
224
+ db.exec('PRAGMA user_version = 16');
212
225
  } catch (e) {
213
226
  console.error(`[context-vault] v15->v16 migration failed: ${(e as Error).message}`);
227
+ return db;
228
+ }
229
+ // Fall through to v16->v17 migration
230
+ }
231
+
232
+ // v16 -> v17: add indexed column for selective indexing
233
+ if (version === 16 || version === 15) {
234
+ try {
235
+ db.exec('ALTER TABLE vault ADD COLUMN indexed INTEGER DEFAULT 1');
236
+ db.exec('CREATE INDEX IF NOT EXISTS idx_vault_indexed ON vault(indexed)');
237
+ db.exec('DROP TRIGGER IF EXISTS vault_ai');
238
+ db.exec('DROP TRIGGER IF EXISTS vault_ad');
239
+ db.exec('DROP TRIGGER IF EXISTS vault_au');
240
+ db.exec(`CREATE TRIGGER vault_ai AFTER INSERT ON vault WHEN new.indexed = 1 BEGIN
241
+ INSERT INTO vault_fts(rowid, title, body, tags, kind)
242
+ VALUES (new.rowid, new.title, new.body, new.tags, new.kind);
243
+ END`);
244
+ db.exec(`CREATE TRIGGER vault_ad AFTER DELETE ON vault WHEN old.indexed = 1 BEGIN
245
+ INSERT INTO vault_fts(vault_fts, rowid, title, body, tags, kind)
246
+ VALUES ('delete', old.rowid, old.title, old.body, old.tags, old.kind);
247
+ END`);
248
+ db.exec(`CREATE TRIGGER vault_au AFTER UPDATE ON vault WHEN old.indexed = 1 OR new.indexed = 1 BEGIN
249
+ INSERT INTO vault_fts(vault_fts, rowid, title, body, tags, kind)
250
+ VALUES ('delete', old.rowid, old.title, old.body, old.tags, old.kind);
251
+ INSERT INTO vault_fts(rowid, title, body, tags, kind)
252
+ SELECT new.rowid, new.title, new.body, new.tags, new.kind WHERE new.indexed = 1;
253
+ END`);
254
+ db.exec('PRAGMA user_version = 17');
255
+ } catch (e) {
256
+ console.error(`[context-vault] v16->v17 migration failed: ${(e as Error).message}`);
257
+ return db;
258
+ }
259
+ // Fall through to v17->v18 migration
260
+ }
261
+
262
+ // v17 -> v18: add recall frequency tracking columns and co_retrievals table
263
+ if (version === 17 || version === 16 || version === 15) {
264
+ try {
265
+ db.exec('ALTER TABLE vault ADD COLUMN recall_count INTEGER DEFAULT 0');
266
+ db.exec('ALTER TABLE vault ADD COLUMN recall_sessions INTEGER DEFAULT 0');
267
+ db.exec('ALTER TABLE vault ADD COLUMN last_recalled_at TEXT');
268
+ db.exec(`CREATE TABLE IF NOT EXISTS co_retrievals (
269
+ entry_a TEXT NOT NULL,
270
+ entry_b TEXT NOT NULL,
271
+ count INTEGER DEFAULT 1,
272
+ last_at TEXT NOT NULL,
273
+ PRIMARY KEY (entry_a, entry_b)
274
+ )`);
275
+ db.exec(`PRAGMA user_version = ${CURRENT_VERSION}`);
276
+ } catch (e) {
277
+ console.error(`[context-vault] v17->v18 migration failed: ${(e as Error).message}`);
214
278
  }
215
279
  return db;
216
280
  }
@@ -256,7 +320,7 @@ export async function initDatabase(dbPath: string): Promise<DatabaseSync> {
256
320
  return freshDb;
257
321
  }
258
322
 
259
- if (version < 16) {
323
+ if (version < 18) {
260
324
  db.exec(SCHEMA_DDL);
261
325
  db.exec(`PRAGMA user_version = ${CURRENT_VERSION}`);
262
326
  }
@@ -271,7 +335,7 @@ export function prepareStatements(db: DatabaseSync): PreparedStatements {
271
335
  try {
272
336
  return {
273
337
  insertEntry: db.prepare(
274
- `INSERT INTO vault (id, kind, category, title, body, meta, tags, source, file_path, identity_key, expires_at, created_at, updated_at, source_files, tier) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
338
+ `INSERT INTO vault (id, kind, category, title, body, meta, tags, source, file_path, identity_key, expires_at, created_at, updated_at, source_files, tier, indexed) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
275
339
  ),
276
340
  updateEntry: db.prepare(
277
341
  `UPDATE vault SET title = ?, body = ?, meta = ?, tags = ?, source = ?, category = ?, identity_key = ?, expires_at = ?, updated_at = datetime('now') WHERE file_path = ?`
@@ -4,7 +4,9 @@ import { dirToKind, walkDir, ulid } from './files.js';
4
4
  import { categoryFor, defaultTierFor, CATEGORY_DIRS } from './categories.js';
5
5
  import { parseFrontmatter, parseEntryFromMarkdown } from './frontmatter.js';
6
6
  import { embedBatch } from './embed.js';
7
- import type { BaseCtx, IndexEntryInput, ReindexStats } from './types.js';
7
+ import type { BaseCtx, IndexEntryInput, IndexingConfig, ReindexStats } from './types.js';
8
+ import { shouldIndex } from './indexing.js';
9
+ import { DEFAULT_INDEXING } from './constants.js';
8
10
 
9
11
  const EXCLUDED_DIRS = new Set(['projects', '_archive']);
10
12
  const EXCLUDED_FILES = new Set(['context.md', 'memory.md', 'README.md']);
@@ -33,6 +35,7 @@ export async function indexEntry(
33
35
  expires_at,
34
36
  source_files,
35
37
  tier,
38
+ indexed = true,
36
39
  } = entry;
37
40
 
38
41
  if (expires_at && new Date(expires_at) <= new Date()) return;
@@ -84,7 +87,8 @@ export async function indexEntry(
84
87
  createdAt,
85
88
  createdAt,
86
89
  sourceFilesJson,
87
- effectiveTier
90
+ effectiveTier,
91
+ indexed ? 1 : 0
88
92
  );
89
93
  } catch (e) {
90
94
  if ((e as Error).message.includes('UNIQUE constraint')) {
@@ -132,7 +136,7 @@ export async function indexEntry(
132
136
  );
133
137
  }
134
138
 
135
- if (cat !== 'event') {
139
+ if (indexed && cat !== 'event') {
136
140
  let embedding: Float32Array | null = null;
137
141
  if (precomputedEmbedding !== undefined) {
138
142
  embedding = precomputedEmbedding;
@@ -184,14 +188,17 @@ export async function pruneExpired(ctx: BaseCtx): Promise<number> {
184
188
 
185
189
  export async function reindex(
186
190
  ctx: BaseCtx,
187
- opts: { fullSync?: boolean } = {}
191
+ opts: { fullSync?: boolean; indexingConfig?: IndexingConfig; dryRun?: boolean; kindFilter?: string } = {}
188
192
  ): Promise<ReindexStats> {
189
- const { fullSync = true } = opts;
193
+ const { fullSync = true, indexingConfig, dryRun = false, kindFilter: reindexKindFilter } = opts;
194
+ const ixConfig = indexingConfig ?? (ctx.config as any)?.indexing ?? DEFAULT_INDEXING;
190
195
  const stats: ReindexStats = {
191
196
  added: 0,
192
197
  updated: 0,
193
198
  removed: 0,
194
199
  unchanged: 0,
200
+ skippedIndexing: 0,
201
+ embeddingsCleared: 0,
195
202
  };
196
203
 
197
204
  if (!existsSync(ctx.config.vaultDir)) return stats;
@@ -225,17 +232,44 @@ export async function reindex(
225
232
  }
226
233
  }
227
234
 
235
+ const filteredKindEntries = reindexKindFilter
236
+ ? kindEntries.filter((ke) => ke.kind === reindexKindFilter)
237
+ : kindEntries;
238
+
228
239
  const pendingEmbeds: { rowid: number; text: string }[] = [];
229
240
 
241
+ if (dryRun) {
242
+ for (const { kind, dir } of filteredKindEntries) {
243
+ const category = categoryFor(kind);
244
+ const mdFiles = walkDir(dir).filter((f) => !EXCLUDED_FILES.has(basename(f.filePath)));
245
+ for (const { filePath } of mdFiles) {
246
+ const raw = readFileSync(filePath, 'utf-8');
247
+ if (!raw.startsWith('---\n')) continue;
248
+ const { meta: fmMeta, body: rawBody } = parseFrontmatter(raw);
249
+ const parsed = parseEntryFromMarkdown(kind, rawBody, fmMeta);
250
+ const entryIndexed = shouldIndex(
251
+ { kind, category, bodyLength: parsed.body.length },
252
+ ixConfig
253
+ );
254
+ if (entryIndexed) {
255
+ stats.added++;
256
+ } else {
257
+ stats.skippedIndexing!++;
258
+ }
259
+ }
260
+ }
261
+ return stats;
262
+ }
263
+
230
264
  ctx.db.exec('BEGIN');
231
265
  try {
232
- for (const { kind, dir } of kindEntries) {
266
+ for (const { kind, dir } of filteredKindEntries) {
233
267
  const category = categoryFor(kind);
234
268
  const mdFiles = walkDir(dir).filter((f) => !EXCLUDED_FILES.has(basename(f.filePath)));
235
269
 
236
270
  const dbRows = ctx.db
237
271
  .prepare(
238
- 'SELECT id, file_path, body, title, tags, meta, related_to FROM vault WHERE kind = ?'
272
+ 'SELECT id, file_path, body, title, tags, meta, related_to, indexed FROM vault WHERE kind = ?'
239
273
  )
240
274
  .all(kind) as Record<string, unknown>[];
241
275
  const dbByPath = new Map(dbRows.map((r) => [r.file_path as string, r]));
@@ -269,6 +303,11 @@ export async function reindex(
269
303
  else delete meta.folder;
270
304
  const metaJson = Object.keys(meta).length ? JSON.stringify(meta) : null;
271
305
 
306
+ const entryIndexed = shouldIndex(
307
+ { kind, category, bodyLength: parsed.body.length },
308
+ ixConfig
309
+ );
310
+
272
311
  if (!existing) {
273
312
  const id = (fmMeta.id as string) || ulid();
274
313
  const tagsJson = fmMeta.tags ? JSON.stringify(fmMeta.tags) : null;
@@ -290,10 +329,11 @@ export async function reindex(
290
329
  (fmMeta.updated as string) || created
291
330
  );
292
331
  if ((result as { changes: number }).changes > 0) {
332
+ ctx.db.prepare('UPDATE vault SET indexed = ? WHERE id = ?').run(entryIndexed ? 1 : 0, id);
293
333
  if (relatedToJson && ctx.stmts.updateRelatedTo) {
294
334
  ctx.stmts.updateRelatedTo.run(relatedToJson, id);
295
335
  }
296
- if (category !== 'event') {
336
+ if (entryIndexed && category !== 'event') {
297
337
  const rowidResult = ctx.stmts.getRowid.get(id) as { rowid: number } | undefined;
298
338
  if (rowidResult?.rowid) {
299
339
  const embeddingText = [parsed.title, parsed.body].filter(Boolean).join(' ');
@@ -303,6 +343,7 @@ export async function reindex(
303
343
  });
304
344
  }
305
345
  }
346
+ if (!entryIndexed) stats.skippedIndexing!++;
306
347
  stats.added++;
307
348
  } else {
308
349
  stats.unchanged++;
@@ -315,7 +356,10 @@ export async function reindex(
315
356
  const metaChanged = metaJson !== ((existing.meta as string) || null);
316
357
  const relatedToChanged = relatedToJson !== ((existing.related_to as string) || null);
317
358
 
318
- if (bodyChanged || titleChanged || tagsChanged || metaChanged || relatedToChanged) {
359
+ const existingIndexed = (existing as any).indexed;
360
+ const indexedChanged = (entryIndexed ? 1 : 0) !== (existingIndexed ?? 1);
361
+
362
+ if (bodyChanged || titleChanged || tagsChanged || metaChanged || relatedToChanged || indexedChanged) {
319
363
  ctx.stmts.updateEntry.run(
320
364
  parsed.title || null,
321
365
  parsed.body,
@@ -327,11 +371,20 @@ export async function reindex(
327
371
  expires_at,
328
372
  filePath
329
373
  );
330
- if (relatedToChanged && ctx.stmts.updateRelatedTo) {
374
+ ctx.db.prepare('UPDATE vault SET indexed = ? WHERE file_path = ?').run(entryIndexed ? 1 : 0, filePath);
375
+ if (relatedToJson && ctx.stmts.updateRelatedTo) {
331
376
  ctx.stmts.updateRelatedTo.run(relatedToJson, existing.id as string);
332
377
  }
333
378
 
334
- if ((bodyChanged || titleChanged) && category !== 'event') {
379
+ if (!entryIndexed) {
380
+ const rowid = (
381
+ ctx.stmts.getRowid.get(existing.id as string) as { rowid: number } | undefined
382
+ )?.rowid;
383
+ if (rowid) {
384
+ try { ctx.deleteVec(rowid); stats.embeddingsCleared!++; } catch {}
385
+ }
386
+ stats.skippedIndexing!++;
387
+ } else if ((bodyChanged || titleChanged) && category !== 'event') {
335
388
  const rowid = (
336
389
  ctx.stmts.getRowid.get(existing.id as string) as { rowid: number } | undefined
337
390
  )?.rowid;
@@ -435,6 +488,7 @@ export async function reindex(
435
488
  .prepare(
436
489
  `SELECT v.rowid, v.title, v.body FROM vault v
437
490
  WHERE v.category != 'event'
491
+ AND v.indexed = 1
438
492
  AND v.rowid NOT IN (SELECT rowid FROM vault_vec)`
439
493
  )
440
494
  .all() as { rowid: number; title: string | null; body: string }[];
@@ -0,0 +1,35 @@
1
+ import type { IndexingConfig } from './types.js';
2
+ import { DEFAULT_INDEXING } from './constants.js';
3
+
4
+ export function shouldIndex(
5
+ opts: {
6
+ kind: string;
7
+ category: string;
8
+ bodyLength: number;
9
+ explicitIndexed?: boolean;
10
+ },
11
+ config?: IndexingConfig | null
12
+ ): boolean {
13
+ if (opts.explicitIndexed === true) return true;
14
+ if (opts.explicitIndexed === false) return false;
15
+
16
+ const c = config ?? DEFAULT_INDEXING;
17
+
18
+ if (c.excludeKinds.length > 0 && c.excludeKinds.includes(opts.kind)) {
19
+ return false;
20
+ }
21
+
22
+ if (c.excludeCategories.length > 0 && c.excludeCategories.includes(opts.category)) {
23
+ return false;
24
+ }
25
+
26
+ if (opts.category === 'event' && !c.autoIndexEvents) {
27
+ return false;
28
+ }
29
+
30
+ if (c.maxBodySize > 0 && opts.bodyLength > c.maxBodySize) {
31
+ return false;
32
+ }
33
+
34
+ return true;
35
+ }
@@ -4,6 +4,7 @@ export type {
4
4
  RecallConfig,
5
5
  ConsolidationConfig,
6
6
  GrowthThresholds,
7
+ IndexingConfig,
7
8
  PreparedStatements,
8
9
  VaultEntry,
9
10
  SearchResult,
@@ -31,6 +32,7 @@ export {
31
32
  MAX_IDENTITY_KEY_LENGTH,
32
33
  DEFAULT_GROWTH_THRESHOLDS,
33
34
  DEFAULT_LIFECYCLE,
35
+ DEFAULT_INDEXING,
34
36
  } from './constants.js';
35
37
 
36
38
  // Categories
@@ -99,5 +101,8 @@ export {
99
101
  // Capture
100
102
  export { writeEntry, updateEntryFile, captureAndIndex } from './capture.js';
101
103
 
104
+ // Indexing
105
+ export { shouldIndex } from './indexing.js';
106
+
102
107
  // Ingest URL
103
108
  export { htmlToMarkdown, extractHtmlContent, ingestUrl } from './ingest-url.js';
@@ -2,6 +2,9 @@ import type { BaseCtx, SearchResult, SearchOptions, VaultEntry } from './types.j
2
2
 
3
3
  const NEAR_DUP_THRESHOLD = 0.92;
4
4
  const RRF_K = 60;
5
+ const RECALL_BOOST_CAP = 2.0;
6
+ const RECALL_HALF_LIFE_DAYS = 30;
7
+ const DISCOVERY_SLOTS = 2;
5
8
 
6
9
  export function recencyDecayScore(updatedAt: string | null | undefined, decayRate = 0.05): number {
7
10
  if (updatedAt == null) return 0.5;
@@ -34,6 +37,18 @@ export function recencyBoost(createdAt: string, category: string, decayDays = 30
34
37
  return 1 / (1 + ageDays / decayDays);
35
38
  }
36
39
 
40
+ export function recallBoost(recallCount: number, lastRecalledAt: string | null): number {
41
+ if (recallCount <= 0) return 1.0;
42
+ const logBoost = Math.log(recallCount + 1);
43
+ let recencyWeight = 1.0;
44
+ if (lastRecalledAt) {
45
+ const ageDays = (Date.now() - new Date(lastRecalledAt).getTime()) / 86400000;
46
+ recencyWeight = Math.pow(0.5, ageDays / RECALL_HALF_LIFE_DAYS);
47
+ }
48
+ const boost = 1 + logBoost * recencyWeight;
49
+ return Math.min(boost, RECALL_BOOST_CAP);
50
+ }
51
+
37
52
  export function buildFilterClauses({
38
53
  categoryFilter,
39
54
  excludeEvents = false,
@@ -73,6 +88,7 @@ export function buildFilterClauses({
73
88
  if (!includeEphemeral) {
74
89
  clauses.push("e.tier != 'ephemeral'");
75
90
  }
91
+ clauses.push('e.indexed = 1');
76
92
  return { clauses, params };
77
93
  }
78
94
 
@@ -191,6 +207,7 @@ export async function hybridSearch(
191
207
  if (since && row.created_at < since) continue;
192
208
  if (until && row.created_at > until) continue;
193
209
  if (row.expires_at && new Date(row.expires_at) <= new Date()) continue;
210
+ if (!row.indexed) continue;
194
211
 
195
212
  const { rowid: _rowid, ...cleanRow } = row;
196
213
  idToRowid.set(cleanRow.id, Number(row.rowid));
@@ -243,6 +260,7 @@ export async function hybridSearch(
243
260
  if (since && row.created_at < since) continue;
244
261
  if (until && row.created_at > until) continue;
245
262
  if (row.expires_at && new Date(row.expires_at) <= new Date()) continue;
263
+ if (!row.indexed) continue;
246
264
 
247
265
  const { rowid: _rowid, ...cleanRow } = row;
248
266
  ctxRankedIds.push(cleanRow.id);
@@ -267,7 +285,11 @@ export async function hybridSearch(
267
285
 
268
286
  for (const [id, entry] of rowMap) {
269
287
  const boost = recencyBoost(entry.created_at, entry.category, decayDays);
270
- rrfScores.set(id, (rrfScores.get(id) ?? 0) * boost);
288
+ const recall = recallBoost(
289
+ entry.recall_count ?? 0,
290
+ entry.last_recalled_at ?? null
291
+ );
292
+ rrfScores.set(id, (rrfScores.get(id) ?? 0) * boost * recall);
271
293
  }
272
294
 
273
295
  const candidates: SearchResult[] = [...rowMap.values()].map((entry) => ({
@@ -316,26 +338,94 @@ export async function hybridSearch(
316
338
  selected.push(candidate);
317
339
  if (vec) selectedVecs.push(vec);
318
340
  }
319
- const dedupedPage = selected.slice(offset, offset + limit);
341
+ const dedupedPage = injectDiscoverySlots(selected.slice(offset, offset + limit), candidates);
320
342
  trackAccess(ctx, dedupedPage);
321
343
  return dedupedPage;
322
344
  }
323
345
 
324
- const finalPage = candidates.slice(offset, offset + limit);
346
+ const page = candidates.slice(offset, offset + limit);
347
+ const finalPage = injectDiscoverySlots(page, candidates);
325
348
  trackAccess(ctx, finalPage);
326
349
  return finalPage;
327
350
  }
328
351
 
352
+ function injectDiscoverySlots(
353
+ page: SearchResult[],
354
+ allCandidates: SearchResult[]
355
+ ): SearchResult[] {
356
+ if (page.length < 4) return page;
357
+ const pageIds = new Set(page.map((e) => e.id));
358
+ const discoveries = allCandidates
359
+ .filter(
360
+ (c) =>
361
+ !pageIds.has(c.id) &&
362
+ (c.recall_count ?? 0) <= 2 &&
363
+ c.score > 0
364
+ )
365
+ .slice(0, DISCOVERY_SLOTS);
366
+ if (!discoveries.length) return page;
367
+ const result = [...page];
368
+ for (let i = 0; i < discoveries.length && result.length > 2; i++) {
369
+ result.splice(result.length - 1 - i, 1, discoveries[i]);
370
+ }
371
+ return result;
372
+ }
373
+
374
+ let _sessionId: string | null = null;
375
+ const _seenSessionIds = new Set<string>();
376
+
377
+ export function setSessionId(id: string): void {
378
+ _sessionId = id;
379
+ }
380
+
329
381
  export function trackAccess(ctx: BaseCtx, entries: SearchResult[]): void {
330
382
  if (!entries.length) return;
383
+
384
+ const ids = entries.map((e) => e.id);
385
+ const now = new Date().toISOString();
386
+
331
387
  try {
332
- const placeholders = entries.map(() => '?').join(',');
388
+ const placeholders = ids.map(() => '?').join(',');
333
389
  ctx.db
334
390
  .prepare(
335
- `UPDATE vault SET hit_count = hit_count + 1, last_accessed_at = datetime('now') WHERE id IN (${placeholders})`
391
+ `UPDATE vault SET hit_count = hit_count + 1, last_accessed_at = datetime('now'), recall_count = recall_count + 1, last_recalled_at = ? WHERE id IN (${placeholders})`
336
392
  )
337
- .run(...entries.map((e) => e.id));
393
+ .run(now, ...ids);
338
394
  } catch {
339
395
  // Non-fatal
340
396
  }
397
+
398
+ const sessionId = _sessionId || 'default';
399
+ const sessionKey = `${sessionId}:${ids.sort().join(',')}`;
400
+ const isNewSession = !_seenSessionIds.has(sessionKey);
401
+ if (isNewSession) {
402
+ _seenSessionIds.add(sessionKey);
403
+ try {
404
+ const placeholders = ids.map(() => '?').join(',');
405
+ ctx.db
406
+ .prepare(
407
+ `UPDATE vault SET recall_sessions = recall_sessions + 1 WHERE id IN (${placeholders})`
408
+ )
409
+ .run(...ids);
410
+ } catch {
411
+ // Non-fatal
412
+ }
413
+ }
414
+
415
+ if (ids.length >= 2) {
416
+ try {
417
+ const upsert = ctx.db.prepare(
418
+ `INSERT INTO co_retrievals (entry_a, entry_b, count, last_at) VALUES (?, ?, 1, ?)
419
+ ON CONFLICT(entry_a, entry_b) DO UPDATE SET count = count + 1, last_at = ?`
420
+ );
421
+ for (let i = 0; i < ids.length; i++) {
422
+ for (let j = i + 1; j < ids.length; j++) {
423
+ const [a, b] = ids[i] < ids[j] ? [ids[i], ids[j]] : [ids[j], ids[i]];
424
+ upsert.run(a, b, now, now);
425
+ }
426
+ }
427
+ } catch {
428
+ // Non-fatal
429
+ }
430
+ }
341
431
  }
@@ -1,5 +1,20 @@
1
1
  import type { DatabaseSync, StatementSync } from 'node:sqlite';
2
2
 
3
+ export interface AutoInsightsConfig {
4
+ enabled: boolean;
5
+ patterns: string[];
6
+ minChars: number;
7
+ maxPerSession: number;
8
+ tier: string;
9
+ }
10
+
11
+ export interface IndexingConfig {
12
+ excludeKinds: string[];
13
+ excludeCategories: string[];
14
+ maxBodySize: number;
15
+ autoIndexEvents: boolean;
16
+ }
17
+
3
18
  export interface VaultConfig {
4
19
  vaultDir: string;
5
20
  dataDir: string;
@@ -14,6 +29,8 @@ export interface VaultConfig {
14
29
  recall: RecallConfig;
15
30
  consolidation: ConsolidationConfig;
16
31
  lifecycle: Record<string, { archiveAfterDays?: number }>;
32
+ autoInsights: AutoInsightsConfig;
33
+ indexing: IndexingConfig;
17
34
  }
18
35
 
19
36
  export interface RecallConfig {
@@ -77,6 +94,10 @@ export interface VaultEntry {
77
94
  source_files: string | null;
78
95
  tier: string;
79
96
  related_to: string | null;
97
+ indexed: number;
98
+ recall_count: number;
99
+ recall_sessions: number;
100
+ last_recalled_at: string | null;
80
101
  rowid?: number;
81
102
  }
82
103
 
@@ -100,6 +121,7 @@ export interface CaptureInput {
100
121
  related_to?: string[] | null;
101
122
  source_files?: Array<{ path: string; hash: string }> | null;
102
123
  tier?: string | null;
124
+ indexed?: boolean;
103
125
  }
104
126
 
105
127
  export interface CaptureResult {
@@ -120,6 +142,7 @@ export interface CaptureResult {
120
142
  related_to: string[] | null;
121
143
  source_files: Array<{ path: string; hash: string }> | null;
122
144
  tier: string | null;
145
+ indexed: boolean;
123
146
  }
124
147
 
125
148
  export interface IndexEntryInput {
@@ -137,6 +160,7 @@ export interface IndexEntryInput {
137
160
  expires_at: string | null;
138
161
  source_files: Array<{ path: string; hash: string }> | null;
139
162
  tier: string | null;
163
+ indexed?: boolean;
140
164
  }
141
165
 
142
166
  export interface ReindexStats {
@@ -144,6 +168,8 @@ export interface ReindexStats {
144
168
  updated: number;
145
169
  removed: number;
146
170
  unchanged: number;
171
+ skippedIndexing?: number;
172
+ embeddingsCleared?: number;
147
173
  }
148
174
 
149
175
  export interface BaseCtx {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-vault",
3
- "version": "3.4.5",
3
+ "version": "3.5.0",
4
4
  "type": "module",
5
5
  "description": "Persistent memory for AI agents — saves and searches knowledge across sessions",
6
6
  "bin": {
@@ -63,7 +63,7 @@
63
63
  "@context-vault/core"
64
64
  ],
65
65
  "dependencies": {
66
- "@context-vault/core": "^3.4.5",
66
+ "@context-vault/core": "^3.5.0",
67
67
  "@modelcontextprotocol/sdk": "^1.26.0",
68
68
  "adm-zip": "^0.5.16",
69
69
  "sqlite-vec": "^0.1.0"
package/src/server.ts CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  } from '@context-vault/core/db';
32
32
  import { registerTools } from './register-tools.js';
33
33
  import { pruneExpired } from '@context-vault/core/index';
34
+ import { setSessionId } from '@context-vault/core/search';
34
35
 
35
36
  const DAEMON_PORT = 3377;
36
37
  const PID_PATH = join(homedir(), '.context-mcp', 'daemon.pid');
@@ -359,6 +360,8 @@ async function main(): Promise<void> {
359
360
  toolStats: { ok: 0, errors: 0, lastError: null },
360
361
  };
361
362
 
363
+ setSessionId(randomUUID());
364
+
362
365
  try {
363
366
  const pruned = await pruneExpired(ctx);
364
367
  if (pruned > 0) {