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
package/README.md
CHANGED
|
@@ -233,18 +233,20 @@ semantic search, append-only versioning, and URL refresh all live there.
|
|
|
233
233
|
|---|---|
|
|
234
234
|
| `botholomew init` | Initialize the current directory as a project (refuses on iCloud/Dropbox/NFS without `--force`) |
|
|
235
235
|
| `botholomew status` | One-command dashboard: workers, task counts/claims, schedules (with a live "due?" check), quarantined files, store info. `--json` for scripting, `--no-evaluate` to skip the LLM schedule check |
|
|
236
|
-
| `botholomew worker run\|start` | Run a worker (foreground or background); `--persist` for long-running, `--task-id <id>` to target one task |
|
|
236
|
+
| `botholomew worker run\|start` | Run a worker (foreground or background); `--persist` for long-running, `--task-id <id>` to target one task, `--unsafe` to bypass the tool-approval gate |
|
|
237
237
|
| `botholomew worker list\|status\|stop\|kill\|reap` | Inspect and manage running workers |
|
|
238
|
-
| `botholomew chat` | Interactive Ink/React TUI |
|
|
238
|
+
| `botholomew chat` | Interactive Ink/React TUI (`--unsafe` to bypass the tool-approval gate) |
|
|
239
|
+
| `botholomew dream` | Reflect on recent threads — consolidate learnings into the knowledge store and update beliefs/goals (`--since`, `--dry-run`); also `/dream` in chat |
|
|
239
240
|
| `botholomew task list\|add\|view\|update\|reset\|delete` | Manage the task queue (markdown files in `tasks/`) |
|
|
240
241
|
| `botholomew schedule list\|add\|view\|enable\|disable\|trigger\|delete` | Recurring work (markdown files in `schedules/`) |
|
|
242
|
+
| `botholomew approval list\|view\|approve\|deny` | Review and decide pending outbound-tool approvals (markdown files in `approvals/`) |
|
|
241
243
|
| `botholomew membot add\|ls\|tree\|read\|write\|search\|info\|versions\|diff\|refresh\|…` | Knowledge-store passthrough to [`membot`](https://github.com/evantahler/membot) — `--config` is resolved from `membot_scope` (default `~/.membot`) |
|
|
242
244
|
| `botholomew membot import-global` | Seed the project from `~/.membot` (copies `index.duckdb` + `config.json` in) |
|
|
243
245
|
| `botholomew capabilities` | Rescan built-in + MCPX tools and rewrite `prompts/capabilities.md` |
|
|
244
246
|
| `botholomew prompts list\|show\|create\|edit\|delete\|validate` | CRUD over the markdown files in `prompts/` (with strict frontmatter validation) |
|
|
245
247
|
| `botholomew mcpx servers\|list\|add\|remove\|info\|search\|exec\|ping\|auth\|deauth\|import-global\|…` | Configure external MCP servers (passthrough to `mcpx`) |
|
|
246
248
|
| `botholomew skill list\|show\|create\|validate` | Manage slash-command skills |
|
|
247
|
-
| `botholomew thread list\|view` | Browse the agent's conversation history (CSVs in `threads/`) |
|
|
249
|
+
| `botholomew thread list\|view\|search\|delete\|follow` | Browse and search the agent's conversation history (CSVs in `threads/`) |
|
|
248
250
|
| `botholomew nuke knowledge\|tasks\|schedules\|threads\|all` | Bulk-erase project state |
|
|
249
251
|
| `botholomew upgrade` | Self-update |
|
|
250
252
|
|
|
@@ -313,11 +315,17 @@ Topics worth understanding in detail:
|
|
|
313
315
|
(Anthropic tool-use, Commander CLI, tests).
|
|
314
316
|
- **[Prompts](docs/prompts.md)** — generic markdown files in `prompts/`,
|
|
315
317
|
strict frontmatter validation, and full CRUD via CLI + agent tools.
|
|
318
|
+
- **[Reflection (dream)](docs/reflection.md)** — `botholomew dream` / `/dream`:
|
|
319
|
+
consolidating recent threads into durable memory and self-edited prompts,
|
|
320
|
+
plus episodic `thread search`.
|
|
316
321
|
- **[Skills (slash commands)](docs/skills.md)** — reusable prompt templates
|
|
317
322
|
with positional arguments and tab completion; the chat agent can also
|
|
318
323
|
create, edit, and search them at runtime.
|
|
319
324
|
- **[MCPX integration](docs/mcpx.md)** — configuring external servers and
|
|
320
325
|
how MCP tools are merged into the agent's toolset.
|
|
326
|
+
- **[Approvals](docs/approvals.md)** — the human-in-the-loop gate on outbound
|
|
327
|
+
mcpx tool calls: default deny, the allowlist, the worker approval queue,
|
|
328
|
+
and `--unsafe`.
|
|
321
329
|
- **[Configuration](docs/configuration.md)** — every key in `config.json`
|
|
322
330
|
and its default.
|
|
323
331
|
- **[Doc captures](docs/captures.md)** — how the screenshots and GIFs in
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "botholomew",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.0",
|
|
4
4
|
"description": "An autonomous AI agent for knowledge work — works your task queue while you sleep.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"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'",
|
|
22
22
|
"test": "bun test",
|
|
23
23
|
"lint": "tsc --noEmit && biome check .",
|
|
24
|
+
"format": "biome check --write .",
|
|
24
25
|
"build": "bun run scripts/build.ts",
|
|
25
26
|
"capture": "bun run scripts/capture.ts",
|
|
26
27
|
"docs:dev": "vitepress dev docs",
|
|
@@ -30,7 +31,7 @@
|
|
|
30
31
|
"dependencies": {
|
|
31
32
|
"@ai-sdk/anthropic": "^3.0.81",
|
|
32
33
|
"@ai-sdk/openai-compatible": "^2.0.48",
|
|
33
|
-
"@evantahler/mcpx": "0.
|
|
34
|
+
"@evantahler/mcpx": "0.22.2",
|
|
34
35
|
"ai": "^6.0.197",
|
|
35
36
|
"ansis": "^4.3.1",
|
|
36
37
|
"commander": "^15.0.0",
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { getTask, updateTaskStatus } from "../tasks/store.ts";
|
|
2
|
+
import { logger } from "../utils/logger.ts";
|
|
3
|
+
import type { Approval } from "./schema.ts";
|
|
4
|
+
import { decideApproval, getApproval } from "./store.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Apply a human decision to a pending approval and re-queue its originating
|
|
8
|
+
* task. Shared by the CLI (`botholomew approval approve|deny`) and the chat
|
|
9
|
+
* TUI approvals panel so both behave identically:
|
|
10
|
+
* - mark the record approved/denied,
|
|
11
|
+
* - flip the parked task back to `pending` so a worker re-claims it; on the
|
|
12
|
+
* re-run the recorded decision short-circuits the gated call.
|
|
13
|
+
*
|
|
14
|
+
* Returns the updated approval, or null if it didn't exist / wasn't pending.
|
|
15
|
+
*/
|
|
16
|
+
export async function decideAndRequeue(
|
|
17
|
+
projectDir: string,
|
|
18
|
+
id: string,
|
|
19
|
+
decision: "approved" | "denied",
|
|
20
|
+
decidedBy: string,
|
|
21
|
+
): Promise<Approval | null> {
|
|
22
|
+
const existing = await getApproval(projectDir, id);
|
|
23
|
+
if (!existing || existing.status !== "pending") return null;
|
|
24
|
+
const decided = await decideApproval(projectDir, id, decision, decidedBy);
|
|
25
|
+
if (decided.task_id) {
|
|
26
|
+
const task = await getTask(projectDir, decided.task_id);
|
|
27
|
+
if (task) {
|
|
28
|
+
await updateTaskStatus(projectDir, decided.task_id, "pending");
|
|
29
|
+
} else {
|
|
30
|
+
logger.warn(
|
|
31
|
+
`Originating task ${decided.task_id} no longer exists; not re-queued.`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return decided;
|
|
36
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thrown by a worker's `onApprovalRequired` callback when a gated mcpx call has
|
|
3
|
+
* no decision yet (a fresh `approvals/<id>.md` was just written, or an existing
|
|
4
|
+
* one is still pending). It propagates out of `McpxClient.exec()` — unlike a
|
|
5
|
+
* `false` return, which mcpx turns into `ToolApprovalDeniedError`. `mcp_exec`
|
|
6
|
+
* catches it, signals the worker loop to park the task as `waiting`, and returns
|
|
7
|
+
* a structured "awaiting approval" result to the agent.
|
|
8
|
+
*/
|
|
9
|
+
export class ApprovalPendingError extends Error {
|
|
10
|
+
readonly approvalId: string;
|
|
11
|
+
readonly server: string;
|
|
12
|
+
readonly tool: string;
|
|
13
|
+
constructor(approvalId: string, server: string, tool: string) {
|
|
14
|
+
super(
|
|
15
|
+
`Tool "${server}/${tool}" is awaiting human approval (${approvalId}).`,
|
|
16
|
+
);
|
|
17
|
+
this.name = "ApprovalPendingError";
|
|
18
|
+
this.approvalId = approvalId;
|
|
19
|
+
this.server = server;
|
|
20
|
+
this.tool = tool;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const APPROVAL_STATUSES = ["pending", "approved", "denied"] as const;
|
|
4
|
+
|
|
5
|
+
export type ApprovalStatus = (typeof APPROVAL_STATUSES)[number];
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Frontmatter validator for `approvals/<id>.md`. An approval record describes a
|
|
9
|
+
* single gated mcpx tool call a worker wanted to make. Strict so a hand-edited
|
|
10
|
+
* or stale file doesn't silently round-trip with bad data; a parse failure
|
|
11
|
+
* quarantines the file (skip, log) the same way tasks/schedules do.
|
|
12
|
+
*
|
|
13
|
+
* `call_key` is a stable hash of (server, tool, args) so a re-run of the same
|
|
14
|
+
* task can match the human's decision to the concrete call that was approved.
|
|
15
|
+
*/
|
|
16
|
+
export const ApprovalFrontmatterSchema = z.object({
|
|
17
|
+
id: z.string().min(1),
|
|
18
|
+
status: z.enum(APPROVAL_STATUSES).default("pending"),
|
|
19
|
+
server: z.string(),
|
|
20
|
+
tool: z.string(),
|
|
21
|
+
/** JSON-encoded tool arguments (kept as a string to avoid nested-YAML quirks). */
|
|
22
|
+
args: z.string().default("{}"),
|
|
23
|
+
/** Stable hash of server+tool+args; how a re-run matches a prior decision. */
|
|
24
|
+
call_key: z.string(),
|
|
25
|
+
/** Originating task/thread/worker — null when the request came from chat. */
|
|
26
|
+
task_id: z.string().nullable().default(null),
|
|
27
|
+
thread_id: z.string().nullable().default(null),
|
|
28
|
+
worker_id: z.string().nullable().default(null),
|
|
29
|
+
/** Human-readable label for why the gate fired (e.g. "not-allowlisted"). */
|
|
30
|
+
reason: z.string().default(""),
|
|
31
|
+
created_at: z.string(),
|
|
32
|
+
updated_at: z.string(),
|
|
33
|
+
decided_at: z.string().nullable().default(null),
|
|
34
|
+
decided_by: z.string().nullable().default(null),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
export type ApprovalFrontmatter = z.infer<typeof ApprovalFrontmatterSchema>;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* In-memory approval representation: frontmatter parsed + filesystem mtime so
|
|
41
|
+
* callers can detect concurrent edits before committing a write.
|
|
42
|
+
*/
|
|
43
|
+
export interface Approval extends ApprovalFrontmatter {
|
|
44
|
+
/** Filesystem mtime in epoch ms, used for atomic-write-if-unchanged. */
|
|
45
|
+
mtimeMs: number;
|
|
46
|
+
/** Markdown body (everything after the frontmatter). */
|
|
47
|
+
body: string;
|
|
48
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readdir, unlink } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import matter from "gray-matter";
|
|
5
|
+
import { getApprovalsDir } from "../constants.ts";
|
|
6
|
+
import {
|
|
7
|
+
atomicWrite,
|
|
8
|
+
atomicWriteIfUnchanged,
|
|
9
|
+
readWithMtime,
|
|
10
|
+
} from "../fs/atomic.ts";
|
|
11
|
+
import { logger } from "../utils/logger.ts";
|
|
12
|
+
import { uuidv7 } from "../utils/uuid.ts";
|
|
13
|
+
import {
|
|
14
|
+
type Approval,
|
|
15
|
+
type ApprovalFrontmatter,
|
|
16
|
+
ApprovalFrontmatterSchema,
|
|
17
|
+
type ApprovalStatus,
|
|
18
|
+
} from "./schema.ts";
|
|
19
|
+
|
|
20
|
+
export function approvalFilePath(projectDir: string, id: string): string {
|
|
21
|
+
return join(getApprovalsDir(projectDir), `${id}.md`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Stable key for an mcpx call: a hash over server + tool + canonicalized args
|
|
26
|
+
* (object keys sorted recursively) so the same logical call always produces
|
|
27
|
+
* the same key regardless of argument ordering.
|
|
28
|
+
*/
|
|
29
|
+
export function callKey(
|
|
30
|
+
server: string,
|
|
31
|
+
tool: string,
|
|
32
|
+
args: Record<string, unknown> | undefined,
|
|
33
|
+
): string {
|
|
34
|
+
const canonical = canonicalize(args ?? {});
|
|
35
|
+
const payload = JSON.stringify({ server, tool, args: canonical });
|
|
36
|
+
return createHash("sha256").update(payload).digest("hex").slice(0, 32);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function canonicalize(value: unknown): unknown {
|
|
40
|
+
if (Array.isArray(value)) return value.map(canonicalize);
|
|
41
|
+
if (value && typeof value === "object") {
|
|
42
|
+
const out: Record<string, unknown> = {};
|
|
43
|
+
for (const k of Object.keys(value as Record<string, unknown>).sort()) {
|
|
44
|
+
out[k] = canonicalize((value as Record<string, unknown>)[k]);
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function approvalBody(fm: ApprovalFrontmatter): string {
|
|
52
|
+
let argsPretty = fm.args;
|
|
53
|
+
try {
|
|
54
|
+
argsPretty = JSON.stringify(JSON.parse(fm.args), null, 2);
|
|
55
|
+
} catch {
|
|
56
|
+
// leave as-is
|
|
57
|
+
}
|
|
58
|
+
return [
|
|
59
|
+
`# Approval: ${fm.server}/${fm.tool}`,
|
|
60
|
+
"",
|
|
61
|
+
`Status: **${fm.status}**`,
|
|
62
|
+
"",
|
|
63
|
+
"## Arguments",
|
|
64
|
+
"",
|
|
65
|
+
"```json",
|
|
66
|
+
argsPretty,
|
|
67
|
+
"```",
|
|
68
|
+
].join("\n");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function serializeApproval(
|
|
72
|
+
fm: ApprovalFrontmatter,
|
|
73
|
+
body?: string,
|
|
74
|
+
): string {
|
|
75
|
+
const content = body ?? approvalBody(fm);
|
|
76
|
+
return matter.stringify(
|
|
77
|
+
`\n${content.trim()}\n`,
|
|
78
|
+
fm as Record<string, unknown>,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface ApprovalParseOk {
|
|
83
|
+
ok: true;
|
|
84
|
+
approval: Approval;
|
|
85
|
+
}
|
|
86
|
+
export interface ApprovalParseFail {
|
|
87
|
+
ok: false;
|
|
88
|
+
reason: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function parseApprovalFile(
|
|
92
|
+
raw: string,
|
|
93
|
+
mtimeMs: number,
|
|
94
|
+
): ApprovalParseOk | ApprovalParseFail {
|
|
95
|
+
let parsed: matter.GrayMatterFile<string>;
|
|
96
|
+
try {
|
|
97
|
+
parsed = matter(raw);
|
|
98
|
+
} catch (err) {
|
|
99
|
+
return { ok: false, reason: `frontmatter parse error: ${err}` };
|
|
100
|
+
}
|
|
101
|
+
const result = ApprovalFrontmatterSchema.safeParse(parsed.data);
|
|
102
|
+
if (!result.success) {
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
reason: `frontmatter validation failed: ${result.error.message}`,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
ok: true,
|
|
110
|
+
approval: { ...result.data, mtimeMs, body: parsed.content.trim() },
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function listApprovalFiles(projectDir: string): Promise<string[]> {
|
|
115
|
+
const dir = getApprovalsDir(projectDir);
|
|
116
|
+
try {
|
|
117
|
+
const names = await readdir(dir);
|
|
118
|
+
return names.filter((n) => n.endsWith(".md")).map((n) => n.slice(0, -3));
|
|
119
|
+
} catch (err) {
|
|
120
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
121
|
+
throw err;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function getApproval(
|
|
126
|
+
projectDir: string,
|
|
127
|
+
id: string,
|
|
128
|
+
): Promise<Approval | null> {
|
|
129
|
+
const file = await readWithMtime(approvalFilePath(projectDir, id));
|
|
130
|
+
if (!file) return null;
|
|
131
|
+
const parsed = parseApprovalFile(file.content, file.mtimeMs);
|
|
132
|
+
if (!parsed.ok) {
|
|
133
|
+
logger.warn(`Approval ${id} is malformed: ${parsed.reason}`);
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
return parsed.approval;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function listApprovals(
|
|
140
|
+
projectDir: string,
|
|
141
|
+
filters?: { status?: ApprovalStatus; limit?: number; offset?: number },
|
|
142
|
+
): Promise<Approval[]> {
|
|
143
|
+
const ids = await listApprovalFiles(projectDir);
|
|
144
|
+
const approvals: Approval[] = [];
|
|
145
|
+
for (const id of ids) {
|
|
146
|
+
const a = await getApproval(projectDir, id);
|
|
147
|
+
if (!a) continue;
|
|
148
|
+
if (filters?.status && a.status !== filters.status) continue;
|
|
149
|
+
approvals.push(a);
|
|
150
|
+
}
|
|
151
|
+
// Newest-first by id (uuidv7 is time-ordered) for deterministic pagination.
|
|
152
|
+
approvals.sort((a, b) => (a.id < b.id ? 1 : -1));
|
|
153
|
+
const offset = filters?.offset ?? 0;
|
|
154
|
+
const limit = filters?.limit ?? approvals.length;
|
|
155
|
+
return approvals.slice(offset, offset + limit);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function createApproval(
|
|
159
|
+
projectDir: string,
|
|
160
|
+
params: {
|
|
161
|
+
server: string;
|
|
162
|
+
tool: string;
|
|
163
|
+
args?: Record<string, unknown>;
|
|
164
|
+
reason?: string;
|
|
165
|
+
task_id?: string | null;
|
|
166
|
+
thread_id?: string | null;
|
|
167
|
+
worker_id?: string | null;
|
|
168
|
+
},
|
|
169
|
+
): Promise<Approval> {
|
|
170
|
+
const id = uuidv7();
|
|
171
|
+
const now = new Date().toISOString();
|
|
172
|
+
const fm: ApprovalFrontmatter = {
|
|
173
|
+
id,
|
|
174
|
+
status: "pending",
|
|
175
|
+
server: params.server,
|
|
176
|
+
tool: params.tool,
|
|
177
|
+
args: JSON.stringify(params.args ?? {}),
|
|
178
|
+
call_key: callKey(params.server, params.tool, params.args),
|
|
179
|
+
task_id: params.task_id ?? null,
|
|
180
|
+
thread_id: params.thread_id ?? null,
|
|
181
|
+
worker_id: params.worker_id ?? null,
|
|
182
|
+
reason: params.reason ?? "",
|
|
183
|
+
created_at: now,
|
|
184
|
+
updated_at: now,
|
|
185
|
+
decided_at: null,
|
|
186
|
+
decided_by: null,
|
|
187
|
+
};
|
|
188
|
+
await atomicWrite(approvalFilePath(projectDir, id), serializeApproval(fm));
|
|
189
|
+
const fresh = await getApproval(projectDir, id);
|
|
190
|
+
if (!fresh) throw new Error(`Failed to read freshly created approval ${id}`);
|
|
191
|
+
return fresh;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export class ApprovalNotFoundError extends Error {
|
|
195
|
+
constructor(readonly id: string) {
|
|
196
|
+
super(`Approval not found: ${id}`);
|
|
197
|
+
this.name = "ApprovalNotFoundError";
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Record a human decision (approve/deny) on a pending approval. Atomic
|
|
203
|
+
* write-if-unchanged so a concurrent edit doesn't get clobbered.
|
|
204
|
+
*/
|
|
205
|
+
export async function decideApproval(
|
|
206
|
+
projectDir: string,
|
|
207
|
+
id: string,
|
|
208
|
+
decision: "approved" | "denied",
|
|
209
|
+
decidedBy: string,
|
|
210
|
+
): Promise<Approval> {
|
|
211
|
+
const a = await getApproval(projectDir, id);
|
|
212
|
+
if (!a) throw new ApprovalNotFoundError(id);
|
|
213
|
+
const fm: ApprovalFrontmatter = {
|
|
214
|
+
...stripRuntime(a),
|
|
215
|
+
status: decision,
|
|
216
|
+
decided_at: new Date().toISOString(),
|
|
217
|
+
decided_by: decidedBy,
|
|
218
|
+
updated_at: new Date().toISOString(),
|
|
219
|
+
};
|
|
220
|
+
await atomicWriteIfUnchanged(
|
|
221
|
+
approvalFilePath(projectDir, id),
|
|
222
|
+
serializeApproval(fm),
|
|
223
|
+
a.mtimeMs,
|
|
224
|
+
);
|
|
225
|
+
const fresh = await getApproval(projectDir, id);
|
|
226
|
+
if (!fresh) throw new ApprovalNotFoundError(id);
|
|
227
|
+
return fresh;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Find the most-recent approval record for a call key (any status), or null.
|
|
232
|
+
* Used by the worker callback to resolve a gated call against a prior decision.
|
|
233
|
+
*/
|
|
234
|
+
export async function findByCallKey(
|
|
235
|
+
projectDir: string,
|
|
236
|
+
key: string,
|
|
237
|
+
): Promise<Approval | null> {
|
|
238
|
+
const all = await listApprovals(projectDir);
|
|
239
|
+
for (const a of all) {
|
|
240
|
+
// listApprovals is newest-first, so the first match is the latest.
|
|
241
|
+
if (a.call_key === key) return a;
|
|
242
|
+
}
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Delete an approval record. Approvals are single-use: once an approved record
|
|
248
|
+
* has authorized its call, it's consumed so a later identical call re-prompts.
|
|
249
|
+
*/
|
|
250
|
+
export async function consumeApproval(
|
|
251
|
+
projectDir: string,
|
|
252
|
+
id: string,
|
|
253
|
+
): Promise<boolean> {
|
|
254
|
+
try {
|
|
255
|
+
await unlink(approvalFilePath(projectDir, id));
|
|
256
|
+
return true;
|
|
257
|
+
} catch (err) {
|
|
258
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
259
|
+
throw err;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export async function deleteAllApprovals(projectDir: string): Promise<number> {
|
|
264
|
+
const ids = await listApprovalFiles(projectDir);
|
|
265
|
+
let n = 0;
|
|
266
|
+
for (const id of ids) {
|
|
267
|
+
if (await consumeApproval(projectDir, id)) n++;
|
|
268
|
+
}
|
|
269
|
+
return n;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Drop the in-memory-only fields before writing frontmatter back to disk. */
|
|
273
|
+
function stripRuntime(a: Approval): ApprovalFrontmatter {
|
|
274
|
+
const { mtimeMs: _m, body: _b, ...fm } = a;
|
|
275
|
+
return fm;
|
|
276
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge between the mcpx approval callback (which runs deep inside a chat turn,
|
|
3
|
+
* awaiting a boolean) and the Ink TUI (which renders a prompt and resolves it on
|
|
4
|
+
* a keypress). The mcpx client is constructed before the TUI mounts, so the
|
|
5
|
+
* callback can't reference React state directly — it talks to this bridge, and
|
|
6
|
+
* a TUI hook subscribes to drive the prompt.
|
|
7
|
+
*
|
|
8
|
+
* Gated tool calls within a single turn run in parallel (`Promise.all`), so the
|
|
9
|
+
* bridge holds a FIFO queue and surfaces one request at a time.
|
|
10
|
+
*/
|
|
11
|
+
export interface ChatApprovalRequest {
|
|
12
|
+
server: string;
|
|
13
|
+
tool: string;
|
|
14
|
+
args: Record<string, unknown>;
|
|
15
|
+
reason: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface PendingApproval {
|
|
19
|
+
req: ChatApprovalRequest;
|
|
20
|
+
resolve: (approved: boolean) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ChatApprovalBridge {
|
|
24
|
+
/** Called by the mcpx callback; resolves once the user decides. */
|
|
25
|
+
request(req: ChatApprovalRequest): Promise<boolean>;
|
|
26
|
+
/** The request currently awaiting a decision (head of the queue), or null. */
|
|
27
|
+
current(): ChatApprovalRequest | null;
|
|
28
|
+
/** Resolve the head request with the user's decision. */
|
|
29
|
+
resolve(approved: boolean): void;
|
|
30
|
+
/** Subscribe to queue changes (UI re-render). Returns an unsubscribe fn. */
|
|
31
|
+
subscribe(cb: () => void): () => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function createApprovalBridge(): ChatApprovalBridge {
|
|
35
|
+
const queue: PendingApproval[] = [];
|
|
36
|
+
const listeners = new Set<() => void>();
|
|
37
|
+
const notify = () => {
|
|
38
|
+
for (const l of listeners) l();
|
|
39
|
+
};
|
|
40
|
+
return {
|
|
41
|
+
request(req) {
|
|
42
|
+
return new Promise<boolean>((resolve) => {
|
|
43
|
+
queue.push({ req, resolve });
|
|
44
|
+
notify();
|
|
45
|
+
});
|
|
46
|
+
},
|
|
47
|
+
current() {
|
|
48
|
+
return queue[0]?.req ?? null;
|
|
49
|
+
},
|
|
50
|
+
resolve(approved) {
|
|
51
|
+
const head = queue.shift();
|
|
52
|
+
head?.resolve(approved);
|
|
53
|
+
notify();
|
|
54
|
+
},
|
|
55
|
+
subscribe(cb) {
|
|
56
|
+
listeners.add(cb);
|
|
57
|
+
return () => {
|
|
58
|
+
listeners.delete(cb);
|
|
59
|
+
};
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reflection ("dream") instructions. Shared by the built-in `/dream` chat
|
|
3
|
+
* command and the `botholomew dream` CLI so both run the exact same
|
|
4
|
+
* consolidation. The CLI appends a precise time window (and, for `--dry-run`,
|
|
5
|
+
* a propose-only directive) after this body.
|
|
6
|
+
*
|
|
7
|
+
* This is intentionally a built-in constant — not a seeded `skills/*.md` file —
|
|
8
|
+
* so reflection behaves consistently and can't be silently broken by edits.
|
|
9
|
+
*/
|
|
10
|
+
export const DREAM_PROMPT_BODY = `You are about to *dream*: review your recent conversations and consolidate what you learned into durable memory.
|
|
11
|
+
|
|
12
|
+
Work through these steps, using your existing tools:
|
|
13
|
+
|
|
14
|
+
1. **Recall.** Use \`list_threads\` and \`search_threads\` to find recent conversations (chat sessions and worker ticks), then \`view_thread\` to read the ones that look substantive. Focus on the most recent window — by default the last day or so, or everything since your most recent prior reflection (look for \`reflections/\` in the knowledge store with \`membot_tree\`).
|
|
15
|
+
|
|
16
|
+
2. **Distill.** Pull out the durable facts, decisions, outcomes, and preferences worth keeping — not the chatter. Write a concise reflection into the knowledge store at \`reflections/<UTC-date>.md\` (e.g. \`reflections/2026-06-07.md\`) using \`membot_write\`. Note the project these came from so reflections from different projects don't blur together. Store genuinely reusable facts under their natural \`logical_path\` too, not only in the reflection log.
|
|
17
|
+
|
|
18
|
+
3. **Self-edit.** Read \`prompts/goals.md\` and \`prompts/beliefs.md\` with \`prompt_read\`. If your recent work justifies it, apply focused updates with \`prompt_edit\` (git-style line patches) — add a newly learned belief, mark a goal done, refine a stale one. Make small, well-justified edits; don't rewrite wholesale. \`prompt_edit\` refuses files marked \`agent-modification: false\`, which is fine — skip them.
|
|
19
|
+
|
|
20
|
+
4. **Report.** Finish with a short audit summary: which threads you reviewed, what you stored in the knowledge store, and which prompt edits you made (or chose not to make).`;
|
package/src/chat/session.ts
CHANGED
|
@@ -3,7 +3,11 @@ import { loadConfig } from "../config/loader.ts";
|
|
|
3
3
|
import type { BotholomewConfig } from "../config/schemas.ts";
|
|
4
4
|
import type { AbortHandle } from "../llm/abort.ts";
|
|
5
5
|
import { BotholomewLlmError } from "../llm/types.ts";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
buildApprovalPolicy,
|
|
8
|
+
createMcpxClient,
|
|
9
|
+
resolveMcpxDir,
|
|
10
|
+
} from "../mcpx/client.ts";
|
|
7
11
|
import { loadSkills } from "../skills/loader.ts";
|
|
8
12
|
import type { SkillDefinition } from "../skills/parser.ts";
|
|
9
13
|
import {
|
|
@@ -16,6 +20,7 @@ import {
|
|
|
16
20
|
} from "../threads/store.ts";
|
|
17
21
|
import { generateThreadTitle } from "../utils/title.ts";
|
|
18
22
|
import { type ChatTurnCallbacks, runChatTurn } from "./agent.ts";
|
|
23
|
+
import { type ChatApprovalBridge, createApprovalBridge } from "./approval.ts";
|
|
19
24
|
|
|
20
25
|
export interface ChatSession {
|
|
21
26
|
threadId: string;
|
|
@@ -25,6 +30,8 @@ export interface ChatSession {
|
|
|
25
30
|
skills: Map<string, SkillDefinition>;
|
|
26
31
|
// biome-ignore lint/suspicious/noExplicitAny: mcpx client
|
|
27
32
|
mcpxClient: any;
|
|
33
|
+
/** Drives the inline tool-approval prompt in the TUI. */
|
|
34
|
+
approvalBridge: ChatApprovalBridge;
|
|
28
35
|
cleanup: () => Promise<void>;
|
|
29
36
|
/** Set by `runChatTurn` while a `streamText(...)` is in flight. */
|
|
30
37
|
activeAbort: AbortHandle | null;
|
|
@@ -46,7 +53,7 @@ export function abortActiveStream(session: ChatSession): boolean {
|
|
|
46
53
|
return false;
|
|
47
54
|
}
|
|
48
55
|
|
|
49
|
-
function requireProviderCreds(config: BotholomewConfig): void {
|
|
56
|
+
export function requireProviderCreds(config: BotholomewConfig): void {
|
|
50
57
|
const { llm } = config;
|
|
51
58
|
if (llm.provider === "anthropic" && !llm.api_key) {
|
|
52
59
|
throw new BotholomewLlmError(
|
|
@@ -65,6 +72,7 @@ function requireProviderCreds(config: BotholomewConfig): void {
|
|
|
65
72
|
export async function startChatSession(
|
|
66
73
|
projectDir: string,
|
|
67
74
|
existingThreadId?: string,
|
|
75
|
+
opts: { unsafe?: boolean } = {},
|
|
68
76
|
): Promise<ChatSession> {
|
|
69
77
|
const config = await loadConfig(projectDir);
|
|
70
78
|
|
|
@@ -106,7 +114,27 @@ export async function startChatSession(
|
|
|
106
114
|
);
|
|
107
115
|
}
|
|
108
116
|
|
|
109
|
-
|
|
117
|
+
// The approval gate. The bridge must exist before the mcpx client so the
|
|
118
|
+
// client's callback can reference it. When the gate is off (`--unsafe` or
|
|
119
|
+
// `approvals.enabled: false`) the policy is undefined and the callback is
|
|
120
|
+
// never invoked.
|
|
121
|
+
const approvalBridge = createApprovalBridge();
|
|
122
|
+
const approvalPolicy = buildApprovalPolicy(config, { unsafe: opts.unsafe });
|
|
123
|
+
const mcpxClient = await createMcpxClient(
|
|
124
|
+
resolveMcpxDir(projectDir, config),
|
|
125
|
+
{
|
|
126
|
+
approvalPolicy,
|
|
127
|
+
onApprovalRequired: approvalPolicy
|
|
128
|
+
? (req) =>
|
|
129
|
+
approvalBridge.request({
|
|
130
|
+
server: req.server,
|
|
131
|
+
tool: req.tool,
|
|
132
|
+
args: req.args,
|
|
133
|
+
reason: req.reason,
|
|
134
|
+
})
|
|
135
|
+
: undefined,
|
|
136
|
+
},
|
|
137
|
+
);
|
|
110
138
|
const skills = await loadSkills(projectDir);
|
|
111
139
|
|
|
112
140
|
const cleanup = async () => {
|
|
@@ -120,6 +148,7 @@ export async function startChatSession(
|
|
|
120
148
|
messages,
|
|
121
149
|
skills,
|
|
122
150
|
mcpxClient,
|
|
151
|
+
approvalBridge,
|
|
123
152
|
cleanup,
|
|
124
153
|
activeAbort: null,
|
|
125
154
|
aborted: false,
|
package/src/cli.ts
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
import ansis from "ansis";
|
|
4
4
|
import { program } from "commander";
|
|
5
|
+
import { registerApprovalCommand } from "./commands/approval.ts";
|
|
5
6
|
import { registerCapabilitiesCommand } from "./commands/capabilities.ts";
|
|
6
7
|
import { registerChatCommand } from "./commands/chat.ts";
|
|
7
8
|
import { registerCheckUpdateCommand } from "./commands/check-update.ts";
|
|
9
|
+
import { registerDreamCommand } from "./commands/dream.ts";
|
|
8
10
|
import { registerInitCommand } from "./commands/init.ts";
|
|
9
11
|
import { registerMcpxCommand } from "./commands/mcpx.ts";
|
|
10
12
|
import { registerMembotCommand } from "./commands/membot.ts";
|
|
@@ -63,7 +65,9 @@ registerWorkerCommand(program);
|
|
|
63
65
|
registerTaskCommand(program);
|
|
64
66
|
registerThreadCommand(program);
|
|
65
67
|
registerScheduleCommand(program);
|
|
68
|
+
registerApprovalCommand(program);
|
|
66
69
|
registerChatCommand(program);
|
|
70
|
+
registerDreamCommand(program);
|
|
67
71
|
registerMembotCommand(program);
|
|
68
72
|
registerCapabilitiesCommand(program);
|
|
69
73
|
registerPromptsCommand(program);
|