botholomew 0.22.2 → 0.23.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 CHANGED
@@ -236,6 +236,7 @@ semantic search, append-only versioning, and URL refresh all live there.
236
236
  | `botholomew worker run\|start` | Run a worker (foreground or background); `--persist` for long-running, `--task-id <id>` to target one task |
237
237
  | `botholomew worker list\|status\|stop\|kill\|reap` | Inspect and manage running workers |
238
238
  | `botholomew chat` | Interactive Ink/React TUI |
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/`) |
241
242
  | `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`) |
@@ -244,7 +245,7 @@ semantic search, append-only versioning, and URL refresh all live there.
244
245
  | `botholomew prompts list\|show\|create\|edit\|delete\|validate` | CRUD over the markdown files in `prompts/` (with strict frontmatter validation) |
245
246
  | `botholomew mcpx servers\|list\|add\|remove\|info\|search\|exec\|ping\|auth\|deauth\|import-global\|…` | Configure external MCP servers (passthrough to `mcpx`) |
246
247
  | `botholomew skill list\|show\|create\|validate` | Manage slash-command skills |
247
- | `botholomew thread list\|view` | Browse the agent's conversation history (CSVs in `threads/`) |
248
+ | `botholomew thread list\|view\|search\|delete\|follow` | Browse and search the agent's conversation history (CSVs in `threads/`) |
248
249
  | `botholomew nuke knowledge\|tasks\|schedules\|threads\|all` | Bulk-erase project state |
249
250
  | `botholomew upgrade` | Self-update |
250
251
 
@@ -313,6 +314,9 @@ Topics worth understanding in detail:
313
314
  (Anthropic tool-use, Commander CLI, tests).
314
315
  - **[Prompts](docs/prompts.md)** — generic markdown files in `prompts/`,
315
316
  strict frontmatter validation, and full CRUD via CLI + agent tools.
317
+ - **[Reflection (dream)](docs/reflection.md)** — `botholomew dream` / `/dream`:
318
+ consolidating recent threads into durable memory and self-edited prompts,
319
+ plus episodic `thread search`.
316
320
  - **[Skills (slash commands)](docs/skills.md)** — reusable prompt templates
317
321
  with positional arguments and tab completion; the chat agent can also
318
322
  create, edit, and search them at runtime.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "botholomew",
3
- "version": "0.22.2",
3
+ "version": "0.23.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",
@@ -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).`;
@@ -46,7 +46,7 @@ export function abortActiveStream(session: ChatSession): boolean {
46
46
  return false;
47
47
  }
48
48
 
