botholomew 0.8.5 → 0.8.7

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "botholomew",
3
- "version": "0.8.5",
3
+ "version": "0.8.7",
4
4
  "description": "An autonomous AI agent for knowledge work — works your task queue while you sleep.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -140,3 +140,22 @@ export async function endChatSession(session: ChatSession): Promise<void> {
140
140
  await withDb(session.dbPath, (conn) => endThread(conn, session.threadId));
141
141
  await session.cleanup();
142
142
  }
143
+
144
+ /**
145
+ * End the current thread and start a fresh one on the same session.
146
+ * The old thread is persisted (marked ended) and can still be resumed
147
+ * via `botholomew chat --thread-id <id>`. Returns the previous thread
148
+ * ID so callers can display it to the user.
149
+ */
150
+ export async function clearChatSession(
151
+ session: ChatSession,
152
+ ): Promise<{ previousThreadId: string; newThreadId: string }> {
153
+ const previousThreadId = session.threadId;
154
+ const newThreadId = await withDb(session.dbPath, async (conn) => {
155
+ await endThread(conn, previousThreadId);
156
+ return createThread(conn, "chat_session", undefined, "New chat");
157
+ });
158
+ session.threadId = newThreadId;
159
+ session.messages.length = 0;
160
+ return { previousThreadId, newThreadId };
161
+ }
@@ -51,155 +51,64 @@ function getDir(program: Command): string {
51
51
  return program.opts().dir;
52
52
  }
53
53
 
