botholomew 0.7.9 → 0.7.11
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 +6 -0
- package/package.json +3 -2
- package/src/chat/agent.ts +2 -4
- package/src/commands/chat.ts +12 -4
- package/src/commands/context.ts +168 -12
- package/src/daemon/fake-llm.ts +204 -0
- package/src/daemon/fake-mcp.ts +74 -0
- package/src/daemon/llm-client.ts +12 -0
- package/src/daemon/llm.ts +2 -4
- package/src/db/context.ts +13 -0
- package/src/tools/mcp/exec.ts +12 -0
- package/src/tools/mcp/search.ts +11 -0
- package/src/tui/App.tsx +22 -0
- package/src/utils/title.ts +2 -4
package/README.md
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
" "
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
+

|
|
10
|
+
|
|
9
11
|
**A local-first AI agent for knowledge work.** Botholomew is a long-running
|
|
10
12
|
autonomous agent that works its way through a task queue — reading email,
|
|
11
13
|
summarizing documents, researching topics, organizing notes, and maintaining
|
|
@@ -111,6 +113,8 @@ Everything the agent can touch is here. No surprises.
|
|
|
111
113
|
|
|
112
114
|
## The CLI
|
|
113
115
|
|
|
116
|
+

|
|
117
|
+
|
|
114
118
|
| Command | Purpose |
|
|
115
119
|
|---|---|
|
|
116
120
|
| `botholomew init` | Create `.botholomew/` with templates and a fresh database |
|
|
@@ -193,6 +197,8 @@ Topics worth understanding in detail:
|
|
|
193
197
|
multi-project service naming.
|
|
194
198
|
- **[Configuration](docs/configuration.md)** — every key in `config.json`
|
|
195
199
|
and its default.
|
|
200
|
+
- **[Doc captures](docs/captures.md)** — how the screenshots and GIFs in
|
|
201
|
+
these docs are regenerated programmatically via VHS and a fake-LLM mode.
|
|
196
202
|
|
|
197
203
|
---
|
|
198
204
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "botholomew",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.11",
|
|
4
4
|
"description": "Local, autonomous AI agent for knowledge work — works your task queue while you sleep.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
"dev": "bun run src/cli.ts",
|
|
20
20
|
"dev:demo": "bun run src/cli.ts chat -p 'learn everything you can about me from the connected MCP services and then save what you'\\''ve learned about me to context'",
|
|
21
21
|
"test": "bun test",
|
|
22
|
-
"lint": "tsc --noEmit && biome check ."
|
|
22
|
+
"lint": "tsc --noEmit && biome check .",
|
|
23
|
+
"capture": "bun run scripts/capture.ts"
|
|
23
24
|
},
|
|
24
25
|
"dependencies": {
|
|
25
26
|
"@anthropic-ai/sdk": "^0.88.0",
|
package/src/chat/agent.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import Anthropic from "@anthropic-ai/sdk";
|
|
2
1
|
import type {
|
|
3
2
|
MessageParam,
|
|
4
3
|
ToolResultBlockParam,
|
|
@@ -9,6 +8,7 @@ import type { BotholomewConfig } from "../config/schemas.ts";
|
|
|
9
8
|
import { embedSingle } from "../context/embedder.ts";
|
|
10
9
|
import { fitToContextWindow, getMaxInputTokens } from "../daemon/context.ts";
|
|
11
10
|
import { maybeStoreResult } from "../daemon/large-results.ts";
|
|
11
|
+
import { createLlmClient } from "../daemon/llm-client.ts";
|
|
12
12
|
import {
|
|
13
13
|
buildMetaHeader,
|
|
14
14
|
extractKeywords,
|
|
@@ -178,9 +178,7 @@ export async function runChatTurn(input: {
|
|
|
178
178
|
callbacks,
|
|
179
179
|
} = input;
|
|
180
180
|
|
|
181
|
-
const client =
|
|
182
|
-
apiKey: config.anthropic_api_key || undefined,
|
|
183
|
-
});
|
|
181
|
+
const client = createLlmClient(config);
|
|
184
182
|
|
|
185
183
|
const chatTools = getChatTools();
|
|
186
184
|
const maxInputTokens = await getMaxInputTokens(
|
package/src/commands/chat.ts
CHANGED
|
@@ -36,6 +36,12 @@ export function registerChatCommand(program: Command) {
|
|
|
36
36
|
await ensureDaemonRunning(dir);
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
// VHS/ttyd doesn't fully negotiate the Kitty Keyboard protocol, so
|
|
40
|
+
// Ink's "enabled" mode drops non-text keystrokes (Tab, Escape) under
|
|
41
|
+
// capture. Use "disabled" mode in capture to keep text input working;
|
|
42
|
+
// captures that need Tab/Escape should use the `-p` prompt flag or
|
|
43
|
+
// a /slash command typed as text instead.
|
|
44
|
+
const isCapture = process.env.BOTHOLOMEW_FAKE_LLM === "1";
|
|
39
45
|
const instance = render(
|
|
40
46
|
React.createElement(App, {
|
|
41
47
|
projectDir: dir,
|
|
@@ -44,10 +50,12 @@ export function registerChatCommand(program: Command) {
|
|
|
44
50
|
}),
|
|
45
51
|
{
|
|
46
52
|
exitOnCtrlC: false,
|
|
47
|
-
kittyKeyboard:
|
|
48
|
-
mode: "
|
|
49
|
-
|
|
50
|
-
|
|
53
|
+
kittyKeyboard: isCapture
|
|
54
|
+
? { mode: "disabled" }
|
|
55
|
+
: {
|
|
56
|
+
mode: "enabled",
|
|
57
|
+
flags: ["disambiguateEscapeCodes"],
|
|
58
|
+
},
|
|
51
59
|
},
|
|
52
60
|
);
|
|
53
61
|
await instance.waitUntilExit();
|
package/src/commands/context.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
createContextItemStrict,
|
|
26
26
|
deleteContextItemByPath,
|
|
27
27
|
getContextItemByPath,
|
|
28
|
+
getContextItemBySourcePath,
|
|
28
29
|
listContextItems,
|
|
29
30
|
listContextItemsByPrefix,
|
|
30
31
|
PathConflictError,
|
|
@@ -193,9 +194,126 @@ export function registerContextCommand(program: Command) {
|
|
|
193
194
|
text: `Found ${totalCount} item(s) to add (${filesToAdd.length} file(s), ${urlsToAdd.length} URL(s)).`,
|
|
194
195
|
});
|
|
195
196
|
|
|
196
|
-
// Phase 1.5: LLM placement for files without an explicit path
|
|
197
197
|
const config = await loadConfig(dir);
|
|
198
198
|
const CONCURRENCY = 10;
|
|
199
|
+
|
|
200
|
+
// Phase 0: Source-path dedup — items whose source_path is already in
|
|
201
|
+
// context are routed per --on-conflict before we pay for LLM placement.
|
|
202
|
+
type AlreadyInContext = {
|
|
203
|
+
sourcePath: string;
|
|
204
|
+
sourceType: "file" | "url";
|
|
205
|
+
existing: ContextItem;
|
|
206
|
+
};
|
|
207
|
+
const alreadyInContext: AlreadyInContext[] = [];
|
|
208
|
+
const remainingFiles: FileToAdd[] = [];
|
|
209
|
+
const remainingUrls: { url: string; contextPath: string }[] = [];
|
|
210
|
+
|
|
211
|
+
for (const f of filesToAdd) {
|
|
212
|
+
const existing = await getContextItemBySourcePath(
|
|
213
|
+
conn,
|
|
214
|
+
f.filePath,
|
|
215
|
+
"file",
|
|
216
|
+
);
|
|
217
|
+
if (existing) {
|
|
218
|
+
alreadyInContext.push({
|
|
219
|
+
sourcePath: f.filePath,
|
|
220
|
+
sourceType: "file",
|
|
221
|
+
existing,
|
|
222
|
+
});
|
|
223
|
+
} else {
|
|
224
|
+
remainingFiles.push(f);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
for (const u of urlsToAdd) {
|
|
228
|
+
const existing = await getContextItemBySourcePath(conn, u.url, "url");
|
|
229
|
+
if (existing) {
|
|
230
|
+
alreadyInContext.push({
|
|
231
|
+
sourcePath: u.url,
|
|
232
|
+
sourceType: "url",
|
|
233
|
+
existing,
|
|
234
|
+
});
|
|
235
|
+
} else {
|
|
236
|
+
remainingUrls.push(u);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
let refreshedCount = 0;
|
|
241
|
+
let refreshedChunks = 0;
|
|
242
|
+
const dedupSkipped: string[] = [];
|
|
243
|
+
|
|
244
|
+
if (alreadyInContext.length > 0) {
|
|
245
|
+
if (policy === "error") {
|
|
246
|
+
logger.error(
|
|
247
|
+
`${alreadyInContext.length} item(s) already in context (matched by source path):`,
|
|
248
|
+
);
|
|
249
|
+
for (const a of alreadyInContext) {
|
|
250
|
+
console.log(
|
|
251
|
+
` ${ansis.red("✗")} ${a.sourcePath} → ${a.existing.context_path} (id: ${a.existing.id})`,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
logger.dim(
|
|
255
|
+
"Re-run with --on-conflict=skip to ignore these items or --on-conflict=overwrite to refresh them from disk.",
|
|
256
|
+
);
|
|
257
|
+
process.exit(1);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (policy === "skip") {
|
|
261
|
+
for (const a of alreadyInContext) {
|
|
262
|
+
logger.dim(
|
|
263
|
+
`⊘ already in context: ${a.sourcePath} → ${a.existing.context_path}`,
|
|
264
|
+
);
|
|
265
|
+
dedupSkipped.push(a.existing.context_path);
|
|
266
|
+
}
|
|
267
|
+
} else {
|
|
268
|
+
// overwrite: refresh existing items (diff + selective re-embed),
|
|
269
|
+
// preserving their original context_path.
|
|
270
|
+
const itemsToRefresh = alreadyInContext.map((a) => a.existing);
|
|
271
|
+
const hasUrls = itemsToRefresh.some((i) => i.source_type === "url");
|
|
272
|
+
const mcpxClient = hasUrls ? await createMcpxClient(dir) : null;
|
|
273
|
+
|
|
274
|
+
const refreshSpinner = createSpinner(
|
|
275
|
+
`Refreshing 0/${itemsToRefresh.length} existing item(s)...`,
|
|
276
|
+
).start();
|
|
277
|
+
const refreshResult = await refreshContextItems(
|
|
278
|
+
conn,
|
|
279
|
+
itemsToRefresh,
|
|
280
|
+
config,
|
|
281
|
+
mcpxClient,
|
|
282
|
+
{
|
|
283
|
+
onItemProgress: (done, total) => {
|
|
284
|
+
refreshSpinner.update({
|
|
285
|
+
text: `Refreshing ${done}/${total} existing item(s)...`,
|
|
286
|
+
});
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
);
|
|
290
|
+
refreshSpinner.success({
|
|
291
|
+
text: `Refreshed ${refreshResult.checked} existing item(s): ${refreshResult.updated} updated, ${refreshResult.unchanged} unchanged, ${refreshResult.missing} missing.`,
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// Count everything we processed OK (updated + unchanged) as
|
|
295
|
+
// "refreshed" for the summary. Missing/error items are reported
|
|
296
|
+
// inline below and don't count toward success.
|
|
297
|
+
refreshedCount = refreshResult.updated + refreshResult.unchanged;
|
|
298
|
+
refreshedChunks = refreshResult.chunks;
|
|
299
|
+
for (const item of refreshResult.items) {
|
|
300
|
+
if (item.status === "missing") {
|
|
301
|
+
logger.warn(` Missing: ${item.source_path}`);
|
|
302
|
+
} else if (item.status === "error") {
|
|
303
|
+
logger.warn(
|
|
304
|
+
` Error refreshing ${item.source_path}: ${item.error}`,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Drop already-handled items from the work lists so downstream phases
|
|
312
|
+
// (LLM placement, description, insert, embed) see only truly-new items.
|
|
313
|
+
filesToAdd.splice(0, filesToAdd.length, ...remainingFiles);
|
|
314
|
+
urlsToAdd.splice(0, urlsToAdd.length, ...remainingUrls);
|
|
315
|
+
|
|
316
|
+
// Phase 1.5: LLM placement for files without an explicit path
|
|
199
317
|
const needsPlacement = filesToAdd.filter((f) => f.contextPath === null);
|
|
200
318
|
// description cache keyed by filePath — populated when LLM placement runs,
|
|
201
319
|
// reused in addFile to avoid a second describe call.
|
|
@@ -378,10 +496,13 @@ export function registerContextCommand(program: Command) {
|
|
|
378
496
|
}
|
|
379
497
|
}
|
|
380
498
|
|
|
381
|
-
// Report conflicts before embeddings so the user sees them prominently
|
|
499
|
+
// Report conflicts before embeddings so the user sees them prominently.
|
|
500
|
+
// Phase 0 already handled source-path matches, so anything here is a
|
|
501
|
+
// target-path collision — an LLM-suggested (or explicit) path that
|
|
502
|
+
// another unrelated item already occupies.
|
|
382
503
|
if (conflicts.length > 0) {
|
|
383
504
|
logger.error(
|
|
384
|
-
`${conflicts.length} path collision(s) — nothing written for these items:`,
|
|
505
|
+
`${conflicts.length} target-path collision(s) — nothing written for these items:`,
|
|
385
506
|
);
|
|
386
507
|
for (const c of conflicts) {
|
|
387
508
|
console.log(
|
|
@@ -389,24 +510,34 @@ export function registerContextCommand(program: Command) {
|
|
|
389
510
|
);
|
|
390
511
|
}
|
|
391
512
|
logger.dim(
|
|
392
|
-
"Re-run with --
|
|
513
|
+
"The suggested path is already in use by a different source. Re-run with --prefix to place these items elsewhere, or delete the existing item first.",
|
|
393
514
|
);
|
|
394
515
|
}
|
|
395
516
|
|
|
517
|
+
// Merge Phase 0 skips into the skip list used by the final summary.
|
|
518
|
+
skipped.push(...dedupSkipped);
|
|
519
|
+
|
|
396
520
|
// Phase 3: Chunk + embed in parallel (network I/O)
|
|
397
521
|
if (itemIds.length === 0 || !config.openai_api_key) {
|
|
398
522
|
if (!config.openai_api_key) {
|
|
399
523
|
logger.dim("Skipping embeddings (no OpenAI API key configured).");
|
|
400
524
|
}
|
|
401
|
-
const msg =
|
|
525
|
+
const msg = buildSummary({
|
|
526
|
+
added: itemIds.length,
|
|
527
|
+
refreshed: refreshedCount,
|
|
528
|
+
skipped: skipped.length,
|
|
529
|
+
chunks: refreshedChunks,
|
|
530
|
+
totalCount,
|
|
531
|
+
handled: itemIds.length + refreshedCount + skipped.length,
|
|
532
|
+
});
|
|
402
533
|
if (conflicts.length > 0) {
|
|
403
534
|
logger.error(msg);
|
|
404
535
|
process.exit(1);
|
|
405
536
|
}
|
|
406
|
-
if (itemIds.length
|
|
537
|
+
if (itemIds.length + skipped.length + refreshedCount >= totalCount) {
|
|
407
538
|
logger.success(msg);
|
|
408
539
|
process.exit(0);
|
|
409
|
-
} else if (itemIds.length === 0) {
|
|
540
|
+
} else if (itemIds.length === 0 && refreshedCount === 0) {
|
|
410
541
|
logger.error(msg);
|
|
411
542
|
process.exit(1);
|
|
412
543
|
} else {
|
|
@@ -452,15 +583,20 @@ export function registerContextCommand(program: Command) {
|
|
|
452
583
|
else filesAdded++;
|
|
453
584
|
}
|
|
454
585
|
|
|
455
|
-
const
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
586
|
+
const summary = buildSummary({
|
|
587
|
+
added: filesAdded,
|
|
588
|
+
updated: filesUpdated,
|
|
589
|
+
refreshed: refreshedCount,
|
|
590
|
+
skipped: skipped.length,
|
|
591
|
+
chunks: chunks + refreshedChunks,
|
|
592
|
+
totalCount,
|
|
593
|
+
handled: itemIds.length + refreshedCount + skipped.length,
|
|
594
|
+
});
|
|
459
595
|
if (conflicts.length > 0) {
|
|
460
596
|
logger.error(summary);
|
|
461
597
|
process.exit(1);
|
|
462
598
|
}
|
|
463
|
-
if (itemIds.length
|
|
599
|
+
if (itemIds.length + skipped.length + refreshedCount >= totalCount) {
|
|
464
600
|
logger.success(summary);
|
|
465
601
|
process.exit(0);
|
|
466
602
|
} else {
|
|
@@ -675,6 +811,26 @@ async function resolveItems(
|
|
|
675
811
|
|
|
676
812
|
type ConflictPolicy = "error" | "overwrite" | "skip";
|
|
677
813
|
|
|
814
|
+
/** Format the final "X added, Y refreshed, Z skipped — N chunks" line. */
|
|
815
|
+
function buildSummary(args: {
|
|
816
|
+
added: number;
|
|
817
|
+
updated?: number;
|
|
818
|
+
refreshed: number;
|
|
819
|
+
skipped: number;
|
|
820
|
+
chunks: number;
|
|
821
|
+
totalCount: number;
|
|
822
|
+
handled?: number;
|
|
823
|
+
}): string {
|
|
824
|
+
const parts: string[] = [];
|
|
825
|
+
if (args.added > 0) parts.push(`${args.added} added`);
|
|
826
|
+
if (args.updated && args.updated > 0) parts.push(`${args.updated} updated`);
|
|
827
|
+
if (args.refreshed > 0) parts.push(`${args.refreshed} refreshed`);
|
|
828
|
+
if (args.skipped > 0) parts.push(`${args.skipped} skipped`);
|
|
829
|
+
const body = parts.length > 0 ? parts.join(", ") : "0 added";
|
|
830
|
+
const handled = args.handled ?? args.added + args.refreshed + args.skipped;
|
|
831
|
+
return `${body} — ${args.chunks} chunk(s) indexed (${handled}/${args.totalCount} item(s)).`;
|
|
832
|
+
}
|
|
833
|
+
|
|
678
834
|
type AddFileResult =
|
|
679
835
|
| { kind: "added"; id: string; contextPath: string }
|
|
680
836
|
| { kind: "skipped"; contextPath: string }
|
|
@@ -0,0 +1,204 @@
|
|
|
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
|
+
/** Optional tool calls to emit after text. */
|
|
21
|
+
toolCalls?: Array<{
|
|
22
|
+
id?: string;
|
|
23
|
+
name: string;
|
|
24
|
+
input: Record<string, unknown>;
|
|
25
|
+
}>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface FakeFixture {
|
|
29
|
+
turns: FakeTurn[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let loadedFixture: FakeFixture | null = null;
|
|
33
|
+
let loadedFixturePath: string | undefined;
|
|
34
|
+
let sequentialIndex = 0;
|
|
35
|
+
|
|
36
|
+
function loadFixture(): FakeFixture {
|
|
37
|
+
const fixturePath = process.env.BOTHOLOMEW_FAKE_LLM_FIXTURE;
|
|
38
|
+
// Reload (and reset the sequential cursor) whenever the fixture path
|
|
39
|
+
// changes — tests rotate fixtures between cases, and callers can swap
|
|
40
|
+
// fixtures mid-session without restarting the process.
|
|
41
|
+
if (loadedFixture && loadedFixturePath === fixturePath) {
|
|
42
|
+
return loadedFixture;
|
|
43
|
+
}
|
|
44
|
+
loadedFixturePath = fixturePath;
|
|
45
|
+
sequentialIndex = 0;
|
|
46
|
+
if (!fixturePath) {
|
|
47
|
+
loadedFixture = { turns: [] };
|
|
48
|
+
return loadedFixture;
|
|
49
|
+
}
|
|
50
|
+
if (!existsSync(fixturePath)) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`BOTHOLOMEW_FAKE_LLM_FIXTURE points to missing file: ${fixturePath}`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
loadedFixture = JSON.parse(readFileSync(fixturePath, "utf8")) as FakeFixture;
|
|
56
|
+
return loadedFixture;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function selectTurn(lastUserText: string): FakeTurn {
|
|
60
|
+
const fixture = loadFixture();
|
|
61
|
+
if (fixture.turns.length === 0) {
|
|
62
|
+
return { text: "(fake LLM: no fixture turns configured)" };
|
|
63
|
+
}
|
|
64
|
+
// Only consider turns at or after the cursor, so that multi-turn fixtures
|
|
65
|
+
// (e.g. text → tool_use → follow-up text) advance past a matched turn even
|
|
66
|
+
// when the agent's next iteration shows the same user text.
|
|
67
|
+
for (let i = sequentialIndex; i < fixture.turns.length; i++) {
|
|
68
|
+
const t = fixture.turns[i];
|
|
69
|
+
if (t?.match && new RegExp(t.match, "i").test(lastUserText)) {
|
|
70
|
+
sequentialIndex = i + 1;
|
|
71
|
+
return t;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (sequentialIndex < fixture.turns.length) {
|
|
75
|
+
const t = fixture.turns[sequentialIndex];
|
|
76
|
+
sequentialIndex++;
|
|
77
|
+
if (t) return t;
|
|
78
|
+
}
|
|
79
|
+
// Out of turns — repeat the last one so the agent loop doesn't spin.
|
|
80
|
+
return fixture.turns[fixture.turns.length - 1] ?? { text: "" };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function chunkText(text: string, size: number): string[] {
|
|
84
|
+
if (size <= 0 || text.length === 0) return text ? [text] : [];
|
|
85
|
+
const out: string[] = [];
|
|
86
|
+
for (let i = 0; i < text.length; i += size) out.push(text.slice(i, i + size));
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function buildFinalMessage(
|
|
91
|
+
text: string,
|
|
92
|
+
toolCalls?: FakeTurn["toolCalls"],
|
|
93
|
+
): Message {
|
|
94
|
+
const content: Array<Record<string, unknown>> = [];
|
|
95
|
+
if (text) content.push({ type: "text", text, citations: null });
|
|
96
|
+
if (toolCalls) {
|
|
97
|
+
for (const tc of toolCalls) {
|
|
98
|
+
content.push({
|
|
99
|
+
type: "tool_use",
|
|
100
|
+
id: tc.id ?? `toolu_${Math.random().toString(36).slice(2, 14)}`,
|
|
101
|
+
name: tc.name,
|
|
102
|
+
input: tc.input,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
id: `msg_${Math.random().toString(36).slice(2, 14)}`,
|
|
108
|
+
type: "message",
|
|
109
|
+
role: "assistant",
|
|
110
|
+
model: "botholomew-fake-llm",
|
|
111
|
+
content,
|
|
112
|
+
stop_reason: toolCalls?.length ? "tool_use" : "end_turn",
|
|
113
|
+
stop_sequence: null,
|
|
114
|
+
usage: {
|
|
115
|
+
input_tokens: 100,
|
|
116
|
+
output_tokens: Math.max(1, Math.floor(text.length / 4)),
|
|
117
|
+
cache_creation_input_tokens: 0,
|
|
118
|
+
cache_read_input_tokens: 0,
|
|
119
|
+
service_tier: "standard",
|
|
120
|
+
server_tool_use: null,
|
|
121
|
+
},
|
|
122
|
+
} as unknown as Message;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
class FakeMessageStream extends EventEmitter {
|
|
126
|
+
private resolveFinal: (m: Message) => void = () => {};
|
|
127
|
+
private readonly finalPromise: Promise<Message>;
|
|
128
|
+
|
|
129
|
+
constructor(private readonly turn: FakeTurn) {
|
|
130
|
+
super();
|
|
131
|
+
this.finalPromise = new Promise<Message>((resolve) => {
|
|
132
|
+
this.resolveFinal = resolve;
|
|
133
|
+
});
|
|
134
|
+
queueMicrotask(() => this.run());
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private async run(): Promise<void> {
|
|
138
|
+
const text = this.turn.text ?? this.turn.chunks?.join("") ?? "";
|
|
139
|
+
const chunks =
|
|
140
|
+
this.turn.chunks ?? chunkText(text, this.turn.chunkSize ?? 6);
|
|
141
|
+
const delay = this.turn.delayMs ?? 40;
|
|
142
|
+
for (const chunk of chunks) {
|
|
143
|
+
this.emit("text", chunk);
|
|
144
|
+
if (delay > 0) await new Promise((r) => setTimeout(r, delay));
|
|
145
|
+
}
|
|
146
|
+
const final = buildFinalMessage(text, this.turn.toolCalls);
|
|
147
|
+
for (const block of final.content) {
|
|
148
|
+
if ((block as { type: string }).type === "tool_use") {
|
|
149
|
+
this.emit("contentBlock", block as ToolUseBlock);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
this.resolveFinal(final);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
finalMessage(): Promise<Message> {
|
|
156
|
+
return this.finalPromise;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function extractLastUserText(
|
|
161
|
+
messages: Array<{ role?: string; content?: unknown }>,
|
|
162
|
+
): string {
|
|
163
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
164
|
+
const m = messages[i];
|
|
165
|
+
if (m?.role === "user" && typeof m.content === "string") return m.content;
|
|
166
|
+
}
|
|
167
|
+
return "";
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function isTitleGeneratorCall(system: unknown): boolean {
|
|
171
|
+
return typeof system === "string" && /title generator/i.test(system);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function createFakeAnthropicClient(): Anthropic {
|
|
175
|
+
return {
|
|
176
|
+
messages: {
|
|
177
|
+
stream(params: {
|
|
178
|
+
system?: unknown;
|
|
179
|
+
messages: Array<{ role?: string; content?: unknown }>;
|
|
180
|
+
}) {
|
|
181
|
+
// Title generation runs in parallel with runChatTurn; don't let it
|
|
182
|
+
// consume a fixture turn meant for the main conversation.
|
|
183
|
+
if (isTitleGeneratorCall(params.system)) {
|
|
184
|
+
return new FakeMessageStream({ text: "Chat session", delayMs: 0 });
|
|
185
|
+
}
|
|
186
|
+
const turn = selectTurn(extractLastUserText(params.messages));
|
|
187
|
+
return new FakeMessageStream(turn);
|
|
188
|
+
},
|
|
189
|
+
async create(params: {
|
|
190
|
+
system?: unknown;
|
|
191
|
+
messages: Array<{ role?: string; content?: unknown }>;
|
|
192
|
+
}): Promise<Message> {
|
|
193
|
+
if (isTitleGeneratorCall(params.system)) {
|
|
194
|
+
return buildFinalMessage("Chat session");
|
|
195
|
+
}
|
|
196
|
+
const turn = selectTurn(extractLastUserText(params.messages));
|
|
197
|
+
return buildFinalMessage(
|
|
198
|
+
turn.text ?? turn.chunks?.join("") ?? "",
|
|
199
|
+
turn.toolCalls,
|
|
200
|
+
);
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
} as unknown as Anthropic;
|
|
204
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canned MCP responses for capture mode (BOTHOLOMEW_FAKE_LLM=1). Lets
|
|
3
|
+
* `mcp_search` and `mcp_exec` return demo-friendly results without requiring
|
|
4
|
+
* a live MCPX gateway. The shapes mirror what the real tools emit.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface FakeMcpSearchResult {
|
|
8
|
+
server: string;
|
|
9
|
+
tool: string;
|
|
10
|
+
description: string;
|
|
11
|
+
score: number;
|
|
12
|
+
match_type: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function isCaptureMode(): boolean {
|
|
16
|
+
return process.env.BOTHOLOMEW_FAKE_LLM === "1";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function fakeMcpSearch(query: string): FakeMcpSearchResult[] | null {
|
|
20
|
+
const q = query.toLowerCase();
|
|
21
|
+
if (/calendar|schedule|event|meeting/.test(q)) {
|
|
22
|
+
return [
|
|
23
|
+
{
|
|
24
|
+
server: "google-calendar",
|
|
25
|
+
tool: "ListEvents",
|
|
26
|
+
description:
|
|
27
|
+
"List events on a user's Google Calendar within a date range.",
|
|
28
|
+
score: 0.94,
|
|
29
|
+
match_type: "semantic",
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
server: "google-calendar",
|
|
33
|
+
tool: "CreateEvent",
|
|
34
|
+
description: "Create a new event on a user's Google Calendar.",
|
|
35
|
+
score: 0.78,
|
|
36
|
+
match_type: "semantic",
|
|
37
|
+
},
|
|
38
|
+
];
|
|
39
|
+
}
|
|
40
|
+
if (/email|gmail|mail/.test(q)) {
|
|
41
|
+
return [
|
|
42
|
+
{
|
|
43
|
+
server: "gmail",
|
|
44
|
+
tool: "SendEmail",
|
|
45
|
+
description: "Send an email from the user's Gmail account.",
|
|
46
|
+
score: 0.91,
|
|
47
|
+
match_type: "semantic",
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function fakeMcpExec(
|
|
55
|
+
server: string,
|
|
56
|
+
tool: string,
|
|
57
|
+
_args: Record<string, unknown> | undefined,
|
|
58
|
+
): string | null {
|
|
59
|
+
if (server === "google-calendar" && tool === "ListEvents") {
|
|
60
|
+
return JSON.stringify(
|
|
61
|
+
{
|
|
62
|
+
events: [
|
|
63
|
+
{ start: "09:00", summary: "Sprint planning" },
|
|
64
|
+
{ start: "11:30", summary: "Design review with Pascal" },
|
|
65
|
+
{ start: "14:00", summary: "Focus block: v0.8 roadmap" },
|
|
66
|
+
{ start: "16:30", summary: "1:1 with Sterling" },
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
null,
|
|
70
|
+
2,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
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
|
+
}
|
package/src/daemon/llm.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import Anthropic from "@anthropic-ai/sdk";
|
|
2
1
|
import type {
|
|
3
2
|
Message,
|
|
4
3
|
MessageParam,
|
|
@@ -14,6 +13,7 @@ import { registerAllTools } from "../tools/registry.ts";
|
|
|
14
13
|
import { getTool, type ToolContext, toAnthropicTools } from "../tools/tool.ts";
|
|
15
14
|
import { fitToContextWindow, getMaxInputTokens } from "./context.ts";
|
|
16
15
|
import { clearLargeResults, maybeStoreResult } from "./large-results.ts";
|
|
16
|
+
import { createLlmClient } from "./llm-client.ts";
|
|
17
17
|
|
|
18
18
|
registerAllTools();
|
|
19
19
|
|
|
@@ -60,9 +60,7 @@ export async function runAgentLoop(input: {
|
|
|
60
60
|
callbacks,
|
|
61
61
|
} = input;
|
|
62
62
|
|
|
63
|
-
const client =
|
|
64
|
-
apiKey: config.anthropic_api_key || undefined,
|
|
65
|
-
});
|
|
63
|
+
const client = createLlmClient(config);
|
|
66
64
|
|
|
67
65
|
// Build predecessor context from completed blocking tasks
|
|
68
66
|
let predecessorContext = "";
|
package/src/db/context.ts
CHANGED
|
@@ -179,6 +179,19 @@ export async function getContextItemByPath(
|
|
|
179
179
|
return row ? rowToContextItem(row) : null;
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
export async function getContextItemBySourcePath(
|
|
183
|
+
db: DbConnection,
|
|
184
|
+
sourcePath: string,
|
|
185
|
+
sourceType: "file" | "url",
|
|
186
|
+
): Promise<ContextItem | null> {
|
|
187
|
+
const row = await db.queryGet<ContextItemRow>(
|
|
188
|
+
"SELECT * FROM context_items WHERE source_path = ?1 AND source_type = ?2 LIMIT 1",
|
|
189
|
+
sourcePath,
|
|
190
|
+
sourceType,
|
|
191
|
+
);
|
|
192
|
+
return row ? rowToContextItem(row) : null;
|
|
193
|
+
}
|
|
194
|
+
|
|
182
195
|
/**
|
|
183
196
|
* Look up a context item by UUID (if the value looks like one) or by context_path.
|
|
184
197
|
*/
|
package/src/tools/mcp/exec.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { fakeMcpExec, isCaptureMode } from "../../daemon/fake-mcp.ts";
|
|
2
3
|
import { formatCallToolResult } from "../../mcpx/client.ts";
|
|
3
4
|
import type { ToolDefinition } from "../tool.ts";
|
|
4
5
|
|
|
@@ -81,6 +82,17 @@ export const mcpExecTool = {
|
|
|
81
82
|
inputSchema,
|
|
82
83
|
outputSchema,
|
|
83
84
|
execute: async (input, ctx) => {
|
|
85
|
+
if (isCaptureMode()) {
|
|
86
|
+
const canned = fakeMcpExec(input.server, input.tool, input.args);
|
|
87
|
+
if (canned) {
|
|
88
|
+
return {
|
|
89
|
+
result: canned,
|
|
90
|
+
is_error: false,
|
|
91
|
+
error_kind: undefined,
|
|
92
|
+
hint: undefined,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
}
|
|
84
96
|
if (!ctx.mcpxClient) {
|
|
85
97
|
return {
|
|
86
98
|
result:
|
package/src/tools/mcp/search.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { fakeMcpSearch, isCaptureMode } from "../../daemon/fake-mcp.ts";
|
|
2
3
|
import type { ToolDefinition } from "../tool.ts";
|
|
3
4
|
|
|
4
5
|
const inputSchema = z.object({
|
|
@@ -36,6 +37,16 @@ export const mcpSearchTool = {
|
|
|
36
37
|
inputSchema,
|
|
37
38
|
outputSchema,
|
|
38
39
|
execute: async (input, ctx) => {
|
|
40
|
+
if (isCaptureMode()) {
|
|
41
|
+
const canned = fakeMcpSearch(input.query);
|
|
42
|
+
if (canned) {
|
|
43
|
+
return {
|
|
44
|
+
results: canned,
|
|
45
|
+
is_error: false,
|
|
46
|
+
hint: "Use mcp_info with server and tool name to see the full input schema before calling mcp_exec.",
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
}
|
|
39
50
|
if (!ctx.mcpxClient) {
|
|
40
51
|
return {
|
|
41
52
|
results: [],
|
package/src/tui/App.tsx
CHANGED
|
@@ -209,6 +209,28 @@ export function App({
|
|
|
209
209
|
return () => clearTimeout(timer);
|
|
210
210
|
}, []);
|
|
211
211
|
|
|
212
|
+
// Capture-mode tab auto-cycle. Under VHS/ttyd the Tab key doesn't reliably
|
|
213
|
+
// reach Ink, so a docs tape can't drive the tab tour by keystroke. When
|
|
214
|
+
// BOTHOLOMEW_CAPTURE_TAB_CYCLE is set, schedule timers that walk through
|
|
215
|
+
// every tab so a single recording can show all panels.
|
|
216
|
+
//
|
|
217
|
+
// Format: "dwellMs" or "dwellMs:startDelayMs". The optional start delay
|
|
218
|
+
// lets a tape finish a streamed chat reply before the cycle kicks in.
|
|
219
|
+
useEffect(() => {
|
|
220
|
+
const spec = process.env.BOTHOLOMEW_CAPTURE_TAB_CYCLE;
|
|
221
|
+
if (!spec) return;
|
|
222
|
+
const [dwellRaw, delayRaw] = spec.split(":");
|
|
223
|
+
const dwellMs = Number.parseInt(dwellRaw ?? "", 10) || 2500;
|
|
224
|
+
const startDelayMs = Number.parseInt(delayRaw ?? "", 10) || 0;
|
|
225
|
+
const sequence: TabId[] = [2, 3, 4, 5, 6, 7, 1];
|
|
226
|
+
const timers = sequence.map((tab, i) =>
|
|
227
|
+
setTimeout(() => setActiveTab(tab), startDelayMs + dwellMs * (i + 1)),
|
|
228
|
+
);
|
|
229
|
+
return () => {
|
|
230
|
+
for (const t of timers) clearTimeout(t);
|
|
231
|
+
};
|
|
232
|
+
}, []);
|
|
233
|
+
|
|
212
234
|
// Stable ref for App-level input handler — same pattern as InputBar to
|
|
213
235
|
// prevent Ink's useInput from re-registering stdin listeners on every render.
|
|
214
236
|
const activeTabRef = useRef(activeTab);
|
package/src/utils/title.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import Anthropic from "@anthropic-ai/sdk";
|
|
2
1
|
import type { BotholomewConfig } from "../config/schemas.ts";
|
|
2
|
+
import { createLlmClient } from "../daemon/llm-client.ts";
|
|
3
3
|
import { withDb } from "../db/connection.ts";
|
|
4
4
|
import { updateThreadTitle } from "../db/threads.ts";
|
|
5
5
|
import { logger } from "./logger.ts";
|
|
@@ -17,9 +17,7 @@ export async function generateThreadTitle(
|
|
|
17
17
|
context: string,
|
|
18
18
|
): Promise<void> {
|
|
19
19
|
try {
|
|
20
|
-
const client =
|
|
21
|
-
apiKey: config.anthropic_api_key || undefined,
|
|
22
|
-
});
|
|
20
|
+
const client = createLlmClient(config);
|
|
23
21
|
|
|
24
22
|
const response = await client.messages.create({
|
|
25
23
|
model: config.chunker_model,
|