context-vault 2.6.1 → 2.7.1
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/bin/cli.js +43 -17
- package/node_modules/@context-vault/core/package.json +1 -1
- package/node_modules/@context-vault/core/src/core/status.js +1 -1
- package/node_modules/@context-vault/core/src/index/index.js +3 -3
- package/node_modules/@context-vault/core/src/retrieve/index.js +1 -1
- package/node_modules/@context-vault/core/src/server/tools/context-status.js +90 -0
- package/node_modules/@context-vault/core/src/server/tools/delete-context.js +48 -0
- package/node_modules/@context-vault/core/src/server/tools/get-context.js +153 -0
- package/node_modules/@context-vault/core/src/server/tools/ingest-url.js +80 -0
- package/node_modules/@context-vault/core/src/server/tools/list-context.js +93 -0
- package/node_modules/@context-vault/core/src/server/tools/save-context.js +173 -0
- package/node_modules/@context-vault/core/src/server/tools/submit-feedback.js +49 -0
- package/node_modules/@context-vault/core/src/server/tools.js +36 -613
- package/node_modules/@context-vault/core/src/server/types.js +78 -0
- package/node_modules/@context-vault/core/src/sync/sync.js +2 -2
- package/package.json +2 -2
package/bin/cli.js
CHANGED
|
@@ -1425,6 +1425,8 @@ async function runImport() {
|
|
|
1425
1425
|
async function runExport() {
|
|
1426
1426
|
const format = getFlag("--format") || "json";
|
|
1427
1427
|
const output = getFlag("--output");
|
|
1428
|
+
const rawPageSize = getFlag("--page-size");
|
|
1429
|
+
const pageSize = rawPageSize ? Math.max(1, parseInt(rawPageSize, 10) || 100) : null;
|
|
1428
1430
|
|
|
1429
1431
|
const { resolveConfig } = await import("@context-vault/core/core/config");
|
|
1430
1432
|
const { initDatabase, prepareStatements } = await import("@context-vault/core/index/db");
|
|
@@ -1438,25 +1440,33 @@ async function runExport() {
|
|
|
1438
1440
|
|
|
1439
1441
|
const db = await initDatabase(config.dbPath);
|
|
1440
1442
|
|
|
1441
|
-
const
|
|
1442
|
-
"SELECT * FROM vault WHERE (expires_at IS NULL OR expires_at > datetime('now')) ORDER BY created_at DESC"
|
|
1443
|
-
).all();
|
|
1443
|
+
const whereClause = "WHERE (expires_at IS NULL OR expires_at > datetime('now'))";
|
|
1444
1444
|
|
|
1445
|
-
|
|
1445
|
+
let entries;
|
|
1446
|
+
if (pageSize) {
|
|
1447
|
+
// Paginated: fetch in chunks to avoid loading everything into memory
|
|
1448
|
+
entries = [];
|
|
1449
|
+
let offset = 0;
|
|
1450
|
+
const stmt = db.prepare(
|
|
1451
|
+
`SELECT * FROM vault ${whereClause} ORDER BY created_at DESC LIMIT ? OFFSET ?`
|
|
1452
|
+
);
|
|
1453
|
+
while (true) {
|
|
1454
|
+
const rows = stmt.all(pageSize, offset);
|
|
1455
|
+
if (rows.length === 0) break;
|
|
1456
|
+
for (const row of rows) {
|
|
1457
|
+
entries.push(mapExportRow(row));
|
|
1458
|
+
}
|
|
1459
|
+
offset += rows.length;
|
|
1460
|
+
if (rows.length < pageSize) break;
|
|
1461
|
+
}
|
|
1462
|
+
} else {
|
|
1463
|
+
const rows = db.prepare(
|
|
1464
|
+
`SELECT * FROM vault ${whereClause} ORDER BY created_at DESC`
|
|
1465
|
+
).all();
|
|
1466
|
+
entries = rows.map(mapExportRow);
|
|
1467
|
+
}
|
|
1446
1468
|
|
|
1447
|
-
|
|
1448
|
-
id: row.id,
|
|
1449
|
-
kind: row.kind,
|
|
1450
|
-
category: row.category,
|
|
1451
|
-
title: row.title || null,
|
|
1452
|
-
body: row.body || null,
|
|
1453
|
-
tags: row.tags ? JSON.parse(row.tags) : [],
|
|
1454
|
-
meta: row.meta ? JSON.parse(row.meta) : {},
|
|
1455
|
-
source: row.source || null,
|
|
1456
|
-
identity_key: row.identity_key || null,
|
|
1457
|
-
expires_at: row.expires_at || null,
|
|
1458
|
-
created_at: row.created_at,
|
|
1459
|
-
}));
|
|
1469
|
+
db.close();
|
|
1460
1470
|
|
|
1461
1471
|
let content;
|
|
1462
1472
|
|
|
@@ -1489,6 +1499,22 @@ async function runExport() {
|
|
|
1489
1499
|
}
|
|
1490
1500
|
}
|
|
1491
1501
|
|
|
1502
|
+
function mapExportRow(row) {
|
|
1503
|
+
return {
|
|
1504
|
+
id: row.id,
|
|
1505
|
+
kind: row.kind,
|
|
1506
|
+
category: row.category,
|
|
1507
|
+
title: row.title || null,
|
|
1508
|
+
body: row.body || null,
|
|
1509
|
+
tags: row.tags ? JSON.parse(row.tags) : [],
|
|
1510
|
+
meta: row.meta ? JSON.parse(row.meta) : {},
|
|
1511
|
+
source: row.source || null,
|
|
1512
|
+
identity_key: row.identity_key || null,
|
|
1513
|
+
expires_at: row.expires_at || null,
|
|
1514
|
+
created_at: row.created_at,
|
|
1515
|
+
};
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1492
1518
|
// ─── Ingest Command ─────────────────────────────────────────────────────────
|
|
1493
1519
|
|
|
1494
1520
|
async function runIngest() {
|
|
@@ -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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
+
}
|