@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.
- package/LICENSE +21 -0
- package/package.json +52 -0
- package/src/capture/file-ops.js +93 -0
- package/src/capture/formatters.js +29 -0
- package/src/capture/import-pipeline.js +46 -0
- package/src/capture/importers.js +387 -0
- package/src/capture/index.js +199 -0
- package/src/capture/ingest-url.js +252 -0
- package/src/constants.js +8 -0
- package/src/core/categories.js +51 -0
- package/src/core/config.js +127 -0
- package/src/core/files.js +108 -0
- package/src/core/frontmatter.js +120 -0
- package/src/core/status.js +146 -0
- package/src/index/db.js +268 -0
- package/src/index/embed.js +101 -0
- package/src/index/index.js +451 -0
- package/src/index.js +62 -0
- package/src/retrieve/index.js +219 -0
- package/src/server/helpers.js +31 -0
- package/src/server/tools/context-status.js +104 -0
- package/src/server/tools/delete-context.js +53 -0
- package/src/server/tools/get-context.js +235 -0
- package/src/server/tools/ingest-url.js +99 -0
- package/src/server/tools/list-context.js +134 -0
- package/src/server/tools/save-context.js +297 -0
- package/src/server/tools/submit-feedback.js +55 -0
- package/src/server/tools.js +111 -0
- package/src/sync/sync.js +235 -0
|
@@ -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
|
+
}
|