context-vault 2.6.1 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@context-vault/core",
3
- "version": "2.6.1",
3
+ "version": "2.7.0",
4
4
  "type": "module",
5
5
  "description": "Shared core: capture, index, retrieve, tools, and utilities for context-vault",
6
6
  "main": "src/index.js",
@@ -10,7 +10,7 @@ import { isEmbedAvailable } from "../index/embed.js";
10
10
  /**
11
11
  * Gather raw vault status data for formatting by consumers.
12
12
  *
13
- * @param {{ db, config }} ctx
13
+ * @param {import('../server/types.js').BaseCtx} ctx
14
14
  * @param {{ userId?: string }} opts — optional userId for per-user stats
15
15
  * @returns {{ fileCount, subdirs, kindCounts, dbSize, stalePaths, resolvedFrom, embeddingStatus, errors }}
16
16
  */
@@ -25,8 +25,8 @@ const EMBED_BATCH_SIZE = 32;
25
25
  *
26
26
  * For entities with identity_key: uses upsertByIdentityKey if existing row found.
27
27
  *
28
- * @param {{ db, stmts, embed, insertVec, deleteVec }} ctx
29
- * @param {{ id, kind, category, title, body, meta, tags, source, filePath, createdAt, identity_key, expires_at }} entry
28
+ * @param {import('../server/types.js').BaseCtx & Partial<import('../server/types.js').HostedCtxExtensions>} ctx
29
+ * @param {{ id, kind, category, title, body, meta, tags, source, filePath, createdAt, identity_key, expires_at, userId }} entry
30
30
  */
