botholomew 0.16.0 → 0.16.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.
@@ -0,0 +1,106 @@
1
+ import type { Interaction } from "../threads/store.ts";
2
+ import { MAX_INLINE_CHARS, PAGE_SIZE_CHARS } from "../worker/large-results.ts";
3
+ import type { ChatMessage } from "./components/MessageList.tsx";
4
+ import type { ToolCallData } from "./components/ToolCall.tsx";
5
+
6
+ let nextRestoreId = 0;
7
+ function restoreMsgId(): string {
8
+ return `restore-msg-${++nextRestoreId}`;
9
+ }
10
+
11
+ function detectToolError(output: string | undefined): boolean {
12
+ if (!output) return false;
13
+ try {
14
+ const parsed = JSON.parse(output);
15
+ if (typeof parsed === "object" && parsed?.is_error === true) return true;
16
+ } catch {
17
+ /* not JSON */
18
+ }
19
+ return false;
20
+ }
21
+
22
+ /**
23
+ * Reconstruct `ChatMessage[]` from a thread's interaction log so the TUI can
24
+ * hydrate chat history (plus the Tools tab) when resuming a session.
25
+ *
26
+ * Tools attach to the assistant message that *issued* them, not the next one.
27
+ * `runChatTurn` logs in the order: assistant text → tool_use(s) → tool_result(s)
28
+ * → next assistant text, so we track the most recent assistant message and
29
+ * append tool calls there until a user message resets the cursor.
30
+ */
31
+ export function restoreMessagesFromInteractions(
32
+ interactions: Interaction[],
33
+ ): ChatMessage[] {
34
+ const result: ChatMessage[] = [];
35
+ let currentAssistant: ChatMessage | null = null;
36
+ let orphanTools: ToolCallData[] = [];
37
+ let restoredIdx = 0;
38
+
39
+ const makeToolCall = (ix: Interaction): ToolCallData => ({
40
+ id: `restored-${restoredIdx++}`,
41
+ name: ix.tool_name ?? "unknown",
42
+ input: ix.tool_input ?? "{}",
43
+ running: false,
44
+ timestamp: ix.created_at,
45
+ });
46
+
47
+ for (const ix of interactions) {
48
+ if (ix.kind === "tool_use") {
49
+ const tc = makeToolCall(ix);
50
+ if (currentAssistant) {
51
+ const list = currentAssistant.toolCalls ?? [];
52
+ list.push(tc);
53
+ currentAssistant.toolCalls = list;
54
+ } else {
55
+ orphanTools.push(tc);
56
+ }
57
+ } else if (ix.kind === "tool_result") {
58
+ const pool = currentAssistant?.toolCalls ?? orphanTools;
59
+ const tc = pool.find((t) => t.name === ix.tool_name && !t.output);
60
+ if (tc) {
61
+ tc.output = ix.content;
62
+ tc.isError = detectToolError(ix.content);
63
+ if (ix.content.length > MAX_INLINE_CHARS) {
64
+ tc.largeResult = {
65
+ id: "(restored)",
66
+ chars: ix.content.length,
67
+ pages: Math.ceil(ix.content.length / PAGE_SIZE_CHARS),
68
+ };
69
+ }
70
+ }
71
+ } else if (ix.kind === "message" && ix.role === "user") {
72
+ result.push({
73
+ id: restoreMsgId(),
74
+ role: "user",
75
+ content: ix.content,
76
+ timestamp: ix.created_at,
77
+ });
78
+ currentAssistant = null;
79
+ } else if (ix.kind === "message" && ix.role === "assistant") {
80
+ const msg: ChatMessage = {
81
+ id: restoreMsgId(),
82
+ role: "assistant",
83
+ content: ix.content,
84
+ timestamp: ix.created_at,
85
+ };
86
+ if (orphanTools.length > 0) {
87
+ msg.toolCalls = [...orphanTools];
88
+ orphanTools = [];
89
+ }
90
+ result.push(msg);
91
+ currentAssistant = msg;
92
+ }
93
+ }
94
+
95
+ if (orphanTools.length > 0) {
96
+ result.push({
97
+ id: restoreMsgId(),
98
+ role: "assistant",
99
+ content: "",
100
+ timestamp: orphanTools[0]?.timestamp ?? new Date(),
101
+ toolCalls: [...orphanTools],
102
+ });
103
+ }
104
+
105
+ return result;
106
+ }
@@ -0,0 +1,26 @@
1
+ import { useStdout } from "ink";
2
+ import { useEffect, useState } from "react";
3
+
4
+ /**
5
+ * Track terminal columns + rows. Ink's `useStdout` doesn't re-render on
6
+ * resize, so panels that compute layout from terminal width (e.g. detail
7
+ * panes that wrap long lines) need this hook to stay accurate.
8
+ */
9
+ export function useTerminalSize(): { cols: number; rows: number } {
10
+ const { stdout } = useStdout();
11
+ const [size, setSize] = useState(() => ({
12
+ cols: stdout?.columns ?? 80,
13
+ rows: stdout?.rows ?? 24,
14
+ }));
15
+ useEffect(() => {
16
+ if (!stdout) return;
17
+ const onResize = () => {
18
+ setSize({ cols: stdout.columns ?? 80, rows: stdout.rows ?? 24 });
19
+ };
20
+ stdout.on("resize", onResize);
21
+ return () => {
22
+ stdout.off("resize", onResize);
23
+ };
24
+ }, [stdout]);
25
+ return size;
26
+ }
@@ -0,0 +1,15 @@
1
+ import wrapAnsi from "wrap-ansi";
2
+
3
+ /**
4
+ * Wrap an ANSI-colored body string to a column width and return one entry
5
+ * per visual line. Used by the right-pane detail views in every list/detail
6
+ * panel (Tools, Tasks, Threads, Schedules, Context) so long lines wrap
7
+ * instead of getting truncated by `<Text wrap="truncate-end">`.
8
+ *
9
+ * `wrap-ansi` preserves SGR state across wrap boundaries, so colorized
10
+ * JSON / markdown stays intact.
11
+ */
12
+ export function wrapDetailLines(text: string, width: number): string[] {
13
+ if (width <= 0) return text.split("\n");
14
+ return wrapAnsi(text, width, { hard: true, trim: false }).split("\n");
15
+ }
@@ -17,6 +17,13 @@ export interface FakeTurn {
17
17
  chunkSize?: number;
18
18
  /** Delay between chunks in milliseconds. */
19
19
  delayMs?: number;
20
+ /**
21
+ * Initial wait before the first chunk emits, in milliseconds. Mirrors a
22
+ * real model's first-token latency — useful for capture fixtures where
23
+ * back-to-back turns would otherwise complete instantly and look
24
+ * unrealistic.
25
+ */
26
+ preDelayMs?: number;
20
27
  /** Optional tool calls to emit after text. */
21
28
  toolCalls?: Array<{
22
29
  id?: string;
@@ -125,6 +132,12 @@ function buildFinalMessage(
125
132
  class FakeMessageStream extends EventEmitter {
126
133
  private resolveFinal: (m: Message) => void = () => {};
127
134
  private readonly finalPromise: Promise<Message>;
135
+ // Buffered events for `for await` consumers; populated by `run()` so the
136
+ // EventEmitter and async-iterator interfaces stay in sync without
137
+ // double-driving the turn.
138
+ private readonly events: Array<Record<string, unknown>> = [];
139
+ private eventsDone = false;
140
+ private notifyEvent: (() => void) | null = null;
128
141
 
129
142
  constructor(private readonly turn: FakeTurn) {
130
143
  super();
@@ -134,13 +147,27 @@ class FakeMessageStream extends EventEmitter {
134
147
  queueMicrotask(() => this.run());
135
148
  }
136
149
 
150
+ private pushEvent(ev: Record<string, unknown>): void {
151
+ this.events.push(ev);
152
+ this.notifyEvent?.();
153
+ }
154
+
137
155
  private async run(): Promise<void> {
138
156
  const text = this.turn.text ?? this.turn.chunks?.join("") ?? "";
139
157
  const chunks =
140
158
  this.turn.chunks ?? chunkText(text, this.turn.chunkSize ?? 6);
141
159
  const delay = this.turn.delayMs ?? 40;
160
+ const preDelay = this.turn.preDelayMs ?? 0;
161
+ if (preDelay > 0) await new Promise((r) => setTimeout(r, preDelay));
142
162
  for (const chunk of chunks) {
143
163
  this.emit("text", chunk);
164
+ const ev = {
165
+ type: "content_block_delta",
166
+ index: 0,
167
+ delta: { type: "text_delta", text: chunk },
168
+ };
169
+ this.emit("streamEvent", ev);
170
+ this.pushEvent(ev);
144
171
  if (delay > 0) await new Promise((r) => setTimeout(r, delay));
145
172
  }
146
173
  const final = buildFinalMessage(text, this.turn.toolCalls);
@@ -162,9 +189,37 @@ class FakeMessageStream extends EventEmitter {
162
189
  blockIndex++;
163
190
  }
164
191
  }
192
+ const stop = { type: "message_stop" };
193
+ this.emit("streamEvent", stop);
194
+ this.pushEvent(stop);
195
+ this.eventsDone = true;
196
+ this.notifyEvent?.();
165
197
  this.resolveFinal(final);
166
198
  }
167
199
 
200
+ /**
201
+ * Async iterator support so `for await (const event of stream)` callers
202
+ * (e.g. `src/context/markdown-converter.ts`) get the same shape as the
203
+ * real SDK. Events are sourced from the buffer populated by `run()`, so
204
+ * the iterator and the EventEmitter interface observe a single timeline.
205
+ */
206
+ async *[Symbol.asyncIterator](): AsyncIterableIterator<
207
+ Record<string, unknown>
208
+ > {
209
+ let cursor = 0;
210
+ while (true) {
211
+ while (cursor < this.events.length) {
212
+ const ev = this.events[cursor++];
213
+ if (ev) yield ev;
214
+ }
215
+ if (this.eventsDone) return;
216
+ await new Promise<void>((resolve) => {
217
+ this.notifyEvent = resolve;
218
+ });
219
+ this.notifyEvent = null;
220
+ }
221
+ }
222
+
168
223
  finalMessage(): Promise<Message> {
169
224
  return this.finalPromise;
170
225
  }
@@ -207,6 +262,11 @@ export function createFakeAnthropicClient(): Anthropic {
207
262
  return buildFinalMessage("Chat session");
208
263
  }
209
264
  const turn = selectTurn(extractLastUserText(params.messages));
265
+ // Honour preDelayMs so non-streaming callers (e.g. the fetcher
266
+ // loop in src/context/fetcher.ts) get the same "thinking" pause
267
+ // a streaming caller would.
268
+ const preDelay = turn.preDelayMs ?? 0;
269
+ if (preDelay > 0) await new Promise((r) => setTimeout(r, preDelay));
210
270
  return buildFinalMessage(
211
271
  turn.text ?? turn.chunks?.join("") ?? "",
212
272
  turn.toolCalls,
@@ -48,6 +48,56 @@ export function fakeMcpSearch(query: string): FakeMcpSearchResult[] | null {
48
48
  },
49
49
  ];
50
50
  }
51
+ if (/google.*doc|docs?\.google|gdoc|document/.test(q)) {
52
+ return [
53
+ {
54
+ server: "google-docs",
55
+ tool: "GetDocumentAsMarkdown",
56
+ description:
57
+ "Fetch the contents of a Google Doc by URL or ID and return it as Markdown.",
58
+ score: 0.96,
59
+ match_type: "semantic",
60
+ },
61
+ {
62
+ server: "google-docs",
63
+ tool: "GetDocumentAsHtml",
64
+ description: "Fetch a Google Doc as raw HTML.",
65
+ score: 0.71,
66
+ match_type: "semantic",
67
+ },
68
+ ];
69
+ }
70
+ if (/github|pull request|\bpr\b|repo|commit/.test(q)) {
71
+ return [
72
+ {
73
+ server: "github",
74
+ tool: "ListMyPullRequests",
75
+ description:
76
+ "List the user's recent pull requests across all repos, with status and last activity.",
77
+ score: 0.93,
78
+ match_type: "semantic",
79
+ },
80
+ {
81
+ server: "github",
82
+ tool: "ListAssignedIssues",
83
+ description: "List GitHub issues assigned to the user.",
84
+ score: 0.79,
85
+ match_type: "semantic",
86
+ },
87
+ ];
88
+ }
89
+ if (/linear|ticket|issue.*track/.test(q)) {
90
+ return [
91
+ {
92
+ server: "linear",
93
+ tool: "ListMyIssues",
94
+ description:
95
+ "List Linear issues assigned to the user, including status and any blocking notes.",
96
+ score: 0.95,
97
+ match_type: "semantic",
98
+ },
99
+ ];
100
+ }
51
101
  return null;
52
102
  }
53
103
 
@@ -70,5 +120,89 @@ export function fakeMcpExec(
70
120
  2,
71
121
  );
72
122
  }
123
+ if (server === "github" && tool === "ListMyPullRequests") {
124
+ return JSON.stringify(
125
+ {
126
+ pull_requests: [
127
+ {
128
+ number: 213,
129
+ repo: "evantahler/botholomew",
130
+ title:
131
+ "TUI: close chat gap, hide chat scrollback on other tabs, wrap detail panes",
132
+ status: "merged",
133
+ merged_at: "2026-05-05T18:42:00Z",
134
+ },
135
+ {
136
+ number: 214,
137
+ repo: "evantahler/botholomew",
138
+ title: "Make /standup demo call GitHub + Linear before synthesis",
139
+ status: "open",
140
+ updated_at: "2026-05-06T09:01:00Z",
141
+ review_state: "requested_changes",
142
+ },
143
+ ],
144
+ },
145
+ null,
146
+ 2,
147
+ );
148
+ }
149
+ if (server === "linear" && tool === "ListMyIssues") {
150
+ return JSON.stringify(
151
+ {
152
+ issues: [
153
+ {
154
+ id: "ENG-487",
155
+ title: "Worker reaper sometimes drops heartbeats under load",
156
+ status: "in_progress",
157
+ priority: "medium",
158
+ },
159
+ {
160
+ id: "ENG-491",
161
+ title: "Flaky context-reindex test under Bun 1.4",
162
+ status: "blocked",
163
+ priority: "high",
164
+ note: "Blocked on upstream Bun fix oven-sh/bun#26081",
165
+ },
166
+ {
167
+ id: "ENG-503",
168
+ title: "Add structured event log for capabilities refresh",
169
+ status: "todo",
170
+ priority: "low",
171
+ },
172
+ ],
173
+ },
174
+ null,
175
+ 2,
176
+ );
177
+ }
178
+ if (server === "google-docs" && tool === "GetDocumentAsMarkdown") {
179
+ return [
180
+ "# Botholomew v0.8 launch plan",
181
+ "",
182
+ "## Themes",
183
+ "",
184
+ "1. Better MCPX ergonomics — fewer steps to wire up a new server.",
185
+ "2. Chat TUI polish — context indicator, idle-pause, slash menu.",
186
+ "3. Doc captures — every GIF in the docs is hermetic + regenerable.",
187
+ "",
188
+ "## Milestones",
189
+ "",
190
+ "- [x] Move tasks/schedules/context onto disk",
191
+ "- [x] Replace OpenAI embeddings with @huggingface/transformers",
192
+ "- [ ] Ship `context import` for Google Docs end-to-end",
193
+ "- [ ] Cut v0.8 release notes",
194
+ "",
195
+ "## Open questions",
196
+ "",
197
+ "- How do we version skill templates across releases?",
198
+ "- Should `context refresh` rate-limit per source-domain?",
199
+ "",
200
+ "## Stakeholders",
201
+ "",
202
+ "- Pascal (design review)",
203
+ "- Sterling (reliability + oncall sign-off)",
204
+ "",
205
+ ].join("\n");
206
+ }
73
207
  return null;
74
208
  }