botholomew 0.7.2 → 0.7.4
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/package.json +2 -2
- package/src/chat/agent.ts +3 -1
- package/src/commands/context.ts +40 -80
- package/src/context/refresh.ts +159 -0
- package/src/db/context.ts +10 -8
- package/src/db/sql/10-dedupe_context_items.sql +26 -0
- package/src/db/sql/11-rebuild_hnsw.sql +9 -0
- package/src/tools/context/refresh.ts +136 -0
- package/src/tools/dir/tree.ts +1 -1
- package/src/tools/registry.ts +2 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "botholomew",
|
|
3
|
-
"version": "0.7.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.7.4",
|
|
4
|
+
"description": "Local, autonomous AI agent for knowledge work — works your task queue while you sleep.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"botholomew": "./src/cli.ts"
|
package/src/chat/agent.ts
CHANGED
|
@@ -33,6 +33,8 @@ const CHAT_TOOL_NAMES = new Set([
|
|
|
33
33
|
"list_tasks",
|
|
34
34
|
"view_task",
|
|
35
35
|
"context_search",
|
|
36
|
+
"context_info",
|
|
37
|
+
"context_refresh",
|
|
36
38
|
"search_grep",
|
|
37
39
|
"search_semantic",
|
|
38
40
|
"list_threads",
|
|
@@ -103,7 +105,7 @@ export async function buildChatSystemPrompt(
|
|
|
103
105
|
"You do NOT execute long-running work directly — enqueue tasks for the daemon instead using create_task.",
|
|
104
106
|
);
|
|
105
107
|
parts.push(
|
|
106
|
-
"Use the available tools to look up tasks, threads, schedules, and context when the user asks about them.",
|
|
108
|
+
"Use the available tools to look up tasks, threads, schedules, and context when the user asks about them. Context items can be looked up by virtual path or by UUID via `context_info` and refreshed via `context_refresh`.",
|
|
107
109
|
);
|
|
108
110
|
parts.push(
|
|
109
111
|
"When multiple tool calls are independent of each other (i.e., one does not depend on the result of another), call them all in a single response. They will be executed in parallel, which is faster than calling them one at a time.",
|
package/src/commands/context.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
prepareIngestion,
|
|
15
15
|
storeIngestion,
|
|
16
16
|
} from "../context/ingest.ts";
|
|
17
|
+
import { refreshContextItems } from "../context/refresh.ts";
|
|
17
18
|
import { isUrl, urlToContextPath } from "../context/url-utils.ts";
|
|
18
19
|
import type { DbConnection } from "../db/connection.ts";
|
|
19
20
|
import {
|
|
@@ -22,7 +23,6 @@ import {
|
|
|
22
23
|
listContextItems,
|
|
23
24
|
listContextItemsByPrefix,
|
|
24
25
|
resolveContextItem,
|
|
25
|
-
updateContextItem,
|
|
26
26
|
upsertContextItem,
|
|
27
27
|
} from "../db/context.ts";
|
|
28
28
|
import { getEmbeddingsForItem, hybridSearch } from "../db/embeddings.ts";
|
|
@@ -444,95 +444,55 @@ export function registerContextCommand(program: Command) {
|
|
|
444
444
|
const hasUrls = sourced.some((i) => i.source_type === "url");
|
|
445
445
|
const mcpxClient = hasUrls ? await createMcpxClient(dir) : null;
|
|
446
446
|
|
|
447
|
-
|
|
448
|
-
const spinner = createSpinner(
|
|
447
|
+
const refreshSpinner = createSpinner(
|
|
449
448
|
`Refreshing 0/${sourced.length} items...`,
|
|
450
449
|
).start();
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
continue;
|
|
473
|
-
}
|
|
474
|
-
content = await bunFile.text();
|
|
475
|
-
}
|
|
450
|
+
const embedSpinner = createSpinner("Embedding 0 item(s)...");
|
|
451
|
+
|
|
452
|
+
const result = await refreshContextItems(
|
|
453
|
+
conn,
|
|
454
|
+
sourced,
|
|
455
|
+
config,
|
|
456
|
+
mcpxClient,
|
|
457
|
+
{
|
|
458
|
+
onItemProgress: (done, total) => {
|
|
459
|
+
refreshSpinner.update({
|
|
460
|
+
text: `Refreshing ${done}/${total} items...`,
|
|
461
|
+
});
|
|
462
|
+
},
|
|
463
|
+
onEmbedProgress: (done, total) => {
|
|
464
|
+
if (done === 1) embedSpinner.start();
|
|
465
|
+
embedSpinner.update({
|
|
466
|
+
text: `Embedding ${done}/${total} item(s)...`,
|
|
467
|
+
});
|
|
468
|
+
},
|
|
469
|
+
},
|
|
470
|
+
);
|
|
476
471
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
continue;
|
|
480
|
-
}
|
|
481
|
-
await updateContextItem(conn, item.id, { content });
|
|
482
|
-
updated++;
|
|
483
|
-
toReembed.push(item.id);
|
|
484
|
-
} catch (err) {
|
|
485
|
-
logger.warn(` Error refreshing ${item.source_path}: ${err}`);
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
spinner.success({
|
|
489
|
-
text: `Checked ${sourced.length} item(s): ${updated} updated, ${unchanged} unchanged, ${missing} missing.`,
|
|
472
|
+
refreshSpinner.success({
|
|
473
|
+
text: `Checked ${result.checked} item(s): ${result.updated} updated, ${result.unchanged} unchanged, ${result.missing} missing.`,
|
|
490
474
|
});
|
|
491
475
|
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
476
|
+
for (const item of result.items) {
|
|
477
|
+
if (item.status === "missing") {
|
|
478
|
+
logger.warn(` Missing: ${item.source_path}`);
|
|
479
|
+
} else if (item.status === "error") {
|
|
480
|
+
logger.warn(
|
|
481
|
+
` Error refreshing ${item.source_path}: ${item.error}`,
|
|
482
|
+
);
|
|
496
483
|
}
|
|
497
|
-
return;
|
|
498
484
|
}
|
|
499
485
|
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
const prepared: PreparedIngestion[] = [];
|
|
507
|
-
for (let i = 0; i < toReembed.length; i += CONCURRENCY) {
|
|
508
|
-
const batch = toReembed.slice(i, i + CONCURRENCY);
|
|
509
|
-
const results = await Promise.all(
|
|
510
|
-
batch.map(async (id) => {
|
|
511
|
-
const result = await prepareIngestion(conn, id, config);
|
|
512
|
-
completed++;
|
|
513
|
-
embedSpinner.update({
|
|
514
|
-
text: `Embedding ${completed}/${toReembed.length} item(s)...`,
|
|
515
|
-
});
|
|
516
|
-
return result;
|
|
517
|
-
}),
|
|
486
|
+
if (result.reembedded > 0) {
|
|
487
|
+
embedSpinner.success({
|
|
488
|
+
text: `Embedded ${result.reembedded} item(s).`,
|
|
489
|
+
});
|
|
490
|
+
logger.success(
|
|
491
|
+
`Refreshed ${result.updated} item(s), ${result.chunks} chunk(s) re-indexed.`,
|
|
518
492
|
);
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
}
|
|
493
|
+
} else if (result.embeddings_skipped) {
|
|
494
|
+
logger.dim("Skipping embeddings (no OpenAI API key configured).");
|
|
522
495
|
}
|
|
523
|
-
embedSpinner.success({
|
|
524
|
-
text: `Embedded ${prepared.length} item(s).`,
|
|
525
|
-
});
|
|
526
|
-
|
|
527
|
-
let chunks = 0;
|
|
528
|
-
for (const p of prepared) {
|
|
529
|
-
const result = await storeIngestion(conn, p);
|
|
530
|
-
chunks += result.chunks;
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
logger.success(
|
|
534
|
-
`Refreshed ${updated} item(s), ${chunks} chunk(s) re-indexed.`,
|
|
535
|
-
);
|
|
536
496
|
}),
|
|
537
497
|
);
|
|
538
498
|
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type { McpxClient } from "@evantahler/mcpx";
|
|
2
|
+
import type { BotholomewConfig } from "../config/schemas.ts";
|
|
3
|
+
import type { DbConnection } from "../db/connection.ts";
|
|
4
|
+
import { type ContextItem, updateContextItem } from "../db/context.ts";
|
|
5
|
+
import { fetchUrl } from "./fetcher.ts";
|
|
6
|
+
import {
|
|
7
|
+
type PreparedIngestion,
|
|
8
|
+
prepareIngestion,
|
|
9
|
+
storeIngestion,
|
|
10
|
+
} from "./ingest.ts";
|
|
11
|
+
|
|
12
|
+
export type RefreshItemStatus = "updated" | "unchanged" | "missing" | "error";
|
|
13
|
+
|
|
14
|
+
export interface RefreshItemResult {
|
|
15
|
+
id: string;
|
|
16
|
+
context_path: string;
|
|
17
|
+
source_path: string;
|
|
18
|
+
source_type: "file" | "url";
|
|
19
|
+
status: RefreshItemStatus;
|
|
20
|
+
error?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface RefreshResult {
|
|
24
|
+
checked: number;
|
|
25
|
+
updated: number;
|
|
26
|
+
unchanged: number;
|
|
27
|
+
missing: number;
|
|
28
|
+
reembedded: number;
|
|
29
|
+
chunks: number;
|
|
30
|
+
embeddings_skipped: boolean;
|
|
31
|
+
items: RefreshItemResult[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface RefreshOptions {
|
|
35
|
+
concurrency?: number;
|
|
36
|
+
onItemProgress?: (done: number, total: number) => void;
|
|
37
|
+
onEmbedProgress?: (done: number, total: number) => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type IngestEmbedFn = (texts: string[]) => Promise<number[][]>;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Refresh a batch of context items: re-read source (file or URL), diff, update
|
|
44
|
+
* content, and re-embed only the items that changed. Side-effect free on the
|
|
45
|
+
* outside world — the caller owns logging and spinners.
|
|
46
|
+
*/
|
|
47
|
+
export async function refreshContextItems(
|
|
48
|
+
conn: DbConnection,
|
|
49
|
+
items: ContextItem[],
|
|
50
|
+
config: Required<BotholomewConfig>,
|
|
51
|
+
mcpxClient: McpxClient | null,
|
|
52
|
+
opts: RefreshOptions = {},
|
|
53
|
+
embedFn?: IngestEmbedFn,
|
|
54
|
+
): Promise<RefreshResult> {
|
|
55
|
+
const sourced = items.filter(
|
|
56
|
+
(i): i is ContextItem & { source_path: string } => !!i.source_path,
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const results: RefreshItemResult[] = [];
|
|
60
|
+
const toReembed: string[] = [];
|
|
61
|
+
|
|
62
|
+
// Phase 1: read each source, diff against stored content, update when changed.
|
|
63
|
+
for (const [idx, item] of sourced.entries()) {
|
|
64
|
+
opts.onItemProgress?.(idx, sourced.length);
|
|
65
|
+
const base = {
|
|
66
|
+
id: item.id,
|
|
67
|
+
context_path: item.context_path,
|
|
68
|
+
source_path: item.source_path,
|
|
69
|
+
source_type: item.source_type,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
let content: string;
|
|
74
|
+
|
|
75
|
+
if (item.source_type === "url") {
|
|
76
|
+
const fetched = await fetchUrl(item.source_path, config, mcpxClient);
|
|
77
|
+
content = fetched.content;
|
|
78
|
+
} else {
|
|
79
|
+
const bunFile = Bun.file(item.source_path);
|
|
80
|
+
if (!(await bunFile.exists())) {
|
|
81
|
+
results.push({ ...base, status: "missing" });
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
content = await bunFile.text();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (content === item.content) {
|
|
88
|
+
results.push({ ...base, status: "unchanged" });
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
await updateContextItem(conn, item.id, { content });
|
|
93
|
+
results.push({ ...base, status: "updated" });
|
|
94
|
+
toReembed.push(item.id);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
results.push({
|
|
97
|
+
...base,
|
|
98
|
+
status: "error",
|
|
99
|
+
error: err instanceof Error ? err.message : String(err),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
opts.onItemProgress?.(sourced.length, sourced.length);
|
|
104
|
+
|
|
105
|
+
const updated = results.filter((r) => r.status === "updated").length;
|
|
106
|
+
const unchanged = results.filter((r) => r.status === "unchanged").length;
|
|
107
|
+
const missing = results.filter((r) => r.status === "missing").length;
|
|
108
|
+
|
|
109
|
+
// Phase 2: re-embed changed items. Skip cleanly if no OpenAI key.
|
|
110
|
+
const hasEmbedder = !!embedFn || !!config.openai_api_key;
|
|
111
|
+
if (toReembed.length === 0 || !hasEmbedder) {
|
|
112
|
+
return {
|
|
113
|
+
checked: sourced.length,
|
|
114
|
+
updated,
|
|
115
|
+
unchanged,
|
|
116
|
+
missing,
|
|
117
|
+
reembedded: 0,
|
|
118
|
+
chunks: 0,
|
|
119
|
+
embeddings_skipped: toReembed.length > 0 && !hasEmbedder,
|
|
120
|
+
items: results,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const concurrency = opts.concurrency ?? 10;
|
|
125
|
+
const prepared: PreparedIngestion[] = [];
|
|
126
|
+
let completed = 0;
|
|
127
|
+
|
|
128
|
+
for (let i = 0; i < toReembed.length; i += concurrency) {
|
|
129
|
+
const batch = toReembed.slice(i, i + concurrency);
|
|
130
|
+
const batchResults = await Promise.all(
|
|
131
|
+
batch.map(async (id) => {
|
|
132
|
+
const r = await prepareIngestion(conn, id, config, embedFn);
|
|
133
|
+
completed++;
|
|
134
|
+
opts.onEmbedProgress?.(completed, toReembed.length);
|
|
135
|
+
return r;
|
|
136
|
+
}),
|
|
137
|
+
);
|
|
138
|
+
for (const r of batchResults) {
|
|
139
|
+
if (r) prepared.push(r);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let chunks = 0;
|
|
144
|
+
for (const p of prepared) {
|
|
145
|
+
const result = await storeIngestion(conn, p);
|
|
146
|
+
chunks += result.chunks;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
checked: sourced.length,
|
|
151
|
+
updated,
|
|
152
|
+
unchanged,
|
|
153
|
+
missing,
|
|
154
|
+
reembedded: prepared.length,
|
|
155
|
+
chunks,
|
|
156
|
+
embeddings_skipped: false,
|
|
157
|
+
items: results,
|
|
158
|
+
};
|
|
159
|
+
}
|
package/src/db/context.ts
CHANGED
|
@@ -290,6 +290,10 @@ export async function getDistinctDirectories(
|
|
|
290
290
|
|
|
291
291
|
// --- Mutations ---
|
|
292
292
|
|
|
293
|
+
// `UPDATE context_items ... RETURNING *` can crash @duckdb/node-api via a C++
|
|
294
|
+
// exception when the table's unique index is in a violated state. Update +
|
|
295
|
+
// separate SELECT is equivalent here (single-connection, no concurrent writers)
|
|
296
|
+
// and avoids the crash entirely.
|
|
293
297
|
export async function updateContextItem(
|
|
294
298
|
db: DbConnection,
|
|
295
299
|
id: string,
|
|
@@ -307,14 +311,13 @@ export async function updateContextItem(
|
|
|
307
311
|
setClauses.push("updated_at = current_timestamp::VARCHAR");
|
|
308
312
|
params.push(id);
|
|
309
313
|
|
|
310
|
-
|
|
314
|
+
await db.queryRun(
|
|
311
315
|
`UPDATE context_items
|
|
312
316
|
SET ${setClauses.join(", ")}
|
|
313
|
-
WHERE id = ?${params.length}
|
|
314
|
-
RETURNING *`,
|
|
317
|
+
WHERE id = ?${params.length}`,
|
|
315
318
|
...params,
|
|
316
319
|
);
|
|
317
|
-
return
|
|
320
|
+
return getContextItem(db, id);
|
|
318
321
|
}
|
|
319
322
|
|
|
320
323
|
export async function updateContextItemContent(
|
|
@@ -322,15 +325,14 @@ export async function updateContextItemContent(
|
|
|
322
325
|
contextPath: string,
|
|
323
326
|
content: string,
|
|
324
327
|
): Promise<ContextItem | null> {
|
|
325
|
-
|
|
328
|
+
await db.queryRun(
|
|
326
329
|
`UPDATE context_items
|
|
327
330
|
SET content = ?1, updated_at = current_timestamp::VARCHAR
|
|
328
|
-
WHERE context_path = ?2
|
|
329
|
-
RETURNING *`,
|
|
331
|
+
WHERE context_path = ?2`,
|
|
330
332
|
content,
|
|
331
333
|
contextPath,
|
|
332
334
|
);
|
|
333
|
-
return
|
|
335
|
+
return getContextItemByPath(db, contextPath);
|
|
334
336
|
}
|
|
335
337
|
|
|
336
338
|
export async function applyPatchesToContextItem(
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
-- Older DBs could accumulate duplicate rows in context_items with the same
|
|
2
|
+
-- context_path: migration 4's CREATE UNIQUE INDEX IF NOT EXISTS silently left
|
|
3
|
+
-- the index metadata in place without enforcing it when duplicates predated
|
|
4
|
+
-- the migration. The resulting "corrupt" unique index triggers a native
|
|
5
|
+
-- crash in @duckdb/node-api on UPDATE ... RETURNING. Rebuild cleanly.
|
|
6
|
+
DROP INDEX IF EXISTS idx_context_items_context_path;
|
|
7
|
+
|
|
8
|
+
DELETE FROM embeddings WHERE context_item_id IN (
|
|
9
|
+
SELECT id FROM (
|
|
10
|
+
SELECT id, ROW_NUMBER() OVER (
|
|
11
|
+
PARTITION BY context_path
|
|
12
|
+
ORDER BY updated_at DESC, id DESC
|
|
13
|
+
) AS rn FROM context_items
|
|
14
|
+
) WHERE rn > 1
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
DELETE FROM context_items WHERE id IN (
|
|
18
|
+
SELECT id FROM (
|
|
19
|
+
SELECT id, ROW_NUMBER() OVER (
|
|
20
|
+
PARTITION BY context_path
|
|
21
|
+
ORDER BY updated_at DESC, id DESC
|
|
22
|
+
) AS rn FROM context_items
|
|
23
|
+
) WHERE rn > 1
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
CREATE UNIQUE INDEX idx_context_items_context_path ON context_items(context_path);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
-- The HNSW index from migration 6 can end up in an internally-inconsistent
|
|
2
|
+
-- state after a native-side crash during embedding writes: the buffered WAL
|
|
3
|
+
-- replay tries to re-insert a node that HNSW's high-level wrapper already has,
|
|
4
|
+
-- and search_semantic then fails with "Duplicate keys not allowed in
|
|
5
|
+
-- high-level wrappers". Dropping and recreating the index rebuilds it cleanly
|
|
6
|
+
-- from the current contents of the embeddings table.
|
|
7
|
+
DROP INDEX IF EXISTS idx_embeddings_cosine;
|
|
8
|
+
|
|
9
|
+
CREATE INDEX idx_embeddings_cosine ON embeddings USING HNSW (embedding) WITH (metric = 'cosine');
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { refreshContextItems } from "../../context/refresh.ts";
|
|
3
|
+
import {
|
|
4
|
+
type ContextItem,
|
|
5
|
+
listContextItems,
|
|
6
|
+
listContextItemsByPrefix,
|
|
7
|
+
resolveContextItem,
|
|
8
|
+
} from "../../db/context.ts";
|
|
9
|
+
import type { ToolDefinition } from "../tool.ts";
|
|
10
|
+
|
|
11
|
+
const inputSchema = z.object({
|
|
12
|
+
path: z
|
|
13
|
+
.string()
|
|
14
|
+
.optional()
|
|
15
|
+
.describe(
|
|
16
|
+
"Context path or ID of a single item, or a path prefix to refresh a subtree. Mutually exclusive with `all`.",
|
|
17
|
+
),
|
|
18
|
+
all: z
|
|
19
|
+
.boolean()
|
|
20
|
+
.optional()
|
|
21
|
+
.describe(
|
|
22
|
+
"Refresh every item that has a source_path (file or URL). Mutually exclusive with `path`.",
|
|
23
|
+
),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const outputSchema = z.object({
|
|
27
|
+
checked: z.number(),
|
|
28
|
+
updated: z.number(),
|
|
29
|
+
unchanged: z.number(),
|
|
30
|
+
missing: z.number(),
|
|
31
|
+
reembedded: z.number(),
|
|
32
|
+
chunks: z.number(),
|
|
33
|
+
embeddings_skipped: z.boolean(),
|
|
34
|
+
items: z.array(
|
|
35
|
+
z.object({
|
|
36
|
+
id: z.string(),
|
|
37
|
+
context_path: z.string(),
|
|
38
|
+
source_path: z.string(),
|
|
39
|
+
source_type: z.enum(["file", "url"]),
|
|
40
|
+
status: z.enum(["updated", "unchanged", "missing", "error"]),
|
|
41
|
+
error: z.string().optional(),
|
|
42
|
+
}),
|
|
43
|
+
),
|
|
44
|
+
message: z.string(),
|
|
45
|
+
is_error: z.boolean(),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const empty = {
|
|
49
|
+
checked: 0,
|
|
50
|
+
updated: 0,
|
|
51
|
+
unchanged: 0,
|
|
52
|
+
missing: 0,
|
|
53
|
+
reembedded: 0,
|
|
54
|
+
chunks: 0,
|
|
55
|
+
embeddings_skipped: false,
|
|
56
|
+
items: [],
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export const contextRefreshTool = {
|
|
60
|
+
name: "context_refresh",
|
|
61
|
+
description:
|
|
62
|
+
"Re-read source files from disk / re-fetch source URLs, update stored content if it changed, and re-embed only changed items. Use `path` for a single item or subtree, or `all: true` for every sourced item. Items without a source_path are skipped. URL fetches use the project's MCPX client when available and fall back to plain HTTP.",
|
|
63
|
+
group: "context",
|
|
64
|
+
inputSchema,
|
|
65
|
+
outputSchema,
|
|
66
|
+
execute: async (input, ctx) => {
|
|
67
|
+
if (!input.path && !input.all) {
|
|
68
|
+
return {
|
|
69
|
+
...empty,
|
|
70
|
+
message: "Provide a `path` or set `all: true`.",
|
|
71
|
+
is_error: true,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
if (input.path && input.all) {
|
|
75
|
+
return {
|
|
76
|
+
...empty,
|
|
77
|
+
message: "`path` and `all` are mutually exclusive.",
|
|
78
|
+
is_error: true,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let items: ContextItem[];
|
|
83
|
+
if (input.all) {
|
|
84
|
+
items = await listContextItems(ctx.conn);
|
|
85
|
+
} else {
|
|
86
|
+
const exact = await resolveContextItem(ctx.conn, input.path as string);
|
|
87
|
+
items = exact
|
|
88
|
+
? [exact]
|
|
89
|
+
: await listContextItemsByPrefix(ctx.conn, input.path as string, {
|
|
90
|
+
recursive: true,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (items.length === 0) {
|
|
95
|
+
return {
|
|
96
|
+
...empty,
|
|
97
|
+
message: `No context items match \`${input.path ?? "all"}\`.`,
|
|
98
|
+
is_error: true,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const sourced = items.filter((i) => i.source_path);
|
|
103
|
+
if (sourced.length === 0) {
|
|
104
|
+
return {
|
|
105
|
+
...empty,
|
|
106
|
+
message:
|
|
107
|
+
"No matching items have a source_path — nothing to refresh. (Items created via `context write` are not sourced.)",
|
|
108
|
+
is_error: false,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const result = await refreshContextItems(
|
|
113
|
+
ctx.conn,
|
|
114
|
+
sourced,
|
|
115
|
+
ctx.config,
|
|
116
|
+
ctx.mcpxClient,
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const parts = [
|
|
120
|
+
`Checked ${result.checked}`,
|
|
121
|
+
`${result.updated} updated`,
|
|
122
|
+
`${result.unchanged} unchanged`,
|
|
123
|
+
`${result.missing} missing`,
|
|
124
|
+
`${result.reembedded} re-embedded (${result.chunks} chunks)`,
|
|
125
|
+
];
|
|
126
|
+
if (result.embeddings_skipped) {
|
|
127
|
+
parts.push("embeddings skipped (no OpenAI API key configured)");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
...result,
|
|
132
|
+
message: parts.join(", "),
|
|
133
|
+
is_error: false,
|
|
134
|
+
};
|
|
135
|
+
},
|
|
136
|
+
} satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
|
package/src/tools/dir/tree.ts
CHANGED
|
@@ -66,7 +66,7 @@ type TreeEntry = DirNode | FileNode;
|
|
|
66
66
|
export const contextTreeTool = {
|
|
67
67
|
name: "context_tree",
|
|
68
68
|
description:
|
|
69
|
-
"
|
|
69
|
+
"Explore your context filesystem with a bird's-eye view — shows many paths across nested directories in one call. Reach for this first when you need to discover what content exists before reading a specific file (context_read) or running a keyword search (context_search). Returns a markdown-style tree; tune max_depth and items_per_dir to bound output, or pass a deeper path to drill into a subtree.",
|
|
70
70
|
group: "context",
|
|
71
71
|
inputSchema,
|
|
72
72
|
outputSchema,
|
package/src/tools/registry.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Context tools
|
|
2
2
|
|
|
3
3
|
import { readLargeResultTool } from "./context/read-large-result.ts";
|
|
4
|
+
import { contextRefreshTool } from "./context/refresh.ts";
|
|
4
5
|
import { contextSearchTool } from "./context/search.ts";
|
|
5
6
|
import { updateBeliefsTool } from "./context/update-beliefs.ts";
|
|
6
7
|
import { updateGoalsTool } from "./context/update-goals.ts";
|
|
@@ -68,6 +69,7 @@ export function registerAllTools(): void {
|
|
|
68
69
|
registerTool(contextExistsTool);
|
|
69
70
|
registerTool(contextCountLinesTool);
|
|
70
71
|
registerTool(contextSearchTool);
|
|
72
|
+
registerTool(contextRefreshTool);
|
|
71
73
|
registerTool(updateBeliefsTool);
|
|
72
74
|
registerTool(updateGoalsTool);
|
|
73
75
|
registerTool(readLargeResultTool);
|