31
31
  export async function indexEntry(ctx, { id, kind, category, title, body, meta, tags, source, filePath, createdAt, identity_key, expires_at, userId }) {
32
32
  const tagsJson = tags ? JSON.stringify(tags) : null;
@@ -108,7 +108,7 @@ export async function indexEntry(ctx, { id, kind, category, title, body, meta, t
108
108
  * P3: Detects title/tag/meta changes, not just body.
109
109
  * P4: Batches embedding calls for performance.
110
110
  *
111
- * @param {{ db, config, stmts, embed, insertVec, deleteVec }} ctx
111
+ * @param {import('../server/types.js').BaseCtx} ctx
112
112
  * @param {{ fullSync?: boolean }} opts — fullSync=true adds/updates/deletes; false=add-only
113
113
  * @returns {Promise<{added: number, updated: number, removed: number, unchanged: number}>}
114
114
  */
@@ -68,7 +68,7 @@ export function buildFilterClauses({ categoryFilter, since, until, userIdFilter,
68
68
  /**
69
69
  * Hybrid search combining FTS5 text matching and vector similarity.
70
70
  *
71
- * @param {{ db, embed }} ctx
71
+ * @param {import('../server/types.js').BaseCtx} ctx
72
72
  * @param {string} query
73
73
  * @param {{ kindFilter?: string|null, categoryFilter?: string|null, since?: string|null, until?: string|null, limit?: number, offset?: number }} opts
74
74
  * @returns {Promise<Array<{id, kind, category, title, body, meta, tags, source, file_path, created_at, score}>>}
@@ -0,0 +1,90 @@
1
+ import { gatherVaultStatus } from "../../core/status.js";
2
+ import { ok } from "../helpers.js";
3
+
4
+ export const name = "context_status";
5
+
6
+ export const description =
7
+ "Show vault health: resolved config, file counts per kind, database size, and any issues. Use to verify setup or troubleshoot. Call this when a user asks about their vault or to debug search issues.";
8
+
9
+ export const inputSchema = {};
10
+
11
+ /**
12
+ * @param {object} _args
13
+ * @param {import('../types.js').BaseCtx & Partial<import('../types.js').HostedCtxExtensions>} ctx
14
+ */
15
+ export function handler(_args, ctx) {
16
+ const { config } = ctx;
17
+ const userId = ctx.userId !== undefined ? ctx.userId : undefined;
18
+
19
+ const status = gatherVaultStatus(ctx, { userId });
20
+
21
+ const hasIssues = status.stalePaths || (status.embeddingStatus?.missing > 0);
22
+ const healthIcon = hasIssues ? "⚠" : "✓";
23
+
24
+ const lines = [
25
+ `## ${healthIcon} Vault Status (connected)`,
26
+ ``,
27
+ `Vault: ${config.vaultDir} (${config.vaultDirExists ? status.fileCount + " files" : "missing"})`,
28
+ `Database: ${config.dbPath} (${status.dbSize})`,
29
+ `Dev dir: ${config.devDir}`,
30
+ `Data dir: ${config.dataDir}`,
31
+ `Config: ${config.configPath}`,
32
+ `Resolved via: ${status.resolvedFrom}`,
33
+ `Schema: v7 (teams)`,
34
+ ];
35
+
36
+ if (status.embeddingStatus) {
37
+ const { indexed, total, missing } = status.embeddingStatus;
38
+ const pct = total > 0 ? Math.round((indexed / total) * 100) : 100;
39
+ lines.push(`Embeddings: ${indexed}/${total} (${pct}%)`);
40
+ }
41
+ if (status.embedModelAvailable === false) {
42
+ lines.push(`Embed model: unavailable (semantic search disabled, FTS still works)`);
43
+ } else if (status.embedModelAvailable === true) {
44
+ lines.push(`Embed model: loaded`);
45
+ }
46
+ lines.push(`Decay: ${config.eventDecayDays} days (event recency window)`);
47
+ if (status.expiredCount > 0) {
48
+ lines.push(`Expired: ${status.expiredCount} entries (pruned on next reindex)`);
49
+ }
50
+
51
+ lines.push(``, `### Indexed`);
52
+
53
+ if (status.kindCounts.length) {
54
+ for (const { kind, c } of status.kindCounts) lines.push(`- ${c} ${kind}s`);
55
+ } else {
56
+ lines.push(`- (empty)`);
57
+ }
58
+
59
+ if (status.categoryCounts.length) {
60
+ lines.push(``);
61
+ lines.push(`### Categories`);
62
+ for (const { category, c } of status.categoryCounts) lines.push(`- ${category}: ${c}`);
63
+ }
64
+
65
+ if (status.subdirs.length) {
66
+ lines.push(``);
67
+ lines.push(`### Disk Directories`);
68
+ for (const { name, count } of status.subdirs) lines.push(`- ${name}/: ${count} files`);
69
+ }
70
+
71
+ if (status.stalePaths) {
72
+ lines.push(``);
73
+ lines.push(`### ⚠ Stale Paths`);
74
+ lines.push(`DB contains ${status.staleCount} paths not matching current vault dir.`);
75
+ lines.push(`Auto-reindex will fix this on next search or save.`);
76
+ }
77
+
78
+ // Suggested actions
79
+ const actions = [];
80
+ if (status.stalePaths) actions.push("- Run `context-vault reindex` to fix stale paths");
81
+ if (status.embeddingStatus?.missing > 0) actions.push("- Run `context-vault reindex` to generate missing embeddings");
82
+ if (!config.vaultDirExists) actions.push("- Run `context-vault setup` to create the vault directory");
83
+ if (status.kindCounts.length === 0 && config.vaultDirExists) actions.push("- Use `save_context` to add your first entry");
84
+
85
+ if (actions.length) {
86
+ lines.push("", "### Suggested Actions", ...actions);
87
+ }
88
+
89
+ return ok(lines.join("\n"));
90
+ }
@@ -0,0 +1,48 @@
1
+ import { z } from "zod";
2
+ import { unlinkSync } from "node:fs";
3
+ import { ok, err } from "../helpers.js";
4
+
5
+ export const name = "delete_context";
6
+
7
+ export const description =
8
+ "Delete an entry from your vault by its ULID id. Removes the file from disk and cleans up the search index.";
9
+
10
+ export const inputSchema = {
11
+ id: z.string().describe("The entry ULID to delete"),
12
+ };
13
+
14
+ /**
15
+ * @param {object} args
16
+ * @param {import('../types.js').BaseCtx & Partial<import('../types.js').HostedCtxExtensions>} ctx
17
+ * @param {import('../types.js').ToolShared} shared
18
+ */
19
+ export async function handler({ id }, ctx, { ensureIndexed }) {
20
+ const userId = ctx.userId !== undefined ? ctx.userId : undefined;
21
+
22
+ if (!id?.trim()) return err("Required: id (non-empty string)", "INVALID_INPUT");
23
+ await ensureIndexed();
24
+
25
+ const entry = ctx.stmts.getEntryById.get(id);
26
+ if (!entry) return err(`Entry not found: ${id}`, "NOT_FOUND");
27
+
28
+ // Ownership check: don't leak existence across users
29
+ if (userId !== undefined && entry.user_id !== userId) {
30
+ return err(`Entry not found: ${id}`, "NOT_FOUND");
31
+ }
32
+
33
+ // Delete file from disk first (source of truth)
34
+ if (entry.file_path) {
35
+ try { unlinkSync(entry.file_path); } catch {}
36
+ }
37
+
38
+ // Delete vector embedding
39
+ const rowidResult = ctx.stmts.getRowid.get(id);
40
+ if (rowidResult?.rowid) {
41
+ try { ctx.deleteVec(Number(rowidResult.rowid)); } catch {}
42
+ }
43
+
44
+ // Delete DB row (FTS trigger handles FTS cleanup)
45
+ ctx.stmts.deleteEntry.run(id);
46
+
47
+ return ok(`Deleted ${entry.kind}: ${entry.title || "(untitled)"} [${id}]`);
48
+ }
@@ -0,0 +1,153 @@
1
+ import { z } from "zod";
2
+ import { hybridSearch } from "../../retrieve/index.js";
3
+ import { categoryFor } from "../../core/categories.js";
4
+ import { normalizeKind } from "../../core/files.js";
5
+ import { ok, err } from "../helpers.js";
6
+ import { isEmbedAvailable } from "../../index/embed.js";
7
+
8
+ export const name = "get_context";
9
+
10
+ export const description =
11
+ "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.";
12
+
13
+ export const inputSchema = {
14
+ query: z.string().optional().describe("Search query (natural language or keywords). Optional if filters (tags, kind, category) are provided."),
15
+ kind: z.string().optional().describe("Filter by kind (e.g. 'insight', 'decision', 'pattern')"),
16
+ category: z.enum(["knowledge", "entity", "event"]).optional().describe("Filter by category"),
17
+ identity_key: z.string().optional().describe("For entity lookup: exact match on identity key. Requires kind."),
18
+ tags: z.array(z.string()).optional().describe("Filter by tags (entries must match at least one)"),
19
+ since: z.string().optional().describe("ISO date, return entries created after this"),
20
+ until: z.string().optional().describe("ISO date, return entries created before this"),
21
+ limit: z.number().optional().describe("Max results to return (default 10)"),
22
+ };
23
+
24
+ /**
25
+ * @param {object} args
26
+ * @param {import('../types.js').BaseCtx & Partial<import('../types.js').HostedCtxExtensions>} ctx
27
+ * @param {import('../types.js').ToolShared} shared
28
+ */
29
+ export async function handler({ query, kind, category, identity_key, tags, since, until, limit }, ctx, { ensureIndexed, reindexFailed }) {
30
+ const { config } = ctx;
31
+ const userId = ctx.userId !== undefined ? ctx.userId : undefined;
32
+
33
+ const hasQuery = query?.trim();
34
+ const hasFilters = kind || category || tags?.length || since || until || identity_key;
35
+ if (!hasQuery && !hasFilters) return err("Required: query or at least one filter (kind, category, tags, since, until, identity_key)", "INVALID_INPUT");
36
+ await ensureIndexed();
37
+
38
+ const kindFilter = kind ? normalizeKind(kind) : null;
39
+
40
+ // Gap 1: Entity exact-match by identity_key
41
+ if (identity_key) {
42
+ if (!kindFilter) return err("identity_key requires kind to be specified", "INVALID_INPUT");
43
+ const match = ctx.stmts.getByIdentityKey.get(kindFilter, identity_key, userId !== undefined ? userId : null);
44
+ if (match) {
45
+ const entryTags = match.tags ? JSON.parse(match.tags) : [];
46
+ const tagStr = entryTags.length ? entryTags.join(", ") : "none";
47
+ const relPath = match.file_path && config.vaultDir ? match.file_path.replace(config.vaultDir + "/", "") : match.file_path || "n/a";
48
+ const lines = [
49
+ `## Entity Match (exact)\n`,
50
+ `### ${match.title || "(untitled)"} [${match.kind}/${match.category}]`,
51
+ `1.000 · ${tagStr} · ${relPath} · id: \`${match.id}\``,
52
+ match.body?.slice(0, 300) + (match.body?.length > 300 ? "..." : ""),
53
+ ];
54
+ return ok(lines.join("\n"));
55
+ }
56
+ // Fall through to semantic search as fallback
57
+ }
58
+
59
+ // Gap 2: Event default time-window
60
+ const effectiveCategory = category || (kindFilter ? categoryFor(kindFilter) : null);
61
+ let effectiveSince = since || null;
62
+ let effectiveUntil = until || null;
63
+ let autoWindowed = false;
64
+ if (effectiveCategory === "event" && !since && !until) {
65
+ const decayMs = (config.eventDecayDays || 30) * 86400000;
66
+ effectiveSince = new Date(Date.now() - decayMs).toISOString();
67
+ autoWindowed = true;
68
+ }
69
+
70
+ const effectiveLimit = limit || 10;
71
+ // When tag-filtering, over-fetch to compensate for post-filter reduction
72
+ const fetchLimit = tags?.length ? effectiveLimit * 10 : effectiveLimit;
73
+
74
+ let filtered;
75
+ if (hasQuery) {
76
+ // Hybrid search mode
77
+ const sorted = await hybridSearch(ctx, query, {
78
+ kindFilter,
79
+ categoryFilter: category || null,
80
+ since: effectiveSince,
81
+ until: effectiveUntil,
82
+ limit: fetchLimit,
83
+ decayDays: config.eventDecayDays || 30,
84
+ userIdFilter: userId,
85
+ });
86
+
87
+ // Post-filter by tags if provided, then apply requested limit
88
+ filtered = tags?.length
89
+ ? sorted.filter((r) => {
90
+ const entryTags = r.tags ? JSON.parse(r.tags) : [];
91
+ return tags.some((t) => entryTags.includes(t));
92
+ }).slice(0, effectiveLimit)
93
+ : sorted;
94
+ } else {
95
+ // Filter-only mode (no query, use SQL directly)
96
+ const clauses = [];
97
+ const params = [];
98
+ if (userId !== undefined) { clauses.push("user_id = ?"); params.push(userId); }
99
+ if (kindFilter) { clauses.push("kind = ?"); params.push(kindFilter); }
100
+ if (category) { clauses.push("category = ?"); params.push(category); }
101
+ if (effectiveSince) { clauses.push("created_at >= ?"); params.push(effectiveSince); }
102
+ if (effectiveUntil) { clauses.push("created_at <= ?"); params.push(effectiveUntil); }
103
+ clauses.push("(expires_at IS NULL OR expires_at > datetime('now'))");
104
+ const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
105
+ params.push(fetchLimit);
106
+ const rows = ctx.db.prepare(`SELECT * FROM vault ${where} ORDER BY created_at DESC LIMIT ?`).all(...params);
107
+
108
+ // Post-filter by tags if provided, then apply requested limit
109
+ filtered = tags?.length
110
+ ? rows.filter((r) => {
111
+ const entryTags = r.tags ? JSON.parse(r.tags) : [];
112
+ return tags.some((t) => entryTags.includes(t));
113
+ }).slice(0, effectiveLimit)
114
+ : rows;
115
+
116
+ // Add score field for consistent output
117
+ for (const r of filtered) r.score = 0;
118
+ }
119
+
120
+ if (!filtered.length) return ok(hasQuery ? "No results found for: " + query : "No entries found matching the given filters.");
121
+
122
+ // Decrypt encrypted entries if ctx.decrypt is available
123
+ if (ctx.decrypt) {
124
+ for (const r of filtered) {
125
+ if (r.body_encrypted) {
126
+ const decrypted = await ctx.decrypt(r);
127
+ r.body = decrypted.body;
128
+ if (decrypted.title) r.title = decrypted.title;
129
+ if (decrypted.meta) r.meta = JSON.stringify(decrypted.meta);
130
+ }
131
+ }
132
+ }
133
+
134
+ const lines = [];
135
+ if (reindexFailed) lines.push(`> **Warning:** Auto-reindex failed. Results may be stale. Run \`context-vault reindex\` to fix.\n`);
136
+ if (hasQuery && isEmbedAvailable() === false) lines.push(`> **Note:** Semantic search unavailable — results ranked by keyword match only. Run \`context-vault setup\` to download the embedding model.\n`);
137
+ const heading = hasQuery ? `Results for "${query}"` : "Filtered entries";
138
+ lines.push(`## ${heading} (${filtered.length} matches)\n`);
139
+ for (let i = 0; i < filtered.length; i++) {
140
+ const r = filtered[i];
141
+ const entryTags = r.tags ? JSON.parse(r.tags) : [];
142
+ const tagStr = entryTags.length ? entryTags.join(", ") : "none";
143
+ const relPath = r.file_path && config.vaultDir ? r.file_path.replace(config.vaultDir + "/", "") : r.file_path || "n/a";
144
+ lines.push(`### [${i + 1}/${filtered.length}] ${r.title || "(untitled)"} [${r.kind}/${r.category}]`);
145
+ lines.push(`${r.score.toFixed(3)} · ${tagStr} · ${relPath} · id: \`${r.id}\``);
146
+ lines.push(r.body?.slice(0, 300) + (r.body?.length > 300 ? "..." : ""));
147
+ lines.push("");
148
+ }
149
+ if (autoWindowed) {
150
+ lines.push(`_Showing events from last ${config.eventDecayDays || 30} days. Use since/until for custom range._`);
151
+ }
152
+ return ok(lines.join("\n"));
153
+ }
@@ -0,0 +1,80 @@
1
+ import { z } from "zod";
2
+ import { captureAndIndex } from "../../capture/index.js";
3
+ import { indexEntry } from "../../index/index.js";
4
+ import { ok, err, ensureVaultExists } from "../helpers.js";
5
+
6
+ // ─── Input size limits (mirrors hosted validation) ────────────────────────────
7
+ const MAX_URL_LENGTH = 2048;
8
+ const MAX_KIND_LENGTH = 64;
9
+ const MAX_TAG_LENGTH = 100;
10
+ const MAX_TAGS_COUNT = 20;
11
+
12
+ export const name = "ingest_url";
13
+
14
+ export const description =
15
+ "Fetch a URL, extract its readable content, and save it as a vault entry. Useful for saving articles, documentation, or web pages to your knowledge vault.";
16
+
17
+ export const inputSchema = {
18
+ url: z.string().describe("The URL to fetch and save"),
19
+ kind: z.string().optional().describe("Entry kind (default: reference)"),
20
+ tags: z.array(z.string()).optional().describe("Tags for the entry"),
21
+ };
22
+
23
+ /**
24
+ * @param {object} args
25
+ * @param {import('../types.js').BaseCtx & Partial<import('../types.js').HostedCtxExtensions>} ctx
26
+ * @param {import('../types.js').ToolShared} shared
27
+ */
28
+ export async function handler({ url: targetUrl, kind, tags }, ctx, { ensureIndexed }) {
29
+ const { config } = ctx;
30
+ const userId = ctx.userId !== undefined ? ctx.userId : undefined;
31
+
32
+ const vaultErr = ensureVaultExists(config);
33
+ if (vaultErr) return vaultErr;
34
+
35
+ if (!targetUrl?.trim()) return err("Required: url (non-empty string)", "INVALID_INPUT");
36
+ if (targetUrl.length > MAX_URL_LENGTH) return err(`url must be under ${MAX_URL_LENGTH} chars`, "INVALID_INPUT");
37
+ if (kind !== undefined && kind !== null) {
38
+ if (typeof kind !== "string" || kind.length > MAX_KIND_LENGTH) {
39
+ return err(`kind must be a string, max ${MAX_KIND_LENGTH} chars`, "INVALID_INPUT");
40
+ }
41
+ }
42
+ if (tags !== undefined && tags !== null) {
43
+ if (!Array.isArray(tags)) return err("tags must be an array of strings", "INVALID_INPUT");
44
+ if (tags.length > MAX_TAGS_COUNT) return err(`tags: max ${MAX_TAGS_COUNT} tags allowed`, "INVALID_INPUT");
45
+ for (const tag of tags) {
46
+ if (typeof tag !== "string" || tag.length > MAX_TAG_LENGTH) {
47
+ return err(`each tag must be a string, max ${MAX_TAG_LENGTH} chars`, "INVALID_INPUT");
48
+ }
49
+ }
50
+ }
51
+
52
+ await ensureIndexed();
53
+
54
+ // Hosted tier limit enforcement
55
+ if (ctx.checkLimits) {
56
+ const usage = ctx.checkLimits();
57
+ if (usage.entryCount >= usage.maxEntries) {
58
+ return err(`Entry limit reached (${usage.maxEntries}). Upgrade to Pro for unlimited entries.`, "LIMIT_EXCEEDED");
59
+ }
60
+ }
61
+
62
+ try {
63
+ const { ingestUrl } = await import("../../capture/ingest-url.js");
64
+ const entryData = await ingestUrl(targetUrl, { kind, tags });
65
+ const entry = await captureAndIndex(ctx, { ...entryData, userId }, indexEntry);
66
+ const relPath = entry.filePath ? entry.filePath.replace(config.vaultDir + "/", "") : entry.filePath;
67
+ const parts = [
68
+ `✓ Ingested URL → ${relPath}`,
69
+ ` id: ${entry.id}`,
70
+ ` title: ${entry.title || "(untitled)"}`,
71
+ ` source: ${entry.source || targetUrl}`,
72
+ ];
73
+ if (entry.tags?.length) parts.push(` tags: ${entry.tags.join(", ")}`);
74
+ parts.push(` body: ${entry.body?.length || 0} chars`);
75
+ parts.push("", "_Use this id to update or delete later._");
76
+ return ok(parts.join("\n"));
77
+ } catch (e) {
78
+ return err(`Failed to ingest URL: ${e.message}`, "INGEST_FAILED");
79
+ }
80
+ }
@@ -0,0 +1,93 @@
1
+ import { z } from "zod";
2
+ import { normalizeKind } from "../../core/files.js";
3
+ import { ok } from "../helpers.js";
4
+
5
+ export const name = "list_context";
6
+
7
+ export const description =
8
+ "Browse vault entries without a search query. Returns id, title, kind, category, tags, created_at. Use get_context with a query for semantic search. Use this to browse by tags or find recent entries.";
9
+
10
+ export const inputSchema = {
11
+ kind: z.string().optional().describe("Filter by kind (e.g. 'insight', 'decision', 'pattern')"),
12
+ category: z.enum(["knowledge", "entity", "event"]).optional().describe("Filter by category"),
13
+ tags: z.array(z.string()).optional().describe("Filter by tags (entries must match at least one)"),
14
+ since: z.string().optional().describe("ISO date, return entries created after this"),
15
+ until: z.string().optional().describe("ISO date, return entries created before this"),
16
+ limit: z.number().optional().describe("Max results to return (default 20, max 100)"),
17
+ offset: z.number().optional().describe("Skip first N results for pagination"),
18
+ };
19
+
20
+ /**
21
+ * @param {object} args
22
+ * @param {import('../types.js').BaseCtx & Partial<import('../types.js').HostedCtxExtensions>} ctx
23
+ * @param {import('../types.js').ToolShared} shared
24
+ */
25
+ export async function handler({ kind, category, tags, since, until, limit, offset }, ctx, { ensureIndexed, reindexFailed }) {
26
+ const { config } = ctx;
27
+ const userId = ctx.userId !== undefined ? ctx.userId : undefined;
28
+
29
+ await ensureIndexed();
30
+
31
+ const clauses = [];
32
+ const params = [];
33
+
34
+ if (userId !== undefined) {
35
+ clauses.push("user_id = ?");
36
+ params.push(userId);
37
+ }
38
+ if (kind) {
39
+ clauses.push("kind = ?");
40
+ params.push(normalizeKind(kind));
41
+ }
42
+ if (category) {
43
+ clauses.push("category = ?");
44
+ params.push(category);
45
+ }
46
+ if (since) {
47
+ clauses.push("created_at >= ?");
48
+ params.push(since);
49
+ }
50
+ if (until) {
51
+ clauses.push("created_at <= ?");
52
+ params.push(until);
53
+ }
54
+ clauses.push("(expires_at IS NULL OR expires_at > datetime('now'))");
55
+
56
+ const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
57
+ const effectiveLimit = Math.min(limit || 20, 100);
58
+ const effectiveOffset = offset || 0;
59
+ // When tag-filtering, over-fetch to compensate for post-filter reduction
60
+ const fetchLimit = tags?.length ? effectiveLimit * 10 : effectiveLimit;
61
+
62
+ const countParams = [...params];
63
+ const total = ctx.db.prepare(`SELECT COUNT(*) as c FROM vault ${where}`).get(...countParams).c;
64
+
65
+ params.push(fetchLimit, effectiveOffset);
66
+ const rows = ctx.db.prepare(`SELECT id, title, kind, category, tags, created_at, SUBSTR(body, 1, 120) as preview FROM vault ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`).all(...params);
67
+
68
+ // Post-filter by tags if provided, then apply requested limit
69
+ const filtered = tags?.length
70
+ ? rows.filter((r) => {
71
+ const entryTags = r.tags ? JSON.parse(r.tags) : [];
72
+ return tags.some((t) => entryTags.includes(t));
73
+ }).slice(0, effectiveLimit)
74
+ : rows;
75
+
76
+ if (!filtered.length) return ok("No entries found matching the given filters.");
77
+
78
+ const lines = [];
79
+ if (reindexFailed) lines.push(`> **Warning:** Auto-reindex failed. Results may be stale. Run \`context-vault reindex\` to fix.\n`);
80
+ lines.push(`## Vault Entries (${filtered.length} shown, ${total} total)\n`);
81
+ for (const r of filtered) {
82
+ const entryTags = r.tags ? JSON.parse(r.tags) : [];
83
+ const tagStr = entryTags.length ? entryTags.join(", ") : "none";
84
+ lines.push(`- **${r.title || "(untitled)"}** [${r.kind}/${r.category}] — ${tagStr} — ${r.created_at} — \`${r.id}\``);
85
+ if (r.preview) lines.push(` ${r.preview.replace(/\n+/g, " ").trim()}${r.preview.length >= 120 ? "…" : ""}`);
86
+ }
87
+
88
+ if (effectiveOffset + effectiveLimit < total) {
89
+ lines.push(`\n_Page ${Math.floor(effectiveOffset / effectiveLimit) + 1}. Use offset: ${effectiveOffset + effectiveLimit} for next page._`);
90
+ }
91
+
92
+ return ok(lines.join("\n"));
93
+ }