botholomew 0.22.2 → 0.24.0
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 +11 -3
- package/package.json +3 -2
- package/src/approvals/decide.ts +36 -0
- package/src/approvals/errors.ts +22 -0
- package/src/approvals/schema.ts +48 -0
- package/src/approvals/store.ts +276 -0
- package/src/chat/approval.ts +62 -0
- package/src/chat/dream-prompt.ts +20 -0
- package/src/chat/session.ts +32 -3
- package/src/cli.ts +4 -0
- package/src/commands/approval.ts +130 -0
- package/src/commands/chat.ts +48 -34
- package/src/commands/dream.ts +194 -0
- package/src/commands/nuke.ts +12 -2
- package/src/commands/status.ts +0 -4
- package/src/commands/thread.ts +64 -0
- package/src/commands/worker.ts +31 -12
- package/src/config/loader.ts +27 -0
- package/src/config/schemas.ts +31 -0
- package/src/constants.ts +6 -0
- package/src/init/index.ts +5 -0
- package/src/mcpx/client.ts +83 -1
- package/src/skills/commands.ts +14 -0
- package/src/tasks/store.ts +4 -4
- package/src/threads/store.ts +102 -0
- package/src/tools/mcp/exec.ts +32 -0
- package/src/tools/skill/write.ts +3 -3
- package/src/tools/thread/search.ts +21 -73
- package/src/tools/tool.ts +7 -0
- package/src/tui/App.tsx +25 -2
- package/src/tui/components/ApprovalPanel.tsx +222 -0
- package/src/tui/components/ApprovalPrompt.tsx +68 -0
- package/src/tui/components/HelpPanel.tsx +3 -0
- package/src/tui/components/TabBar.tsx +13 -6
- package/src/tui/components/TabPanels.tsx +9 -0
- package/src/tui/hooks/useAppKeybindings.ts +9 -0
- package/src/tui/hooks/useApprovalCount.ts +32 -0
- package/src/tui/hooks/useApprovalPrompt.ts +49 -0
- package/src/tui/hooks/useCaptureTabCycle.ts +1 -1
- package/src/tui/hooks/useChatSession.ts +5 -3
- package/src/tui/keys.ts +1 -0
- package/src/worker/approval.ts +60 -0
- package/src/worker/index.ts +37 -4
- package/src/worker/llm.ts +18 -0
- package/src/worker/run.ts +3 -1
- package/src/worker/spawn.ts +3 -0
- package/src/worker/tick.ts +25 -2
- package/src/workers/store.ts +4 -4
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import ansis from "ansis";
|
|
2
|
+
import type { Command } from "commander";
|
|
3
|
+
import { decideAndRequeue } from "../approvals/decide.ts";
|
|
4
|
+
import {
|
|
5
|
+
APPROVAL_STATUSES,
|
|
6
|
+
type Approval,
|
|
7
|
+
type ApprovalStatus,
|
|
8
|
+
} from "../approvals/schema.ts";
|
|
9
|
+
import { getApproval, listApprovals } from "../approvals/store.ts";
|
|
10
|
+
import { logger } from "../utils/logger.ts";
|
|
11
|
+
|
|
12
|
+
function statusColor(status: ApprovalStatus): string {
|
|
13
|
+
switch (status) {
|
|
14
|
+
case "approved":
|
|
15
|
+
return ansis.green(status);
|
|
16
|
+
case "denied":
|
|
17
|
+
return ansis.red(status);
|
|
18
|
+
case "pending":
|
|
19
|
+
return ansis.yellow(status);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function printApproval(a: Approval) {
|
|
24
|
+
console.log(
|
|
25
|
+
`${ansis.bold(a.id.slice(0, 8))} ${statusColor(a.status).padEnd(18)} ${ansis.cyan(`${a.server}/${a.tool}`)}`,
|
|
26
|
+
);
|
|
27
|
+
console.log(` args: ${a.args}`);
|
|
28
|
+
if (a.task_id) console.log(` task: ${a.task_id}`);
|
|
29
|
+
if (a.reason) console.log(` reason: ${a.reason}`);
|
|
30
|
+
console.log(` created: ${a.created_at}`);
|
|
31
|
+
if (a.decided_at) {
|
|
32
|
+
console.log(` decided: ${a.decided_at} by ${a.decided_by ?? "?"}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function registerApprovalCommand(program: Command) {
|
|
37
|
+
const approval = program
|
|
38
|
+
.command("approval")
|
|
39
|
+
.description("Review and decide pending mcpx tool-call approvals");
|
|
40
|
+
|
|
41
|
+
approval
|
|
42
|
+
.command("list")
|
|
43
|
+
.description("List approval requests (newest first)")
|
|
44
|
+
.option(
|
|
45
|
+
"-s, --status <status>",
|
|
46
|
+
`filter by status (${APPROVAL_STATUSES.join("|")})`,
|
|
47
|
+
)
|
|
48
|
+
.option("-l, --limit <n>", "max number of approvals", Number.parseInt)
|
|
49
|
+
.option("-o, --offset <n>", "skip first N approvals", Number.parseInt)
|
|
50
|
+
.action(
|
|
51
|
+
async (opts: {
|
|
52
|
+
status?: ApprovalStatus;
|
|
53
|
+
limit?: number;
|
|
54
|
+
offset?: number;
|
|
55
|
+
}) => {
|
|
56
|
+
if (opts.status && !APPROVAL_STATUSES.includes(opts.status)) {
|
|
57
|
+
logger.error(
|
|
58
|
+
`Unknown status: ${opts.status}. Use one of: ${APPROVAL_STATUSES.join(", ")}`,
|
|
59
|
+
);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
const dir = program.opts().dir;
|
|
63
|
+
const approvals = await listApprovals(dir, {
|
|
64
|
+
status: opts.status,
|
|
65
|
+
limit: opts.limit,
|
|
66
|
+
offset: opts.offset,
|
|
67
|
+
});
|
|
68
|
+
if (approvals.length === 0) {
|
|
69
|
+
logger.dim("No approvals found.");
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
for (const a of approvals) {
|
|
73
|
+
printApproval(a);
|
|
74
|
+
console.log("");
|
|
75
|
+
}
|
|
76
|
+
console.log(ansis.dim(`${approvals.length} approval(s)`));
|
|
77
|
+
},
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
approval
|
|
81
|
+
.command("view <id>")
|
|
82
|
+
.description("Show a single approval request")
|
|
83
|
+
.action(async (id: string) => {
|
|
84
|
+
const dir = program.opts().dir;
|
|
85
|
+
const a = await getApproval(dir, id);
|
|
86
|
+
if (!a) {
|
|
87
|
+
logger.error(`No approval found with id ${id}.`);
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
printApproval(a);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
approval
|
|
94
|
+
.command("approve <id>")
|
|
95
|
+
.description("Approve a pending request and re-queue its task")
|
|
96
|
+
.action(async (id: string) => {
|
|
97
|
+
const dir = program.opts().dir;
|
|
98
|
+
const a = await getApproval(dir, id);
|
|
99
|
+
if (!a) {
|
|
100
|
+
logger.error(`No approval found with id ${id}.`);
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
if (a.status !== "pending") {
|
|
104
|
+
logger.warn(`Approval ${id} is already ${a.status}.`);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
await decideAndRequeue(dir, id, "approved", "cli");
|
|
108
|
+
logger.success(`Approved ${a.server}/${a.tool} (${id}).`);
|
|
109
|
+
if (a.task_id) logger.dim(`Re-queued task ${a.task_id} (now pending).`);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
approval
|
|
113
|
+
.command("deny <id>")
|
|
114
|
+
.description("Deny a pending request and re-queue its task to recover")
|
|
115
|
+
.action(async (id: string) => {
|
|
116
|
+
const dir = program.opts().dir;
|
|
117
|
+
const a = await getApproval(dir, id);
|
|
118
|
+
if (!a) {
|
|
119
|
+
logger.error(`No approval found with id ${id}.`);
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
if (a.status !== "pending") {
|
|
123
|
+
logger.warn(`Approval ${id} is already ${a.status}.`);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
await decideAndRequeue(dir, id, "denied", "cli");
|
|
127
|
+
logger.success(`Denied ${a.server}/${a.tool} (${id}).`);
|
|
128
|
+
if (a.task_id) logger.dim(`Re-queued task ${a.task_id} (now pending).`);
|
|
129
|
+
});
|
|
130
|
+
}
|
package/src/commands/chat.ts
CHANGED
|
@@ -8,8 +8,9 @@ export function registerChatCommand(program: Command) {
|
|
|
8
8
|
"Open the interactive chat TUI\n\n" +
|
|
9
9
|
" Tab navigation (Ctrl+<letter> from any tab):\n" +
|
|
10
10
|
" Ctrl+a Chat Ctrl+t Tasks Ctrl+w Workers\n" +
|
|
11
|
-
" Ctrl+o Tools Ctrl+e Threads Ctrl+
|
|
12
|
-
" Ctrl+n Context Ctrl+s Schedules
|
|
11
|
+
" Ctrl+o Tools Ctrl+e Threads Ctrl+p Approvals\n" +
|
|
12
|
+
" Ctrl+n Context Ctrl+s Schedules Ctrl+g Help\n" +
|
|
13
|
+
" Esc Return to Chat\n\n" +
|
|
13
14
|
" Refresh: Ctrl+R refreshes Context · Tasks · Threads · Schedules · Workers\n\n" +
|
|
14
15
|
" Chat input:\n" +
|
|
15
16
|
" Enter Send message\n" +
|
|
@@ -21,42 +22,55 @@ export function registerChatCommand(program: Command) {
|
|
|
21
22
|
" Slash commands:\n" +
|
|
22
23
|
" /help Show chat-command reference (Help tab has the full keymap)\n" +
|
|
23
24
|
" /skills List available skills\n" +
|
|
25
|
+
" /dream Reflect on recent threads — consolidate learnings, update beliefs/goals\n" +
|
|
24
26
|
" /clear End current thread and start a new one\n" +
|
|
25
27
|
" /exit End the chat session",
|
|
26
28
|
)
|
|
27
29
|
.option("--thread-id <id>", "Resume an existing chat thread")
|
|
28
30
|
.option("-p, --prompt <text>", "Start chat with an initial prompt")
|
|
29
|
-
.
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
31
|
+
.option(
|
|
32
|
+
"--unsafe",
|
|
33
|
+
"bypass the mcpx approval gate (allow every tool without approval)",
|
|
34
|
+
false,
|
|
35
|
+
)
|
|
36
|
+
.action(
|
|
37
|
+
async (opts: {
|
|
38
|
+
threadId?: string;
|
|
39
|
+
prompt?: string;
|
|
40
|
+
unsafe?: boolean;
|
|
41
|
+
}) => {
|
|
42
|
+
const { render } = await import("ink");
|
|
43
|
+
const React = await import("react");
|
|
44
|
+
const { App } = await import("../tui/App.tsx");
|
|
45
|
+
const dir = program.opts().dir;
|
|
46
|
+
const config = await loadConfig(dir);
|
|
47
|
+
const idleTimeoutMs = config.tui_idle_timeout_seconds * 1000;
|
|
36
48
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
49
|
+
// VHS/ttyd doesn't fully negotiate the Kitty Keyboard protocol, so
|
|
50
|
+
// Ink's "enabled" mode drops non-text keystrokes (Tab, Escape) under
|
|
51
|
+
// capture. Use "disabled" mode in capture to keep text input working;
|
|
52
|
+
// captures that need Tab/Escape should use the `-p` prompt flag or
|
|
53
|
+
// a /slash command typed as text instead.
|
|
54
|
+
const isCapture = process.env.BOTHOLOMEW_FAKE_LLM === "1";
|
|
55
|
+
const instance = render(
|
|
56
|
+
React.createElement(App, {
|
|
57
|
+
projectDir: dir,
|
|
58
|
+
threadId: opts.threadId,
|
|
59
|
+
initialPrompt: opts.prompt,
|
|
60
|
+
idleTimeoutMs,
|
|
61
|
+
unsafe: opts.unsafe,
|
|
62
|
+
}),
|
|
63
|
+
{
|
|
64
|
+
exitOnCtrlC: false,
|
|
65
|
+
kittyKeyboard: isCapture
|
|
66
|
+
? { mode: "disabled" }
|
|
67
|
+
: {
|
|
68
|
+
mode: "enabled",
|
|
69
|
+
flags: ["disambiguateEscapeCodes"],
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
);
|
|
73
|
+
await instance.waitUntilExit();
|
|
74
|
+
},
|
|
75
|
+
);
|
|
62
76
|
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import type { LanguageModel } from "ai";
|
|
2
|
+
import ansis from "ansis";
|
|
3
|
+
import type { Command } from "commander";
|
|
4
|
+
import { type ChatTurnCallbacks, runChatTurn } from "../chat/agent.ts";
|
|
5
|
+
import { DREAM_PROMPT_BODY } from "../chat/dream-prompt.ts";
|
|
6
|
+
import { requireProviderCreds } from "../chat/session.ts";
|
|
7
|
+
import { loadConfig } from "../config/loader.ts";
|
|
8
|
+
import { createMcpxClient, resolveMcpxDir } from "../mcpx/client.ts";
|
|
9
|
+
import type { WithMem } from "../mem/client.ts";
|
|
10
|
+
import {
|
|
11
|
+
createThread,
|
|
12
|
+
endThread,
|
|
13
|
+
ensureThreadsDir,
|
|
14
|
+
logInteraction,
|
|
15
|
+
} from "../threads/store.ts";
|
|
16
|
+
import { logger } from "../utils/logger.ts";
|
|
17
|
+
import { utcDateString } from "../utils/v7-date.ts";
|
|
18
|
+
|
|
19
|
+
/** Gray `HH:MM:SS` stamp, matching the logger's line prefix. */
|
|
20
|
+
function ts(): string {
|
|
21
|
+
return ansis.gray(new Date().toTimeString().slice(0, 8));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Collapse a tool-input blob to a single readable line. */
|
|
25
|
+
function previewInput(input: string, max = 100): string {
|
|
26
|
+
const oneLine = input.replace(/\s+/g, " ").trim();
|
|
27
|
+
return oneLine.length > max ? `${oneLine.slice(0, max)}…` : oneLine;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface DreamOptions {
|
|
31
|
+
/** ISO date or relative duration (`24h`, `7d`). Defaults to `dream_lookback_hours`. */
|
|
32
|
+
since?: string;
|
|
33
|
+
/** Propose edits without writing anything. */
|
|
34
|
+
dryRun?: boolean;
|
|
35
|
+
/** Test seam: inject a language model + membot accessor. */
|
|
36
|
+
_testModel?: LanguageModel;
|
|
37
|
+
_testWithMem?: WithMem;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const RELATIVE_RE = /^(\d+)\s*([hd])$/i;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolve the start of the recall window. Accepts a relative duration
|
|
44
|
+
* (`24h`, `7d`), an ISO date, or — when omitted — `now - lookbackHours`.
|
|
45
|
+
*/
|
|
46
|
+
export function resolveSince(
|
|
47
|
+
since: string | undefined,
|
|
48
|
+
lookbackHours: number,
|
|
49
|
+
now: Date,
|
|
50
|
+
): Date {
|
|
51
|
+
if (!since) {
|
|
52
|
+
return new Date(now.getTime() - lookbackHours * 3600_000);
|
|
53
|
+
}
|
|
54
|
+
const rel = RELATIVE_RE.exec(since.trim());
|
|
55
|
+
if (rel) {
|
|
56
|
+
const n = Number(rel[1]);
|
|
57
|
+
const unitHours = rel[2]?.toLowerCase() === "d" ? 24 : 1;
|
|
58
|
+
return new Date(now.getTime() - n * unitHours * 3600_000);
|
|
59
|
+
}
|
|
60
|
+
const parsed = new Date(since);
|
|
61
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`Could not parse --since "${since}". Use an ISO date (2026-06-01) or a relative duration like 24h / 7d.`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
return parsed;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Run one reflection ("dream") pass: review recent threads, consolidate
|
|
71
|
+
* durable facts into the knowledge store, and (unless `--dry-run`) apply
|
|
72
|
+
* justified edits to the agent's prompt files. Reuses the chat agent loop and
|
|
73
|
+
* its tool set, so this composes the existing primitives — no new machinery.
|
|
74
|
+
* Returns the audit thread id.
|
|
75
|
+
*/
|
|
76
|
+
export async function runDream(
|
|
77
|
+
projectDir: string,
|
|
78
|
+
opts: DreamOptions = {},
|
|
79
|
+
): Promise<string> {
|
|
80
|
+
const config = await loadConfig(projectDir);
|
|
81
|
+
requireProviderCreds(config);
|
|
82
|
+
await ensureThreadsDir(projectDir);
|
|
83
|
+
|
|
84
|
+
const now = new Date();
|
|
85
|
+
const since = resolveSince(opts.since, config.dream_lookback_hours, now);
|
|
86
|
+
|
|
87
|
+
const threadId = await createThread(
|
|
88
|
+
projectDir,
|
|
89
|
+
"chat_session",
|
|
90
|
+
undefined,
|
|
91
|
+
`Dream — ${utcDateString(now)}`,
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const windowLine = `Scope your recall to conversations from ${since.toISOString()} onward (it is now ${now.toISOString()}).`;
|
|
95
|
+
const dryRunLine = opts.dryRun
|
|
96
|
+
? "\n\nIMPORTANT — DRY RUN: Do NOT call `prompt_edit` or any `membot` write tool. Instead, end with a report describing the reflection you would store and the exact prompt edits you would propose, then stop."
|
|
97
|
+
: "";
|
|
98
|
+
const userMessage = `${DREAM_PROMPT_BODY}\n\n${windowLine}${dryRunLine}`;
|
|
99
|
+
|
|
100
|
+
await logInteraction(projectDir, threadId, {
|
|
101
|
+
role: "user",
|
|
102
|
+
kind: "message",
|
|
103
|
+
content: userMessage,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
logger.info(
|
|
107
|
+
`${ansis.magenta.bold("Dreaming")} — reviewing threads since ${ansis.cyan(
|
|
108
|
+
since.toISOString(),
|
|
109
|
+
)}${opts.dryRun ? ` ${ansis.yellow("(dry run)")}` : ""}`,
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const mcpxClient = await createMcpxClient(resolveMcpxDir(projectDir, config));
|
|
113
|
+
|
|
114
|
+
// Chat callbacks don't carry tool durations, so time each call by id.
|
|
115
|
+
const toolStartedAt = new Map<string, number>();
|
|
116
|
+
let midStream = false;
|
|
117
|
+
|
|
118
|
+
const callbacks: ChatTurnCallbacks = {
|
|
119
|
+
onToken: (text) => {
|
|
120
|
+
process.stdout.write(text);
|
|
121
|
+
midStream = true;
|
|
122
|
+
},
|
|
123
|
+
onToolStart: (id, name, input) => {
|
|
124
|
+
if (midStream) {
|
|
125
|
+
process.stdout.write("\n");
|
|
126
|
+
midStream = false;
|
|
127
|
+
}
|
|
128
|
+
toolStartedAt.set(id, Date.now());
|
|
129
|
+
process.stdout.write(
|
|
130
|
+
`${ts()} ${ansis.yellow("▶")} ${ansis.bold(name)} ${ansis.dim(
|
|
131
|
+
previewInput(input),
|
|
132
|
+
)}\n`,
|
|
133
|
+
);
|
|
134
|
+
},
|
|
135
|
+
onToolEnd: (id, name, _output, isError) => {
|
|
136
|
+
const startedAt = toolStartedAt.get(id);
|
|
137
|
+
const elapsed = startedAt
|
|
138
|
+
? ` ${ansis.dim(`(${((Date.now() - startedAt) / 1000).toFixed(1)}s)`)}`
|
|
139
|
+
: "";
|
|
140
|
+
toolStartedAt.delete(id);
|
|
141
|
+
const mark = isError ? ansis.red("✗") : ansis.green("✓");
|
|
142
|
+
const status = isError ? ` ${ansis.red("error")}` : "";
|
|
143
|
+
process.stdout.write(
|
|
144
|
+
`${ts()} ${mark} ${ansis.bold(name)}${status}${elapsed}\n`,
|
|
145
|
+
);
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
await runChatTurn({
|
|
151
|
+
messages: [{ role: "user", content: userMessage }],
|
|
152
|
+
projectDir,
|
|
153
|
+
config,
|
|
154
|
+
threadId,
|
|
155
|
+
mcpxClient,
|
|
156
|
+
callbacks,
|
|
157
|
+
_testModel: opts._testModel,
|
|
158
|
+
_testWithMem: opts._testWithMem,
|
|
159
|
+
});
|
|
160
|
+
} finally {
|
|
161
|
+
process.stdout.write("\n");
|
|
162
|
+
await endThread(projectDir, threadId);
|
|
163
|
+
await mcpxClient?.close();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return threadId;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function registerDreamCommand(program: Command) {
|
|
170
|
+
program
|
|
171
|
+
.command("dream")
|
|
172
|
+
.description(
|
|
173
|
+
"Reflect on recent threads: consolidate durable facts into the knowledge store and update beliefs/goals",
|
|
174
|
+
)
|
|
175
|
+
.option(
|
|
176
|
+
"-s, --since <when>",
|
|
177
|
+
"ISO date or relative duration (24h, 7d) to scope recall (default: dream_lookback_hours)",
|
|
178
|
+
)
|
|
179
|
+
.option(
|
|
180
|
+
"--dry-run",
|
|
181
|
+
"propose edits without writing prompts or the knowledge store",
|
|
182
|
+
false,
|
|
183
|
+
)
|
|
184
|
+
.action(async (opts: { since?: string; dryRun?: boolean }) => {
|
|
185
|
+
const dir = program.opts().dir;
|
|
186
|
+
const threadId = await runDream(dir, {
|
|
187
|
+
since: opts.since,
|
|
188
|
+
dryRun: opts.dryRun,
|
|
189
|
+
});
|
|
190
|
+
logger.success(
|
|
191
|
+
`Dream complete — audit with ${ansis.cyan(`botholomew thread view ${threadId}`)}`,
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
}
|
package/src/commands/nuke.ts
CHANGED
|
@@ -2,8 +2,14 @@ import { rm } from "node:fs/promises";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import ansis from "ansis";
|
|
4
4
|
import type { Command } from "commander";
|
|
5
|
+
import { deleteAllApprovals } from "../approvals/store.ts";
|
|
5
6
|
import { loadConfig } from "../config/loader.ts";
|
|
6
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
APPROVALS_DIR,
|
|
9
|
+
SCHEDULES_DIR,
|
|
10
|
+
TASKS_DIR,
|
|
11
|
+
THREADS_DIR,
|
|
12
|
+
} from "../constants.ts";
|
|
7
13
|
import { openMembot, resolveMembotDir } from "../mem/client.ts";
|
|
8
14
|
import { deleteAllSchedules } from "../schedules/store.ts";
|
|
9
15
|
import { deleteAllTasks } from "../tasks/store.ts";
|
|
@@ -86,6 +92,10 @@ async function runNuke(projectDir: string, scope: NukeScope): Promise<void> {
|
|
|
86
92
|
`Deleted ${threads} threads (${interactions} interactions) from ${THREADS_DIR}/`,
|
|
87
93
|
);
|
|
88
94
|
}
|
|
95
|
+
if (scope === "all") {
|
|
96
|
+
const n = await deleteAllApprovals(projectDir);
|
|
97
|
+
logger.success(`Deleted ${n} approval file(s) from ${APPROVALS_DIR}/`);
|
|
98
|
+
}
|
|
89
99
|
}
|
|
90
100
|
|
|
91
101
|
function registerScope(
|
|
@@ -151,6 +161,6 @@ export function registerNukeCommand(program: Command) {
|
|
|
151
161
|
program,
|
|
152
162
|
nuke,
|
|
153
163
|
"all",
|
|
154
|
-
"Erase all agent-writable data: membot store, tasks/, schedules/, threads/",
|
|
164
|
+
"Erase all agent-writable data: membot store, tasks/, schedules/, threads/, approvals/",
|
|
155
165
|
);
|
|
156
166
|
}
|
package/src/commands/status.ts
CHANGED
|
@@ -22,10 +22,6 @@ function formatAge(fromIso: string | null, to = new Date()): string {
|
|
|
22
22
|
return `${Math.floor(hours / 24)}d ago`;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
function padColored(colored: string, raw: string, width: number): string {
|
|
26
|
-
return colored + " ".repeat(Math.max(0, width - raw.length));
|
|
27
|
-
}
|
|
28
|
-
|
|
29
25
|
function workerStatusColor(status: WorkerStatus): string {
|
|
30
26
|
switch (status) {
|
|
31
27
|
case "running":
|
package/src/commands/thread.ts
CHANGED
|
@@ -6,9 +6,12 @@ import {
|
|
|
6
6
|
getInteractionsAfter,
|
|
7
7
|
getThread,
|
|
8
8
|
type Interaction,
|
|
9
|
+
type InteractionRole,
|
|
9
10
|
isThreadEnded,
|
|
10
11
|
listThreads,
|
|
12
|
+
searchThreads,
|
|
11
13
|
type Thread,
|
|
14
|
+
type ThreadType,
|
|
12
15
|
} from "../threads/store.ts";
|
|
13
16
|
import { logger } from "../utils/logger.ts";
|
|
14
17
|
|
|
@@ -39,6 +42,67 @@ export function registerThreadCommand(program: Command) {
|
|
|
39
42
|
}
|
|
40
43
|
});
|
|
41
44
|
|
|
45
|
+
thread
|
|
46
|
+
.command("search <query>")
|
|
47
|
+
.description("Search past conversations for a regex (or plain substring)")
|
|
48
|
+
.option("--ignore-case", "case-insensitive match (default true)", true)
|
|
49
|
+
.option("--no-ignore-case", "case-sensitive match")
|
|
50
|
+
.option(
|
|
51
|
+
"-r, --role <role>",
|
|
52
|
+
"filter by role (user, assistant, system, tool)",
|
|
53
|
+
)
|
|
54
|
+
.option("-t, --type <type>", "filter by type (worker_tick, chat_session)")
|
|
55
|
+
.option("--since <iso>", "only threads started on or after this ISO date")
|
|
56
|
+
.option("--until <iso>", "only threads started on or before this ISO date")
|
|
57
|
+
.option("-l, --limit <n>", "max hits to return", Number.parseInt)
|
|
58
|
+
.action(
|
|
59
|
+
async (
|
|
60
|
+
query: string,
|
|
61
|
+
opts: {
|
|
62
|
+
ignoreCase?: boolean;
|
|
63
|
+
role?: string;
|
|
64
|
+
type?: string;
|
|
65
|
+
since?: string;
|
|
66
|
+
until?: string;
|
|
67
|
+
limit?: number;
|
|
68
|
+
},
|
|
69
|
+
) => {
|
|
70
|
+
const dir = program.opts().dir;
|
|
71
|
+
let regex: RegExp;
|
|
72
|
+
try {
|
|
73
|
+
regex = new RegExp(query, opts.ignoreCase === false ? "" : "i");
|
|
74
|
+
} catch (err) {
|
|
75
|
+
logger.error(
|
|
76
|
+
`Invalid regex: ${err instanceof Error ? err.message : String(err)}`,
|
|
77
|
+
);
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
const { hits, threadsScanned } = await searchThreads(dir, {
|
|
81
|
+
regex,
|
|
82
|
+
role: opts.role as InteractionRole | undefined,
|
|
83
|
+
type: opts.type as ThreadType | undefined,
|
|
84
|
+
since: opts.since ? new Date(opts.since) : undefined,
|
|
85
|
+
until: opts.until ? new Date(opts.until) : undefined,
|
|
86
|
+
maxResults: opts.limit,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
if (hits.length === 0) {
|
|
90
|
+
logger.dim(`No hits in ${threadsScanned} thread(s).`);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
for (const h of hits) {
|
|
95
|
+
const id = ansis.dim(h.thread_id);
|
|
96
|
+
const seq = ansis.dim(`#${h.sequence}`);
|
|
97
|
+
const snippet = h.content_snippet.replace(/\n/g, " ");
|
|
98
|
+
console.log(` ${id} ${seq} ${roleColor(h.role)} ${snippet}`);
|
|
99
|
+
}
|
|
100
|
+
logger.dim(
|
|
101
|
+
`\n${hits.length} hit(s) in ${threadsScanned} thread(s). View context: botholomew thread view <id>`,
|
|
102
|
+
);
|
|
103
|
+
},
|
|
104
|
+
);
|
|
105
|
+
|
|
42
106
|
thread
|
|
43
107
|
.command("view <id>")
|
|
44
108
|
.description("View thread details and interactions")
|
package/src/commands/worker.ts
CHANGED
|
@@ -64,11 +64,17 @@ export function registerWorkerCommand(program: Command) {
|
|
|
64
64
|
"run exactly this task (implies one-shot; incompatible with --persist)",
|
|
65
65
|
)
|
|
66
66
|
.option("--no-eval-schedules", "skip schedule evaluation this run")
|
|
67
|
+
.option(
|
|
68
|
+
"--unsafe",
|
|
69
|
+
"bypass the mcpx approval gate (allow every tool without approval)",
|
|
70
|
+
false,
|
|
71
|
+
)
|
|
67
72
|
.action(
|
|
68
73
|
async (opts: {
|
|
69
74
|
persist?: boolean;
|
|
70
75
|
taskId?: string;
|
|
71
76
|
evalSchedules?: boolean;
|
|
77
|
+
unsafe?: boolean;
|
|
72
78
|
}) => {
|
|
73
79
|
if (opts.persist && opts.taskId) {
|
|
74
80
|
logger.error("--persist and --task-id are mutually exclusive.");
|
|
@@ -81,6 +87,7 @@ export function registerWorkerCommand(program: Command) {
|
|
|
81
87
|
mode: opts.persist ? "persist" : "once",
|
|
82
88
|
taskId: opts.taskId,
|
|
83
89
|
evalSchedules: opts.evalSchedules,
|
|
90
|
+
unsafe: opts.unsafe,
|
|
84
91
|
});
|
|
85
92
|
},
|
|
86
93
|
);
|
|
@@ -90,18 +97,30 @@ export function registerWorkerCommand(program: Command) {
|
|
|
90
97
|
.description("Spawn a worker as a detached background process")
|
|
91
98
|
.option("--persist", "keep running, looping over the tick cycle", false)
|
|
92
99
|
.option("--task-id <id>", "run exactly this task (implies one-shot)")
|
|
93
|
-
.
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
})
|
|
104
|
-
|
|
100
|
+
.option(
|
|
101
|
+
"--unsafe",
|
|
102
|
+
"bypass the mcpx approval gate (allow every tool without approval)",
|
|
103
|
+
false,
|
|
104
|
+
)
|
|
105
|
+
.action(
|
|
106
|
+
async (opts: {
|
|
107
|
+
persist?: boolean;
|
|
108
|
+
taskId?: string;
|
|
109
|
+
unsafe?: boolean;
|
|
110
|
+
}) => {
|
|
111
|
+
if (opts.persist && opts.taskId) {
|
|
112
|
+
logger.error("--persist and --task-id are mutually exclusive.");
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
115
|
+
const dir = program.opts().dir;
|
|
116
|
+
const { spawnWorker } = await import("../worker/spawn.ts");
|
|
117
|
+
await spawnWorker(dir, {
|
|
118
|
+
mode: opts.persist ? "persist" : "once",
|
|
119
|
+
taskId: opts.taskId,
|
|
120
|
+
unsafe: opts.unsafe,
|
|
121
|
+
});
|
|
122
|
+
},
|
|
123
|
+
);
|
|
105
124
|
|
|
106
125
|
worker
|
|
107
126
|
.command("list")
|
package/src/config/loader.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { getConfigPath } from "../constants.ts";
|
|
|
3
3
|
import { setLogLevel } from "../utils/logger.ts";
|
|
4
4
|
import {
|
|
5
5
|
type BotholomewConfig,
|
|
6
|
+
DEFAULT_APPROVALS,
|
|
6
7
|
DEFAULT_CHUNKER_LLM,
|
|
7
8
|
DEFAULT_CONFIG,
|
|
8
9
|
DEFAULT_LLM,
|
|
@@ -60,6 +61,9 @@ export async function loadConfig(
|
|
|
60
61
|
...userConfig,
|
|
61
62
|
llm: mergeLlmBlock(DEFAULT_LLM, userConfig.llm),
|
|
62
63
|
chunker_llm: mergeLlmBlock(DEFAULT_CHUNKER_LLM, userConfig.chunker_llm),
|
|
64
|
+
// Deep-merge so a config predating the approval gate (or only overriding
|
|
65
|
+
// one key) still gets the safe defaults — and back-compat keeps the gate ON.
|
|
66
|
+
approvals: { ...DEFAULT_APPROVALS, ...(userConfig.approvals ?? {}) },
|
|
63
67
|
};
|
|
64
68
|
|
|
65
69
|
const config = applyEnvOverrides(merged);
|
|
@@ -101,3 +105,26 @@ export async function saveConfig(
|
|
|
101
105
|
const configPath = getConfigPath(projectDir);
|
|
102
106
|
await Bun.write(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
|
103
107
|
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Append an mcpx tool pattern to `approvals.allowed_tools` on disk, preserving
|
|
111
|
+
* every other key in the file (a surgical merge, not a full rewrite of merged
|
|
112
|
+
* defaults). Used by the chat TUI's "always allow" decision. No-op if the
|
|
113
|
+
* pattern is already present.
|
|
114
|
+
*/
|
|
115
|
+
export async function addAllowedTool(
|
|
116
|
+
projectDir: string,
|
|
117
|
+
pattern: string,
|
|
118
|
+
): Promise<void> {
|
|
119
|
+
const configPath = getConfigPath(projectDir);
|
|
120
|
+
const file = Bun.file(configPath);
|
|
121
|
+
const raw: Record<string, unknown> = (await file.exists())
|
|
122
|
+
? JSON.parse(await file.text())
|
|
123
|
+
: {};
|
|
124
|
+
if (!raw.approvals || typeof raw.approvals !== "object") raw.approvals = {};
|
|
125
|
+
const approvals = raw.approvals as Record<string, unknown>;
|
|
126
|
+
if (!Array.isArray(approvals.allowed_tools)) approvals.allowed_tools = [];
|
|
127
|
+
const allowed = approvals.allowed_tools as string[];
|
|
128
|
+
if (!allowed.includes(pattern)) allowed.push(pattern);
|
|
129
|
+
await Bun.write(configPath, `${JSON.stringify(raw, null, 2)}\n`);
|
|
130
|
+
}
|