54
+ // Slice process.argv from the token after "mcpx" so flags (including --help)
55
+ // and positional args flow through to upstream mcpx verbatim.
56
+ function getRawMcpxArgs(): string[] {
57
+ const idx = process.argv.indexOf("mcpx");
58
+ return idx === -1 ? [] : process.argv.slice(idx + 1);
59
+ }
60
+
61
+ const PASSTHROUGH_SUBCOMMANDS: ReadonlyArray<[name: string, desc: string]> = [
62
+ ["servers", "List configured MCP server names"],
63
+ ["info", "Show server overview or schema for a specific tool"],
64
+ ["search", "Search tools by keyword and/or semantic similarity"],
65
+ ["exec", "Execute a tool call"],
66
+ ["add", "Add an MCP server"],
67
+ ["remove", "Remove an MCP server"],
68
+ ["ping", "Check connectivity to MCP servers"],
69
+ ["auth", "Authenticate with an HTTP MCP server"],
70
+ ["deauth", "Remove stored authentication for a server"],
71
+ ["resource", "List resources for a server, or read a specific resource"],
72
+ ["prompt", "List prompts for a server, or get a specific prompt"],
73
+ ["task", "Manage async tool tasks (list, get, result, cancel)"],
74
+ ["index", "Build the search index from all configured servers"],
75
+ ];
76
+
54
77
  export function registerMcpxCommand(program: Command) {
55
78
  const mcpx = program
56
79
  .command("mcpx")
57
80
  .description("Manage MCP servers via MCPX");
58
81
 
59
- // --- servers ---
60
- mcpx
61
- .command("servers")
62
- .description("List configured MCP server names")
63
- .action(async () => {
64
- const out = await runMcpx(getDir(program), ["servers"]);
65
- process.stdout.write(out);
66
- });
67
-
68
- // --- info ---
69
- mcpx
70
- .command("info <first> [second]")
71
- .description(
72
- "Show server overview, or schema for a specific tool (server is optional if tool name is unambiguous)",
73
- )
74
- .action(async (first: string, second?: string) => {
75
- const out = await runMcpx(getDir(program), ["info", first, second]);
76
- process.stdout.write(out);
77
- });
78
-
79
- // --- search ---
80
- mcpx
81
- .command("search <terms...>")
82
- .description("Search tools by keyword and/or semantic similarity")
83
- .action(async (terms: string[]) => {
84
- const out = await runMcpx(getDir(program), ["search", ...terms]);
85
- process.stdout.write(out);
86
- });
82
+ for (const [name, description] of PASSTHROUGH_SUBCOMMANDS) {
83
+ mcpx
84
+ .command(name)
85
+ .description(description)
86
+ .allowUnknownOption(true)
87
+ .helpOption(false)
88
+ .argument("[args...]", "arguments forwarded to mcpx")
89
+ .action(async () => {
90
+ await runMcpx(getDir(program), getRawMcpxArgs(), { inherit: true });
91
+ });
92
+ }
87
93
 
88
- // --- exec ---
94
+ // Upstream mcpx's "list" is the default action when invoked with no
95
+ // subcommand — not a registered subcommand — so we strip the "list"
96
+ // token before forwarding.
89
97
  mcpx
90
- .command("exec <first> [second] [third]")
98
+ .command("list")
91
99
  .description(
92
- "Execute a tool call (server is optional if tool name is unambiguous)",
100
+ "List all tools, resources, and prompts across all configured servers",
93
101
  )
94
- .action(async (first: string, second?: string, third?: string) => {
95
- const out = await runMcpx(getDir(program), [
96
- "exec",
97
- first,
98
- second,
99
- third,
100
- ]);
101
- process.stdout.write(out);
102
- });
103
-
104
- // --- add ---
105
- mcpx
106
- .command("add <name>")
107
- .description("Add an MCP server")
108
- .option("--command <cmd>", "Stdio server command")
109
- .option("--args <args...>", "Stdio server arguments")
110
- .option("--url <url>", "HTTP server URL")
111
- .option("--transport <type>", "HTTP transport: sse or streamable-http")
112
- .option("--env <pairs...>", "Environment variables as KEY=VALUE pairs")
113
- .action(
114
- async (
115
- name: string,
116
- opts: {
117
- command?: string;
118
- args?: string[];
119
- url?: string;
120
- transport?: string;
121
- env?: string[];
122
- },
123
- ) => {
124
- const cliArgs: string[] = ["add", name];
125
- if (opts.command) cliArgs.push("--command", opts.command);
126
- if (opts.args) {
127
- for (const a of opts.args) cliArgs.push("--args", a);
128
- }
129
- if (opts.url) cliArgs.push("--url", opts.url);
130
- if (opts.transport) cliArgs.push("--transport", opts.transport);
131
- if (opts.env) {
132
- for (const e of opts.env) cliArgs.push("--env", e);
133
- }
134
- const out = await runMcpx(getDir(program), cliArgs);
135
- process.stdout.write(out);
136
- },
137
- );
138
-
139
- // --- remove ---
140
- mcpx
141
- .command("remove <name>")
142
- .description("Remove an MCP server")
143
- .action(async (name: string) => {
144
- const out = await runMcpx(getDir(program), ["remove", name]);
145
- process.stdout.write(out);
146
- });
147
-
148
- // --- ping ---
149
- mcpx
150
- .command("ping [servers...]")
151
- .description("Check connectivity to MCP servers")
152
- .action(async (servers: string[]) => {
153
- const out = await runMcpx(getDir(program), ["ping", ...servers]);
154
- process.stdout.write(out);
155
- });
156
-
157
- // --- auth ---
158
- mcpx
159
- .command("auth <server>")
160
- .description("Authenticate with an HTTP MCP server")
161
- .action(async (server: string) => {
162
- await runMcpx(getDir(program), ["auth", server], { inherit: true });
163
- });
164
-
165
- // --- resource ---
166
- mcpx
167
- .command("resource [server] [uri]")
168
- .description("List resources for a server, or read a specific resource")
169
- .action(async (server?: string, uri?: string) => {
170
- const out = await runMcpx(getDir(program), ["resource", server, uri]);
171
- process.stdout.write(out);
172
- });
173
-
174
- // --- prompt ---
175
- mcpx
176
- .command("prompt [server] [name] [args]")
177
- .description("List prompts for a server, or get a specific prompt")
178
- .action(async (server?: string, name?: string, argsJson?: string) => {
179
- const out = await runMcpx(getDir(program), [
180
- "prompt",
181
- server,
182
- name,
183
- argsJson,
184
- ]);
185
- process.stdout.write(out);
186
- });
187
-
188
- // --- task ---
189
- mcpx
190
- .command("task <action> <server> [taskId]")
191
- .description("Manage async tasks (actions: list, get, result, cancel)")
192
- .action(async (action: string, server: string, taskId?: string) => {
193
- const out = await runMcpx(getDir(program), [
194
- "task",
195
- action,
196
- server,
197
- taskId,
198
- ]);
199
- process.stdout.write(out);
102
+ .allowUnknownOption(true)
103
+ .helpOption(false)
104
+ .argument("[args...]", "arguments forwarded to mcpx")
105
+ .action(async () => {
106
+ const raw = getRawMcpxArgs();
107
+ const args = raw[0] === "list" ? raw.slice(1) : raw;
108
+ await runMcpx(getDir(program), args, { inherit: true });
200
109
  });
201
110
 
202
- // --- import-global ---
111
+ // Botholomew-specific: copy system-wide MCPX settings into this project.
203
112
  mcpx
204
113
  .command("import-global")
205
114
  .description("Copy system-wide MCPX settings (~/.mcpx) into this project")
@@ -234,12 +143,4 @@ export function registerMcpxCommand(program: Command) {
234
143
  );
235
144
  }
236
145
  });
