@context-vault/core 2.8.3

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.
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Retrieve Layer — Public API
3
+ *
4
+ * All read-path query logic: hybrid semantic search and any future
5
+ * query patterns (scoped, recency-weighted, etc.).
6
+ *
7
+ * Agent Constraint: Read-only access to DB. Never writes.
8
+ */
9
+
10
+ const FTS_WEIGHT = 0.4;
11
+ const VEC_WEIGHT = 0.6;
12
+
13
+ /**
14
+ * Strip FTS5 metacharacters from query words and build an AND query.
15
+ * Returns null if no valid words remain.
16
+ */
17
+ export function buildFtsQuery(query) {
18
+ const words = query
19
+ .split(/\s+/)
20
+ .map((w) => w.replace(/[*"()\-:^~{}]/g, ""))
21
+ .filter((w) => w.length > 0);
22
+ if (!words.length) return null;
23
+ return words.map((w) => `"${w}"`).join(" AND ");
24
+ }
25
+
26
+ /**
27
+ * Category-aware recency decay:
28
+ * knowledge + entity: no decay (enduring)
29
+ * event: steeper decay (~0.5 at 30 days)
30
+ */
31
+ export function recencyBoost(createdAt, category, decayDays = 30) {
32
+ if (category !== "event") return 1.0;
33
+ const ageDays = (Date.now() - new Date(createdAt).getTime()) / 86400000;
34
+ return 1 / (1 + ageDays / decayDays);
35
+ }
36
+
37
+ /**
38
+ * Build additional WHERE clauses for category/time filtering.
39
+ * Returns { clauses: string[], params: any[] }
40
+ */
41
+ export function buildFilterClauses({
42
+ categoryFilter,
43
+ since,
44
+ until,
45
+ userIdFilter,
46
+ teamIdFilter,
47
+ }) {
48
+ const clauses = [];
49
+ const params = [];
50
+ if (userIdFilter !== undefined) {
51
+ clauses.push("e.user_id = ?");
52
+ params.push(userIdFilter);
53
+ }
54
+ if (teamIdFilter) {
55
+ clauses.push("e.team_id = ?");
56
+ params.push(teamIdFilter);
57
+ }
58
+ if (categoryFilter) {
59
+ clauses.push("e.category = ?");
60
+ params.push(categoryFilter);
61
+ }
62
+ if (since) {
63
+ clauses.push("e.created_at >= ?");
64
+ params.push(since);
65
+ }
66
+ if (until) {
67
+ clauses.push("e.created_at <= ?");
68
+ params.push(until);
69
+ }
70
+ clauses.push("(e.expires_at IS NULL OR e.expires_at > datetime('now'))");
71
+ return { clauses, params };
72
+ }
73
+
74
+ /**
75
+ * Hybrid search combining FTS5 text matching and vector similarity.
76
+ *
77
+ * @param {import('../server/types.js').BaseCtx} ctx
78
+ * @param {string} query
79
+ * @param {{ kindFilter?: string|null, categoryFilter?: string|null, since?: string|null, until?: string|null, limit?: number, offset?: number }} opts
80
+ * @returns {Promise<Array<{id, kind, category, title, body, meta, tags, source, file_path, created_at, score}>>}
81
+ */
82
+ export async function hybridSearch(
83
+ ctx,
84
+ query,
85
+ {
86
+ kindFilter = null,
87
+ categoryFilter = null,
88
+ since = null,
89
+ until = null,
90
+ limit = 20,
91
+ offset = 0,
92
+ decayDays = 30,
93
+ userIdFilter,
94
+ teamIdFilter = null,
95
+ } = {},
96
+ ) {
97
+ const results = new Map();
98
+ const extraFilters = buildFilterClauses({
99
+ categoryFilter,
100
+ since,
101
+ until,
102
+ userIdFilter,
103
+ teamIdFilter,
104
+ });
105
+
106
+ // FTS5 search
107
+ const ftsQuery = buildFtsQuery(query);
108
+ if (ftsQuery) {
109
+ try {
110
+ const whereParts = ["vault_fts MATCH ?"];
111
+ const ftsParams = [ftsQuery];
112
+
113
+ if (kindFilter) {
114
+ whereParts.push("e.kind = ?");
115
+ ftsParams.push(kindFilter);
116
+ }
117
+ whereParts.push(...extraFilters.clauses);
118
+ ftsParams.push(...extraFilters.params);
119
+
120
+ const ftsSQL = `SELECT e.*, rank FROM vault_fts f JOIN vault e ON f.rowid = e.rowid WHERE ${whereParts.join(" AND ")} ORDER BY rank LIMIT 15`;
121
+ const rows = ctx.db.prepare(ftsSQL).all(...ftsParams);
122
+
123
+ // Normalize FTS scores to [0, 1]
124
+ const ftsScores = rows.map((r) => Math.abs(r.rank || 0));
125
+ const maxFts = Math.max(...ftsScores, 1);
126
+
127
+ for (let i = 0; i < rows.length; i++) {
128
+ const { rank: _rank, ...row } = rows[i];
129
+ const normalized = ftsScores[i] / maxFts;
130
+ results.set(row.id, { ...row, score: normalized * FTS_WEIGHT });
131
+ }
132
+ } catch (err) {
133
+ if (err.message?.includes("fts5: syntax error")) {
134
+ // Expected: malformed query, fall through to vector search
135
+ } else {
136
+ console.error(`[retrieve] FTS search error: ${err.message}`);
137
+ }
138
+ }
139
+ }
140
+
141
+ // Vector similarity search (skipped if embedding unavailable)
142
+ try {
143
+ const vecCount = ctx.db
144
+ .prepare("SELECT COUNT(*) as c FROM vault_vec")
145
+ .get().c;
146
+ if (vecCount > 0) {
147
+ const queryVec = await ctx.embed(query);
148
+ if (queryVec) {
149
+ // Increase limits in hosted mode to compensate for post-filtering
150
+ const hasPostFilter = userIdFilter !== undefined || teamIdFilter;
151
+ const vecLimit = hasPostFilter
152
+ ? kindFilter
153
+ ? 60
154
+ : 30
155
+ : kindFilter
156
+ ? 30
157
+ : 15;
158
+ const vecRows = ctx.db
159
+ .prepare(
160
+ `SELECT v.rowid, v.distance FROM vault_vec v WHERE embedding MATCH ? ORDER BY distance LIMIT ${vecLimit}`,
161
+ )
162
+ .all(queryVec);
163
+
164
+ if (vecRows.length) {
165
+ // Batch hydration: single query instead of N+1
166
+ const rowids = vecRows.map((vr) => vr.rowid);
167
+ const placeholders = rowids.map(() => "?").join(",");
168
+ const hydrated = ctx.db
169
+ .prepare(
170
+ `SELECT rowid, * FROM vault WHERE rowid IN (${placeholders})`,
171
+ )
172
+ .all(...rowids);
173
+
174
+ const byRowid = new Map();
175
+ for (const row of hydrated) byRowid.set(row.rowid, row);
176
+
177
+ for (const vr of vecRows) {
178
+ const row = byRowid.get(vr.rowid);
179
+ if (!row) continue;
180
+ if (userIdFilter !== undefined && row.user_id !== userIdFilter)
181
+ continue;
182
+ if (teamIdFilter && row.team_id !== teamIdFilter) continue;
183
+ if (kindFilter && row.kind !== kindFilter) continue;
184
+ if (categoryFilter && row.category !== categoryFilter) continue;
185
+ if (since && row.created_at < since) continue;
186
+ if (until && row.created_at > until) continue;
187
+ if (row.expires_at && new Date(row.expires_at) <= new Date())
188
+ continue;
189
+
190
+ const { rowid: _rowid, ...cleanRow } = row;
191
+ // sqlite-vec returns L2 distance [0, 2] for normalized vectors.
192
+ // Convert to similarity [1, 0] with: 1 - distance/2
193
+ const vecScore = Math.max(0, 1 - vr.distance / 2) * VEC_WEIGHT;
194
+ const existing = results.get(cleanRow.id);
195
+ if (existing) {
196
+ existing.score += vecScore;
197
+ } else {
198
+ results.set(cleanRow.id, { ...cleanRow, score: vecScore });
199
+ }
200
+ }
201
+ }
202
+ }
203
+ }
204
+ } catch (err) {
205
+ if (err.message?.includes("no such table")) {
206
+ // Expected on fresh vaults with no vec table yet
207
+ } else {
208
+ console.error(`[retrieve] Vector search error: ${err.message}`);
209
+ }
210
+ }
211
+
212
+ // Apply category-aware recency boost
213
+ for (const [, entry] of results) {
214
+ entry.score *= recencyBoost(entry.created_at, entry.category, decayDays);
215
+ }
216
+
217
+ const sorted = [...results.values()].sort((a, b) => b.score - a.score);
218
+ return sorted.slice(offset, offset + limit);
219
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * helpers.js — Shared MCP response helpers and validation
3
+ */
4
+
5
+ export function ok(text) {
6
+ return { content: [{ type: "text", text }] };
7
+ }
8
+
9
+ export function err(text, code = "UNKNOWN") {
10
+ return { content: [{ type: "text", text }], isError: true, code };
11
+ }
12
+
13
+ export function ensureVaultExists(config) {
14
+ if (!config.vaultDirExists) {
15
+ return err(
16
+ `Vault directory not found: ${config.vaultDir}. Run context_status for diagnostics.`,
17
+ "VAULT_NOT_FOUND",
18
+ );
19
+ }
20
+ return null;
21
+ }
22
+
23
+ export function ensureValidKind(kind) {
24
+ if (!/^[a-z][a-z0-9_-]*$/.test(kind)) {
25
+ return err(
26
+ "Required: kind (lowercase alphanumeric, e.g. 'insight', 'reference')",
27
+ "INVALID_KIND",
28
+ );
29
+ }
30
+ return null;
31
+ }
@@ -0,0 +1,104 @@
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(
43
+ `Embed model: unavailable (semantic search disabled, FTS still works)`,
44
+ );
45
+ } else if (status.embedModelAvailable === true) {
46
+ lines.push(`Embed model: loaded`);
47
+ }
48
+ lines.push(`Decay: ${config.eventDecayDays} days (event recency window)`);
49
+ if (status.expiredCount > 0) {
50
+ lines.push(
51
+ `Expired: ${status.expiredCount} entries (pruned on next reindex)`,
52
+ );
53
+ }
54
+
55
+ lines.push(``, `### Indexed`);
56
+
57
+ if (status.kindCounts.length) {
58
+ for (const { kind, c } of status.kindCounts) lines.push(`- ${c} ${kind}s`);
59
+ } else {
60
+ lines.push(`- (empty)`);
61
+ }
62
+
63
+ if (status.categoryCounts.length) {
64
+ lines.push(``);
65
+ lines.push(`### Categories`);
66
+ for (const { category, c } of status.categoryCounts)
67
+ lines.push(`- ${category}: ${c}`);
68
+ }
69
+
70
+ if (status.subdirs.length) {
71
+ lines.push(``);
72
+ lines.push(`### Disk Directories`);
73
+ for (const { name, count } of status.subdirs)
74
+ lines.push(`- ${name}/: ${count} files`);
75
+ }
76
+
77
+ if (status.stalePaths) {
78
+ lines.push(``);
79
+ lines.push(`### ⚠ Stale Paths`);
80
+ lines.push(
81
+ `DB contains ${status.staleCount} paths not matching current vault dir.`,
82
+ );
83
+ lines.push(`Auto-reindex will fix this on next search or save.`);
84
+ }
85
+
86
+ // Suggested actions
87
+ const actions = [];
88
+ if (status.stalePaths)
89
+ actions.push("- Run `context-vault reindex` to fix stale paths");
90
+ if (status.embeddingStatus?.missing > 0)
91
+ actions.push(
92
+ "- Run `context-vault reindex` to generate missing embeddings",
93
+ );
94
+ if (!config.vaultDirExists)
95
+ actions.push("- Run `context-vault setup` to create the vault directory");
96
+ if (status.kindCounts.length === 0 && config.vaultDirExists)
97
+ actions.push("- Use `save_context` to add your first entry");
98
+
99
+ if (actions.length) {
100
+ lines.push("", "### Suggested Actions", ...actions);
101
+ }
102
+
103
+ return ok(lines.join("\n"));
104
+ }
@@ -0,0 +1,53 @@
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())
23
+ return err("Required: id (non-empty string)", "INVALID_INPUT");
24
+ await ensureIndexed();
25
+
26
+ const entry = ctx.stmts.getEntryById.get(id);
27
+ if (!entry) return err(`Entry not found: ${id}`, "NOT_FOUND");
28
+
29
+ // Ownership check: don't leak existence across users
30
+ if (userId !== undefined && entry.user_id !== userId) {
31
+ return err(`Entry not found: ${id}`, "NOT_FOUND");
32
+ }
33
+
34
+ // Delete file from disk first (source of truth)
35
+ if (entry.file_path) {
36
+ try {
37
+ unlinkSync(entry.file_path);
38
+ } catch {}
39
+ }
40
+
41
+ // Delete vector embedding
42
+ const rowidResult = ctx.stmts.getRowid.get(id);
43
+ if (rowidResult?.rowid) {
44
+ try {
45
+ ctx.deleteVec(Number(rowidResult.rowid));
46
+ } catch {}
47
+ }
48
+
49
+ // Delete DB row (FTS trigger handles FTS cleanup)
50
+ ctx.stmts.deleteEntry.run(id);
51
+
52
+ return ok(`Deleted ${entry.kind}: ${entry.title || "(untitled)"} [${id}]`);
53
+ }
@@ -0,0 +1,235 @@
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
15
+ .string()
16
+ .optional()
17
+ .describe(
18
+ "Search query (natural language or keywords). Optional if filters (tags, kind, category) are provided.",
19
+ ),
20
+ kind: z
21
+ .string()
22
+ .optional()
23
+ .describe("Filter by kind (e.g. 'insight', 'decision', 'pattern')"),
24
+ category: z
25
+ .enum(["knowledge", "entity", "event"])
26
+ .optional()
27
+ .describe("Filter by category"),
28
+ identity_key: z
29
+ .string()
30
+ .optional()
31
+ .describe("For entity lookup: exact match on identity key. Requires kind."),
32
+ tags: z
33
+ .array(z.string())
34
+ .optional()
35
+ .describe("Filter by tags (entries must match at least one)"),
36
+ since: z
37
+ .string()
38
+ .optional()
39
+ .describe("ISO date, return entries created after this"),
40
+ until: z
41
+ .string()
42
+ .optional()
43
+ .describe("ISO date, return entries created before this"),
44
+ limit: z.number().optional().describe("Max results to return (default 10)"),
45
+ };
46
+
47
+ /**
48
+ * @param {object} args
49
+ * @param {import('../types.js').BaseCtx & Partial<import('../types.js').HostedCtxExtensions>} ctx
50
+ * @param {import('../types.js').ToolShared} shared
51
+ */
52
+ export async function handler(
53
+ { query, kind, category, identity_key, tags, since, until, limit },
54
+ ctx,
55
+ { ensureIndexed, reindexFailed },
56
+ ) {
57
+ const { config } = ctx;
58
+ const userId = ctx.userId !== undefined ? ctx.userId : undefined;
59
+
60
+ const hasQuery = query?.trim();
61
+ const hasFilters =
62
+ kind || category || tags?.length || since || until || identity_key;
63
+ if (!hasQuery && !hasFilters)
64
+ return err(
65
+ "Required: query or at least one filter (kind, category, tags, since, until, identity_key)",
66
+ "INVALID_INPUT",
67
+ );
68
+ await ensureIndexed();
69
+
70
+ const kindFilter = kind ? normalizeKind(kind) : null;
71
+
72
+ // Gap 1: Entity exact-match by identity_key
73
+ if (identity_key) {
74
+ if (!kindFilter)
75
+ return err("identity_key requires kind to be specified", "INVALID_INPUT");
76
+ const match = ctx.stmts.getByIdentityKey.get(
77
+ kindFilter,
78
+ identity_key,
79
+ userId !== undefined ? userId : null,
80
+ );
81
+ if (match) {
82
+ const entryTags = match.tags ? JSON.parse(match.tags) : [];
83
+ const tagStr = entryTags.length ? entryTags.join(", ") : "none";
84
+ const relPath =
85
+ match.file_path && config.vaultDir
86
+ ? match.file_path.replace(config.vaultDir + "/", "")
87
+ : match.file_path || "n/a";
88
+ const lines = [
89
+ `## Entity Match (exact)\n`,
90
+ `### ${match.title || "(untitled)"} [${match.kind}/${match.category}]`,
91
+ `1.000 · ${tagStr} · ${relPath} · id: \`${match.id}\``,
92
+ match.body?.slice(0, 300) + (match.body?.length > 300 ? "..." : ""),
93
+ ];
94
+ return ok(lines.join("\n"));
95
+ }
96
+ // Fall through to semantic search as fallback
97
+ }
98
+
99
+ // Gap 2: Event default time-window
100
+ const effectiveCategory =
101
+ category || (kindFilter ? categoryFor(kindFilter) : null);
102
+ let effectiveSince = since || null;
103
+ let effectiveUntil = until || null;
104
+ let autoWindowed = false;
105
+ if (effectiveCategory === "event" && !since && !until) {
106
+ const decayMs = (config.eventDecayDays || 30) * 86400000;
107
+ effectiveSince = new Date(Date.now() - decayMs).toISOString();
108
+ autoWindowed = true;
109
+ }
110
+
111
+ const effectiveLimit = limit || 10;
112
+ // When tag-filtering, over-fetch to compensate for post-filter reduction
113
+ const fetchLimit = tags?.length ? effectiveLimit * 10 : effectiveLimit;
114
+
115
+ let filtered;
116
+ if (hasQuery) {
117
+ // Hybrid search mode
118
+ const sorted = await hybridSearch(ctx, query, {
119
+ kindFilter,
120
+ categoryFilter: category || null,
121
+ since: effectiveSince,
122
+ until: effectiveUntil,
123
+ limit: fetchLimit,
124
+ decayDays: config.eventDecayDays || 30,
125
+ userIdFilter: userId,
126
+ });
127
+
128
+ // Post-filter by tags if provided, then apply requested limit
129
+ filtered = tags?.length
130
+ ? sorted
131
+ .filter((r) => {
132
+ const entryTags = r.tags ? JSON.parse(r.tags) : [];
133
+ return tags.some((t) => entryTags.includes(t));
134
+ })
135
+ .slice(0, effectiveLimit)
136
+ : sorted;
137
+ } else {
138
+ // Filter-only mode (no query, use SQL directly)
139
+ const clauses = [];
140
+ const params = [];
141
+ if (userId !== undefined) {
142
+ clauses.push("user_id = ?");
143
+ params.push(userId);
144
+ }
145
+ if (kindFilter) {
146
+ clauses.push("kind = ?");
147
+ params.push(kindFilter);
148
+ }
149
+ if (category) {
150
+ clauses.push("category = ?");
151
+ params.push(category);
152
+ }
153
+ if (effectiveSince) {
154
+ clauses.push("created_at >= ?");
155
+ params.push(effectiveSince);
156
+ }
157
+ if (effectiveUntil) {
158
+ clauses.push("created_at <= ?");
159
+ params.push(effectiveUntil);
160
+ }
161
+ clauses.push("(expires_at IS NULL OR expires_at > datetime('now'))");
162
+ const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
163
+ params.push(fetchLimit);
164
+ const rows = ctx.db
165
+ .prepare(`SELECT * FROM vault ${where} ORDER BY created_at DESC LIMIT ?`)
166
+ .all(...params);
167
+
168
+ // Post-filter by tags if provided, then apply requested limit
169
+ filtered = tags?.length
170
+ ? rows
171
+ .filter((r) => {
172
+ const entryTags = r.tags ? JSON.parse(r.tags) : [];
173
+ return tags.some((t) => entryTags.includes(t));
174
+ })
175
+ .slice(0, effectiveLimit)
176
+ : rows;
177
+
178
+ // Add score field for consistent output
179
+ for (const r of filtered) r.score = 0;
180
+ }
181
+
182
+ if (!filtered.length)
183
+ return ok(
184
+ hasQuery
185
+ ? "No results found for: " + query
186
+ : "No entries found matching the given filters.",
187
+ );
188
+
189
+ // Decrypt encrypted entries if ctx.decrypt is available
190
+ if (ctx.decrypt) {
191
+ for (const r of filtered) {
192
+ if (r.body_encrypted) {
193
+ const decrypted = await ctx.decrypt(r);
194
+ r.body = decrypted.body;
195
+ if (decrypted.title) r.title = decrypted.title;
196
+ if (decrypted.meta) r.meta = JSON.stringify(decrypted.meta);
197
+ }
198
+ }
199
+ }
200
+
201
+ const lines = [];
202
+ if (reindexFailed)
203
+ lines.push(
204
+ `> **Warning:** Auto-reindex failed. Results may be stale. Run \`context-vault reindex\` to fix.\n`,
205
+ );
206
+ if (hasQuery && isEmbedAvailable() === false)
207
+ lines.push(
208
+ `> **Note:** Semantic search unavailable — results ranked by keyword match only. Run \`context-vault setup\` to download the embedding model.\n`,
209
+ );
210
+ const heading = hasQuery ? `Results for "${query}"` : "Filtered entries";
211
+ lines.push(`## ${heading} (${filtered.length} matches)\n`);
212
+ for (let i = 0; i < filtered.length; i++) {
213
+ const r = filtered[i];
214
+ const entryTags = r.tags ? JSON.parse(r.tags) : [];
215
+ const tagStr = entryTags.length ? entryTags.join(", ") : "none";
216
+ const relPath =
217
+ r.file_path && config.vaultDir
218
+ ? r.file_path.replace(config.vaultDir + "/", "")
219
+ : r.file_path || "n/a";
220
+ lines.push(
221
+ `### [${i + 1}/${filtered.length}] ${r.title || "(untitled)"} [${r.kind}/${r.category}]`,
222
+ );
223
+ lines.push(
224
+ `${r.score.toFixed(3)} · ${tagStr} · ${relPath} · id: \`${r.id}\``,
225
+ );
226
+ lines.push(r.body?.slice(0, 300) + (r.body?.length > 300 ? "..." : ""));
227
+ lines.push("");
228
+ }
229
+ if (autoWindowed) {
230
+ lines.push(
231
+ `_Showing events from last ${config.eventDecayDays || 30} days. Use since/until for custom range._`,
232
+ );
233
+ }
234
+ return ok(lines.join("\n"));
235
+ }