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/src/config/schemas.ts
CHANGED
|
@@ -14,9 +14,30 @@ export interface LlmBlock {
|
|
|
14
14
|
supports_tools: boolean;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Human-in-the-loop approval gate for outbound mcpx tool calls. The gate is
|
|
19
|
+
* ON by default (`enabled: true`) and gates **every** mcpx tool — users opt
|
|
20
|
+
* specific tools out via `allowed_tools`. A run launched with `--unsafe`
|
|
21
|
+
* bypasses the gate entirely (see `buildApprovalPolicy` in `src/mcpx/client.ts`).
|
|
22
|
+
*/
|
|
23
|
+
export interface ApprovalConfig {
|
|
24
|
+
/** Master switch. When false the gate is off (equivalent to running `--unsafe`). Default true. */
|
|
25
|
+
enabled: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Opt-in allowlist of tools that run WITHOUT approval. Patterns match against
|
|
28
|
+
* "server/tool": exact ("gmail/send_email"), wildcards on either side
|
|
29
|
+
* ("gmail/" + star, or star + "/search"), or a "/regex/" tested against the
|
|
30
|
+
* tool name. Empty (default) ⇒ gate everything.
|
|
31
|
+
*/
|
|
32
|
+
allowed_tools: string[];
|
|
33
|
+
/** Convenience: also skip the gate for tools the server annotates `readOnlyHint: true`. Default false. */
|
|
34
|
+
auto_allow_read_only: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
17
37
|
export interface BotholomewConfig {
|
|
18
38
|
llm: LlmBlock;
|
|
19
39
|
chunker_llm: LlmBlock;
|
|
40
|
+
approvals: ApprovalConfig;
|
|
20
41
|
embedding_model: string;
|
|
21
42
|
embedding_dimension: number;
|
|
22
43
|
tick_interval_seconds: number;
|
|
@@ -30,6 +51,8 @@ export interface BotholomewConfig {
|
|
|
30
51
|
schedule_min_interval_seconds: number;
|
|
31
52
|
schedule_claim_stale_seconds: number;
|
|
32
53
|
tui_idle_timeout_seconds: number;
|
|
54
|
+
/** Default window (in hours) of recent threads the `dream` reflection reviews when `--since` is omitted. */
|
|
55
|
+
dream_lookback_hours: number;
|
|
33
56
|
log_level: string;
|
|
34
57
|
membot_scope: Scope;
|
|
35
58
|
mcpx_scope: Scope;
|
|
@@ -49,9 +72,16 @@ export const DEFAULT_CHUNKER_LLM: LlmBlock = {
|
|
|
49
72
|
model: "claude-haiku-4-5-20251001",
|
|
50
73
|
};
|
|
51
74
|
|
|
75
|
+
export const DEFAULT_APPROVALS: ApprovalConfig = {
|
|
76
|
+
enabled: true,
|
|
77
|
+
allowed_tools: [],
|
|
78
|
+
auto_allow_read_only: false,
|
|
79
|
+
};
|
|
80
|
+
|
|
52
81
|
export const DEFAULT_CONFIG: BotholomewConfig = {
|
|
53
82
|
llm: DEFAULT_LLM,
|
|
54
83
|
chunker_llm: DEFAULT_CHUNKER_LLM,
|
|
84
|
+
approvals: DEFAULT_APPROVALS,
|
|
55
85
|
embedding_model: "Xenova/bge-small-en-v1.5",
|
|
56
86
|
embedding_dimension: 384,
|
|
57
87
|
tick_interval_seconds: 300,
|
|
@@ -65,6 +95,7 @@ export const DEFAULT_CONFIG: BotholomewConfig = {
|
|
|
65
95
|
schedule_min_interval_seconds: 60,
|
|
66
96
|
schedule_claim_stale_seconds: 300,
|
|
67
97
|
tui_idle_timeout_seconds: 180,
|
|
98
|
+
dream_lookback_hours: 24,
|
|
68
99
|
log_level: "",
|
|
69
100
|
membot_scope: "global",
|
|
70
101
|
mcpx_scope: "global",
|
package/src/constants.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { join } from "node:path";
|
|
|
15
15
|
* tasks/.locks/<id>.lock O_EXCL claim files
|
|
16
16
|
* schedules/<id>.md
|
|
17
17
|
* schedules/.locks/<id>.lock
|
|
18
|
+
* approvals/<id>.md pending/decided mcpx approval requests
|
|
18
19
|
* threads/<YYYY-MM-DD>/<id>.csv conversation history
|
|
19
20
|
* workers/<id>.json pidfile + heartbeat
|
|
20
21
|
* logs/ worker logs
|
|
@@ -42,6 +43,7 @@ export const SKILLS_DIR = "skills";
|
|
|
42
43
|
export const MCPX_DIR = "mcpx";
|
|
43
44
|
export const TASKS_DIR = "tasks";
|
|
44
45
|
export const SCHEDULES_DIR = "schedules";
|
|
46
|
+
export const APPROVALS_DIR = "approvals";
|
|
45
47
|
export const LOCKS_SUBDIR = ".locks";
|
|
46
48
|
export const LOGS_DIR = "logs";
|
|
47
49
|
export const WORKERS_DIR = "workers";
|
|
@@ -106,6 +108,10 @@ export function getSchedulesDir(projectDir: string): string {
|
|
|
106
108
|
return join(projectDir, SCHEDULES_DIR);
|
|
107
109
|
}
|
|
108
110
|
|
|
111
|
+
export function getApprovalsDir(projectDir: string): string {
|
|
112
|
+
return join(projectDir, APPROVALS_DIR);
|
|
113
|
+
}
|
|
114
|
+
|
|
109
115
|
export function getSchedulesLockDir(projectDir: string): string {
|
|
110
116
|
return join(projectDir, SCHEDULES_DIR, LOCKS_SUBDIR);
|
|
111
117
|
}
|
package/src/init/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { LlmProvider } from "../config/schemas.ts";
|
|
|
5
5
|
import {
|
|
6
6
|
CONFIG_DIR,
|
|
7
7
|
CONFIG_FILENAME,
|
|
8
|
+
getApprovalsDir,
|
|
8
9
|
getConfigPath,
|
|
9
10
|
getMcpxDir,
|
|
10
11
|
getPromptsDir,
|
|
@@ -74,6 +75,7 @@ export async function initProject(
|
|
|
74
75
|
await mkdir(getTasksLockDir(projectDir), { recursive: true });
|
|
75
76
|
await mkdir(getSchedulesDir(projectDir), { recursive: true });
|
|
76
77
|
await mkdir(getSchedulesLockDir(projectDir), { recursive: true });
|
|
78
|
+
await mkdir(getApprovalsDir(projectDir), { recursive: true });
|
|
77
79
|
await mkdir(getWorkersDir(projectDir), { recursive: true });
|
|
78
80
|
await mkdir(getThreadsDir(projectDir), { recursive: true });
|
|
79
81
|
await mkdir(join(projectDir, LOGS_DIR), { recursive: true });
|
|
@@ -150,6 +152,9 @@ export async function initProject(
|
|
|
150
152
|
logger.dim(` ${TASKS_DIR}/ one markdown file per task`);
|
|
151
153
|
logger.dim(` ${LOCKS_SUBDIR}/ worker claim lockfiles`);
|
|
152
154
|
logger.dim(` ${SCHEDULES_DIR}/ one markdown file per schedule`);
|
|
155
|
+
logger.dim(
|
|
156
|
+
` approvals/ one markdown file per gated tool-call request`,
|
|
157
|
+
);
|
|
153
158
|
logger.dim(` threads/ one CSV per conversation, by UTC date`);
|
|
154
159
|
logger.dim(` workers/ one JSON pidfile per worker (heartbeats)`);
|
|
155
160
|
logger.dim(` skills/, mcpx/, logs/`);
|
package/src/mcpx/client.ts
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
type ApprovalPolicy,
|
|
6
|
+
type CallToolResult,
|
|
7
|
+
isWriteable,
|
|
8
|
+
McpxClient,
|
|
9
|
+
type Tool,
|
|
10
|
+
type ToolApprovalCallback,
|
|
11
|
+
} from "@evantahler/mcpx";
|
|
5
12
|
import type { BotholomewConfig } from "../config/schemas.ts";
|
|
6
13
|
import { getMcpxDir, MCPX_SERVERS_FILENAME } from "../constants.ts";
|
|
7
14
|
|
|
@@ -19,13 +26,24 @@ export function resolveMcpxDir(
|
|
|
19
26
|
: join(homedir(), ".mcpx");
|
|
20
27
|
}
|
|
21
28
|
|
|
29
|
+
export interface McpxApprovalOptions {
|
|
30
|
+
/** mcpx approval policy. Omit/undefined ⇒ no gate (back-compat). */
|
|
31
|
+
approvalPolicy?: ApprovalPolicy;
|
|
32
|
+
/** Callback invoked when a gated tool is about to run. */
|
|
33
|
+
onApprovalRequired?: ToolApprovalCallback;
|
|
34
|
+
}
|
|
35
|
+
|
|
22
36
|
/**
|
|
23
37
|
* Create an McpxClient from `<mcpxDir>/servers.json`. Returns null if the
|
|
24
38
|
* file is missing or has no servers configured. The caller is responsible
|
|
25
39
|
* for resolving `mcpxDir` via `resolveMcpxDir`.
|
|
40
|
+
*
|
|
41
|
+
* Pass `approval` to wire the human-in-the-loop approval gate (see
|
|
42
|
+
* `buildApprovalPolicy`). When omitted the client gates nothing.
|
|
26
43
|
*/
|
|
27
44
|
export async function createMcpxClient(
|
|
28
45
|
mcpxDir: string,
|
|
46
|
+
approval: McpxApprovalOptions = {},
|
|
29
47
|
): Promise<McpxClient | null> {
|
|
30
48
|
const serversPath = join(mcpxDir, MCPX_SERVERS_FILENAME);
|
|
31
49
|
if (!existsSync(serversPath)) return null;
|
|
@@ -52,9 +70,73 @@ export async function createMcpxClient(
|
|
|
52
70
|
auth,
|
|
53
71
|
searchIndex,
|
|
54
72
|
configDir: mcpxDir,
|
|
73
|
+
approvalPolicy: approval.approvalPolicy,
|
|
74
|
+
onApprovalRequired: approval.onApprovalRequired,
|
|
55
75
|
});
|
|
56
76
|
}
|
|
57
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Translate the Botholomew `approvals` config into an mcpx `ApprovalPolicy`.
|
|
80
|
+
*
|
|
81
|
+
* The gate is ON by default and gates **every** mcpx tool; the predicate
|
|
82
|
+
* returns `true` (require approval) for any tool NOT covered by the allowlist
|
|
83
|
+
* (and, when `auto_allow_read_only`, not annotated read-only). Returns
|
|
84
|
+
* `undefined` — meaning "gate nothing", mcpx's zero-overhead path — when the
|
|
85
|
+
* run is `--unsafe` or `approvals.enabled` is false.
|
|
86
|
+
*/
|
|
87
|
+
export function buildApprovalPolicy(
|
|
88
|
+
config: Pick<BotholomewConfig, "approvals">,
|
|
89
|
+
opts: { unsafe?: boolean } = {},
|
|
90
|
+
): ApprovalPolicy | undefined {
|
|
91
|
+
const approvals = config.approvals;
|
|
92
|
+
if (opts.unsafe || !approvals.enabled) return undefined;
|
|
93
|
+
return (tool: Tool, server: string): boolean => {
|
|
94
|
+
if (approvals.auto_allow_read_only && !isWriteable(tool)) return false;
|
|
95
|
+
return !matchesAllowlist(approvals.allowed_tools, server, tool.name);
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* True when "server/toolName" matches any allowlist pattern. Patterns:
|
|
101
|
+
* - exact "server/tool"
|
|
102
|
+
* - wildcard, where "*" on either side of the slash matches anything
|
|
103
|
+
* - a "/regex/" (with optional flags) tested against the tool name
|
|
104
|
+
* A bare token with no slash matches the tool name (server side wildcarded).
|
|
105
|
+
*/
|
|
106
|
+
export function matchesAllowlist(
|
|
107
|
+
patterns: string[],
|
|
108
|
+
server: string,
|
|
109
|
+
toolName: string,
|
|
110
|
+
): boolean {
|
|
111
|
+
for (const raw of patterns) {
|
|
112
|
+
const pattern = raw.trim();
|
|
113
|
+
if (!pattern) continue;
|
|
114
|
+
if (pattern.startsWith("/") && pattern.lastIndexOf("/") > 0) {
|
|
115
|
+
const close = pattern.lastIndexOf("/");
|
|
116
|
+
const body = pattern.slice(1, close);
|
|
117
|
+
const flags = pattern.slice(close + 1);
|
|
118
|
+
try {
|
|
119
|
+
if (new RegExp(body, flags).test(toolName)) return true;
|
|
120
|
+
} catch {
|
|
121
|
+
// invalid regex — ignore this pattern
|
|
122
|
+
}
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const [serverPat, toolPat] = pattern.includes("/")
|
|
126
|
+
? pattern.split("/", 2)
|
|
127
|
+
: ["*", pattern];
|
|
128
|
+
if (wildcardEq(serverPat, server) && wildcardEq(toolPat, toolName)) {
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function wildcardEq(pattern: string | undefined, value: string): boolean {
|
|
136
|
+
if (pattern === undefined || pattern === "*" || pattern === "") return true;
|
|
137
|
+
return pattern === value;
|
|
138
|
+
}
|
|
139
|
+
|
|
58
140
|
/**
|
|
59
141
|
* Serialize a CallToolResult's content array into a plain text string.
|
|
60
142
|
*/
|
package/src/skills/commands.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { DREAM_PROMPT_BODY } from "../chat/dream-prompt.ts";
|
|
1
2
|
import type { SkillDefinition } from "./parser.ts";
|
|
2
3
|
import { renderSkill, tokenizeForSkill, validateSkillArgs } from "./parser.ts";
|
|
3
4
|
|
|
@@ -10,6 +11,11 @@ export interface SlashCommand {
|
|
|
10
11
|
export const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [
|
|
11
12
|
{ name: "help", description: "Show command reference and shortcuts" },
|
|
12
13
|
{ name: "skills", description: "List available skills" },
|
|
14
|
+
{
|
|
15
|
+
name: "dream",
|
|
16
|
+
description:
|
|
17
|
+
"Reflect on recent threads: consolidate learnings and update beliefs/goals",
|
|
18
|
+
},
|
|
13
19
|
{ name: "clear", description: "End current thread and start a new one" },
|
|
14
20
|
{ name: "exit", description: "End the chat session" },
|
|
15
21
|
];
|
|
@@ -120,6 +126,14 @@ export function handleSlashCommand(
|
|
|
120
126
|
return true;
|
|
121
127
|
}
|
|
122
128
|
|
|
129
|
+
if (name === "dream") {
|
|
130
|
+
// Built-in (not a user-editable skill) so reflection behaves consistently.
|
|
131
|
+
// Queue the reflection prompt as a normal user message — the chat agent
|
|
132
|
+
// already has every tool it needs (thread search, membot, prompt_edit).
|
|
133
|
+
ctx.queueUserMessage(DREAM_PROMPT_BODY, { display: input });
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
|
|
123
137
|
if (name === "skills") {
|
|
124
138
|
if (ctx.skills.size === 0) {
|
|
125
139
|
ctx.addSystemMessage("No skills loaded. Add .md files to skills/");
|
package/src/tasks/store.ts
CHANGED
|
@@ -302,7 +302,7 @@ export async function resetStaleTasks(
|
|
|
302
302
|
const reset: string[] = [];
|
|
303
303
|
for (const id of ids) {
|
|
304
304
|
const t = await getTask(projectDir, id);
|
|
305
|
-
if (
|
|
305
|
+
if (t?.status !== "in_progress") continue;
|
|
306
306
|
const claimedAt = t.claimed_at ? Date.parse(t.claimed_at) : Date.now();
|
|
307
307
|
if (claimedAt >= cutoff) continue;
|
|
308
308
|
const fm: TaskFrontmatter = {
|
|
@@ -365,7 +365,7 @@ export async function claimSpecificTask(
|
|
|
365
365
|
workerId: string,
|
|
366
366
|
): Promise<Task | null> {
|
|
367
367
|
const t = await getTask(projectDir, id);
|
|
368
|
-
if (
|
|
368
|
+
if (t?.status !== "pending") return null;
|
|
369
369
|
return tryClaim(projectDir, id, workerId);
|
|
370
370
|
}
|
|
371
371
|
|
|
@@ -383,7 +383,7 @@ async function tryClaim(
|
|
|
383
383
|
}
|
|
384
384
|
try {
|
|
385
385
|
const t = await getTask(projectDir, id);
|
|
386
|
-
if (
|
|
386
|
+
if (t?.status !== "pending") {
|
|
387
387
|
await releaseLock(lockPath);
|
|
388
388
|
return null;
|
|
389
389
|
}
|
|
@@ -427,7 +427,7 @@ async function isUnblocked(projectDir: string, t: Task): Promise<boolean> {
|
|
|
427
427
|
if (t.blocked_by.length === 0) return true;
|
|
428
428
|
for (const blockerId of t.blocked_by) {
|
|
429
429
|
const blocker = await getTask(projectDir, blockerId);
|
|
430
|
-
if (
|
|
430
|
+
if (blocker?.status !== "complete") return false;
|
|
431
431
|
}
|
|
432
432
|
return true;
|
|
433
433
|
}
|
package/src/threads/store.ts
CHANGED
|
@@ -456,6 +456,108 @@ export async function listThreads(
|
|
|
456
456
|
return out.slice(offset, offset + limit);
|
|
457
457
|
}
|
|
458
458
|
|
|
459
|
+
export interface ThreadSearchHit {
|
|
460
|
+
thread_id: string;
|
|
461
|
+
thread_title: string;
|
|
462
|
+
thread_type: ThreadType;
|
|
463
|
+
/** 1-based sequence of the matching interaction. Plug into `view_thread({ id, offset: sequence-1 })`. */
|
|
464
|
+
sequence: number;
|
|
465
|
+
role: InteractionRole;
|
|
466
|
+
kind: InteractionKind;
|
|
467
|
+
content_snippet: string;
|
|
468
|
+
created_at: Date;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
export interface ThreadSearchOptions {
|
|
472
|
+
/** Compiled regex matched against content, tool_name, and tool_input. */
|
|
473
|
+
regex: RegExp;
|
|
474
|
+
role?: InteractionRole;
|
|
475
|
+
kind?: InteractionKind;
|
|
476
|
+
type?: ThreadType;
|
|
477
|
+
/** Only consider threads started on or after this date. */
|
|
478
|
+
since?: Date;
|
|
479
|
+
/** Only consider threads started on or before this date. */
|
|
480
|
+
until?: Date;
|
|
481
|
+
/** Maximum number of hits to return across all threads. */
|
|
482
|
+
maxResults?: number;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Scan past conversations for a regex match. Shared by the `search_threads`
|
|
487
|
+
* agent tool and the `botholomew thread search` CLI so both stay in lockstep.
|
|
488
|
+
* Returns hits with `(thread_id, sequence)` pairs and the count of threads
|
|
489
|
+
* actually opened.
|
|
490
|
+
*/
|
|
491
|
+
export async function searchThreads(
|
|
492
|
+
projectDir: string,
|
|
493
|
+
opts: ThreadSearchOptions,
|
|
494
|
+
): Promise<{ hits: ThreadSearchHit[]; threadsScanned: number }> {
|
|
495
|
+
const sinceMs = opts.since ? opts.since.getTime() : Number.NEGATIVE_INFINITY;
|
|
496
|
+
const untilMs = opts.until ? opts.until.getTime() : Number.POSITIVE_INFINITY;
|
|
497
|
+
const maxResults = opts.maxResults ?? 20;
|
|
498
|
+
|
|
499
|
+
const threads = await listThreads(projectDir, { type: opts.type });
|
|
500
|
+
const hits: ThreadSearchHit[] = [];
|
|
501
|
+
let scanned = 0;
|
|
502
|
+
|
|
503
|
+
for (const t of threads) {
|
|
504
|
+
const startedMs = t.started_at.getTime();
|
|
505
|
+
if (startedMs < sinceMs || startedMs > untilMs) continue;
|
|
506
|
+
const data = await getThread(projectDir, t.id);
|
|
507
|
+
if (!data) continue;
|
|
508
|
+
scanned++;
|
|
509
|
+
for (const ix of data.interactions) {
|
|
510
|
+
if (opts.role && ix.role !== opts.role) continue;
|
|
511
|
+
if (opts.kind && ix.kind !== opts.kind) continue;
|
|
512
|
+
if (!matchInteraction(ix, opts.regex)) continue;
|
|
513
|
+
hits.push({
|
|
514
|
+
thread_id: t.id,
|
|
515
|
+
thread_title: t.title || "(untitled)",
|
|
516
|
+
thread_type: t.type,
|
|
517
|
+
sequence: ix.sequence,
|
|
518
|
+
role: ix.role,
|
|
519
|
+
kind: ix.kind,
|
|
520
|
+
content_snippet: snippetForMatch(ix.content, opts.regex),
|
|
521
|
+
created_at: ix.created_at,
|
|
522
|
+
});
|
|
523
|
+
if (hits.length >= maxResults) break;
|
|
524
|
+
}
|
|
525
|
+
if (hits.length >= maxResults) break;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
return { hits, threadsScanned: scanned };
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const SNIPPET_MAX = 240;
|
|
532
|
+
|
|
533
|
+
function matchInteraction(ix: Interaction, regex: RegExp): boolean {
|
|
534
|
+
// We treat the user-visible content as the primary haystack, but a
|
|
535
|
+
// tool_use interaction's content is just "Calling <name>" — fall through
|
|
536
|
+
// to the tool name + JSON args so a search for an exact tool argument
|
|
537
|
+
// still finds the call.
|
|
538
|
+
if (regex.test(ix.content)) return true;
|
|
539
|
+
if (ix.tool_name && regex.test(ix.tool_name)) return true;
|
|
540
|
+
if (ix.tool_input && regex.test(ix.tool_input)) return true;
|
|
541
|
+
return false;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Pick a short window around the first regex match so callers get enough
|
|
546
|
+
* context to know whether the hit is relevant without paging the whole
|
|
547
|
+
* interaction. Falls back to the head when the match index isn't available.
|
|
548
|
+
*/
|
|
549
|
+
function snippetForMatch(content: string, regex: RegExp): string {
|
|
550
|
+
const m = regex.exec(content);
|
|
551
|
+
if (!m) return content.slice(0, SNIPPET_MAX);
|
|
552
|
+
const idx = m.index;
|
|
553
|
+
const start = Math.max(0, idx - 60);
|
|
554
|
+
const end = Math.min(content.length, idx + SNIPPET_MAX - 60);
|
|
555
|
+
let snippet = content.slice(start, end);
|
|
556
|
+
if (start > 0) snippet = `…${snippet}`;
|
|
557
|
+
if (end < content.length) snippet = `${snippet}…`;
|
|
558
|
+
return snippet;
|
|
559
|
+
}
|
|
560
|
+
|
|
459
561
|
// ---------------------------------------------------------------------------
|
|
460
562
|
// internals
|
|
461
563
|
// ---------------------------------------------------------------------------
|
package/src/tools/mcp/exec.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ToolApprovalDeniedError,
|
|
3
|
+
ToolApprovalRequiredError,
|
|
4
|
+
} from "@evantahler/mcpx";
|
|
1
5
|
import { z } from "zod";
|
|
6
|
+
import { ApprovalPendingError } from "../../approvals/errors.ts";
|
|
2
7
|
import { formatCallToolResult } from "../../mcpx/client.ts";
|
|
3
8
|
import { fakeMcpExec, isCaptureMode } from "../../worker/fake-mcp.ts";
|
|
4
9
|
import { getTool, type ToolDefinition } from "../tool.ts";
|
|
@@ -131,6 +136,33 @@ export const mcpExecTool = {
|
|
|
131
136
|
: undefined,
|
|
132
137
|
};
|
|
133
138
|
} catch (err) {
|
|
139
|
+
// Human-in-the-loop approval gate outcomes (see src/mcpx/client.ts).
|
|
140
|
+
if (err instanceof ApprovalPendingError) {
|
|
141
|
+
// Worker context: signal the loop to park this task as `waiting`.
|
|
142
|
+
ctx.onApprovalPending?.(err.approvalId);
|
|
143
|
+
return {
|
|
144
|
+
result: `This action is queued for human approval (id ${err.approvalId}).`,
|
|
145
|
+
is_error: true,
|
|
146
|
+
error_kind: "permanent" as const,
|
|
147
|
+
hint: `Awaiting approval. Call wait_task with a reason referencing approval ${err.approvalId}; the task will be re-queued automatically once a human approves or denies it.`,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
if (err instanceof ToolApprovalDeniedError) {
|
|
151
|
+
return {
|
|
152
|
+
result: `This action was denied by a human reviewer (${input.server}/${input.tool}).`,
|
|
153
|
+
is_error: true,
|
|
154
|
+
error_kind: "permanent" as const,
|
|
155
|
+
hint: "Do not retry the same call — the human said no. Try a different approach, or call fail_task explaining that the required action was denied.",
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
if (err instanceof ToolApprovalRequiredError) {
|
|
159
|
+
return {
|
|
160
|
+
result: `This action requires approval, but no approver is wired up.`,
|
|
161
|
+
is_error: true,
|
|
162
|
+
error_kind: "permanent" as const,
|
|
163
|
+
hint: "The approval gate is active but no approver is available. Call fail_task; a human must re-run with --unsafe or allowlist this tool in config.",
|
|
164
|
+
};
|
|
165
|
+
}
|
|
134
166
|
const { error_kind, hint } = classifyError(err);
|
|
135
167
|
return {
|
|
136
168
|
result: `MCP tool error: ${err}`,
|
package/src/tools/skill/write.ts
CHANGED
|
@@ -30,7 +30,7 @@ const inputSchema = z.object({
|
|
|
30
30
|
name: z
|
|
31
31
|
.string()
|
|
32
32
|
.describe(
|
|
33
|
-
"Skill name (slash-command identifier). Will be normalized to lowercase + [a-z0-9-]. Reserved: help, skills, clear, exit.",
|
|
33
|
+
"Skill name (slash-command identifier). Will be normalized to lowercase + [a-z0-9-]. Reserved: help, skills, dream, clear, exit.",
|
|
34
34
|
),
|
|
35
35
|
description: z
|
|
36
36
|
.string()
|
|
@@ -67,7 +67,7 @@ const outputSchema = z.object({
|
|
|
67
67
|
export const skillWriteTool = {
|
|
68
68
|
name: "skill_write",
|
|
69
69
|
description:
|
|
70
|
-
"[[ bash equivalent command: tee ]] Create or overwrite a skill file (user-defined slash command) at skills/<name>.md. Fails with path_conflict when the file exists unless on_conflict='overwrite'. Reserved names (help, skills, clear, exit) are rejected. The generated file is parsed to validate before being written.",
|
|
70
|
+
"[[ bash equivalent command: tee ]] Create or overwrite a skill file (user-defined slash command) at skills/<name>.md. Fails with path_conflict when the file exists unless on_conflict='overwrite'. Reserved names (help, skills, dream, clear, exit) are rejected. The generated file is parsed to validate before being written.",
|
|
71
71
|
group: "skill",
|
|
72
72
|
inputSchema,
|
|
73
73
|
outputSchema,
|
|
@@ -78,7 +78,7 @@ export const skillWriteTool = {
|
|
|
78
78
|
nameCheck.reason === "reserved" ? "reserved_name" : "invalid_name";
|
|
79
79
|
const message =
|
|
80
80
|
nameCheck.reason === "reserved"
|
|
81
|
-
? `'${input.name}' is reserved by a built-in slash command (help, skills, clear, exit).`
|
|
81
|
+
? `'${input.name}' is reserved by a built-in slash command (help, skills, dream, clear, exit).`
|
|
82
82
|
: nameCheck.reason === "too_long"
|
|
83
83
|
? `Skill name too long (max 64 chars after normalization).`
|
|
84
84
|
: `'${input.name}' is not a valid skill name. After normalization (lowercase, [a-z0-9-], trimmed hyphens) it is empty.`;
|
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import {
|
|
3
|
-
getThread,
|
|
4
|
-
type Interaction,
|
|
5
3
|
type InteractionKind,
|
|
6
4
|
type InteractionRole,
|
|
7
|
-
|
|
8
|
-
type Thread,
|
|
5
|
+
searchThreads,
|
|
9
6
|
} from "../../threads/store.ts";
|
|
10
7
|
import type { ToolDefinition } from "../tool.ts";
|
|
11
8
|
|
|
@@ -19,8 +16,6 @@ const KINDS = [
|
|
|
19
16
|
"status_change",
|
|
20
17
|
] as const;
|
|
21
18
|
|
|
22
|
-
const SNIPPET_MAX = 240;
|
|
23
|
-
|
|
24
19
|
const inputSchema = z.object({
|
|
25
20
|
pattern: z
|
|
26
21
|
.string()
|
|
@@ -112,17 +107,16 @@ export const searchThreadsTool = {
|
|
|
112
107
|
};
|
|
113
108
|
}
|
|
114
109
|
|
|
115
|
-
|
|
116
|
-
? Date.parse(input.since)
|
|
117
|
-
: Number.NEGATIVE_INFINITY;
|
|
118
|
-
const untilMs = input.until
|
|
119
|
-
? Date.parse(input.until)
|
|
120
|
-
: Number.POSITIVE_INFINITY;
|
|
121
|
-
|
|
122
|
-
let threads: Thread[];
|
|
110
|
+
let scanResult: Awaited<ReturnType<typeof searchThreads>>;
|
|
123
111
|
try {
|
|
124
|
-
|
|
112
|
+
scanResult = await searchThreads(ctx.projectDir, {
|
|
113
|
+
regex,
|
|
114
|
+
role: input.role,
|
|
115
|
+
kind: input.kind,
|
|
125
116
|
type: input.thread_type,
|
|
117
|
+
since: input.since ? new Date(input.since) : undefined,
|
|
118
|
+
until: input.until ? new Date(input.until) : undefined,
|
|
119
|
+
maxResults: input.max_results,
|
|
126
120
|
});
|
|
127
121
|
} catch (err) {
|
|
128
122
|
return {
|
|
@@ -134,75 +128,29 @@ export const searchThreadsTool = {
|
|
|
134
128
|
};
|
|
135
129
|
}
|
|
136
130
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
for (const ix of data.interactions) {
|
|
148
|
-
if (input.role && ix.role !== input.role) continue;
|
|
149
|
-
if (input.kind && ix.kind !== input.kind) continue;
|
|
150
|
-
if (!matchInteraction(ix, regex)) continue;
|
|
151
|
-
matches.push({
|
|
152
|
-
thread_id: t.id,
|
|
153
|
-
thread_title: t.title || "(untitled)",
|
|
154
|
-
thread_type: t.type,
|
|
155
|
-
sequence: ix.sequence,
|
|
156
|
-
role: ix.role,
|
|
157
|
-
kind: ix.kind,
|
|
158
|
-
content_snippet: snippetForMatch(ix.content, regex),
|
|
159
|
-
created_at: ix.created_at.toISOString(),
|
|
160
|
-
});
|
|
161
|
-
if (matches.length >= input.max_results) break;
|
|
162
|
-
}
|
|
163
|
-
if (matches.length >= input.max_results) break;
|
|
164
|
-
}
|
|
131
|
+
const matches = scanResult.hits.map((h) => ({
|
|
132
|
+
thread_id: h.thread_id,
|
|
133
|
+
thread_title: h.thread_title,
|
|
134
|
+
thread_type: h.thread_type,
|
|
135
|
+
sequence: h.sequence,
|
|
136
|
+
role: h.role,
|
|
137
|
+
kind: h.kind,
|
|
138
|
+
content_snippet: h.content_snippet,
|
|
139
|
+
created_at: h.created_at.toISOString(),
|
|
140
|
+
}));
|
|
165
141
|
|
|
166
142
|
return {
|
|
167
143
|
matches,
|
|
168
|
-
threads_scanned:
|
|
144
|
+
threads_scanned: scanResult.threadsScanned,
|
|
169
145
|
is_error: false,
|
|
170
146
|
next_action_hint:
|
|
171
147
|
matches.length === 0
|
|
172
|
-
? `No hits in ${
|
|
148
|
+
? `No hits in ${scanResult.threadsScanned} thread(s). Try a broader pattern or remove role/kind filters.`
|
|
173
149
|
: `Pass any (thread_id, sequence) into view_thread({ id: thread_id, offset: sequence - 1, limit: 5 }) to read surrounding context.`,
|
|
174
150
|
};
|
|
175
151
|
},
|
|
176
152
|
} satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
|
|
177
153
|
|
|
178
|
-
function matchInteraction(ix: Interaction, regex: RegExp): boolean {
|
|
179
|
-
// We treat the user-visible content as the primary haystack, but a
|
|
180
|
-
// tool_use interaction's content is just "Calling <name>" — fall through
|
|
181
|
-
// to the tool name + JSON args so a search for an exact tool argument
|
|
182
|
-
// still finds the call.
|
|
183
|
-
if (regex.test(ix.content)) return true;
|
|
184
|
-
if (ix.tool_name && regex.test(ix.tool_name)) return true;
|
|
185
|
-
if (ix.tool_input && regex.test(ix.tool_input)) return true;
|
|
186
|
-
return false;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* Pick a short window around the first regex match so the agent gets enough
|
|
191
|
-
* context to know whether the hit is relevant without paging the whole
|
|
192
|
-
* interaction. Falls back to the head when the match index isn't available.
|
|
193
|
-
*/
|
|
194
|
-
function snippetForMatch(content: string, regex: RegExp): string {
|
|
195
|
-
const m = regex.exec(content);
|
|
196
|
-
if (!m) return content.slice(0, SNIPPET_MAX);
|
|
197
|
-
const idx = m.index;
|
|
198
|
-
const start = Math.max(0, idx - 60);
|
|
199
|
-
const end = Math.min(content.length, idx + SNIPPET_MAX - 60);
|
|
200
|
-
let snippet = content.slice(start, end);
|
|
201
|
-
if (start > 0) snippet = `…${snippet}`;
|
|
202
|
-
if (end < content.length) snippet = `${snippet}…`;
|
|
203
|
-
return snippet;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
154
|
// Keep the role/kind unions exported for tests that want to type-pin filters.
|
|
207
155
|
export type SearchThreadsRole = InteractionRole;
|
|
208
156
|
export type SearchThreadsKind = InteractionKind;
|
package/src/tools/tool.ts
CHANGED
|
@@ -36,6 +36,13 @@ export interface ToolContext {
|
|
|
36
36
|
* back to `logger.info` so worker logs are unchanged.
|
|
37
37
|
*/
|
|
38
38
|
notify?: (message: string) => void;
|
|
39
|
+
/**
|
|
40
|
+
* Worker-mode only. Called by `mcp_exec` when a gated mcpx call has no
|
|
41
|
+
* decision yet and a pending `approvals/<id>.md` was written. The worker
|
|
42
|
+
* loop records the id and parks the task as `waiting` after the turn.
|
|
43
|
+
* Chat leaves this `undefined` (chat resolves approvals inline).
|
|
44
|
+
*/
|
|
45
|
+
onApprovalPending?: (approvalId: string) => void;
|
|
39
46
|
}
|
|
40
47
|
|
|
41
48
|
type ToolOutputBase = { is_error: z.ZodBoolean };
|