@context-vault/core 2.17.0 → 3.0.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 (101) hide show
  1. package/dist/capture.d.ts +21 -0
  2. package/dist/capture.d.ts.map +1 -0
  3. package/dist/capture.js +269 -0
  4. package/dist/capture.js.map +1 -0
  5. package/dist/categories.d.ts +6 -0
  6. package/dist/categories.d.ts.map +1 -0
  7. package/dist/categories.js +50 -0
  8. package/dist/categories.js.map +1 -0
  9. package/dist/config.d.ts +4 -0
  10. package/dist/config.d.ts.map +1 -0
  11. package/dist/config.js +190 -0
  12. package/dist/config.js.map +1 -0
  13. package/dist/constants.d.ts +33 -0
  14. package/dist/constants.d.ts.map +1 -0
  15. package/dist/constants.js +23 -0
  16. package/dist/constants.js.map +1 -0
  17. package/dist/db.d.ts +13 -0
  18. package/dist/db.d.ts.map +1 -0
  19. package/dist/db.js +191 -0
  20. package/dist/db.js.map +1 -0
  21. package/dist/embed.d.ts +5 -0
  22. package/dist/embed.d.ts.map +1 -0
  23. package/dist/embed.js +78 -0
  24. package/dist/embed.js.map +1 -0
  25. package/dist/files.d.ts +13 -0
  26. package/dist/files.d.ts.map +1 -0
  27. package/dist/files.js +66 -0
  28. package/dist/files.js.map +1 -0
  29. package/dist/formatters.d.ts +8 -0
  30. package/dist/formatters.d.ts.map +1 -0
  31. package/dist/formatters.js +18 -0
  32. package/dist/formatters.js.map +1 -0
  33. package/dist/frontmatter.d.ts +12 -0
  34. package/dist/frontmatter.d.ts.map +1 -0
  35. package/dist/frontmatter.js +101 -0
  36. package/dist/frontmatter.js.map +1 -0
  37. package/dist/index.d.ts +10 -0
  38. package/dist/index.d.ts.map +1 -0
  39. package/dist/index.js +297 -0
  40. package/dist/index.js.map +1 -0
  41. package/dist/ingest-url.d.ts +20 -0
  42. package/dist/ingest-url.d.ts.map +1 -0
  43. package/dist/ingest-url.js +113 -0
  44. package/dist/ingest-url.js.map +1 -0
  45. package/dist/main.d.ts +14 -0
  46. package/dist/main.d.ts.map +1 -0
  47. package/dist/main.js +25 -0
  48. package/dist/main.js.map +1 -0
  49. package/dist/search.d.ts +18 -0
  50. package/dist/search.d.ts.map +1 -0
  51. package/dist/search.js +238 -0
  52. package/dist/search.js.map +1 -0
  53. package/dist/types.d.ts +176 -0
  54. package/dist/types.d.ts.map +1 -0
  55. package/dist/types.js +2 -0
  56. package/dist/types.js.map +1 -0
  57. package/package.json +66 -17
  58. package/src/capture.ts +308 -0
  59. package/src/categories.ts +54 -0
  60. package/src/{core/config.js → config.ts} +34 -33
  61. package/src/{constants.js → constants.ts} +6 -3
  62. package/src/db.ts +229 -0
  63. package/src/{index/embed.js → embed.ts} +10 -35
  64. package/src/files.ts +80 -0
  65. package/src/{capture/formatters.js → formatters.ts} +13 -11
  66. package/src/{core/frontmatter.js → frontmatter.ts} +27 -33
  67. package/src/index.ts +351 -0
  68. package/src/ingest-url.ts +99 -0
  69. package/src/main.ts +111 -0
  70. package/src/search.ts +285 -0
  71. package/src/types.ts +166 -0
  72. package/src/capture/file-ops.js +0 -97
  73. package/src/capture/import-pipeline.js +0 -46
  74. package/src/capture/importers.js +0 -387
  75. package/src/capture/index.js +0 -236
  76. package/src/capture/ingest-url.js +0 -252
  77. package/src/consolidation/index.js +0 -112
  78. package/src/core/categories.js +0 -72
  79. package/src/core/error-log.js +0 -54
  80. package/src/core/files.js +0 -108
  81. package/src/core/status.js +0 -350
  82. package/src/core/telemetry.js +0 -90
  83. package/src/index/db.js +0 -416
  84. package/src/index/index.js +0 -522
  85. package/src/index.js +0 -66
  86. package/src/retrieve/index.js +0 -500
  87. package/src/server/helpers.js +0 -44
  88. package/src/server/tools/clear-context.js +0 -47
  89. package/src/server/tools/context-status.js +0 -182
  90. package/src/server/tools/create-snapshot.js +0 -231
  91. package/src/server/tools/delete-context.js +0 -60
  92. package/src/server/tools/get-context.js +0 -678
  93. package/src/server/tools/ingest-project.js +0 -244
  94. package/src/server/tools/ingest-url.js +0 -88
  95. package/src/server/tools/list-buckets.js +0 -116
  96. package/src/server/tools/list-context.js +0 -163
  97. package/src/server/tools/save-context.js +0 -609
  98. package/src/server/tools/session-start.js +0 -285
  99. package/src/server/tools/submit-feedback.js +0 -55
  100. package/src/server/tools.js +0 -174
  101. package/src/sync/sync.js +0 -235