237
-
238
- // --- index ---
239
- mcpx
240
- .command("index")
241
- .description("Build the search index from all configured servers")
242
- .action(async () => {
243
- await runMcpx(getDir(program), ["index"], { inherit: true });
244
- });
245
146
  }
@@ -4,11 +4,13 @@ import { renderSkill } from "./parser.ts";
4
4
  export interface SlashCommand {
5
5
  name: string;
6
6
  description: string;
7
+ takesArgs?: boolean;
7
8
  }
8
9
 
9
10
  export const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [
10
11
  { name: "help", description: "Show command reference and shortcuts" },
11
12
  { name: "skills", description: "List available skills" },
13
+ { name: "clear", description: "End current thread and start a new one" },
12
14
  { name: "exit", description: "End the chat session" },
13
15
  ];
14
16
 
@@ -17,6 +19,7 @@ export interface SlashCommandContext {
17
19
  addSystemMessage: (content: string) => void;
18
20
  queueUserMessage: (content: string) => void;
19
21
  exit: () => void;
22
+ clearChat?: () => void;
20
23
  }
21
24
 
22
25
  /**
@@ -38,6 +41,15 @@ export function handleSlashCommand(
38
41
  return true;
39
42
  }
40
43
 
44
+ if (name === "clear") {
45
+ if (ctx.clearChat) {
46
+ ctx.clearChat();
47
+ } else {
48
+ ctx.addSystemMessage("/clear is only available in the chat TUI.");
49
+ }
50
+ return true;
51
+ }
52
+
41
53
  if (name === "skills") {
42
54
  if (ctx.skills.size === 0) {
43
55
  ctx.addSystemMessage(
package/src/tui/App.tsx CHANGED
@@ -2,6 +2,7 @@ import { Box, Static, Text, useApp, useInput } from "ink";
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
3
  import {
4
4
  type ChatSession,
5
+ clearChatSession,
5
6
  endChatSession,
6
7
  sendMessage,
7
8
  startChatSession,
@@ -126,6 +127,7 @@ export function App({
126
127
  }: AppProps) {
127
128
  const { exit } = useApp();
128
129
  const [messages, setMessages] = useState<ChatMessage[]>([]);
130
+ const [messagesEpoch, setMessagesEpoch] = useState(0);
129
131
  const [inputValue, setInputValue] = useState("");
130
132
  const [inputHistory, setInputHistory] = useState<string[]>([]);
131
133
  const [isLoading, setIsLoading] = useState(false);
@@ -490,7 +492,8 @@ export function App({
490
492
  " ⌥+Enter Insert newline",
491
493
  " ↑/↓ Browse input history",
492
494
  " / Open slash-command autocomplete",
493
- " Tab/Enter Accept highlighted command (popup open)",
495
+ " Enter Run highlighted command / insert if it takes args (popup open)",
496
+ " Tab Insert highlighted command without submitting (popup open)",
494
497
  " ↑/↓ Move highlight (popup open)",
495
498
  " Esc Close popup",
496
499
  "",
@@ -534,6 +537,7 @@ export function App({
534
537
  "Commands:",
535
538
  " /help Show this help",
536
539
  " /skills List available skills",
540
+ " /clear End current thread and start a new one",
537
541
  " /exit End the chat session",
538
542
  ...skillLines,
539
543
  ].join("\n"),
@@ -563,6 +567,42 @@ export function App({
563
567
  processQueue();
564
568
  },
565
569
  exit,
570
+ clearChat: () => {
571
+ const session = sessionRef.current;
572
+ if (!session) return;
573
+ // Drain any queued messages so they don't leak into the new thread.
574
+ queueRef.current.length = 0;
575
+ syncQueue();
576
+ clearChatSession(session)
577
+ .then(({ previousThreadId, newThreadId }) => {
578
+ // Ink's <Static> writes messages to terminal scrollback and
579
+ // can't un-write them, so setMessages alone leaves the old
580
+ // lines visible. Clear the terminal (including scrollback)
581
+ // and bump the epoch key on <Static> to force a fresh mount.
582
+ process.stdout.write("\x1b[2J\x1b[3J\x1b[H");
583
+ setMessages([
584
+ {
585
+ id: msgId(),
586
+ role: "system",
587
+ content: `Started a new chat thread (${newThreadId}). Previous thread saved — resume with: botholomew chat --thread-id ${previousThreadId}`,
588
+ timestamp: new Date(),
589
+ },
590
+ ]);
591
+ setMessagesEpoch((n) => n + 1);
592
+ setChatTitle(undefined);
593
+ })
594
+ .catch((err) => {
595
+ setMessages((prev) => [
596
+ ...prev,
597
+ {
598
+ id: msgId(),
599
+ role: "system",
600
+ content: `Failed to clear chat: ${err}`,
601
+ timestamp: new Date(),
602
+ },
603
+ ]);
604
+ });
605
+ },
566
606
  });
567
607
  if (handled) return;
568
608
  }
@@ -595,6 +635,10 @@ export function App({
595
635
  ? Array.from(sessionSkills.values()).map((s) => ({
596
636
  name: s.name,
597
637
  description: s.description,
638
+ takesArgs:
639
+ s.arguments.length > 0 ||
640
+ /\$ARGUMENTS\b/.test(s.body) ||
641
+ /\$[1-9]\b/.test(s.body),
598
642
  }))
599
643
  : [];
600
644
  return buildSlashCommands(BUILTIN_SLASH_COMMANDS, skillList);
@@ -640,7 +684,7 @@ export function App({
640
684
  node always has proper terminal width in its Yoga layout.
641
685
  Otherwise Ink's border renderer crashes with a negative
642
686
  contentWidth when tool-call boxes are rendered at width 0. */}