49
- function requireProviderCreds(config: BotholomewConfig): void {
49
+ export function requireProviderCreds(config: BotholomewConfig): void {
50
50
  const { llm } = config;
51
51
  if (llm.provider === "anthropic" && !llm.api_key) {
52
52
  throw new BotholomewLlmError(
package/src/cli.ts CHANGED
@@ -5,6 +5,7 @@ import { program } from "commander";
5
5
  import { registerCapabilitiesCommand } from "./commands/capabilities.ts";
6
6
  import { registerChatCommand } from "./commands/chat.ts";
7
7
  import { registerCheckUpdateCommand } from "./commands/check-update.ts";
8
+ import { registerDreamCommand } from "./commands/dream.ts";
8
9
  import { registerInitCommand } from "./commands/init.ts";
9
10
  import { registerMcpxCommand } from "./commands/mcpx.ts";
10
11
  import { registerMembotCommand } from "./commands/membot.ts";
@@ -64,6 +65,7 @@ registerTaskCommand(program);
64
65
  registerThreadCommand(program);
65
66
  registerScheduleCommand(program);
66
67
  registerChatCommand(program);
68
+ registerDreamCommand(program);
67
69
  registerMembotCommand(program);
68
70
  registerCapabilitiesCommand(program);
69
71
  registerPromptsCommand(program);
@@ -21,6 +21,7 @@ export function registerChatCommand(program: Command) {
21
21
  " Slash commands:\n" +
22
22
  " /help Show chat-command reference (Help tab has the full keymap)\n" +
23
23
  " /skills List available skills\n" +
24
+ " /dream Reflect on recent threads — consolidate learnings, update beliefs/goals\n" +
24
25
  " /clear End current thread and start a new one\n" +
25
26
  " /exit End the chat session",
26
27
  )
@@ -0,0 +1,194 @@
1
+ import type { LanguageModel } from "ai";
2
+ import ansis from "ansis";
3
+ import type { Command } from "commander";
4
+ import { type ChatTurnCallbacks, runChatTurn } from "../chat/agent.ts";
5
+ import { DREAM_PROMPT_BODY } from "../chat/dream-prompt.ts";
6
+ import { requireProviderCreds } from "../chat/session.ts";
7
+ import { loadConfig } from "../config/loader.ts";
8
+ import { createMcpxClient, resolveMcpxDir } from "../mcpx/client.ts";
9
+ import type { WithMem } from "../mem/client.ts";
10
+ import {
11
+ createThread,
12
+ endThread,
13
+ ensureThreadsDir,
14
+ logInteraction,
15
+ } from "../threads/store.ts";
16
+ import { logger } from "../utils/logger.ts";
17
+ import { utcDateString } from "../utils/v7-date.ts";
18
+
19
+ /** Gray `HH:MM:SS` stamp, matching the logger's line prefix. */
20
+ function ts(): string {
21
+ return ansis.gray(new Date().toTimeString().slice(0, 8));
22
+ }
23
+
24
+ /** Collapse a tool-input blob to a single readable line. */
25
+ function previewInput(input: string, max = 100): string {
26
+ const oneLine = input.replace(/\s+/g, " ").trim();
27
+ return oneLine.length > max ? `${oneLine.slice(0, max)}…` : oneLine;
28
+ }
29
+
30
+ export interface DreamOptions {
31
+ /** ISO date or relative duration (`24h`, `7d`). Defaults to `dream_lookback_hours`. */
32
+ since?: string;
33
+ /** Propose edits without writing anything. */
34
+ dryRun?: boolean;
35
+ /** Test seam: inject a language model + membot accessor. */
36
+ _testModel?: LanguageModel;
37
+ _testWithMem?: WithMem;
38
+ }
39
+
40
+ const RELATIVE_RE = /^(\d+)\s*([hd])$/i;
41
+
42
+ /**
43
+ * Resolve the start of the recall window. Accepts a relative duration
44
+ * (`24h`, `7d`), an ISO date, or — when omitted — `now - lookbackHours`.
45
+ */
46
+ export function resolveSince(
47
+ since: string | undefined,
48
+ lookbackHours: number,
49
+ now: Date,
50
+ ): Date {
51
+ if (!since) {
52
+ return new Date(now.getTime() - lookbackHours * 3600_000);
53
+ }
54
+ const rel = RELATIVE_RE.exec(since.trim());
55
+ if (rel) {
56
+ const n = Number(rel[1]);
57
+ const unitHours = rel[2]?.toLowerCase() === "d" ? 24 : 1;
58
+ return new Date(now.getTime() - n * unitHours * 3600_000);
59
+ }
60
+ const parsed = new Date(since);
61
+ if (Number.isNaN(parsed.getTime())) {
62
+ throw new Error(
63
+ `Could not parse --since "${since}". Use an ISO date (2026-06-01) or a relative duration like 24h / 7d.`,
64
+ );
65
+ }
66
+ return parsed;
67
+ }
68
+
69
+ /**
70
+ * Run one reflection ("dream") pass: review recent threads, consolidate
71
+ * durable facts into the knowledge store, and (unless `--dry-run`) apply
72
+ * justified edits to the agent's prompt files. Reuses the chat agent loop and
73
+ * its tool set, so this composes the existing primitives — no new machinery.
74
+ * Returns the audit thread id.
75
+ */
76
+ export async function runDream(
77
+ projectDir: string,
78
+ opts: DreamOptions = {},
79
+ ): Promise<string> {
80
+ const config = await loadConfig(projectDir);
81
+ requireProviderCreds(config);
82
+ await ensureThreadsDir(projectDir);
83
+
84
+ const now = new Date();
85
+ const since = resolveSince(opts.since, config.dream_lookback_hours, now);
86
+
87
+ const threadId = await createThread(
88
+ projectDir,
89
+ "chat_session",
90
+ undefined,
91
+ `Dream — ${utcDateString(now)}`,
92
+ );
93
+
94
+ const windowLine = `Scope your recall to conversations from ${since.toISOString()} onward (it is now ${now.toISOString()}).`;
95
+ const dryRunLine = opts.dryRun
96
+ ? "\n\nIMPORTANT — DRY RUN: Do NOT call `prompt_edit` or any `membot` write tool. Instead, end with a report describing the reflection you would store and the exact prompt edits you would propose, then stop."
97
+ : "";
98
+ const userMessage = `${DREAM_PROMPT_BODY}\n\n${windowLine}${dryRunLine}`;
99
+
100
+ await logInteraction(projectDir, threadId, {
101
+ role: "user",
102
+ kind: "message",
103
+ content: userMessage,
104
+ });
105
+
106
+ logger.info(
107
+ `${ansis.magenta.bold("Dreaming")} — reviewing threads since ${ansis.cyan(
108
+ since.toISOString(),
109
+ )}${opts.dryRun ? ` ${ansis.yellow("(dry run)")}` : ""}`,
110
+ );
111
+
112
+ const mcpxClient = await createMcpxClient(resolveMcpxDir(projectDir, config));
113
+
114
+ // Chat callbacks don't carry tool durations, so time each call by id.
115
+ const toolStartedAt = new Map<string, number>();
116
+ let midStream = false;
117
+
118
+ const callbacks: ChatTurnCallbacks = {
119
+ onToken: (text) => {
120
+ process.stdout.write(text);
121
+ midStream = true;
122
+ },
123
+ onToolStart: (id, name, input) => {
124
+ if (midStream) {
125
+ process.stdout.write("\n");
126
+ midStream = false;
127
+ }
128
+ toolStartedAt.set(id, Date.now());
129
+ process.stdout.write(
130
+ `${ts()} ${ansis.yellow("▶")} ${ansis.bold(name)} ${ansis.dim(
131
+ previewInput(input),
132
+ )}\n`,
133
+ );
134
+ },
135
+ onToolEnd: (id, name, _output, isError) => {
136
+ const startedAt = toolStartedAt.get(id);
137
+ const elapsed = startedAt
138
+ ? ` ${ansis.dim(`(${((Date.now() - startedAt) / 1000).toFixed(1)}s)`)}`
139
+ : "";
140
+ toolStartedAt.delete(id);
141
+ const mark = isError ? ansis.red("✗") : ansis.green("✓");
142
+ const status = isError ? ` ${ansis.red("error")}` : "";
143
+ process.stdout.write(
144
+ `${ts()} ${mark} ${ansis.bold(name)}${status}${elapsed}\n`,
145
+ );
146
+ },
147
+ };
148
+
149
+ try {
150
+ await runChatTurn({
151
+ messages: [{ role: "user", content: userMessage }],
152
+ projectDir,
153
+ config,
154
+ threadId,
155
+ mcpxClient,
156
+ callbacks,
157
+ _testModel: opts._testModel,
158
+ _testWithMem: opts._testWithMem,
159
+ });
160
+ } finally {
161
+ process.stdout.write("\n");
162
+ await endThread(projectDir, threadId);
163
+ await mcpxClient?.close();
164
+ }
165
+
166
+ return threadId;
167
+ }
168
+
169
+ export function registerDreamCommand(program: Command) {
170
+ program
171
+ .command("dream")
172
+ .description(
173
+ "Reflect on recent threads: consolidate durable facts into the knowledge store and update beliefs/goals",
174
+ )
175
+ .option(
176
+ "-s, --since <when>",
177
+ "ISO date or relative duration (24h, 7d) to scope recall (default: dream_lookback_hours)",
178
+ )
179
+ .option(
180
+ "--dry-run",
181
+ "propose edits without writing prompts or the knowledge store",
182
+ false,
183
+ )
184
+ .action(async (opts: { since?: string; dryRun?: boolean }) => {
185
+ const dir = program.opts().dir;
186
+ const threadId = await runDream(dir, {
187
+ since: opts.since,
188
+ dryRun: opts.dryRun,
189
+ });
190
+ logger.success(
191
+ `Dream complete — audit with ${ansis.cyan(`botholomew thread view ${threadId}`)}`,
192
+ );
193
+ });
194
+ }
@@ -22,10 +22,6 @@ function formatAge(fromIso: string | null, to = new Date()): string {
22
22
  return `${Math.floor(hours / 24)}d ago`;
23
23
  }
24
24
 
25
- function padColored(colored: string, raw: string, width: number): string {
26
- return colored + " ".repeat(Math.max(0, width - raw.length));
27
- }
28
-
29
25
  function workerStatusColor(status: WorkerStatus): string {
30
26
  switch (status) {
31
27
  case "running":
@@ -6,9 +6,12 @@ import {
6
6
  getInteractionsAfter,
7
7
  getThread,
8
8
  type Interaction,
9
+ type InteractionRole,
9
10
  isThreadEnded,
10
11
  listThreads,
12
+ searchThreads,
11
13
  type Thread,
14
+ type ThreadType,
12
15
  } from "../threads/store.ts";
13
16
  import { logger } from "../utils/logger.ts";
14
17
 
@@ -39,6 +42,67 @@ export function registerThreadCommand(program: Command) {
39
42
  }
40
43
  });
41
44
 
45
+ thread
46
+ .command("search <query>")
47
+ .description("Search past conversations for a regex (or plain substring)")
48
+ .option("--ignore-case", "case-insensitive match (default true)", true)
49
+ .option("--no-ignore-case", "case-sensitive match")
50
+ .option(
51
+ "-r, --role <role>",
52
+ "filter by role (user, assistant, system, tool)",
53
+ )
54
+ .option("-t, --type <type>", "filter by type (worker_tick, chat_session)")
55
+ .option("--since <iso>", "only threads started on or after this ISO date")
56
+ .option("--until <iso>", "only threads started on or before this ISO date")
57
+ .option("-l, --limit <n>", "max hits to return", Number.parseInt)
58
+ .action(
59
+ async (
60
+ query: string,
61
+ opts: {
62
+ ignoreCase?: boolean;
63
+ role?: string;
64
+ type?: string;
65
+ since?: string;
66
+ until?: string;
67
+ limit?: number;
68
+ },
69
+ ) => {
70
+ const dir = program.opts().dir;
71
+ let regex: RegExp;
72
+ try {
73
+ regex = new RegExp(query, opts.ignoreCase === false ? "" : "i");
74
+ } catch (err) {
75
+ logger.error(
76
+ `Invalid regex: ${err instanceof Error ? err.message : String(err)}`,
77
+ );
78
+ process.exit(1);
79
+ }
80
+ const { hits, threadsScanned } = await searchThreads(dir, {
81
+ regex,
82
+ role: opts.role as InteractionRole | undefined,
83
+ type: opts.type as ThreadType | undefined,
84
+ since: opts.since ? new Date(opts.since) : undefined,
85
+ until: opts.until ? new Date(opts.until) : undefined,
86
+ maxResults: opts.limit,
87
+ });
88
+
89
+ if (hits.length === 0) {
90
+ logger.dim(`No hits in ${threadsScanned} thread(s).`);
91
+ return;
92
+ }
93
+
94
+ for (const h of hits) {
95
+ const id = ansis.dim(h.thread_id);
96
+ const seq = ansis.dim(`#${h.sequence}`);
97
+ const snippet = h.content_snippet.replace(/\n/g, " ");
98
+ console.log(` ${id} ${seq} ${roleColor(h.role)} ${snippet}`);
99
+ }
100
+ logger.dim(
101
+ `\n${hits.length} hit(s) in ${threadsScanned} thread(s). View context: botholomew thread view <id>`,
102
+ );
103
+ },
104
+ );
105
+
42
106
  thread
43
107
  .command("view <id>")
44
108
  .description("View thread details and interactions")
@@ -30,6 +30,8 @@ export interface BotholomewConfig {
30
30
  schedule_min_interval_seconds: number;
31
31
  schedule_claim_stale_seconds: number;
32
32
  tui_idle_timeout_seconds: number;
33
+ /** Default window (in hours) of recent threads the `dream` reflection reviews when `--since` is omitted. */
34
+ dream_lookback_hours: number;
33
35
  log_level: string;
34
36
  membot_scope: Scope;
35
37
  mcpx_scope: Scope;
@@ -65,6 +67,7 @@ export const DEFAULT_CONFIG: BotholomewConfig = {
65
67
  schedule_min_interval_seconds: 60,
66
68
  schedule_claim_stale_seconds: 300,
67
69
  tui_idle_timeout_seconds: 180,
70
+ dream_lookback_hours: 24,
68
71
  log_level: "",
69
72
  membot_scope: "global",
70
73
  mcpx_scope: "global",
@@ -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
  // ---------------------------------------------------------------------------
@@ -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;
@@ -87,7 +87,7 @@ export async function registerWorker(
87
87
  */
88
88
  export async function heartbeat(projectDir: string, id: string): Promise<void> {
89
89
  const worker = await readWorker(projectDir, id);
90
- if (!worker || worker.status !== "running") return;
90
+ if (worker?.status !== "running") return;
91
91
  worker.last_heartbeat_at = new Date().toISOString();
92
92
  await writeWorker(projectDir, worker);
93
93
  }
@@ -97,7 +97,7 @@ export async function markWorkerStopped(
97
97
  id: string,
98
98
  ): Promise<void> {
99
99
  const worker = await readWorker(projectDir, id);
100
- if (!worker || worker.status !== "running") return;
100
+ if (worker?.status !== "running") return;
101
101
  worker.status = "stopped";
102
102
  worker.stopped_at = new Date().toISOString();
103
103
  await writeWorker(projectDir, worker);
@@ -130,7 +130,7 @@ export async function reapDeadWorkers(
130
130
  const reaped: string[] = [];
131
131
  for (const id of ids) {
132
132
  const w = await readWorker(projectDir, id);
133
- if (!w || w.status !== "running") continue;
133
+ if (w?.status !== "running") continue;
134
134
  const heartbeatMs = Date.parse(w.last_heartbeat_at);
135
135
  if (Number.isFinite(heartbeatMs) && heartbeatMs >= cutoff) continue;
136
136
  w.status = "dead";
@@ -162,7 +162,7 @@ export async function pruneStoppedWorkers(
162
162
  const pruned: string[] = [];
163
163
  for (const id of ids) {
164
164
  const w = await readWorker(projectDir, id);
165
- if (!w || w.status !== "stopped" || !w.stopped_at) continue;
165
+ if (w?.status !== "stopped" || !w.stopped_at) continue;
166
166
  const stoppedMs = Date.parse(w.stopped_at);
167
167
  if (Number.isFinite(stoppedMs) && stoppedMs >= cutoff) continue;
168
168
  try {