botholomew 0.13.0 → 0.14.2
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 +1 -1
- package/src/chat/agent.ts +17 -4
- package/src/commands/context.ts +35 -9
- package/src/context/fetcher-errors.ts +8 -0
- package/src/context/fetcher.ts +96 -27
- package/src/context/markdown-converter.ts +186 -0
- package/src/context/store.ts +209 -36
- package/src/fs/sandbox.ts +18 -4
- package/src/tools/dir/create.ts +1 -1
- package/src/tools/dir/tree.ts +3 -2
- package/src/tools/file/copy.ts +1 -1
- package/src/tools/file/delete.ts +11 -2
- package/src/tools/file/edit.ts +1 -1
- package/src/tools/file/info.ts +3 -1
- package/src/tools/file/move.ts +1 -1
- package/src/tools/file/write.ts +1 -1
- package/src/tools/registry.ts +5 -0
- package/src/tools/tool.ts +5 -0
- package/src/tools/util/sleep.ts +77 -0
- package/src/tui/components/SleepProgress.tsx +70 -0
- package/src/tui/components/ToolCall.tsx +10 -0
- package/src/utils/frontmatter.ts +10 -2
package/src/tools/tool.ts
CHANGED
|
@@ -17,6 +17,11 @@ export interface ToolContext {
|
|
|
17
17
|
projectDir: string;
|
|
18
18
|
config: Required<BotholomewConfig>;
|
|
19
19
|
mcpxClient: McpxClient | null;
|
|
20
|
+
/**
|
|
21
|
+
* Chat-mode only. Lets long-running tools (e.g. `sleep`) poll for
|
|
22
|
+
* Esc-to-abort by reading `session.aborted`. Workers leave this `undefined`.
|
|
23
|
+
*/
|
|
24
|
+
shouldAbort?: () => boolean;
|
|
20
25
|
}
|
|
21
26
|
|
|
22
27
|
type ToolOutputBase = { is_error: z.ZodBoolean };
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { ToolDefinition } from "../tool.ts";
|
|
3
|
+
|
|
4
|
+
const MIN_SECONDS = 1;
|
|
5
|
+
const MAX_SECONDS = 3600;
|
|
6
|
+
const POLL_INTERVAL_MS = 250;
|
|
7
|
+
|
|
8
|
+
const inputSchema = z.object({
|
|
9
|
+
seconds: z
|
|
10
|
+
.number()
|
|
11
|
+
.int()
|
|
12
|
+
.min(MIN_SECONDS)
|
|
13
|
+
.max(MAX_SECONDS)
|
|
14
|
+
.describe(
|
|
15
|
+
`How long to sleep, in seconds (${MIN_SECONDS}–${MAX_SECONDS}). For longer pauses, create a schedule instead.`,
|
|
16
|
+
),
|
|
17
|
+
reason: z
|
|
18
|
+
.string()
|
|
19
|
+
.min(1)
|
|
20
|
+
.describe(
|
|
21
|
+
"Why you're sleeping — shown to the user under the progress bar. Be specific (e.g. 'waiting for worker to finish task abc').",
|
|
22
|
+
),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const outputSchema = z.object({
|
|
26
|
+
message: z.string(),
|
|
27
|
+
slept_seconds: z.number(),
|
|
28
|
+
aborted: z.boolean(),
|
|
29
|
+
is_error: z.boolean(),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export const sleepTool = {
|
|
33
|
+
name: "sleep",
|
|
34
|
+
description:
|
|
35
|
+
"[[ bash equivalent command: sleep ]] Pause the chat agent for a fixed number of seconds. Useful after enqueuing tasks for workers, before checking results. The user sees a progress bar while you wait; pressing Esc cancels the wait. Returns when the time elapses or the user steers.",
|
|
36
|
+
group: "util",
|
|
37
|
+
inputSchema,
|
|
38
|
+
outputSchema,
|
|
39
|
+
execute: async (input, ctx): Promise<z.infer<typeof outputSchema>> => {
|
|
40
|
+
const startedAt = Date.now();
|
|
41
|
+
const totalMs = input.seconds * 1000;
|
|
42
|
+
const shouldAbort = ctx.shouldAbort;
|
|
43
|
+
|
|
44
|
+
let aborted: boolean = false;
|
|
45
|
+
await new Promise<void>((resolve) => {
|
|
46
|
+
let timeout: ReturnType<typeof setTimeout> | null = null;
|
|
47
|
+
let interval: ReturnType<typeof setInterval> | null = null;
|
|
48
|
+
|
|
49
|
+
const finish = () => {
|
|
50
|
+
if (timeout) clearTimeout(timeout);
|
|
51
|
+
if (interval) clearInterval(interval);
|
|
52
|
+
resolve();
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
timeout = setTimeout(finish, totalMs);
|
|
56
|
+
|
|
57
|
+
if (shouldAbort) {
|
|
58
|
+
interval = setInterval(() => {
|
|
59
|
+
if (shouldAbort()) {
|
|
60
|
+
aborted = true;
|
|
61
|
+
finish();
|
|
62
|
+
}
|
|
63
|
+
}, POLL_INTERVAL_MS);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const sleptSeconds = (Date.now() - startedAt) / 1000;
|
|
68
|
+
return {
|
|
69
|
+
message: aborted
|
|
70
|
+
? `Sleep interrupted after ${sleptSeconds.toFixed(1)}s of ${input.seconds}s — user steered.`
|
|
71
|
+
: `Slept ${sleptSeconds.toFixed(1)}s. ${input.reason}`,
|
|
72
|
+
slept_seconds: sleptSeconds,
|
|
73
|
+
aborted,
|
|
74
|
+
is_error: false,
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
} satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Box, Text } from "ink";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { theme } from "../theme.ts";
|
|
4
|
+
|
|
5
|
+
interface SleepProgressProps {
|
|
6
|
+
startedAt: Date;
|
|
7
|
+
totalSeconds: number;
|
|
8
|
+
reason?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const BAR_WIDTH = 24;
|
|
12
|
+
const TICK_MS = 200;
|
|
13
|
+
|
|
14
|
+
export function SleepProgress({
|
|
15
|
+
startedAt,
|
|
16
|
+
totalSeconds,
|
|
17
|
+
reason,
|
|
18
|
+
}: SleepProgressProps) {
|
|
19
|
+
const [now, setNow] = useState(() => Date.now());
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
const id = setInterval(() => setNow(Date.now()), TICK_MS);
|
|
23
|
+
return () => clearInterval(id);
|
|
24
|
+
}, []);
|
|
25
|
+
|
|
26
|
+
const totalMs = totalSeconds * 1000;
|
|
27
|
+
const elapsedMs = Math.min(totalMs, Math.max(0, now - startedAt.getTime()));
|
|
28
|
+
const ratio = totalMs > 0 ? elapsedMs / totalMs : 1;
|
|
29
|
+
const filled = Math.round(ratio * BAR_WIDTH);
|
|
30
|
+
const bar = "█".repeat(filled) + "░".repeat(BAR_WIDTH - filled);
|
|
31
|
+
const elapsedSec = (elapsedMs / 1000).toFixed(1);
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<Box flexDirection="column">
|
|
35
|
+
<Box>
|
|
36
|
+
<Text dimColor>{" "}</Text>
|
|
37
|
+
<Text color={theme.accent}>{bar}</Text>
|
|
38
|
+
<Text dimColor>
|
|
39
|
+
{" "}
|
|
40
|
+
{elapsedSec}s / {totalSeconds}s
|
|
41
|
+
</Text>
|
|
42
|
+
</Box>
|
|
43
|
+
{reason && (
|
|
44
|
+
<Text dimColor wrap="truncate-end">
|
|
45
|
+
{" "}
|
|
46
|
+
{reason}
|
|
47
|
+
</Text>
|
|
48
|
+
)}
|
|
49
|
+
</Box>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Pull `seconds` and `reason` out of a sleep tool's stringified JSON input.
|
|
55
|
+
* Returns `null` if the input can't be parsed or doesn't have a numeric duration.
|
|
56
|
+
*/
|
|
57
|
+
export function parseSleepInput(
|
|
58
|
+
raw: string,
|
|
59
|
+
): { seconds: number; reason?: string } | null {
|
|
60
|
+
try {
|
|
61
|
+
const parsed = JSON.parse(raw);
|
|
62
|
+
if (typeof parsed?.seconds !== "number") return null;
|
|
63
|
+
return {
|
|
64
|
+
seconds: parsed.seconds,
|
|
65
|
+
reason: typeof parsed.reason === "string" ? parsed.reason : undefined,
|
|
66
|
+
};
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Box, Text } from "ink";
|
|
2
2
|
import { theme } from "../theme.ts";
|
|
3
|
+
import { parseSleepInput, SleepProgress } from "./SleepProgress.tsx";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* For mcp_exec calls, extract server/tool into a top-level display name
|
|
@@ -53,6 +54,8 @@ export function ToolCall({ tool }: ToolCallProps) {
|
|
|
53
54
|
const truncatedInput =
|
|
54
55
|
displayInput.length > 60 ? `${displayInput.slice(0, 60)}…` : displayInput;
|
|
55
56
|
const truncatedOutput = tool.output ? tool.output.slice(0, 120) : "";
|
|
57
|
+
const sleepArgs =
|
|
58
|
+
tool.name === "sleep" && tool.running ? parseSleepInput(tool.input) : null;
|
|
56
59
|
|
|
57
60
|
return (
|
|
58
61
|
<Box flexDirection="column">
|
|
@@ -83,6 +86,13 @@ export function ToolCall({ tool }: ToolCallProps) {
|
|
|
83
86
|
{tool.name === "mcp_exec" && <Text dimColor> (exec)</Text>}
|
|
84
87
|
<Text dimColor> ({truncatedInput})</Text>
|
|
85
88
|
</Box>
|
|
89
|
+
{sleepArgs && (
|
|
90
|
+
<SleepProgress
|
|
91
|
+
startedAt={tool.timestamp}
|
|
92
|
+
totalSeconds={sleepArgs.seconds}
|
|
93
|
+
reason={sleepArgs.reason}
|
|
94
|
+
/>
|
|
95
|
+
)}
|
|
86
96
|
{truncatedOutput && !tool.running && (
|
|
87
97
|
<Text dimColor wrap="truncate-end">
|
|
88
98
|
{" → "}
|
package/src/utils/frontmatter.ts
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import matter from "gray-matter";
|
|
2
2
|
|
|
3
3
|
export interface ContextFileMeta {
|
|
4
|
-
loading
|
|
5
|
-
"agent-modification"
|
|
4
|
+
loading?: "always" | "contextual";
|
|
5
|
+
"agent-modification"?: boolean;
|
|
6
|
+
// Set by `bothy context import <url>` so the saved file remembers
|
|
7
|
+
// where it came from. Optional so files written by other paths
|
|
8
|
+
// (prompts/, beliefs/, agent-authored notes) aren't required to
|
|
9
|
+
// carry import metadata.
|
|
10
|
+
source_url?: string;
|
|
11
|
+
imported_at?: string;
|
|
12
|
+
title?: string;
|
|
13
|
+
[key: string]: unknown;
|
|
6
14
|
}
|
|
7
15
|
|
|
8
16
|
export function parseContextFile(raw: string): {
|