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.
Files changed (48) hide show
  1. package/README.md +11 -3
  2. package/package.json +3 -2
  3. package/src/approvals/decide.ts +36 -0
  4. package/src/approvals/errors.ts +22 -0
  5. package/src/approvals/schema.ts +48 -0
  6. package/src/approvals/store.ts +276 -0
  7. package/src/chat/approval.ts +62 -0
  8. package/src/chat/dream-prompt.ts +20 -0
  9. package/src/chat/session.ts +32 -3
  10. package/src/cli.ts +4 -0
  11. package/src/commands/approval.ts +130 -0
  12. package/src/commands/chat.ts +48 -34
  13. package/src/commands/dream.ts +194 -0
  14. package/src/commands/nuke.ts +12 -2
  15. package/src/commands/status.ts +0 -4
  16. package/src/commands/thread.ts +64 -0
  17. package/src/commands/worker.ts +31 -12
  18. package/src/config/loader.ts +27 -0
  19. package/src/config/schemas.ts +31 -0
  20. package/src/constants.ts +6 -0
  21. package/src/init/index.ts +5 -0
  22. package/src/mcpx/client.ts +83 -1
  23. package/src/skills/commands.ts +14 -0
  24. package/src/tasks/store.ts +4 -4
  25. package/src/threads/store.ts +102 -0
  26. package/src/tools/mcp/exec.ts +32 -0
  27. package/src/tools/skill/write.ts +3 -3
  28. package/src/tools/thread/search.ts +21 -73
  29. package/src/tools/tool.ts +7 -0
  30. package/src/tui/App.tsx +25 -2
  31. package/src/tui/components/ApprovalPanel.tsx +222 -0
  32. package/src/tui/components/ApprovalPrompt.tsx +68 -0
  33. package/src/tui/components/HelpPanel.tsx +3 -0
  34. package/src/tui/components/TabBar.tsx +13 -6
  35. package/src/tui/components/TabPanels.tsx +9 -0
  36. package/src/tui/hooks/useAppKeybindings.ts +9 -0
  37. package/src/tui/hooks/useApprovalCount.ts +32 -0
  38. package/src/tui/hooks/useApprovalPrompt.ts +49 -0
  39. package/src/tui/hooks/useCaptureTabCycle.ts +1 -1
  40. package/src/tui/hooks/useChatSession.ts +5 -3
  41. package/src/tui/keys.ts +1 -0
  42. package/src/worker/approval.ts +60 -0
  43. package/src/worker/index.ts +37 -4
  44. package/src/worker/llm.ts +18 -0
  45. package/src/worker/run.ts +3 -1
  46. package/src/worker/spawn.ts +3 -0
  47. package/src/worker/tick.ts +25 -2
  48. package/src/workers/store.ts +4 -4
@@ -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/`);
@@ -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 { type CallToolResult, McpxClient } from "@evantahler/mcpx";
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
  */
@@ -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/");
@@ -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 (!t || t.status !== "in_progress") continue;
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 (!t || t.status !== "pending") return null;
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 (!t || t.status !== "pending") {
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 (!blocker || blocker.status !== "complete") return false;
430
+ if (blocker?.status !== "complete") return false;
431
431
  }
432
432
  return true;
433
433
  }
@@ -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
  // ---------------------------------------------------------------------------
@@ -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}`,
@@ -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
- listThreads,
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
- const sinceMs = input.since
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
- threads = await listThreads(ctx.projectDir, {
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
- type Hit = z.infer<typeof HitSchema>;
138
- const matches: Hit[] = [];
139
- let scanned = 0;
140
-
141
- for (const t of threads) {
142
- const startedMs = t.started_at.getTime();
143
- if (startedMs < sinceMs || startedMs > untilMs) continue;
144
- const data = await getThread(ctx.projectDir, t.id);
145
- if (!data) continue;
146
- scanned++;
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: 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 ${scanned} thread(s). Try a broader pattern or remove role/kind filters.`
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 };