botholomew 0.6.1 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -24,11 +24,11 @@ const outputSchema = z.object({
24
24
  is_error: z.boolean(),
25
25
  });
26
26
 
27
- export const fileEditTool = {
28
- name: "file_edit",
27
+ export const contextEditTool = {
28
+ name: "context_edit",
29
29
  description:
30
- "Apply git-style patches to a file. Each patch specifies a line range to replace.",
31
- group: "file",
30
+ "Apply git-style patches to a context item. Each patch specifies a line range to replace.",
31
+ group: "context",
32
32
  inputSchema,
33
33
  outputSchema,
34
34
  execute: async (input, ctx) => {
@@ -1,9 +1,9 @@
1
1
  import { z } from "zod";
2
- import { contextPathExists } from "../../db/context.ts";
2
+ import { resolveContextItem } from "../../db/context.ts";
3
3
  import type { ToolDefinition } from "../tool.ts";
4
4
 
5
5
  const inputSchema = z.object({
6
- path: z.string().describe("File path to check"),
6
+ path: z.string().describe("File path or context item ID"),
7
7
  });
8
8
 
9
9
  const outputSchema = z.object({
@@ -11,14 +11,14 @@ const outputSchema = z.object({
11
11
  is_error: z.boolean(),
12
12
  });
13
13
 
14
- export const fileExistsTool = {
15
- name: "file_exists",
16
- description: "Check if a file exists in the virtual filesystem.",
17
- group: "file",
14
+ export const contextExistsTool = {
15
+ name: "context_exists",
16
+ description: "Check if a context item exists.",
17
+ group: "context",
18
18
  inputSchema,
19
19
  outputSchema,
20
20
  execute: async (input, ctx) => {
21
- const exists = await contextPathExists(ctx.conn, input.path);
22
- return { exists, is_error: false };
21
+ const item = await resolveContextItem(ctx.conn, input.path);
22
+ return { exists: item !== null, is_error: false };
23
23
  },
24
24
  } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
@@ -1,9 +1,9 @@
1
1
  import { z } from "zod";
2
- import { getContextItemByPath } from "../../db/context.ts";
2
+ import { resolveContextItemOrThrow } from "../../db/context.ts";
3
3
  import type { ToolDefinition } from "../tool.ts";
4
4
 
5
5
  const inputSchema = z.object({
6
- path: z.string().describe("File path"),
6
+ path: z.string().describe("File path or context item ID"),
7
7
  });
8
8
 
9
9
  const outputSchema = z.object({
@@ -22,15 +22,15 @@ const outputSchema = z.object({
22
22
  is_error: z.boolean(),
23
23
  });
24
24
 
25
- export const fileInfoTool = {
26
- name: "file_info",
27
- description: "Show file metadata (size, MIME type, line count, etc.).",
28
- group: "file",
25
+ export const contextInfoTool = {
26
+ name: "context_info",
27
+ description:
28
+ "Show context item metadata (size, MIME type, line count, etc.).",
29
+ group: "context",
29
30
  inputSchema,
30
31
  outputSchema,
31
32
  execute: async (input, ctx) => {
32
- const item = await getContextItemByPath(ctx.conn, input.path);
33
- if (!item) throw new Error(`Not found: ${input.path}`);
33
+ const item = await resolveContextItemOrThrow(ctx.conn, input.path);
34
34
 
35
35
  const content = item.content ?? "";
36
36
  return {
@@ -17,10 +17,10 @@ const outputSchema = z.object({
17
17
  is_error: z.boolean(),
18
18
  });
19
19
 
20
- export const fileMoveTool = {
21
- name: "file_move",
22
- description: "Move or rename a file in the virtual filesystem.",
23
- group: "file",
20
+ export const contextMoveTool = {
21
+ name: "context_move",
22
+ description: "Move or rename a context item.",
23
+ group: "context",
24
24
  inputSchema,
25
25
  outputSchema,
26
26
  execute: async (input, ctx) => {
@@ -1,9 +1,9 @@
1
1
  import { z } from "zod";
2
- import { getContextItemByPath } from "../../db/context.ts";
2
+ import { resolveContextItemOrThrow } from "../../db/context.ts";
3
3
  import type { ToolDefinition } from "../tool.ts";
4
4
 
5
5
  const inputSchema = z.object({
6
- path: z.string().describe("File path to read"),
6
+ path: z.string().describe("File path or context item ID"),
7
7
  offset: z
8
8
  .number()
9
9
  .optional()
@@ -16,15 +16,14 @@ const outputSchema = z.object({
16
16
  is_error: z.boolean(),
17
17
  });
18
18
 
19
- export const fileReadTool = {
20
- name: "file_read",
21
- description: "Read a file's contents from the virtual filesystem.",
22
- group: "file",
19
+ export const contextReadTool = {
20
+ name: "context_read",
21
+ description: "Read a context item's contents.",
22
+ group: "context",
23
23
  inputSchema,
24
24
  outputSchema,
25
25
  execute: async (input, ctx) => {
26
- const item = await getContextItemByPath(ctx.conn, input.path);
27
- if (!item) throw new Error(`Not found: ${input.path}`);
26
+ const item = await resolveContextItemOrThrow(ctx.conn, input.path);
28
27
  if (item.content == null) throw new Error(`No text content: ${input.path}`);
29
28
 
30
29
  let content = item.content;
@@ -38,11 +38,11 @@ const outputSchema = z.object({
38
38
  is_error: z.boolean(),
39
39
  });
40
40
 
41
- export const fileWriteTool = {
42
- name: "file_write",
41
+ export const contextWriteTool = {
42
+ name: "context_write",
43
43
  description:
44
- "Write content to a file in the virtual filesystem. Creates the file if it doesn't exist, or overwrites if it does.",
45
- group: "file",
44
+ "Write content to a context item. Creates the item if it doesn't exist, or overwrites if it does.",
45
+ group: "context",
46
46
  inputSchema,
47
47
  outputSchema,
48
48
  execute: async (input, ctx) => {
@@ -1,23 +1,24 @@
1
1
  // Context tools
2
+
2
3
  import { readLargeResultTool } from "./context/read-large-result.ts";
3
- import { searchContextTool } from "./context/search.ts";
4
+ import { contextSearchTool } from "./context/search.ts";
4
5
  import { updateBeliefsTool } from "./context/update-beliefs.ts";
5
6
  import { updateGoalsTool } from "./context/update-goals.ts";
6
- // Directory tools
7
- import { dirCreateTool } from "./dir/create.ts";
8
- import { dirListTool } from "./dir/list.ts";
9
- import { dirSizeTool } from "./dir/size.ts";
10
- import { dirTreeTool } from "./dir/tree.ts";
11
- import { fileCopyTool } from "./file/copy.ts";
12
- import { fileCountLinesTool } from "./file/count-lines.ts";
13
- import { fileDeleteTool } from "./file/delete.ts";
14
- import { fileEditTool } from "./file/edit.ts";
15
- import { fileExistsTool } from "./file/exists.ts";
16
- import { fileInfoTool } from "./file/info.ts";
17
- import { fileMoveTool } from "./file/move.ts";
18
- // File tools
19
- import { fileReadTool } from "./file/read.ts";
20
- import { fileWriteTool } from "./file/write.ts";
7
+ // Context — directory operations
8
+ import { contextCreateDirTool } from "./dir/create.ts";
9
+ import { contextListDirTool } from "./dir/list.ts";
10
+ import { contextDirSizeTool } from "./dir/size.ts";
11
+ import { contextTreeTool } from "./dir/tree.ts";
12
+ // Context file operations
13
+ import { contextCopyTool } from "./file/copy.ts";
14
+ import { contextCountLinesTool } from "./file/count-lines.ts";
15
+ import { contextDeleteTool } from "./file/delete.ts";
16
+ import { contextEditTool } from "./file/edit.ts";
17
+ import { contextExistsTool } from "./file/exists.ts";
18
+ import { contextInfoTool } from "./file/info.ts";
19
+ import { contextMoveTool } from "./file/move.ts";
20
+ import { contextReadTool } from "./file/read.ts";
21
+ import { contextWriteTool } from "./file/write.ts";
21
22
  // MCP tools
22
23
  import { mcpExecTool } from "./mcp/exec.ts";
23
24
  import { mcpInfoTool } from "./mcp/info.ts";
@@ -52,22 +53,24 @@ export function registerAllTools(): void {
52
53
  registerTool(listTasksTool);
53
54
  registerTool(viewTaskTool);
54
55
 
55
- // Directory
56
- registerTool(dirCreateTool);
57
- registerTool(dirListTool);
58
- registerTool(dirTreeTool);
59
- registerTool(dirSizeTool);
60
-
61
- // File
62
- registerTool(fileReadTool);
63
- registerTool(fileWriteTool);
64
- registerTool(fileEditTool);
65
- registerTool(fileDeleteTool);
66
- registerTool(fileCopyTool);
67
- registerTool(fileMoveTool);
68
- registerTool(fileInfoTool);
69
- registerTool(fileExistsTool);
70
- registerTool(fileCountLinesTool);
56
+ // Context
57
+ registerTool(contextCreateDirTool);
58
+ registerTool(contextListDirTool);
59
+ registerTool(contextTreeTool);
60
+ registerTool(contextDirSizeTool);
61
+ registerTool(contextReadTool);
62
+ registerTool(contextWriteTool);
63
+ registerTool(contextEditTool);
64
+ registerTool(contextDeleteTool);
65
+ registerTool(contextCopyTool);
66
+ registerTool(contextMoveTool);
67
+ registerTool(contextInfoTool);
68
+ registerTool(contextExistsTool);
69
+ registerTool(contextCountLinesTool);
70
+ registerTool(contextSearchTool);
71
+ registerTool(updateBeliefsTool);
72
+ registerTool(updateGoalsTool);
73
+ registerTool(readLargeResultTool);
71
74
 
72
75
  // Schedule
73
76
  registerTool(createScheduleTool);
@@ -81,12 +84,6 @@ export function registerAllTools(): void {
81
84
  registerTool(listThreadsTool);
82
85
  registerTool(viewThreadTool);
83
86
 
84
- // Context
85
- registerTool(searchContextTool);
86
- registerTool(updateBeliefsTool);
87
- registerTool(updateGoalsTool);
88
- registerTool(readLargeResultTool);
89
-
90
87
  // MCP
91
88
  registerTool(mcpListToolsTool);
92
89
  registerTool(mcpSearchTool);
package/src/tui/App.tsx CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  import { MAX_INLINE_CHARS, PAGE_SIZE_CHARS } from "../daemon/large-results.ts";
10
10
  import type { Interaction } from "../db/threads.ts";
11
11
  import { getThread } from "../db/threads.ts";
12
+ import { handleSlashCommand } from "../skills/commands.ts";
12
13
  import { ContextPanel } from "./components/ContextPanel.tsx";
13
14
  import { HelpPanel } from "./components/HelpPanel.tsx";
14
15
  import { InputBar } from "./components/InputBar.tsx";
@@ -209,6 +210,11 @@ export function App({
209
210
  queuedMessagesRef.current = queuedMessages;
210
211
  selectedQueueIndexRef.current = selectedQueueIndex;
211
212
 
213
+ const tabConsumedRef = useRef(false);
214
+ const handleTabConsumed = useCallback(() => {
215
+ tabConsumedRef.current = true;
216
+ }, []);
217
+
212
218
  const stableAppHandler = useCallback(
213
219
  // biome-ignore lint/suspicious/noExplicitAny: Ink's Key type is not exported
214
220
  (input: string, key: any) => {
@@ -218,8 +224,12 @@ export function App({
218
224
  return;
219
225
  }
220
226
 
221
- // Tab key cycles tabs — always active (InputBar ignores tab)
227
+ // Tab key cycles tabs — unless InputBar consumed it for completion
222
228
  if (key.tab && !key.shift) {
229
+ if (tabConsumedRef.current) {
230
+ tabConsumedRef.current = false;
231
+ return;
232
+ }
223
233
  setActiveTab((t) => ((t % 7) + 1) as TabId);
224
234
  return;
225
235
  }
@@ -414,6 +424,23 @@ export function App({
414
424
  setInputValue("");
415
425
 
416
426
  if (trimmed === "/help") {
427
+ const skills = sessionRef.current.skills;
428
+ const skillLines: string[] = [];
429
+ if (skills.size > 0) {
430
+ skillLines.push("", "Skills:");
431
+ for (const [skillName, skill] of skills) {
432
+ skillLines.push(
433
+ ` /${skillName.padEnd(14)} ${skill.description || "(no description)"}`,
434
+ );
435
+ }
436
+ } else {
437
+ skillLines.push(
438
+ "",
439
+ "Skills:",
440
+ " (none — add .md files to .botholomew/skills/)",
441
+ );
442
+ }
443
+
417
444
  const helpMsg: ChatMessage = {
418
445
  id: msgId(),
419
446
  role: "system",
@@ -467,7 +494,9 @@ export function App({
467
494
  "",
468
495
  "Commands:",
469
496
  " /help Show this help",
497
+ " /skills List available skills",
470
498
  " /quit, /exit End the chat session",
499
+ ...skillLines,
471
500
  ].join("\n"),
472
501
  timestamp: new Date(),
473
502
  };
@@ -475,9 +504,28 @@ export function App({
475
504
  return;
476
505
  }
477
506
 
478
- if (trimmed === "/quit" || trimmed === "/exit") {
479
- exit();
480
- return;
507
+ if (trimmed.startsWith("/")) {
508
+ const skills = sessionRef.current.skills;
509
+ const handled = handleSlashCommand(trimmed, {
510
+ skills,
511
+ addSystemMessage: (content) => {
512
+ const msg: ChatMessage = {
513
+ id: msgId(),
514
+ role: "system",
515
+ content,
516
+ timestamp: new Date(),
517
+ };
518
+ setMessages((prev) => [...prev, msg]);
519
+ },
520
+ queueUserMessage: (content) => {
521
+ setInputHistory((prev) => [...prev, trimmed]);
522
+ queueRef.current.push(content);
523
+ syncQueue();
524
+ processQueue();
525
+ },
526
+ exit,
527
+ });
528
+ if (handled) return;
481
529
  }
482
530
 
483
531
  setInputHistory((prev) => [...prev, trimmed]);
@@ -502,6 +550,15 @@ export function App({
502
550
  [projectDir, sessionConn, chatTitle],
503
551
  );
504
552
 
553
+ const sessionSkills = ready ? sessionRef.current?.skills : undefined;
554
+ const skillCompletions = useMemo(() => {
555
+ const builtins = ["/help", "/quit", "/exit", "/skills"];
556
+ const skillNames = Array.from(sessionSkills?.keys() ?? []).map(
557
+ (name) => `/${name}`,
558
+ );
559
+ return [...builtins, ...skillNames];
560
+ }, [sessionSkills]);
561
+
505
562
  const allToolCalls = useMemo(
506
563
  () => messages.flatMap((m) => m.toolCalls ?? []),
507
564
  [messages],
@@ -624,6 +681,8 @@ export function App({
624
681
  disabled={activeTab !== 1}
625
682
  history={inputHistory}
626
683
  header={inputBarHeader}
684
+ completions={skillCompletions}
685
+ onTabConsumed={handleTabConsumed}
627
686
  />
628
687
  <TabBar activeTab={activeTab} />
629
688
  </Box>
@@ -15,6 +15,8 @@ interface InputBarProps {
15
15
  disabled: boolean;
16
16
  history: string[];
17
17
  header?: ReactNode;
18
+ completions?: string[];
19
+ onTabConsumed?: () => void;
18
20
  }
19
21
 
20
22
  export const InputBar = memo(function InputBar({
@@ -24,6 +26,8 @@ export const InputBar = memo(function InputBar({
24
26
  disabled,
25
27
  history,
26
28
  header,
29
+ completions,
30
+ onTabConsumed,
27
31
  }: InputBarProps) {
28
32
  const [historyIndex, setHistoryIndex] = useState(-1);
29
33
  const [cursorPos, setCursorPos] = useState(0);
@@ -39,6 +43,9 @@ export const InputBar = memo(function InputBar({
39
43
  const onChangeRef = useRef(onChange);
40
44
  const onSubmitRef = useRef(onSubmit);
41
45
  const historyRef = useRef(history);
46
+ const completionsRef = useRef(completions);
47
+ const onTabConsumedRef = useRef(onTabConsumed);
48
+ const tabCycleRef = useRef(-1);
42
49
 
43
50
  valueRef.current = value;
44
51
  cursorPosRef.current = cursorPos;
@@ -46,6 +53,8 @@ export const InputBar = memo(function InputBar({
46
53
  onChangeRef.current = onChange;
47
54
  onSubmitRef.current = onSubmit;
48
55
  historyRef.current = history;
56
+ completionsRef.current = completions;
57
+ onTabConsumedRef.current = onTabConsumed;
49
58
 
50
59
  // Blink cursor when input is active — skip ticks while typing so the
51
60
  // cursor stays solid and we avoid unnecessary renders during rapid input.
@@ -173,8 +182,37 @@ export const InputBar = memo(function InputBar({
173
182
  return;
174
183
  }
175
184
 
185
+ // Tab-completion for slash commands
186
+ if (key.tab) {
187
+ const comps = completionsRef.current;
188
+ if (val.startsWith("/") && comps && comps.length > 0) {
189
+ const matches = comps.filter((c) => c.startsWith(val));
190
+ if (matches.length === 1) {
191
+ const completed = `${matches[0] ?? ""} `;
192
+ valueRef.current = completed;
193
+ cursorPosRef.current = completed.length;
194
+ onChangeRef.current(completed);
195
+ setCursorPos(completed.length);
196
+ tabCycleRef.current = -1;
197
+ } else if (matches.length > 1) {
198
+ const idx = (tabCycleRef.current + 1) % matches.length;
199
+ tabCycleRef.current = idx;
200
+ const completed = matches[idx] ?? "";
201
+ valueRef.current = completed;
202
+ cursorPosRef.current = completed.length;
203
+ onChangeRef.current(completed);
204
+ setCursorPos(completed.length);
205
+ }
206
+ onTabConsumedRef.current?.();
207
+ }
208
+ return;
209
+ }
210
+
211
+ // Reset tab cycle on any non-tab key
212
+ tabCycleRef.current = -1;
213
+
176
214
  // Ignore other control keys
177
- if (key.ctrl || key.escape || key.tab) {
215
+ if (key.ctrl || key.escape) {
178
216
  return;
179
217
  }
180
218