botholomew 0.18.7 → 0.19.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/README.md +56 -2
- package/package.json +12 -9
- package/src/chat/agent.ts +175 -181
- package/src/chat/session.ts +30 -31
- package/src/chat/usage.ts +19 -20
- package/src/commands/init.ts +20 -0
- package/src/config/loader.ts +79 -10
- package/src/config/schemas.ts +48 -22
- package/src/init/index.ts +12 -5
- package/src/init/templates.ts +45 -4
- package/src/llm/abort.ts +9 -0
- package/src/llm/cache-control.ts +65 -0
- package/src/llm/capabilities.ts +155 -0
- package/src/llm/error-format.ts +95 -0
- package/src/llm/fake.ts +226 -0
- package/src/llm/index.ts +19 -0
- package/src/llm/provider-options.ts +29 -0
- package/src/llm/provider.ts +65 -0
- package/src/llm/tools.ts +24 -0
- package/src/llm/types.ts +20 -0
- package/src/llm/usage.ts +33 -0
- package/src/prompts/capabilities.ts +72 -108
- package/src/tools/tool.ts +2 -22
- package/src/tui/hooks/useMessageQueue.ts +2 -1
- package/src/utils/title.ts +21 -22
- package/src/worker/context.ts +45 -77
- package/src/worker/llm.ts +147 -112
- package/src/worker/prompt.ts +1 -1
- package/src/worker/schedules.ts +43 -54
- package/src/worker/tick.ts +3 -3
- package/src/worker/fake-llm.ts +0 -277
- package/src/worker/llm-client.ts +0 -12
package/src/worker/fake-llm.ts
DELETED
|
@@ -1,277 +0,0 @@
|
|
|
1
|
-
import { EventEmitter } from "node:events";
|
|
2
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
-
import type Anthropic from "@anthropic-ai/sdk";
|
|
4
|
-
import type {
|
|
5
|
-
Message,
|
|
6
|
-
ToolUseBlock,
|
|
7
|
-
} from "@anthropic-ai/sdk/resources/messages";
|
|
8
|
-
|
|
9
|
-
export interface FakeTurn {
|
|
10
|
-
/** Optional regex matched against the most recent user-authored text. */
|
|
11
|
-
match?: string;
|
|
12
|
-
/** Full reply text; auto-chunked if `chunks` is absent. */
|
|
13
|
-
text?: string;
|
|
14
|
-
/** Explicit token chunks; overrides auto-chunking. */
|
|
15
|
-
chunks?: string[];
|
|
16
|
-
/** Characters per auto-chunk when `chunks` is absent. */
|
|
17
|
-
chunkSize?: number;
|
|
18
|
-
/** Delay between chunks in milliseconds. */
|
|
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;
|
|
27
|
-
/** Optional tool calls to emit after text. */
|
|
28
|
-
toolCalls?: Array<{
|
|
29
|
-
id?: string;
|
|
30
|
-
name: string;
|
|
31
|
-
input: Record<string, unknown>;
|
|
32
|
-
}>;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export interface FakeFixture {
|
|
36
|
-
turns: FakeTurn[];
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
let loadedFixture: FakeFixture | null = null;
|
|
40
|
-
let loadedFixturePath: string | undefined;
|
|
41
|
-
let sequentialIndex = 0;
|
|
42
|
-
|
|
43
|
-
function loadFixture(): FakeFixture {
|
|
44
|
-
const fixturePath = process.env.BOTHOLOMEW_FAKE_LLM_FIXTURE;
|
|
45
|
-
// Reload (and reset the sequential cursor) whenever the fixture path
|
|
46
|
-
// changes — tests rotate fixtures between cases, and callers can swap
|
|
47
|
-
// fixtures mid-session without restarting the process.
|
|
48
|
-
if (loadedFixture && loadedFixturePath === fixturePath) {
|
|
49
|
-
return loadedFixture;
|
|
50
|
-
}
|
|
51
|
-
loadedFixturePath = fixturePath;
|
|
52
|
-
sequentialIndex = 0;
|
|
53
|
-
if (!fixturePath) {
|
|
54
|
-
loadedFixture = { turns: [] };
|
|
55
|
-
return loadedFixture;
|
|
56
|
-
}
|
|
57
|
-
if (!existsSync(fixturePath)) {
|
|
58
|
-
throw new Error(
|
|
59
|
-
`BOTHOLOMEW_FAKE_LLM_FIXTURE points to missing file: ${fixturePath}`,
|
|
60
|
-
);
|
|
61
|
-
}
|
|
62
|
-
loadedFixture = JSON.parse(readFileSync(fixturePath, "utf8")) as FakeFixture;
|
|
63
|
-
return loadedFixture;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function selectTurn(lastUserText: string): FakeTurn {
|
|
67
|
-
const fixture = loadFixture();
|
|
68
|
-
if (fixture.turns.length === 0) {
|
|
69
|
-
return { text: "(fake LLM: no fixture turns configured)" };
|
|
70
|
-
}
|
|
71
|
-
// Only consider turns at or after the cursor, so that multi-turn fixtures
|
|
72
|
-
// (e.g. text → tool_use → follow-up text) advance past a matched turn even
|
|
73
|
-
// when the agent's next iteration shows the same user text.
|
|
74
|
-
for (let i = sequentialIndex; i < fixture.turns.length; i++) {
|
|
75
|
-
const t = fixture.turns[i];
|
|
76
|
-
if (t?.match && new RegExp(t.match, "i").test(lastUserText)) {
|
|
77
|
-
sequentialIndex = i + 1;
|
|
78
|
-
return t;
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
if (sequentialIndex < fixture.turns.length) {
|
|
82
|
-
const t = fixture.turns[sequentialIndex];
|
|
83
|
-
sequentialIndex++;
|
|
84
|
-
if (t) return t;
|
|
85
|
-
}
|
|
86
|
-
// Out of turns — repeat the last one so the agent loop doesn't spin.
|
|
87
|
-
return fixture.turns[fixture.turns.length - 1] ?? { text: "" };
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function chunkText(text: string, size: number): string[] {
|
|
91
|
-
if (size <= 0 || text.length === 0) return text ? [text] : [];
|
|
92
|
-
const out: string[] = [];
|
|
93
|
-
for (let i = 0; i < text.length; i += size) out.push(text.slice(i, i + size));
|
|
94
|
-
return out;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function buildFinalMessage(
|
|
98
|
-
text: string,
|
|
99
|
-
toolCalls?: FakeTurn["toolCalls"],
|
|
100
|
-
): Message {
|
|
101
|
-
const content: Array<Record<string, unknown>> = [];
|
|
102
|
-
if (text) content.push({ type: "text", text, citations: null });
|
|
103
|
-
if (toolCalls) {
|
|
104
|
-
for (const tc of toolCalls) {
|
|
105
|
-
content.push({
|
|
106
|
-
type: "tool_use",
|
|
107
|
-
id: tc.id ?? `toolu_${Math.random().toString(36).slice(2, 14)}`,
|
|
108
|
-
name: tc.name,
|
|
109
|
-
input: tc.input,
|
|
110
|
-
});
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
return {
|
|
114
|
-
id: `msg_${Math.random().toString(36).slice(2, 14)}`,
|
|
115
|
-
type: "message",
|
|
116
|
-
role: "assistant",
|
|
117
|
-
model: "botholomew-fake-llm",
|
|
118
|
-
content,
|
|
119
|
-
stop_reason: toolCalls?.length ? "tool_use" : "end_turn",
|
|
120
|
-
stop_sequence: null,
|
|
121
|
-
usage: {
|
|
122
|
-
input_tokens: 100,
|
|
123
|
-
output_tokens: Math.max(1, Math.floor(text.length / 4)),
|
|
124
|
-
cache_creation_input_tokens: 0,
|
|
125
|
-
cache_read_input_tokens: 0,
|
|
126
|
-
service_tier: "standard",
|
|
127
|
-
server_tool_use: null,
|
|
128
|
-
},
|
|
129
|
-
} as unknown as Message;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
class FakeMessageStream extends EventEmitter {
|
|
133
|
-
private resolveFinal: (m: Message) => void = () => {};
|
|
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;
|
|
141
|
-
|
|
142
|
-
constructor(private readonly turn: FakeTurn) {
|
|
143
|
-
super();
|
|
144
|
-
this.finalPromise = new Promise<Message>((resolve) => {
|
|
145
|
-
this.resolveFinal = resolve;
|
|
146
|
-
});
|
|
147
|
-
queueMicrotask(() => this.run());
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
private pushEvent(ev: Record<string, unknown>): void {
|
|
151
|
-
this.events.push(ev);
|
|
152
|
-
this.notifyEvent?.();
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
private async run(): Promise<void> {
|
|
156
|
-
const text = this.turn.text ?? this.turn.chunks?.join("") ?? "";
|
|
157
|
-
const chunks =
|
|
158
|
-
this.turn.chunks ?? chunkText(text, this.turn.chunkSize ?? 6);
|
|
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));
|
|
162
|
-
for (const chunk of chunks) {
|
|
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);
|
|
171
|
-
if (delay > 0) await new Promise((r) => setTimeout(r, delay));
|
|
172
|
-
}
|
|
173
|
-
const final = buildFinalMessage(text, this.turn.toolCalls);
|
|
174
|
-
let blockIndex = text ? 1 : 0;
|
|
175
|
-
for (const block of final.content) {
|
|
176
|
-
if ((block as { type: string }).type === "tool_use") {
|
|
177
|
-
const toolUse = block as ToolUseBlock;
|
|
178
|
-
this.emit("streamEvent", {
|
|
179
|
-
type: "content_block_start",
|
|
180
|
-
index: blockIndex,
|
|
181
|
-
content_block: {
|
|
182
|
-
type: "tool_use",
|
|
183
|
-
id: toolUse.id,
|
|
184
|
-
name: toolUse.name,
|
|
185
|
-
input: {},
|
|
186
|
-
},
|
|
187
|
-
});
|
|
188
|
-
this.emit("contentBlock", toolUse);
|
|
189
|
-
blockIndex++;
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
const stop = { type: "message_stop" };
|
|
193
|
-
this.emit("streamEvent", stop);
|
|
194
|
-
this.pushEvent(stop);
|
|
195
|
-
this.eventsDone = true;
|
|
196
|
-
this.notifyEvent?.();
|
|
197
|
-
this.resolveFinal(final);
|
|
198
|
-
}
|
|
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
|
-
|
|
223
|
-
finalMessage(): Promise<Message> {
|
|
224
|
-
return this.finalPromise;
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
function extractLastUserText(
|
|
229
|
-
messages: Array<{ role?: string; content?: unknown }>,
|
|
230
|
-
): string {
|
|
231
|
-
for (let i = messages.length - 1; i >= 0; i--) {
|
|
232
|
-
const m = messages[i];
|
|
233
|
-
if (m?.role === "user" && typeof m.content === "string") return m.content;
|
|
234
|
-
}
|
|
235
|
-
return "";
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
function isTitleGeneratorCall(system: unknown): boolean {
|
|
239
|
-
return typeof system === "string" && /title generator/i.test(system);
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
export function createFakeAnthropicClient(): Anthropic {
|
|
243
|
-
return {
|
|
244
|
-
messages: {
|
|
245
|
-
stream(params: {
|
|
246
|
-
system?: unknown;
|
|
247
|
-
messages: Array<{ role?: string; content?: unknown }>;
|
|
248
|
-
}) {
|
|
249
|
-
// Title generation runs in parallel with runChatTurn; don't let it
|
|
250
|
-
// consume a fixture turn meant for the main conversation.
|
|
251
|
-
if (isTitleGeneratorCall(params.system)) {
|
|
252
|
-
return new FakeMessageStream({ text: "Chat session", delayMs: 0 });
|
|
253
|
-
}
|
|
254
|
-
const turn = selectTurn(extractLastUserText(params.messages));
|
|
255
|
-
return new FakeMessageStream(turn);
|
|
256
|
-
},
|
|
257
|
-
async create(params: {
|
|
258
|
-
system?: unknown;
|
|
259
|
-
messages: Array<{ role?: string; content?: unknown }>;
|
|
260
|
-
}): Promise<Message> {
|
|
261
|
-
if (isTitleGeneratorCall(params.system)) {
|
|
262
|
-
return buildFinalMessage("Chat session");
|
|
263
|
-
}
|
|
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));
|
|
270
|
-
return buildFinalMessage(
|
|
271
|
-
turn.text ?? turn.chunks?.join("") ?? "",
|
|
272
|
-
turn.toolCalls,
|
|
273
|
-
);
|
|
274
|
-
},
|
|
275
|
-
},
|
|
276
|
-
} as unknown as Anthropic;
|
|
277
|
-
}
|
package/src/worker/llm-client.ts
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import Anthropic from "@anthropic-ai/sdk";
|
|
2
|
-
import type { BotholomewConfig } from "../config/schemas.ts";
|
|
3
|
-
import { createFakeAnthropicClient } from "./fake-llm.ts";
|
|
4
|
-
|
|
5
|
-
export function createLlmClient(config: BotholomewConfig): Anthropic {
|
|
6
|
-
if (process.env.BOTHOLOMEW_FAKE_LLM === "1") {
|
|
7
|
-
return createFakeAnthropicClient();
|
|
8
|
-
}
|
|
9
|
-
return new Anthropic({
|
|
10
|
-
apiKey: config.anthropic_api_key || undefined,
|
|
11
|
-
});
|
|
12
|
-
}
|