@@ -1,678 +0,0 @@
1
- import { z } from "zod";
2
- import { createHash } from "node:crypto";
3
- import { readFileSync, existsSync } from "node:fs";
4
- import { resolve } from "node:path";
5
- import { hybridSearch } from "../../retrieve/index.js";
6
- import { categoryFor } from "../../core/categories.js";
7
- import { normalizeKind } from "../../core/files.js";
8
- import { ok, err } from "../helpers.js";
9
- import { isEmbedAvailable } from "../../index/embed.js";
10
-
11
- const STALE_DUPLICATE_DAYS = 7;
12
- const DEFAULT_PIVOT_COUNT = 2;
13
- const SKELETON_BODY_CHARS = 100;
14
- const CONSOLIDATION_TAG_THRESHOLD = 10;
15
- const CONSOLIDATION_SNAPSHOT_MAX_AGE_DAYS = 7;
16
- const BRIEF_SCORE_BOOST = 0.05;
17
-
18
- /**
19
- * Truncate a body string to ~SKELETON_BODY_CHARS, breaking at sentence or
20
- * word boundary. Returns the truncated string with "..." appended.
21
- */
22
- export function skeletonBody(body) {
23
- if (!body) return "";
24
- if (body.length <= SKELETON_BODY_CHARS) return body;
25
- const slice = body.slice(0, SKELETON_BODY_CHARS);
26
- const sentenceEnd = Math.max(
27
- slice.lastIndexOf(". "),
28
- slice.lastIndexOf(".\n"),
29
- );
30
- if (sentenceEnd > SKELETON_BODY_CHARS * 0.4) {
31
- return slice.slice(0, sentenceEnd + 1) + "...";
32
- }
33
- const wordEnd = slice.lastIndexOf(" ");
34
- if (wordEnd > SKELETON_BODY_CHARS * 0.4) {
35
- return slice.slice(0, wordEnd) + "...";
36
- }
37
- return slice + "...";
38
- }
39
-
40
- /**
41
- * Detect conflicts among a set of search result entries.
42
- *
43
- * Two checks are performed:
44
- * 1. Supersession: if entry A's `superseded_by` points to any entry B in the
45
- * result set, A is stale and should be discarded in favour of B.
46
- * 2. Stale duplicate: two entries share the same kind and at least one common
47
- * tag, but their `updated_at` timestamps differ by more than
48
- * STALE_DUPLICATE_DAYS days — suggesting the older one may be outdated.
49
- *
50
- * No LLM calls, no new dependencies — pure in-memory set operations on the
51
- * rows already fetched from the DB.
52
- *
53
- * @param {Array} entries - Result rows (as returned by hybridSearch / filter-only mode)
54
- * @param {import('../types.js').BaseCtx} _ctx - Unused for now; reserved for future DB look-ups
55
- * @returns {Array<{entry_a_id: string, entry_b_id: string, reason: string, recommendation: string}>}
56
- */
57
- export function detectConflicts(entries, _ctx) {
58
- const conflicts = [];
59
- const idSet = new Set(entries.map((e) => e.id));
60
-
61
- for (const entry of entries) {
62
- if (entry.superseded_by && idSet.has(entry.superseded_by)) {
63
- conflicts.push({
64
- entry_a_id: entry.id,
65
- entry_b_id: entry.superseded_by,
66
- reason: "superseded",
67
- recommendation: `Discard \`${entry.id}\` — it has been explicitly superseded by \`${entry.superseded_by}\`.`,
68
- });
69
- }
70
- }
71
-
72
- const supersededConflictPairs = new Set(
73
- conflicts.map((c) => `${c.entry_a_id}|${c.entry_b_id}`),
74
- );
75
-
76
- for (let i = 0; i < entries.length; i++) {
77
- for (let j = i + 1; j < entries.length; j++) {
78
- const a = entries[i];
79
- const b = entries[j];
80
-
81
- if (
82
- supersededConflictPairs.has(`${a.id}|${b.id}`) ||
83
- supersededConflictPairs.has(`${b.id}|${a.id}`)
84
- ) {
85
- continue;
86
- }
87
-
88
- if (a.kind !== b.kind) continue;
89
-
90
- const tagsA = a.tags ? JSON.parse(a.tags) : [];
91
- const tagsB = b.tags ? JSON.parse(b.tags) : [];
92
-
93
- if (!tagsA.length || !tagsB.length) continue;
94
-
95
- const tagsSetA = new Set(tagsA);
96
- const sharedTag = tagsB.some((t) => tagsSetA.has(t));
97
- if (!sharedTag) continue;
98
-
99
- const dateA = new Date(a.updated_at || a.created_at);
100
- const dateB = new Date(b.updated_at || b.created_at);
101
- if (isNaN(dateA.getTime()) || isNaN(dateB.getTime())) continue;
102
-
103
- const diffDays = Math.abs(dateA - dateB) / 86400000;
104
- if (diffDays <= STALE_DUPLICATE_DAYS) continue;
105
-
106
- const [older, newer] = dateA < dateB ? [a, b] : [b, a];
107
- conflicts.push({
108
- entry_a_id: older.id,
109
- entry_b_id: newer.id,
110
- reason: "stale_duplicate",
111
- recommendation: `Verify \`${older.id}\` is still accurate — it shares kind "${older.kind}" and tags with \`${newer.id}\` but was last updated ${Math.round(diffDays)} days earlier.`,
112
- });
113
- }
114
- }
115
-
116
- return conflicts;
117
- }
118
-
119
- /**
120
- * Detect tag clusters that would benefit from consolidation via create_snapshot.
121
- * A suggestion is emitted when a tag appears on threshold+ entries in the full
122
- * vault AND no recent brief (kind='brief') exists for that tag within the
123
- * staleness window.
124
- *
125
- * Tag counts are derived from the full vault (not just the search result set)
126
- * so the check reflects the true size of the knowledge cluster. Only tags that
127
- * appear in the current search results are evaluated — this keeps the check
128
- * targeted to what the user is actually working with.
129
- *
130
- * @param {Array} entries - Search result rows (used to select candidate tags)
131
- * @param {import('node:sqlite').DatabaseSync} db - Database handle for vault-wide counts and brief lookups
132
- * @param {number|undefined} userId - Optional user_id scope
133
- * @param {{ tagThreshold?: number, maxAgeDays?: number }} opts - Configurable thresholds
134
- * @returns {Array<{tag: string, entry_count: number, last_snapshot_age_days: number|null}>}
135
- */
136
- export function detectConsolidationHints(entries, db, userId, opts = {}) {
137
- const tagThreshold = opts.tagThreshold ?? CONSOLIDATION_TAG_THRESHOLD;
138
- const maxAgeDays = opts.maxAgeDays ?? CONSOLIDATION_SNAPSHOT_MAX_AGE_DAYS;
139
-
140
- const candidateTags = new Set();
141
- for (const entry of entries) {
142
- if (entry.kind === "brief") continue;
143
- const entryTags = entry.tags ? JSON.parse(entry.tags) : [];
144
- for (const tag of entryTags) candidateTags.add(tag);
145
- }
146
-
147
- if (candidateTags.size === 0) return [];
148
-
149
- const suggestions = [];
150
- const cutoff = new Date(Date.now() - maxAgeDays * 86400000).toISOString();
151
-
152
- for (const tag of candidateTags) {
153
- let vaultCount = 0;
154
- try {
155
- const userClause =
156
- userId !== undefined ? " AND user_id = ?" : " AND user_id IS NULL";
157
- const countParams =
158
- userId !== undefined ? [`%"${tag}"%`, userId] : [`%"${tag}"%`];
159
- const countRow = db
160
- .prepare(
161
- `SELECT COUNT(*) as c FROM vault WHERE kind != 'brief' AND tags LIKE ?${userClause} AND (expires_at IS NULL OR expires_at > datetime('now')) AND superseded_by IS NULL`,
162
- )
163
- .get(...countParams);
164
- vaultCount = countRow?.c ?? 0;
165
- } catch {
166
- continue;
167
- }
168
-
169
- if (vaultCount < tagThreshold) continue;
170
-
171
- let lastSnapshotAgeDays = null;
172
- try {
173
- const userClause =
174
- userId !== undefined ? " AND user_id = ?" : " AND user_id IS NULL";
175
- const params =
176
- userId !== undefined ? [`%"${tag}"%`, userId] : [`%"${tag}"%`];
177
- const recentBrief = db
178
- .prepare(
179
- `SELECT created_at FROM vault WHERE kind = 'brief' AND tags LIKE ?${userClause} ORDER BY created_at DESC LIMIT 1`,
180
- )
181
- .get(...params);
182
-
183
- if (recentBrief) {
184
- lastSnapshotAgeDays = Math.round(
185
- (Date.now() - new Date(recentBrief.created_at).getTime()) / 86400000,
186
- );
187
- if (recentBrief.created_at >= cutoff) continue;
188
- }
189
- } catch {
190
- continue;
191
- }
192
-
193
- suggestions.push({
194
- tag,
195
- entry_count: vaultCount,
196
- last_snapshot_age_days: lastSnapshotAgeDays,
197
- });
198
- }
199
-
200
- return suggestions;
201
- }
202
-
203
- /**
204
- * Check if an entry's source files have changed since the entry was saved.
205
- * Returns { stale: true, stale_reason } if stale, or null if fresh.
206
- * Best-effort: any read/parse failure returns null (no crash).
207
- *
208
- * @param {object} entry - DB row with source_files JSON column
209
- * @returns {{ stale: boolean, stale_reason: string } | null}
210
- */
211
- function checkStaleness(entry) {
212
- if (!entry.source_files) return null;
213
- let sourceFiles;
214
- try {
215
- sourceFiles = JSON.parse(entry.source_files);
216
- } catch {
217
- return null;
218
- }
219
- if (!Array.isArray(sourceFiles) || sourceFiles.length === 0) return null;
220
-
221
- for (const sf of sourceFiles) {
222
- try {
223
- const absPath = sf.path.startsWith("/")
224
- ? sf.path
225
- : resolve(process.cwd(), sf.path);
226
- if (!existsSync(absPath)) {
227
- return { stale: true, stale_reason: "source file not found" };
228
- }
229
- const contents = readFileSync(absPath);
230
- const currentHash = createHash("sha256").update(contents).digest("hex");
231
- if (currentHash !== sf.hash) {
232
- return {
233
- stale: true,
234
- stale_reason: "source file modified since observation",
235
- };
236
- }
237
- } catch {
238
- // skip this file on any error — best-effort
239
- }
240
- }
241
- return null;
242
- }
243
-
244
- export const name = "get_context";
245
-
246
- export const description =
247
- "Search your knowledge vault. Returns entries ranked by relevance using hybrid full-text + semantic search. Use this to find insights, decisions, patterns, or any saved context. Each result includes an `id` you can use with save_context or delete_context.";
248
-
249
- export const inputSchema = {
250
- query: z
251
- .string()
252
- .optional()
253
- .describe(
254
- "Search query (natural language or keywords). Optional if filters (tags, kind, category) are provided.",
255
- ),
256
- kind: z
257
- .string()
258
- .optional()
259
- .describe("Filter by kind (e.g. 'insight', 'decision', 'pattern')"),
260
- category: z
261
- .enum(["knowledge", "entity", "event"])
262
- .optional()
263
- .describe("Filter by category"),
264
- identity_key: z
265
- .string()
266
- .optional()
267
- .describe("For entity lookup: exact match on identity key. Requires kind."),
268
- tags: z
269
- .array(z.string())
270
- .optional()
271
- .describe(
272
- "Filter by tags (entries must match at least one). Use 'bucket:' prefixed tags for project-scoped retrieval (e.g., ['bucket:autohub']).",
273
- ),
274
- buckets: z
275
- .array(z.string())
276
- .optional()
277
- .describe(
278
- "Filter by project-scoped buckets. Each name expands to a 'bucket:<name>' tag. Composes with 'tags' via OR (entries matching any tag or any bucket are included).",
279
- ),
280
- since: z
281
- .string()
282
- .optional()
283
- .describe("ISO date, return entries created after this"),
284
- until: z
285
- .string()
286
- .optional()
287
- .describe("ISO date, return entries created before this"),
288
- limit: z.number().optional().describe("Max results to return (default 10)"),
289
- include_superseded: z
290
- .boolean()
291
- .optional()
292
- .describe(
293
- "If true, include entries that have been superseded by newer ones. Default: false.",
294
- ),
295
- detect_conflicts: z
296
- .boolean()
297
- .optional()
298
- .describe(
299
- "If true, compare results for contradicting entries and append a conflicts array. Flags superseded entries still in results and stale duplicates (same kind+tags, updated_at >7 days apart). No LLM calls — pure DB logic.",
300
- ),
301
- max_tokens: z
302
- .number()
303
- .optional()
304
- .describe(
305
- "Limit output to entries that fit within this token budget (rough estimate: 1 token ≈ 4 chars). Entries are packed greedily by relevance rank. At least 1 result is always returned. Response metadata includes tokens_used and tokens_budget.",
306
- ),
307
- pivot_count: z
308
- .number()
309
- .optional()
310
- .describe(
311
- "Skeleton mode: top pivot_count entries by relevance are returned with full body. Remaining entries are returned as skeletons (title + tags + first ~100 chars of body). Default: 2. Set to 0 to skeleton all results, or a high number to disable.",
312
- ),
313
- include_ephemeral: z
314
- .boolean()
315
- .optional()
316
- .describe(
317
- "If true, include ephemeral tier entries in results. Default: false — only working and durable tiers are returned.",
318
- ),
319
- include_events: z
320
- .boolean()
321
- .optional()
322
- .describe(
323
- "If true, include event category entries in semantic search results. Default: false — events are excluded from query-based search but remain accessible via category/tag filters.",
324
- ),
325
- };
326
-
327
- /**
328
- * @param {object} args
329
- * @param {import('../types.js').BaseCtx & Partial<import('../types.js').HostedCtxExtensions>} ctx
330
- * @param {import('../types.js').ToolShared} shared
331
- */
332
- export async function handler(
333
- {
334
- query,
335
- kind,
336
- category,
337
- identity_key,
338
- tags,
339
- buckets,
340
- since,
341
- until,
342
- limit,
343
- include_superseded,
344
- detect_conflicts,
345
- max_tokens,
346
- pivot_count,
347
- include_ephemeral,
348
- include_events,
349
- },
350
- ctx,
351
- { ensureIndexed, reindexFailed },
352
- ) {
353
- const { config } = ctx;
354
- const userId = ctx.userId !== undefined ? ctx.userId : undefined;
355
-
356
- const hasQuery = query?.trim();
357
- const shouldExcludeEvents = hasQuery && !include_events && !category;
358
- // Expand buckets to bucket: prefixed tags and merge with explicit tags
359
- const bucketTags = buckets?.length ? buckets.map((b) => `bucket:${b}`) : [];
360
- const effectiveTags = [...(tags ?? []), ...bucketTags];
361
- const hasFilters =
362
- kind || category || effectiveTags.length || since || until || identity_key;
363
- if (!hasQuery && !hasFilters)
364
- return err(
365
- "Required: query or at least one filter (kind, category, tags, since, until, identity_key)",
366
- "INVALID_INPUT",
367
- );
368
- await ensureIndexed();
369
-
370
- const kindFilter = kind ? normalizeKind(kind) : null;
371
-
372
- // Gap 1: Entity exact-match by identity_key
373
- if (identity_key) {
374
- if (!kindFilter)
375
- return err("identity_key requires kind to be specified", "INVALID_INPUT");
376
- const match = ctx.stmts.getByIdentityKey.get(
377
- kindFilter,
378
- identity_key,
379
- userId !== undefined ? userId : null,
380
- );
381
- if (match) {
382
- const entryTags = match.tags ? JSON.parse(match.tags) : [];
383
- const tagStr = entryTags.length ? entryTags.join(", ") : "none";
384
- const relPath =
385
- match.file_path && config.vaultDir
386
- ? match.file_path.replace(config.vaultDir + "/", "")
387
- : match.file_path || "n/a";
388
- const lines = [
389
- `## Entity Match (exact)\n`,
390
- `### ${match.title || "(untitled)"} [${match.kind}/${match.category}]`,
391
- `1.000 · ${tagStr} · ${relPath} · id: \`${match.id}\``,
392
- match.body?.slice(0, 300) + (match.body?.length > 300 ? "..." : ""),
393
- ];
394
- return ok(lines.join("\n"));
395
- }
396
- // Fall through to semantic search as fallback
397
- }
398
-
399
- // Gap 2: Event default time-window
400
- const effectiveCategory =
401
- category || (kindFilter ? categoryFor(kindFilter) : null);
402
- let effectiveSince = since || null;
403
- let effectiveUntil = until || null;
404
- let autoWindowed = false;
405
- if (effectiveCategory === "event" && !since && !until) {
406
- const decayMs = (config.eventDecayDays || 30) * 86400000;
407
- effectiveSince = new Date(Date.now() - decayMs).toISOString();
408
- autoWindowed = true;
409
- }
410
-
411
- const effectiveLimit = limit || 10;
412
- // When tag-filtering, over-fetch to compensate for post-filter reduction
413
- const MAX_FETCH_LIMIT = 500;
414
- const fetchLimit = effectiveTags.length
415
- ? Math.min(effectiveLimit * 10, MAX_FETCH_LIMIT)
416
- : effectiveLimit;
417
-
418
- let filtered;
419
- if (hasQuery) {
420
- // Hybrid search mode
421
- const sorted = await hybridSearch(ctx, query, {
422
- kindFilter,
423
- categoryFilter: category || null,
424
- excludeEvents: shouldExcludeEvents,
425
- since: effectiveSince,
426
- until: effectiveUntil,
427
- limit: fetchLimit,
428
- decayDays: config.eventDecayDays || 30,
429
- userIdFilter: userId,
430
- includeSuperseeded: include_superseded ?? false,
431
- });
432
-
433
- // Post-filter by tags if provided, then apply requested limit
434
- filtered = effectiveTags.length
435
- ? sorted
436
- .filter((r) => {
437
- const entryTags = r.tags ? JSON.parse(r.tags) : [];
438
- return effectiveTags.some((t) => entryTags.includes(t));
439
- })
440
- .slice(0, effectiveLimit)
441
- : sorted;
442
- } else {
443
- // Filter-only mode (no query, use SQL directly)
444
- const clauses = [];
445
- const params = [];
446
- if (userId !== undefined) {
447
- clauses.push("user_id = ?");
448
- params.push(userId);
449
- }
450
- if (kindFilter) {
451
- clauses.push("kind = ?");
452
- params.push(kindFilter);
453
- }
454
- if (category) {
455
- clauses.push("category = ?");
456
- params.push(category);
457
- }
458
- if (effectiveSince) {
459
- clauses.push("created_at >= ?");
460
- params.push(effectiveSince);
461
- }
462
- if (effectiveUntil) {
463
- clauses.push("created_at <= ?");
464
- params.push(effectiveUntil);
465
- }
466
- clauses.push("(expires_at IS NULL OR expires_at > datetime('now'))");
467
- if (!include_superseded) {
468
- clauses.push("superseded_by IS NULL");
469
- }
470
- const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
471
- params.push(fetchLimit);
472
- const rows = ctx.db
473
- .prepare(`SELECT * FROM vault ${where} ORDER BY created_at DESC LIMIT ?`)
474
- .all(...params);
475
-
476
- // Post-filter by tags if provided, then apply requested limit
477
- filtered = effectiveTags.length
478
- ? rows
479
- .filter((r) => {
480
- const entryTags = r.tags ? JSON.parse(r.tags) : [];
481
- return effectiveTags.some((t) => entryTags.includes(t));
482
- })
483
- .slice(0, effectiveLimit)
484
- : rows;
485
-
486
- // Add score field for consistent output
487
- for (const r of filtered) r.score = 0;
488
- }
489
-
490
- // Brief score boost: briefs rank slightly higher so consolidated snapshots
491
- // surface above the individual entries they summarize.
492
- for (const r of filtered) {
493
- if (r.kind === "brief") r.score = (r.score || 0) + BRIEF_SCORE_BOOST;
494
- }
495
- filtered.sort((a, b) => b.score - a.score);
496
-
497
- // Tier filter: exclude ephemeral entries by default (NULL tier treated as working)
498
- if (!include_ephemeral) {
499
- filtered = filtered.filter((r) => r.tier !== "ephemeral");
500
- }
501
-
502
- // Event category filter: exclude events from semantic search by default
503
- if (shouldExcludeEvents) {
504
- filtered = filtered.filter((r) => r.category !== "event");
505
- }
506
-
507
- if (!filtered.length) {
508
- if (autoWindowed) {
509
- const days = config.eventDecayDays || 30;
510
- return ok(
511
- hasQuery
512
- ? `No results found for "${query}" in events (last ${days} days).\nTry with \`since: "YYYY-MM-DD"\` to search older events.`
513
- : `No entries found matching the given filters in events (last ${days} days).\nTry with \`since: "YYYY-MM-DD"\` to search older events.`,
514
- );
515
- }
516
- return ok(
517
- hasQuery
518
- ? "No results found for: " + query
519
- : "No entries found matching the given filters.",
520
- );
521
- }
522
-
523
- // Decrypt encrypted entries if ctx.decrypt is available
524
- if (ctx.decrypt) {
525
- for (const r of filtered) {
526
- if (r.body_encrypted) {
527
- const decrypted = await ctx.decrypt(r);
528
- r.body = decrypted.body;
529
- if (decrypted.title) r.title = decrypted.title;
530
- if (decrypted.meta) r.meta = JSON.stringify(decrypted.meta);
531
- }
532
- }
533
- }
534
-
535
- // Token-budgeted packing
536
- let tokensBudget = null;
537
- let tokensUsed = null;
538
- if (max_tokens != null && max_tokens > 0) {
539
- tokensBudget = max_tokens;
540
- const packed = [];
541
- let used = 0;
542
- for (const entry of filtered) {
543
- const entryTokens = Math.ceil((entry.body?.length || 0) / 4);
544
- if (packed.length === 0 || used + entryTokens <= tokensBudget) {
545
- packed.push(entry);
546
- used += entryTokens;
547
- }
548
- if (used >= tokensBudget) break;
549
- }
550
- tokensUsed = used;
551
- filtered = packed;
552
- }
553
-
554
- // Skeleton mode: determine pivot threshold
555
- const effectivePivot =
556
- pivot_count != null ? pivot_count : DEFAULT_PIVOT_COUNT;
557
-
558
- // Conflict detection
559
- const conflicts = detect_conflicts ? detectConflicts(filtered, ctx) : [];
560
-
561
- const lines = [];
562
- if (reindexFailed)
563
- lines.push(
564
- `> **Warning:** Auto-reindex failed. Results may be stale. Run \`context-vault reindex\` to fix.\n`,
565
- );
566
- if (hasQuery && isEmbedAvailable() === false)
567
- lines.push(
568
- `> **Note:** Semantic search unavailable — results ranked by keyword match only. Run \`context-vault setup\` to download the embedding model.\n`,
569
- );
570
- const heading = hasQuery ? `Results for "${query}"` : "Filtered entries";
571
- lines.push(`## ${heading} (${filtered.length} matches)\n`);
572
- if (tokensBudget != null) {
573
- lines.push(
574
- `> Token budget: ${tokensUsed} / ${tokensBudget} tokens used.\n`,
575
- );
576
- }
577
- if (autoWindowed) {
578
- const days = config.eventDecayDays || 30;
579
- lines.push(
580
- `> ℹ Event search limited to last ${days} days. Use \`since\` parameter for older results.\n`,
581
- );
582
- }
583
- for (let i = 0; i < filtered.length; i++) {
584
- const r = filtered[i];
585
- const isSkeleton = i >= effectivePivot;
586
- const entryTags = r.tags ? JSON.parse(r.tags) : [];
587
- const tagStr = entryTags.length ? entryTags.join(", ") : "none";
588
- const relPath =
589
- r.file_path && config.vaultDir
590
- ? r.file_path.replace(config.vaultDir + "/", "")
591
- : r.file_path || "n/a";
592
- const skeletonLabel = isSkeleton ? " ⊘ skeleton" : "";
593
- lines.push(
594
- `### [${i + 1}/${filtered.length}] ${r.title || "(untitled)"} [${r.kind}/${r.category}]${skeletonLabel}`,
595
- );
596
- const dateStr =
597
- r.updated_at && r.updated_at !== r.created_at
598
- ? `${r.created_at} (updated ${r.updated_at})`
599
- : r.created_at || "";
600
- const tierStr = r.tier ? ` · tier: ${r.tier}` : "";
601
- lines.push(
602
- `${r.score.toFixed(3)} · ${tagStr} · ${relPath} · ${dateStr} · skeleton: ${isSkeleton}${tierStr} · id: \`${r.id}\``,
603
- );
604
- const stalenessResult = checkStaleness(r);
605
- if (stalenessResult) {
606
- r.stale = true;
607
- r.stale_reason = stalenessResult.stale_reason;
608
- lines.push(`> ⚠ **Stale**: ${stalenessResult.stale_reason}`);
609
- }
610
- if (isSkeleton) {
611
- lines.push(skeletonBody(r.body));
612
- } else {
613
- lines.push(r.body?.slice(0, 300) + (r.body?.length > 300 ? "..." : ""));
614
- }
615
- lines.push("");
616
- }
617
-
618
- if (detect_conflicts) {
619
- if (conflicts.length === 0) {
620
- lines.push(
621
- `## Conflict Detection\n\nNo conflicts detected among results.\n`,
622
- );
623
- } else {
624
- lines.push(`## Conflict Detection (${conflicts.length} flagged)\n`);
625
- for (const c of conflicts) {
626
- lines.push(
627
- `- **${c.reason}**: \`${c.entry_a_id}\` vs \`${c.entry_b_id}\``,
628
- );
629
- lines.push(` Recommendation: ${c.recommendation}`);
630
- }
631
- lines.push("");
632
- }
633
- }
634
-
635
- // Consolidation suggestion detection — lazy, opportunistic, vault-wide
636
- const consolidationOpts = {
637
- tagThreshold:
638
- config.consolidation?.tagThreshold ?? CONSOLIDATION_TAG_THRESHOLD,
639
- maxAgeDays:
640
- config.consolidation?.maxAgeDays ?? CONSOLIDATION_SNAPSHOT_MAX_AGE_DAYS,
641
- };
642
- const consolidationSuggestions = detectConsolidationHints(
643
- filtered,
644
- ctx.db,
645
- userId,
646
- consolidationOpts,
647
- );
648
-
649
- // Auto-consolidate: fire-and-forget create_snapshot for eligible tags
650
- if (
651
- config.consolidation?.autoConsolidate &&
652
- consolidationSuggestions.length > 0
653
- ) {
654
- const { handler: snapshotHandler } = await import("./create-snapshot.js");
655
- for (const suggestion of consolidationSuggestions) {
656
- snapshotHandler({ topic: suggestion.tag, tags: [suggestion.tag] }, ctx, {
657
- ensureIndexed: async () => {},
658
- }).catch(() => {});
659
- }
660
- }
661
-
662
- const result = ok(lines.join("\n"));
663
- const meta = {};
664
- if (tokensBudget != null) {
665
- meta.tokens_used = tokensUsed;
666
- meta.tokens_budget = tokensBudget;
667
- }
668
- if (buckets?.length) {
669
- meta.buckets = buckets;
670
- }
671
- if (consolidationSuggestions.length > 0) {
672
- meta.consolidation_suggestions = consolidationSuggestions;
673
- }
674
- if (Object.keys(meta).length > 0) {
675
- result._meta = meta;
676
- }
677
- return result;
678
- }