botholomew 0.15.0 → 0.15.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/README.md +2 -0
- package/package.json +3 -2
- package/src/chat/agent.ts +40 -0
- package/src/chat/usage.ts +69 -0
- package/src/commands/chat.ts +5 -1
- package/src/config/schemas.ts +2 -0
- package/src/context/fetcher.ts +1 -1
- package/src/context/store.ts +11 -5
- package/src/db/embeddings.ts +17 -0
- package/src/fs/sandbox.ts +31 -6
- package/src/tui/App.tsx +55 -13
- package/src/tui/components/ContextPanel.tsx +42 -1
- package/src/tui/components/DeleteArmedBanner.tsx +18 -0
- package/src/tui/components/HelpPanel.tsx +73 -6
- package/src/tui/components/InputBar.tsx +6 -4
- package/src/tui/components/Logo.tsx +15 -5
- package/src/tui/components/SchedulePanel.tsx +18 -25
- package/src/tui/components/SleepProgress.tsx +5 -2
- package/src/tui/components/StatusBar.tsx +9 -6
- package/src/tui/components/TabBar.tsx +29 -4
- package/src/tui/components/TaskPanel.tsx +18 -4
- package/src/tui/components/ThreadPanel.tsx +18 -26
- package/src/tui/components/WorkerPanel.tsx +38 -2
- package/src/tui/idle.tsx +68 -0
- package/src/tui/useDeleteConfirm.ts +115 -0
- package/src/utils/frontmatter.ts +1 -1
- package/src/workers/store.ts +24 -2
|
@@ -1,13 +1,20 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
1
2
|
import { Box, Text, useInput, useStdout } from "ink";
|
|
2
3
|
import { memo, useEffect, useMemo, useState } from "react";
|
|
3
4
|
import { readLogTail } from "../../worker/log-reader.ts";
|
|
4
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
deleteWorkerLog,
|
|
7
|
+
listWorkers,
|
|
8
|
+
type Worker,
|
|
9
|
+
} from "../../workers/store.ts";
|
|
5
10
|
import {
|
|
6
11
|
detailPaneBorderProps,
|
|
7
12
|
type FocusState,
|
|
8
13
|
handleListDetailKey,
|
|
9
14
|
} from "../listDetailKeys.ts";
|
|
15
|
+
import { useDeleteConfirm } from "../useDeleteConfirm.ts";
|
|
10
16
|
import { useLatestRef } from "../useLatestRef.ts";
|
|
17
|
+
import { DeleteArmedBanner } from "./DeleteArmedBanner.tsx";
|
|
11
18
|
import { Scrollbar } from "./Scrollbar.tsx";
|
|
12
19
|
|
|
13
20
|
interface WorkerPanelProps {
|
|
@@ -161,6 +168,21 @@ export const WorkerPanel = memo(function WorkerPanel({
|
|
|
161
168
|
const itemCountRef = useLatestRef(workers.length);
|
|
162
169
|
const maxLogScrollRef = useLatestRef(maxLogScroll);
|
|
163
170
|
const focusRef = useLatestRef(focus);
|
|
171
|
+
const viewModeRef = useLatestRef(viewMode);
|
|
172
|
+
const selectedLogPathRef = useLatestRef(selectedLogPath);
|
|
173
|
+
|
|
174
|
+
const deleteConfirm = useDeleteConfirm(() => {
|
|
175
|
+
const path = selectedLogPathRef.current;
|
|
176
|
+
if (!path) return;
|
|
177
|
+
deleteWorkerLog(projectDir, path)
|
|
178
|
+
.catch(() => {})
|
|
179
|
+
.finally(() => {
|
|
180
|
+
setLogContent("");
|
|
181
|
+
setLogSize(0);
|
|
182
|
+
setLogTruncated(false);
|
|
183
|
+
setLogScroll(0);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
164
186
|
|
|
165
187
|
// The right pane scrolls with arrows when focused. Tee the log scroll into
|
|
166
188
|
// the follow-state so reaching the bottom resumes follow mode (and any
|
|
@@ -181,6 +203,8 @@ export const WorkerPanel = memo(function WorkerPanel({
|
|
|
181
203
|
(input, key) => {
|
|
182
204
|
if (!isActive) return;
|
|
183
205
|
|
|
206
|
+
if (input !== "d") deleteConfirm.cancel();
|
|
207
|
+
|
|
184
208
|
// `l` toggles between detail (worker info) and log (tail) view in the
|
|
185
209
|
// right pane.
|
|
186
210
|
if (input === "l") {
|
|
@@ -206,6 +230,14 @@ export const WorkerPanel = memo(function WorkerPanel({
|
|
|
206
230
|
setFilterIdx((i) => (i + 1) % STATUS_FILTERS.length);
|
|
207
231
|
return;
|
|
208
232
|
}
|
|
233
|
+
|
|
234
|
+
if (input === "d") {
|
|
235
|
+
if (viewModeRef.current !== "log") return;
|
|
236
|
+
const path = selectedLogPathRef.current;
|
|
237
|
+
if (!path) return;
|
|
238
|
+
deleteConfirm.pressDelete(`worker log: ${basename(path)}`);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
209
241
|
},
|
|
210
242
|
{ isActive },
|
|
211
243
|
);
|
|
@@ -225,10 +257,14 @@ export const WorkerPanel = memo(function WorkerPanel({
|
|
|
225
257
|
{focus === "detail"
|
|
226
258
|
? " · ↑↓ scroll ⇧↑↓ page g/G top/bot ← back to list l toggle"
|
|
227
259
|
: viewMode === "log"
|
|
228
|
-
? " · ↑↓ select → enter log l detail f filter"
|
|
260
|
+
? " · ↑↓ select → enter log l detail f filter d delete log (×2)"
|
|
229
261
|
: " · ↑↓ select → enter detail l view log f filter"}
|
|
230
262
|
</Text>
|
|
231
263
|
</Box>
|
|
264
|
+
<DeleteArmedBanner
|
|
265
|
+
armed={deleteConfirm.armed}
|
|
266
|
+
label={deleteConfirm.armedLabel}
|
|
267
|
+
/>
|
|
232
268
|
|
|
233
269
|
{workers.length === 0 ? (
|
|
234
270
|
<Text dimColor>
|
package/src/tui/idle.tsx
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
type ReactNode,
|
|
4
|
+
useCallback,
|
|
5
|
+
useContext,
|
|
6
|
+
useEffect,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
} from "react";
|
|
10
|
+
|
|
11
|
+
const CHECK_INTERVAL_MS = 10_000;
|
|
12
|
+
|
|
13
|
+
export function shouldBeIdle(
|
|
14
|
+
lastActivity: number,
|
|
15
|
+
now: number,
|
|
16
|
+
timeoutMs: number,
|
|
17
|
+
): boolean {
|
|
18
|
+
if (timeoutMs <= 0) return false;
|
|
19
|
+
return now - lastActivity >= timeoutMs;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface IdleContextValue {
|
|
23
|
+
isIdle: boolean;
|
|
24
|
+
markActivity: () => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const IdleContext = createContext<IdleContextValue>({
|
|
28
|
+
isIdle: false,
|
|
29
|
+
markActivity: () => {},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
interface IdleProviderProps {
|
|
33
|
+
timeoutMs: number;
|
|
34
|
+
children: ReactNode;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function IdleProvider({ timeoutMs, children }: IdleProviderProps) {
|
|
38
|
+
const lastActivityRef = useRef(Date.now());
|
|
39
|
+
const [isIdle, setIsIdle] = useState(false);
|
|
40
|
+
const isIdleRef = useRef(isIdle);
|
|
41
|
+
isIdleRef.current = isIdle;
|
|
42
|
+
|
|
43
|
+
const markActivity = useCallback(() => {
|
|
44
|
+
lastActivityRef.current = Date.now();
|
|
45
|
+
if (isIdleRef.current) {
|
|
46
|
+
setIsIdle(false);
|
|
47
|
+
}
|
|
48
|
+
}, []);
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (timeoutMs <= 0) return;
|
|
52
|
+
const id = setInterval(() => {
|
|
53
|
+
const idle = shouldBeIdle(lastActivityRef.current, Date.now(), timeoutMs);
|
|
54
|
+
setIsIdle((prev) => (prev === idle ? prev : idle));
|
|
55
|
+
}, CHECK_INTERVAL_MS);
|
|
56
|
+
return () => clearInterval(id);
|
|
57
|
+
}, [timeoutMs]);
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<IdleContext.Provider value={{ isIdle, markActivity }}>
|
|
61
|
+
{children}
|
|
62
|
+
</IdleContext.Provider>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function useIdle(): IdleContextValue {
|
|
67
|
+
return useContext(IdleContext);
|
|
68
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Two-press delete confirmation. First press arms; second press within
|
|
5
|
+
* `ttlMs` confirms. Any non-`d` keystroke should call `cancel()`. The TTL
|
|
6
|
+
* is a safety net for idle/escape.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface DeleteConfirmController {
|
|
10
|
+
isArmed(): boolean;
|
|
11
|
+
armedLabel(): string | null;
|
|
12
|
+
pressDelete: (label: string) => void;
|
|
13
|
+
cancel: () => void;
|
|
14
|
+
dispose: () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function createDeleteConfirmController(
|
|
18
|
+
onConfirm: () => void,
|
|
19
|
+
opts: { ttlMs?: number; onChange?: () => void } = {},
|
|
20
|
+
): DeleteConfirmController {
|
|
21
|
+
const ttlMs = opts.ttlMs ?? 3000;
|
|
22
|
+
let armed = false;
|
|
23
|
+
let label: string | null = null;
|
|
24
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
25
|
+
|
|
26
|
+
const clearTimer = () => {
|
|
27
|
+
if (timer) {
|
|
28
|
+
clearTimeout(timer);
|
|
29
|
+
timer = null;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const notify = () => {
|
|
34
|
+
opts.onChange?.();
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const pressDelete = (next: string) => {
|
|
38
|
+
if (armed) {
|
|
39
|
+
clearTimer();
|
|
40
|
+
armed = false;
|
|
41
|
+
label = null;
|
|
42
|
+
notify();
|
|
43
|
+
onConfirm();
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
armed = true;
|
|
47
|
+
label = next;
|
|
48
|
+
timer = setTimeout(() => {
|
|
49
|
+
armed = false;
|
|
50
|
+
label = null;
|
|
51
|
+
timer = null;
|
|
52
|
+
notify();
|
|
53
|
+
}, ttlMs);
|
|
54
|
+
notify();
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const cancel = () => {
|
|
58
|
+
if (!armed && !timer) return;
|
|
59
|
+
clearTimer();
|
|
60
|
+
armed = false;
|
|
61
|
+
label = null;
|
|
62
|
+
notify();
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const dispose = () => {
|
|
66
|
+
clearTimer();
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
isArmed: () => armed,
|
|
71
|
+
armedLabel: () => label,
|
|
72
|
+
pressDelete,
|
|
73
|
+
cancel,
|
|
74
|
+
dispose,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface UseDeleteConfirmResult {
|
|
79
|
+
armed: boolean;
|
|
80
|
+
armedLabel: string | null;
|
|
81
|
+
pressDelete: (label: string) => void;
|
|
82
|
+
cancel: () => void;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function useDeleteConfirm(
|
|
86
|
+
onConfirm: () => void,
|
|
87
|
+
opts: { ttlMs?: number } = {},
|
|
88
|
+
): UseDeleteConfirmResult {
|
|
89
|
+
const [, setTick] = useState(0);
|
|
90
|
+
|
|
91
|
+
const onConfirmRef = useRef(onConfirm);
|
|
92
|
+
onConfirmRef.current = onConfirm;
|
|
93
|
+
|
|
94
|
+
const controllerRef = useRef<DeleteConfirmController | null>(null);
|
|
95
|
+
if (!controllerRef.current) {
|
|
96
|
+
controllerRef.current = createDeleteConfirmController(
|
|
97
|
+
() => onConfirmRef.current(),
|
|
98
|
+
{ ttlMs: opts.ttlMs, onChange: () => setTick((t) => t + 1) },
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
return () => {
|
|
104
|
+
controllerRef.current?.dispose();
|
|
105
|
+
};
|
|
106
|
+
}, []);
|
|
107
|
+
|
|
108
|
+
const c = controllerRef.current;
|
|
109
|
+
return {
|
|
110
|
+
armed: c.isArmed(),
|
|
111
|
+
armedLabel: c.armedLabel(),
|
|
112
|
+
pressDelete: c.pressDelete,
|
|
113
|
+
cancel: c.cancel,
|
|
114
|
+
};
|
|
115
|
+
}
|
package/src/utils/frontmatter.ts
CHANGED
|
@@ -3,7 +3,7 @@ import matter from "gray-matter";
|
|
|
3
3
|
export interface ContextFileMeta {
|
|
4
4
|
loading?: "always" | "contextual";
|
|
5
5
|
"agent-modification"?: boolean;
|
|
6
|
-
// Set by `
|
|
6
|
+
// Set by `botholomew context import <url>` so the saved file remembers
|
|
7
7
|
// where it came from. Optional so files written by other paths
|
|
8
8
|
// (prompts/, beliefs/, agent-authored notes) aren't required to
|
|
9
9
|
// carry import metadata.
|
package/src/workers/store.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readdir, stat, unlink } from "node:fs/promises";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import { getWorkersDir } from "../constants.ts";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { getWorkerLogsDir, getWorkersDir } from "../constants.ts";
|
|
4
4
|
import { atomicWrite, readWithMtime } from "../fs/atomic.ts";
|
|
5
5
|
|
|
6
6
|
export const WORKER_MODES = ["persist", "once"] as const;
|
|
@@ -217,6 +217,28 @@ export async function deleteWorker(
|
|
|
217
217
|
}
|
|
218
218
|
}
|
|
219
219
|
|
|
220
|
+
/**
|
|
221
|
+
* Delete a worker's on-disk log file. Refuses to touch anything outside
|
|
222
|
+
* `<projectDir>/logs/`. ENOENT is treated as success (idempotent).
|
|
223
|
+
*/
|
|
224
|
+
export async function deleteWorkerLog(
|
|
225
|
+
projectDir: string,
|
|
226
|
+
logPath: string,
|
|
227
|
+
): Promise<boolean> {
|
|
228
|
+
const logsDir = resolve(getWorkerLogsDir(projectDir));
|
|
229
|
+
const target = resolve(logPath);
|
|
230
|
+
if (target !== logsDir && !target.startsWith(`${logsDir}/`)) {
|
|
231
|
+
throw new Error(`refusing to delete log outside ${logsDir}: ${logPath}`);
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
await unlink(target);
|
|
235
|
+
return true;
|
|
236
|
+
} catch (err) {
|
|
237
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
238
|
+
throw err;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
220
242
|
async function listWorkerIds(projectDir: string): Promise<string[]> {
|
|
221
243
|
const dir = getWorkersDir(projectDir);
|
|
222
244
|
try {
|