botholomew 0.8.10 → 0.9.5

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.
@@ -1,8 +1,14 @@
1
1
  import { z } from "zod";
2
- import { listContextItemsByPrefix } from "../../db/context.ts";
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("Directory to search in (defaults to /)"),
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 matching this glob pattern"),
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
- const searchPath = input.path ?? "/";
47
- const items = await listContextItemsByPrefix(ctx.conn, searchPath, {
48
- recursive: true,
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.context_path.split("/").pop() ?? "";
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
- path: item.context_path,
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
- path: z.string(),
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 files using vector embeddings. Finds conceptually related content, not just keyword matches.",
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
- path: r.source_path || r.context_item_id,
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.context_path.startsWith(`${d}/`)))
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(conn, entry.path);
185
- } else {
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 === "directory") {
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 slashIdx = ci.context_path.lastIndexOf("/");
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>{dir}</Text>
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.context_path}
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
- // Render directory listing with scroll window
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
- {currentPath}
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
- : (entries[cursor] as FileEntry).item.title}
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}>
@@ -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" | "source_path" | "context_path">,
9
+ item: Pick<ContextItem, "mime_type" | "path">,
10
10
  ): boolean {
11
11
  if (item.mime_type === "text/markdown") return true;
12
- if (item.source_path?.toLowerCase().endsWith(".md")) return true;
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
  }
@@ -107,8 +107,9 @@ export async function buildSystemPrompt(
107
107
  if (results.length > 0) {
108
108
  prompt += "## Relevant Context\n";
109
109
  for (const r of results) {
110
- const path = r.source_path || r.context_item_id;
111
- prompt += `### ${r.title} (${path})\n`;
110
+ const ref =
111
+ r.drive && r.path ? `${r.drive}:${r.path}` : r.context_item_id;
112
+ prompt += `### ${r.title} (${ref})\n`;
112
113
  if (r.chunk_content) {
113
114
  prompt += `${r.chunk_content.slice(0, 1000)}\n`;
114
115
  }
@@ -130,6 +131,8 @@ When calling complete_task, write a summary that captures your key findings, dec
130
131
  prompt += `
131
132
  ## External Tools (MCP)
132
133
 
134
+ Before reaching for MCP tools to **find** information, check local context first — content from Drive, Gmail, GitHub, URLs, and prior agent runs is often already ingested. Use \`search_semantic\` (semantic) or \`context_search\` (keyword) across drives, then \`context_read\` / \`context_tree\` to drill in. Only fall through to \`mcp_exec\` when the data is fresh, write-side (sending an email, creating an issue), or genuinely missing locally.
135
+
133
136
  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:
134
137
 
135
138
  1. Discover tools with \`mcp_search\` (preferred — semantic) or \`mcp_list_tools\`.
@@ -1,89 +0,0 @@
1
- import { z } from "zod";
2
- import {
3
- getDistinctDirectories,
4
- listContextItemsByPrefix,
5
- } from "../../db/context.ts";
6
- import type { ToolDefinition } from "../tool.ts";
7
-
8
- const DirEntrySchema = z.object({
9
- name: z.string(),
10
- type: z.enum(["file", "directory"]),
11
- size: z.number(),
12
- });
13
-
14
- const inputSchema = z.object({
15
- path: z.string().optional().describe("Directory path (defaults to /)"),
16
- recursive: z
17
- .boolean()
18
- .optional()
19
- .default(true)
20
- .describe("Include contents of subdirectories (defaults to true)"),
21
- limit: z
22
- .number()
23
- .optional()
24
- .default(100)
25
- .describe("Maximum number of entries to return (defaults to 100)"),
26
- offset: z
27
- .number()
28
- .optional()
29
- .default(0)
30
- .describe("Number of entries to skip (defaults to 0)"),
31
- });
32
-
33
- const outputSchema = z.object({
34
- entries: z.array(DirEntrySchema),
35
- total: z.number(),
36
- is_error: z.boolean(),
37
- });
38
-
39
- export const contextListDirTool = {
40
- name: "context_list_dir",
41
- description:
42
- "[[ bash equivalent command: ls ]] List directory contents in context.",
43
- group: "context",
44
- inputSchema,
45
- outputSchema,
46
- execute: async (input, ctx) => {
47
- const path = input.path ?? "/";
48
- const recursive = input.recursive ?? true;
49
- const limit = input.limit ?? 100;
50
- const offset = input.offset ?? 0;
51
- const normalizedPath = path.endsWith("/") ? path : `${path}/`;
52
-
53
- const allItems = await listContextItemsByPrefix(ctx.conn, path, {
54
- recursive,
55
- });
56
-
57
- const entries: z.infer<typeof DirEntrySchema>[] = allItems.map((item) => ({
58
- name: recursive
59
- ? item.context_path
60
- : item.context_path.slice(normalizedPath.length),
61
- type:
62
- item.mime_type === "inode/directory"
63
- ? ("directory" as const)
64
- : ("file" as const),
65
- size: item.content?.length ?? 0,
66
- }));
67
-
68
- // Add subdirectories (if not recursive, show immediate child dirs)
69
- if (!recursive) {
70
- const dirs = await getDistinctDirectories(ctx.conn, path);
71
- for (const dir of dirs) {
72
- const name = dir.slice(normalizedPath.length);
73
- if (!entries.some((e) => e.name === name)) {
74
- entries.push({ name, type: "directory", size: 0 });
75
- }
76
- }
77
- }
78
-
79
- entries.sort((a, b) => {
80
- if (a.type !== b.type) return a.type === "directory" ? -1 : 1;
81
- return a.name.localeCompare(b.name);
82
- });
83
-
84
- const total = entries.length;
85
- const paginated = entries.slice(offset, offset + limit);
86
-
87
- return { entries: paginated, total, is_error: false };
88
- },
89
- } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;