botholomew 0.8.9 → 0.9.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 +1 -1
- package/src/chat/agent.ts +34 -31
- package/src/commands/context.ts +223 -373
- package/src/commands/tools.ts +100 -11
- package/src/context/describer.ts +3 -118
- package/src/context/drives.ts +110 -0
- package/src/context/fetcher.ts +11 -1
- package/src/context/ingest.ts +13 -10
- package/src/context/refresh.ts +39 -24
- package/src/context/url-utils.ts +0 -23
- package/src/db/context.ts +195 -119
- package/src/db/embeddings.ts +35 -16
- package/src/db/sql/13-drive-paths.sql +49 -0
- package/src/tools/context/list-drives.ts +36 -0
- package/src/tools/context/refresh.ts +41 -23
- package/src/tools/context/search.ts +8 -3
- package/src/tools/dir/create.ts +14 -11
- package/src/tools/dir/size.ts +3 -2
- package/src/tools/dir/tree.ts +57 -17
- package/src/tools/file/copy.ts +14 -8
- package/src/tools/file/count-lines.ts +6 -3
- package/src/tools/file/delete.ts +12 -5
- package/src/tools/file/edit.ts +5 -3
- package/src/tools/file/exists.ts +25 -3
- package/src/tools/file/info.ts +90 -18
- package/src/tools/file/move.ts +15 -16
- package/src/tools/file/read.ts +79 -5
- package/src/tools/file/write.ts +29 -12
- package/src/tools/registry.ts +2 -2
- package/src/tools/search/grep.ts +44 -11
- package/src/tools/search/semantic.ts +7 -3
- package/src/tui/components/ContextPanel.tsx +73 -35
- package/src/tui/markdown.ts +2 -3
- package/src/worker/prompt.ts +42 -46
- package/src/tools/dir/list.ts +0 -89
package/src/tools/search/grep.ts
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import {
|
|
2
|
+
import { formatDriveRef } from "../../context/drives.ts";
|
|
3
|
+
import {
|
|
4
|
+
listContextItems,
|
|
5
|
+
listContextItemsByPrefix,
|
|
6
|
+
} from "../../db/context.ts";
|
|
3
7
|
import type { ToolDefinition } from "../tool.ts";
|
|
4
8
|
|
|
5
9
|
const GrepMatchSchema = z.object({
|
|
10
|
+
ref: z.string(),
|
|
11
|
+
drive: z.string(),
|
|
6
12
|
path: z.string(),
|
|
7
13
|
line: z.number(),
|
|
8
14
|
content: z.string(),
|
|
@@ -11,14 +17,20 @@ const GrepMatchSchema = z.object({
|
|
|
11
17
|
|
|
12
18
|
const inputSchema = z.object({
|
|
13
19
|
pattern: z.string().describe("Regex pattern to search for"),
|
|
20
|
+
drive: z
|
|
21
|
+
.string()
|
|
22
|
+
.optional()
|
|
23
|
+
.describe("Restrict search to a single drive (defaults to all drives)"),
|
|
14
24
|
path: z
|
|
15
25
|
.string()
|
|
16
26
|
.optional()
|
|
17
|
-
.describe(
|
|
27
|
+
.describe(
|
|
28
|
+
"Directory to search under within the drive (defaults to /). Requires `drive`.",
|
|
29
|
+
),
|
|
18
30
|
glob: z
|
|
19
31
|
.string()
|
|
20
32
|
.optional()
|
|
21
|
-
.describe("Only search files
|
|
33
|
+
.describe("Only search files whose basename matches this glob pattern"),
|
|
22
34
|
ignore_case: z.boolean().optional().describe("Case-insensitive search"),
|
|
23
35
|
context: z
|
|
24
36
|
.number()
|
|
@@ -33,20 +45,39 @@ const inputSchema = z.object({
|
|
|
33
45
|
const outputSchema = z.object({
|
|
34
46
|
matches: z.array(GrepMatchSchema),
|
|
35
47
|
is_error: z.boolean(),
|
|
48
|
+
error_type: z.string().optional(),
|
|
49
|
+
message: z.string().optional(),
|
|
36
50
|
});
|
|
37
51
|
|
|
38
52
|
export const searchGrepTool = {
|
|
39
53
|
name: "search_grep",
|
|
40
|
-
description:
|
|
41
|
-
"Search file contents by regex pattern in the virtual filesystem.",
|
|
54
|
+
description: "Search file contents by regex pattern across context drives.",
|
|
42
55
|
group: "search",
|
|
43
56
|
inputSchema,
|
|
44
57
|
outputSchema,
|
|
45
58
|
execute: async (input, ctx) => {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
59
|
+
// `path` scopes to a directory within a single drive; requiring `drive`
|
|
60
|
+
// alongside prevents a silent full-DB scan when only `path` is passed.
|
|
61
|
+
if (input.path && !input.drive) {
|
|
62
|
+
return {
|
|
63
|
+
matches: [],
|
|
64
|
+
is_error: true,
|
|
65
|
+
error_type: "invalid_arguments",
|
|
66
|
+
message:
|
|
67
|
+
"`path` requires `drive` — use context_list_drives to see which drives exist, then pass `drive` alongside `path`.",
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const items = input.drive
|
|
72
|
+
? await listContextItemsByPrefix(
|
|
73
|
+
ctx.conn,
|
|
74
|
+
input.drive,
|
|
75
|
+
input.path ?? "/",
|
|
76
|
+
{
|
|
77
|
+
recursive: true,
|
|
78
|
+
},
|
|
79
|
+
)
|
|
80
|
+
: await listContextItems(ctx.conn);
|
|
50
81
|
|
|
51
82
|
const flags = input.ignore_case ? "gi" : "g";
|
|
52
83
|
const regex = new RegExp(input.pattern, flags);
|
|
@@ -60,7 +91,7 @@ export const searchGrepTool = {
|
|
|
60
91
|
if (item.content == null) continue;
|
|
61
92
|
|
|
62
93
|
if (globRegex) {
|
|
63
|
-
const filename = item.
|
|
94
|
+
const filename = item.path.split("/").pop() ?? "";
|
|
64
95
|
if (!globRegex.test(filename)) continue;
|
|
65
96
|
}
|
|
66
97
|
|
|
@@ -72,7 +103,9 @@ export const searchGrepTool = {
|
|
|
72
103
|
const start = Math.max(0, i - contextLines);
|
|
73
104
|
const end = Math.min(lines.length, i + contextLines + 1);
|
|
74
105
|
matches.push({
|
|
75
|
-
|
|
106
|
+
ref: formatDriveRef(item),
|
|
107
|
+
drive: item.drive,
|
|
108
|
+
path: item.path,
|
|
76
109
|
line: i + 1,
|
|
77
110
|
content: line,
|
|
78
111
|
context_lines: lines.slice(start, end),
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { formatDriveRef } from "../../context/drives.ts";
|
|
2
3
|
import { embedSingle } from "../../context/embedder.ts";
|
|
3
4
|
import { hybridSearch } from "../../db/embeddings.ts";
|
|
4
5
|
import type { ToolDefinition } from "../tool.ts";
|
|
@@ -19,7 +20,7 @@ const inputSchema = z.object({
|
|
|
19
20
|
const outputSchema = z.object({
|
|
20
21
|
results: z.array(
|
|
21
22
|
z.object({
|
|
22
|
-
|
|
23
|
+
ref: z.string(),
|
|
23
24
|
title: z.string(),
|
|
24
25
|
score: z.number(),
|
|
25
26
|
snippet: z.string(),
|
|
@@ -31,7 +32,7 @@ const outputSchema = z.object({
|
|
|
31
32
|
export const searchSemanticTool = {
|
|
32
33
|
name: "search_semantic",
|
|
33
34
|
description:
|
|
34
|
-
"Semantic search over indexed
|
|
35
|
+
"Semantic search over indexed context using vector embeddings. Finds conceptually related content, not just keyword matches.",
|
|
35
36
|
group: "search",
|
|
36
37
|
inputSchema,
|
|
37
38
|
outputSchema,
|
|
@@ -53,7 +54,10 @@ export const searchSemanticTool = {
|
|
|
53
54
|
return {
|
|
54
55
|
results: filtered
|
|
55
56
|
.map((r) => ({
|
|
56
|
-
|
|
57
|
+
ref:
|
|
58
|
+
r.drive && r.path
|
|
59
|
+
? formatDriveRef({ drive: r.drive, path: r.path })
|
|
60
|
+
: r.context_item_id,
|
|
57
61
|
title: r.title,
|
|
58
62
|
score: Math.round(r.score * 1000) / 1000,
|
|
59
63
|
snippet: (r.chunk_content || "").slice(0, 300),
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Box, Text, useInput, useStdout } from "ink";
|
|
2
2
|
import { memo, useCallback, useEffect, useMemo, useState } from "react";
|
|
3
|
+
import { formatDriveRef } from "../../context/drives.ts";
|
|
3
4
|
import { withDb } from "../../db/connection.ts";
|
|
4
5
|
import {
|
|
5
6
|
type ContextItem,
|
|
@@ -7,6 +8,7 @@ import {
|
|
|
7
8
|
deleteContextItemsByPrefix,
|
|
8
9
|
getDistinctDirectories,
|
|
9
10
|
listContextItemsByPrefix,
|
|
11
|
+
listDriveSummaries,
|
|
10
12
|
searchContextByKeyword,
|
|
11
13
|
} from "../../db/context.ts";
|
|
12
14
|
import { isMarkdownItem, renderMarkdown } from "../markdown.ts";
|
|
@@ -16,6 +18,12 @@ interface ContextPanelProps {
|
|
|
16
18
|
isActive: boolean;
|
|
17
19
|
}
|
|
18
20
|
|
|
21
|
+
interface DriveEntry {
|
|
22
|
+
type: "drive";
|
|
23
|
+
drive: string;
|
|
24
|
+
count: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
19
27
|
interface DirEntry {
|
|
20
28
|
type: "directory";
|
|
21
29
|
name: string;
|
|
@@ -27,9 +35,8 @@ interface FileEntry {
|
|
|
27
35
|
item: ContextItem;
|
|
28
36
|
}
|
|
29
37
|
|
|
30
|
-
type Entry = DirEntry | FileEntry;
|
|
38
|
+
type Entry = DriveEntry | DirEntry | FileEntry;
|
|
31
39
|
|
|
32
|
-
// Reserve lines for header, search bar, padding, tab bar, status/input bar
|
|
33
40
|
const CHROME_LINES = 8;
|
|
34
41
|
|
|
35
42
|
export const ContextPanel = memo(function ContextPanel({
|
|
@@ -39,6 +46,8 @@ export const ContextPanel = memo(function ContextPanel({
|
|
|
39
46
|
const { stdout } = useStdout();
|
|
40
47
|
const termRows = stdout?.rows ?? 24;
|
|
41
48
|
|
|
49
|
+
// currentDrive === null means we're at the "pick a drive" level.
|
|
50
|
+
const [currentDrive, setCurrentDrive] = useState<string | null>(null);
|
|
42
51
|
const [currentPath, setCurrentPath] = useState("/");
|
|
43
52
|
const [entries, setEntries] = useState<Entry[]>([]);
|
|
44
53
|
const [cursor, setCursor] = useState(0);
|
|
@@ -54,7 +63,6 @@ export const ContextPanel = memo(function ContextPanel({
|
|
|
54
63
|
|
|
55
64
|
const visibleRows = Math.max(1, termRows - CHROME_LINES);
|
|
56
65
|
|
|
57
|
-
// Keep cursor in view by adjusting scroll offset
|
|
58
66
|
useEffect(() => {
|
|
59
67
|
if (cursor < scrollOffset) {
|
|
60
68
|
setScrollOffset(cursor);
|
|
@@ -64,10 +72,26 @@ export const ContextPanel = memo(function ContextPanel({
|
|
|
64
72
|
}, [cursor, scrollOffset, visibleRows]);
|
|
65
73
|
|
|
66
74
|
const loadEntries = useCallback(
|
|
67
|
-
async (path: string) => {
|
|
75
|
+
async (drive: string | null, path: string) => {
|
|
76
|
+
if (drive === null) {
|
|
77
|
+
const summaries = await withDb(dbPath, (conn) =>
|
|
78
|
+
listDriveSummaries(conn),
|
|
79
|
+
);
|
|
80
|
+
const driveEntries: DriveEntry[] = summaries.map((s) => ({
|
|
81
|
+
type: "drive",
|
|
82
|
+
drive: s.drive,
|
|
83
|
+
count: s.count,
|
|
84
|
+
}));
|
|
85
|
+
setEntries(driveEntries);
|
|
86
|
+
setCursor(0);
|
|
87
|
+
setScrollOffset(0);
|
|
88
|
+
setPreview(null);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
68
92
|
const [dirs, files] = await withDb(dbPath, async (conn) => [
|
|
69
|
-
await getDistinctDirectories(conn, path),
|
|
70
|
-
await listContextItemsByPrefix(conn, path, { recursive: false }),
|
|
93
|
+
await getDistinctDirectories(conn, drive, path),
|
|
94
|
+
await listContextItemsByPrefix(conn, drive, path, { recursive: false }),
|
|
71
95
|
]);
|
|
72
96
|
|
|
73
97
|
const dirEntries: DirEntry[] = dirs.map((d) => ({
|
|
@@ -77,7 +101,7 @@ export const ContextPanel = memo(function ContextPanel({
|
|
|
77
101
|
}));
|
|
78
102
|
|
|
79
103
|
const fileEntries: FileEntry[] = files
|
|
80
|
-
.filter((f) => !dirs.some((d) => f.
|
|
104
|
+
.filter((f) => !dirs.some((d) => f.path.startsWith(`${d}/`)))
|
|
81
105
|
.map((f) => ({ type: "file", item: f }));
|
|
82
106
|
|
|
83
107
|
setEntries([...dirEntries, ...fileEntries]);
|
|
@@ -90,9 +114,9 @@ export const ContextPanel = memo(function ContextPanel({
|
|
|
90
114
|
|
|
91
115
|
useEffect(() => {
|
|
92
116
|
if (searchResults === null) {
|
|
93
|
-
loadEntries(currentPath);
|
|
117
|
+
loadEntries(currentDrive, currentPath);
|
|
94
118
|
}
|
|
95
|
-
}, [currentPath, loadEntries, searchResults]);
|
|
119
|
+
}, [currentDrive, currentPath, loadEntries, searchResults]);
|
|
96
120
|
|
|
97
121
|
const executeSearch = useCallback(
|
|
98
122
|
async (query: string) => {
|
|
@@ -111,7 +135,6 @@ export const ContextPanel = memo(function ContextPanel({
|
|
|
111
135
|
[dbPath],
|
|
112
136
|
);
|
|
113
137
|
|
|
114
|
-
// Compute the items list and visible window for the current view
|
|
115
138
|
const items = searchResults ?? entries;
|
|
116
139
|
const itemCount = items.length;
|
|
117
140
|
const visibleItems = useMemo(
|
|
@@ -119,9 +142,6 @@ export const ContextPanel = memo(function ContextPanel({
|
|
|
119
142
|
[items, scrollOffset, visibleRows],
|
|
120
143
|
);
|
|
121
144
|
|
|
122
|
-
// Preview content split into lines for scrolling. Markdown files are
|
|
123
|
-
// rendered through Bun.markdown.ansi so headers/emphasis/code display
|
|
124
|
-
// with ANSI formatting in the terminal.
|
|
125
145
|
const previewLines = useMemo(() => {
|
|
126
146
|
if (!preview?.content) return [];
|
|
127
147
|
const body = isMarkdownItem(preview)
|
|
@@ -132,7 +152,6 @@ export const ContextPanel = memo(function ContextPanel({
|
|
|
132
152
|
|
|
133
153
|
useInput(
|
|
134
154
|
(input, key) => {
|
|
135
|
-
// Search mode: capture text input
|
|
136
155
|
if (searchMode) {
|
|
137
156
|
if (key.return) {
|
|
138
157
|
setSearchMode(false);
|
|
@@ -155,7 +174,6 @@ export const ContextPanel = memo(function ContextPanel({
|
|
|
155
174
|
return;
|
|
156
175
|
}
|
|
157
176
|
|
|
158
|
-
// Preview mode: scroll content
|
|
159
177
|
if (preview) {
|
|
160
178
|
if (key.upArrow) {
|
|
161
179
|
setPreviewScroll((s) => Math.max(0, s - 1));
|
|
@@ -174,20 +192,23 @@ export const ContextPanel = memo(function ContextPanel({
|
|
|
174
192
|
return;
|
|
175
193
|
}
|
|
176
194
|
|
|
177
|
-
// Delete confirmation mode
|
|
178
195
|
if (confirmDelete) {
|
|
179
196
|
if (input === "y" || input === "d") {
|
|
180
197
|
const entry = entries[cursor];
|
|
181
198
|
if (entry) {
|
|
182
199
|
void withDb(dbPath, async (conn) => {
|
|
183
|
-
if (entry.type === "directory") {
|
|
184
|
-
await deleteContextItemsByPrefix(
|
|
185
|
-
|
|
200
|
+
if (entry.type === "directory" && currentDrive) {
|
|
201
|
+
await deleteContextItemsByPrefix(
|
|
202
|
+
conn,
|
|
203
|
+
currentDrive,
|
|
204
|
+
entry.path,
|
|
205
|
+
);
|
|
206
|
+
} else if (entry.type === "file") {
|
|
186
207
|
await deleteContextItem(conn, entry.item.id);
|
|
187
208
|
}
|
|
188
209
|
});
|
|
189
210
|
setConfirmDelete(false);
|
|
190
|
-
loadEntries(currentPath);
|
|
211
|
+
loadEntries(currentDrive, currentPath);
|
|
191
212
|
}
|
|
192
213
|
} else {
|
|
193
214
|
setConfirmDelete(false);
|
|
@@ -195,7 +216,6 @@ export const ContextPanel = memo(function ContextPanel({
|
|
|
195
216
|
return;
|
|
196
217
|
}
|
|
197
218
|
|
|
198
|
-
// Normal navigation
|
|
199
219
|
if (input === "d" && itemCount > 0 && searchResults === null) {
|
|
200
220
|
setConfirmDelete(true);
|
|
201
221
|
return;
|
|
@@ -236,7 +256,10 @@ export const ContextPanel = memo(function ContextPanel({
|
|
|
236
256
|
}
|
|
237
257
|
const entry = entries[cursor];
|
|
238
258
|
if (!entry) return;
|
|
239
|
-
if (entry.type === "
|
|
259
|
+
if (entry.type === "drive") {
|
|
260
|
+
setCurrentDrive(entry.drive);
|
|
261
|
+
setCurrentPath("/");
|
|
262
|
+
} else if (entry.type === "directory") {
|
|
240
263
|
setCurrentPath(entry.path);
|
|
241
264
|
} else {
|
|
242
265
|
setPreview(entry.item);
|
|
@@ -251,13 +274,14 @@ export const ContextPanel = memo(function ContextPanel({
|
|
|
251
274
|
parts.pop();
|
|
252
275
|
const parent = parts.length <= 1 ? "/" : `${parts.join("/")}/`;
|
|
253
276
|
setCurrentPath(parent);
|
|
277
|
+
} else if (currentDrive !== null) {
|
|
278
|
+
setCurrentDrive(null);
|
|
254
279
|
}
|
|
255
280
|
}
|
|
256
281
|
},
|
|
257
282
|
{ isActive },
|
|
258
283
|
);
|
|
259
284
|
|
|
260
|
-
// Render search results view
|
|
261
285
|
if (searchResults !== null && !preview) {
|
|
262
286
|
return (
|
|
263
287
|
<Box flexDirection="column" flexGrow={1} paddingX={1} overflow="hidden">
|
|
@@ -272,17 +296,14 @@ export const ContextPanel = memo(function ContextPanel({
|
|
|
272
296
|
{visibleItems.map((item, vi) => {
|
|
273
297
|
const i = vi + scrollOffset;
|
|
274
298
|
const ci = item as ContextItem;
|
|
275
|
-
const
|
|
276
|
-
const dir =
|
|
277
|
-
slashIdx >= 0 ? ci.context_path.slice(0, slashIdx + 1) : "";
|
|
299
|
+
const ref = formatDriveRef(ci);
|
|
278
300
|
return (
|
|
279
301
|
<Box key={ci.id}>
|
|
280
302
|
<Text
|
|
281
303
|
backgroundColor={i === cursor ? "#333" : undefined}
|
|
282
304
|
color={i === cursor ? "cyan" : undefined}
|
|
283
305
|
>
|
|
284
|
-
{" "}📄 <Text dimColor>{
|
|
285
|
-
{ci.title}
|
|
306
|
+
{" "}📄 <Text dimColor>{ref}</Text> — {ci.title}
|
|
286
307
|
<Text dimColor> ({ci.mime_type})</Text>
|
|
287
308
|
</Text>
|
|
288
309
|
</Box>
|
|
@@ -301,7 +322,6 @@ export const ContextPanel = memo(function ContextPanel({
|
|
|
301
322
|
);
|
|
302
323
|
}
|
|
303
324
|
|
|
304
|
-
// Render file preview with scrolling
|
|
305
325
|
if (preview) {
|
|
306
326
|
const visiblePreviewLines = previewLines.slice(
|
|
307
327
|
previewScroll,
|
|
@@ -311,7 +331,7 @@ export const ContextPanel = memo(function ContextPanel({
|
|
|
311
331
|
<Box flexDirection="column" flexGrow={1} paddingX={1} overflow="hidden">
|
|
312
332
|
<Box>
|
|
313
333
|
<Text bold color="cyan">
|
|
314
|
-
{preview
|
|
334
|
+
{formatDriveRef(preview)}
|
|
315
335
|
</Text>
|
|
316
336
|
<Text dimColor> (esc to go back · ↑↓ to scroll)</Text>
|
|
317
337
|
</Box>
|
|
@@ -321,7 +341,6 @@ export const ContextPanel = memo(function ContextPanel({
|
|
|
321
341
|
{preview.description ? ` · ${preview.description}` : ""}
|
|
322
342
|
</Text>
|
|
323
343
|
<Text dimColor>
|
|
324
|
-
Source: {preview.source_path ?? "n/a"} ·{" "}
|
|
325
344
|
{preview.indexed_at ? "Indexed" : "Not indexed"} · Updated:{" "}
|
|
326
345
|
{preview.updated_at.toLocaleDateString()}
|
|
327
346
|
</Text>
|
|
@@ -354,12 +373,16 @@ export const ContextPanel = memo(function ContextPanel({
|
|
|
354
373
|
);
|
|
355
374
|
}
|
|
356
375
|
|
|
357
|
-
|
|
376
|
+
const headerLabel =
|
|
377
|
+
currentDrive === null
|
|
378
|
+
? "(drives)"
|
|
379
|
+
: formatDriveRef({ drive: currentDrive, path: currentPath });
|
|
380
|
+
|
|
358
381
|
return (
|
|
359
382
|
<Box flexDirection="column" flexGrow={1} paddingX={1} overflow="hidden">
|
|
360
383
|
<Box>
|
|
361
384
|
<Text bold color="cyan">
|
|
362
|
-
{
|
|
385
|
+
{headerLabel}
|
|
363
386
|
</Text>
|
|
364
387
|
<Text dimColor>
|
|
365
388
|
{" "}
|
|
@@ -378,8 +401,10 @@ export const ContextPanel = memo(function ContextPanel({
|
|
|
378
401
|
<Text color="red" bold>
|
|
379
402
|
Delete{" "}
|
|
380
403
|
{entries[cursor].type === "directory"
|
|
381
|
-
? `${entries[cursor].name}/ and all contents`
|
|
382
|
-
:
|
|
404
|
+
? `${(entries[cursor] as DirEntry).name}/ and all contents`
|
|
405
|
+
: entries[cursor].type === "file"
|
|
406
|
+
? (entries[cursor] as FileEntry).item.title
|
|
407
|
+
: "(pick a file first)"}
|
|
383
408
|
? (y/n)
|
|
384
409
|
</Text>
|
|
385
410
|
</Box>
|
|
@@ -390,6 +415,19 @@ export const ContextPanel = memo(function ContextPanel({
|
|
|
390
415
|
const i = vi + scrollOffset;
|
|
391
416
|
const entry = raw as Entry;
|
|
392
417
|
const isSelected = i === cursor;
|
|
418
|
+
if (entry.type === "drive") {
|
|
419
|
+
return (
|
|
420
|
+
<Box key={entry.drive}>
|
|
421
|
+
<Text
|
|
422
|
+
backgroundColor={isSelected ? "#333" : undefined}
|
|
423
|
+
color={isSelected ? "cyan" : "magenta"}
|
|
424
|
+
bold={isSelected}
|
|
425
|
+
>
|
|
426
|
+
{" "}🗄 {entry.drive}:/ <Text dimColor>({entry.count})</Text>
|
|
427
|
+
</Text>
|
|
428
|
+
</Box>
|
|
429
|
+
);
|
|
430
|
+
}
|
|
393
431
|
if (entry.type === "directory") {
|
|
394
432
|
return (
|
|
395
433
|
<Box key={entry.path}>
|
package/src/tui/markdown.ts
CHANGED
|
@@ -6,10 +6,9 @@ export function renderMarkdown(text: string): string {
|
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
export function isMarkdownItem(
|
|
9
|
-
item: Pick<ContextItem, "mime_type" | "
|
|
9
|
+
item: Pick<ContextItem, "mime_type" | "path">,
|
|
10
10
|
): boolean {
|
|
11
11
|
if (item.mime_type === "text/markdown") return true;
|
|
12
|
-
if (item.
|
|
13
|
-
if (item.context_path.toLowerCase().endsWith(".md")) return true;
|
|
12
|
+
if (item.path.toLowerCase().endsWith(".md")) return true;
|
|
14
13
|
return false;
|
|
15
14
|
}
|
package/src/worker/prompt.ts
CHANGED
|
@@ -29,16 +29,16 @@ export function extractKeywords(text: string): Set<string> {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
|
-
* Load persistent context files from .botholomew/ directory
|
|
33
|
-
*
|
|
34
|
-
*
|
|
32
|
+
* Load persistent context files from .botholomew/ directory as a single
|
|
33
|
+
* formatted string. Includes "always" files unconditionally and "contextual"
|
|
34
|
+
* files whose content overlaps the provided taskKeywords.
|
|
35
35
|
*/
|
|
36
36
|
export async function loadPersistentContext(
|
|
37
37
|
projectDir: string,
|
|
38
38
|
taskKeywords?: Set<string> | null,
|
|
39
|
-
): Promise<string
|
|
39
|
+
): Promise<string> {
|
|
40
40
|
const dotDir = getBotholomewDir(projectDir);
|
|
41
|
-
|
|
41
|
+
let out = "";
|
|
42
42
|
|
|
43
43
|
try {
|
|
44
44
|
const files = await readdir(dotDir);
|
|
@@ -50,18 +50,14 @@ export async function loadPersistentContext(
|
|
|
50
50
|
const { meta, content } = parseContextFile(raw);
|
|
51
51
|
|
|
52
52
|
if (meta.loading === "always") {
|
|
53
|
-
|
|
54
|
-
parts.push(content);
|
|
55
|
-
parts.push("");
|
|
53
|
+
out += `## ${filename}\n${content}\n\n`;
|
|
56
54
|
} else if (meta.loading === "contextual" && taskKeywords) {
|
|
57
55
|
const contentLower = content.toLowerCase();
|
|
58
56
|
const hasOverlap = [...taskKeywords].some((kw) =>
|
|
59
57
|
contentLower.includes(kw),
|
|
60
58
|
);
|
|
61
59
|
if (hasOverlap) {
|
|
62
|
-
|
|
63
|
-
parts.push(content);
|
|
64
|
-
parts.push("");
|
|
60
|
+
out += `## ${filename} (contextual)\n${content}\n\n`;
|
|
65
61
|
}
|
|
66
62
|
}
|
|
67
63
|
}
|
|
@@ -69,21 +65,20 @@ export async function loadPersistentContext(
|
|
|
69
65
|
// .botholomew dir might not have md files yet
|
|
70
66
|
}
|
|
71
67
|
|
|
72
|
-
return
|
|
68
|
+
return out;
|
|
73
69
|
}
|
|
74
70
|
|
|
75
71
|
/**
|
|
76
72
|
* Build common meta header (version, time, OS, user).
|
|
77
73
|
*/
|
|
78
|
-
export function buildMetaHeader(projectDir: string): string
|
|
79
|
-
return
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
];
|
|
74
|
+
export function buildMetaHeader(projectDir: string): string {
|
|
75
|
+
return `# Botholomew v${pkg.version}
|
|
76
|
+
Current time: ${new Date().toISOString()}
|
|
77
|
+
Project directory: ${projectDir}
|
|
78
|
+
OS: ${process.platform} ${process.arch}
|
|
79
|
+
User: ${process.env.USER || process.env.USERNAME || "unknown"}
|
|
80
|
+
|
|
81
|
+
`;
|
|
87
82
|
}
|
|
88
83
|
|
|
89
84
|
export async function buildSystemPrompt(
|
|
@@ -93,20 +88,14 @@ export async function buildSystemPrompt(
|
|
|
93
88
|
_config?: Required<BotholomewConfig>,
|
|
94
89
|
options?: { hasMcpTools?: boolean },
|
|
95
90
|
): Promise<string> {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
// Meta information
|
|
99
|
-
parts.push(...buildMetaHeader(projectDir));
|
|
91
|
+
let prompt = buildMetaHeader(projectDir);
|
|
100
92
|
|
|
101
|
-
// Build keyword set from task for contextual loading
|
|
102
93
|
const taskKeywords = task
|
|
103
94
|
? extractKeywords(`${task.name} ${task.description}`)
|
|
104
95
|
: null;
|
|
105
96
|
|
|
106
|
-
|
|
107
|
-
parts.push(...(await loadPersistentContext(projectDir, taskKeywords)));
|
|
97
|
+
prompt += await loadPersistentContext(projectDir, taskKeywords);
|
|
108
98
|
|
|
109
|
-
// Relevant context from embeddings search
|
|
110
99
|
if (task && dbPath && _config?.openai_api_key) {
|
|
111
100
|
try {
|
|
112
101
|
const query = `${task.name} ${task.description}`;
|
|
@@ -116,14 +105,15 @@ export async function buildSystemPrompt(
|
|
|
116
105
|
);
|
|
117
106
|
|
|
118
107
|
if (results.length > 0) {
|
|
119
|
-
|
|
108
|
+
prompt += "## Relevant Context\n";
|
|
120
109
|
for (const r of results) {
|
|
121
|
-
const
|
|
122
|
-
|
|
110
|
+
const ref =
|
|
111
|
+
r.drive && r.path ? `${r.drive}:${r.path}` : r.context_item_id;
|
|
112
|
+
prompt += `### ${r.title} (${ref})\n`;
|
|
123
113
|
if (r.chunk_content) {
|
|
124
|
-
|
|
114
|
+
prompt += `${r.chunk_content.slice(0, 1000)}\n`;
|
|
125
115
|
}
|
|
126
|
-
|
|
116
|
+
prompt += "\n";
|
|
127
117
|
}
|
|
128
118
|
}
|
|
129
119
|
} catch (err) {
|
|
@@ -131,19 +121,25 @@ export async function buildSystemPrompt(
|
|
|
131
121
|
}
|
|
132
122
|
}
|
|
133
123
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
124
|
+
prompt += `## Instructions
|
|
125
|
+
You are Botholomew, a wise-owl worker that works through tasks. Use available tools to complete your assigned task, then call complete_task, fail_task, or wait_task. Use create_task for subtasks and update_task to refine pending tasks. Batch independent tool calls in a single response for parallel execution.
|
|
126
|
+
|
|
127
|
+
When calling complete_task, write a summary that captures your key findings, decisions, and outputs. This summary becomes the task's output and is provided to any downstream tasks that depend on this one. Include specific results (data, names, paths, conclusions) rather than vague descriptions of what you did — downstream tasks will rely on this information to do their work.
|
|
128
|
+
`;
|
|
129
|
+
|
|
139
130
|
if (options?.hasMcpTools) {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
131
|
+
prompt += `
|
|
132
|
+
## External Tools (MCP)
|
|
133
|
+
|
|
134
|
+
You have access to external tools via MCP servers. Before calling any MCP tool you haven't used yet this session, you MUST fetch its schema first:
|
|
135
|
+
|
|
136
|
+
1. Discover tools with \`mcp_search\` (preferred — semantic) or \`mcp_list_tools\`.
|
|
137
|
+
2. Call \`mcp_info\` with the exact \`server\` and \`tool\` to read the tool's input schema, required fields, and types.
|
|
138
|
+
3. Only then call \`mcp_exec\` with arguments that conform to that schema.
|
|
139
|
+
|
|
140
|
+
Skip step 2 only if you already called \`mcp_info\` for that exact server+tool earlier in this conversation. Do not guess arguments from the tool's description alone — descriptions omit types and required/optional markers.
|
|
141
|
+
`;
|
|
145
142
|
}
|
|
146
|
-
parts.push("");
|
|
147
143
|
|
|
148
|
-
return
|
|
144
|
+
return prompt;
|
|
149
145
|
}
|