643
- <Static items={messages}>
687
+ <Static key={messagesEpoch} items={messages}>
644
688
  {(msg) => <MessageBubble key={msg.id} message={msg} />}
645
689
  </Static>
646
690
 
@@ -9,7 +9,7 @@ import {
9
9
  useState,
10
10
  } from "react";
11
11
  import type { SlashCommand } from "../../skills/commands.ts";
12
- import { getSlashMatches } from "../slashCompletion.ts";
12
+ import { getSlashMatches, shouldSubmitOnEnter } from "../slashCompletion.ts";
13
13
  import { SlashCommandPopup } from "./SlashCommandPopup.tsx";
14
14
 
15
15
  interface InputBarProps {
@@ -128,11 +128,23 @@ export const InputBar = memo(function InputBar({
128
128
  ? getSlashMatches(val, slashCommandsRef.current ?? [])
129
129
  : null;
130
130
 
131
- const acceptSelection = () => {
131
+ const acceptSelection = (mode: "insert" | "submit") => {
132
132
  if (!popupOpen) return false;
133
133
  const chosen =
134
134
  popupOpen[Math.min(selectedIndexRef.current, popupOpen.length - 1)];
135
135
  if (!chosen) return false;
136
+ if (mode === "submit") {
137
+ const completed = `/${chosen.name}`;
138
+ valueRef.current = completed;
139
+ cursorPosRef.current = 0;
140
+ onChangeRef.current(completed);
141
+ setCursorPos(0);
142
+ historyIndexRef.current = -1;
143
+ setHistoryIndex(-1);
144
+ savedInput.current = "";
145
+ onSubmitRef.current(completed);
146
+ return true;
147
+ }
136
148
  const completed = `/${chosen.name} `;
137
149
  valueRef.current = completed;
138
150
  cursorPosRef.current = completed.length;
@@ -152,11 +164,16 @@ export const InputBar = memo(function InputBar({
152
164
  return;
153
165
  }
154
166
 
155
- // Enter: if popup is open, accept selection (do not submit).
156
- // Otherwise submit as before.
167
+ // Enter: if popup is open, accept the highlighted entry. No-arg
168
+ // commands submit in one keystroke; commands that take args insert
169
+ // `/<name> ` and wait for the user to finish typing.
157
170
  if (key.return) {
158
171
  if (popupOpen && !key.shift && !key.meta) {
159
- acceptSelection();
172
+ const chosen =
173
+ popupOpen[Math.min(selectedIndexRef.current, popupOpen.length - 1)];
174
+ acceptSelection(
175
+ chosen && shouldSubmitOnEnter(chosen) ? "submit" : "insert",
176
+ );
160
177
  return;
161
178
  }
162
179
  if (key.shift || key.meta) {
@@ -179,10 +196,10 @@ export const InputBar = memo(function InputBar({
179
196
  return;
180
197
  }
181
198
 
182
- // Tab: accept popup selection if open. No-op otherwise.
199
+ // Tab: insert the highlighted completion so the user can keep editing.
183
200
  if (key.tab) {
184
201
  if (popupOpen) {
185
- acceptSelection();
202
+ acceptSelection("insert");
186
203
  }
187
204
  return;
188
205
  }
@@ -28,11 +28,25 @@ export function getSlashMatches(
28
28
 
29
29
  export function buildSlashCommands(
30
30
  builtins: SlashCommand[],
31
- skills: Iterable<{ name: string; description: string }>,
31
+ skills: Iterable<{ name: string; description: string; takesArgs?: boolean }>,
32
32
  ): SlashCommand[] {
33
33
  const out: SlashCommand[] = [...builtins];
34
34
  for (const s of skills) {
35
- out.push({ name: s.name, description: s.description });
35
+ out.push({
36
+ name: s.name,
37
+ description: s.description,
38
+ takesArgs: s.takesArgs,
39
+ });
36
40
  }
37
41
  return out;
38
42
  }
43
+
44
+ /**
45
+ * Decide whether pressing Enter on a highlighted popup entry should both
46
+ * accept the completion and immediately submit. True for no-argument
47
+ * commands (single-Enter runs them); false for commands that take args,
48
+ * where we insert `/<name> ` and wait for the user to finish typing.
49
+ */
50
+ export function shouldSubmitOnEnter(cmd: SlashCommand): boolean {
51
+ return !cmd.takesArgs;
52
+ }