botholomew 0.9.9 → 0.9.11

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.
@@ -55,7 +55,7 @@ export function parseSkillFile(raw: string, filePath: string): SkillDefinition {
55
55
  * Split a raw argument string into positional tokens,
56
56
  * respecting double-quoted strings.
57
57
  */
58
- function tokenize(raw: string): string[] {
58
+ export function tokenize(raw: string): string[] {
59
59
  const tokens: string[] = [];
60
60
  let current = "";
61
61
  let inQuote = false;
@@ -77,10 +77,30 @@ function tokenize(raw: string): string[] {
77
77
  return tokens;
78
78
  }
79
79
 
80
+ function escapeRegex(s: string): string {
81
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
82
+ }
83
+
80
84
  export function renderSkill(skill: SkillDefinition, rawArgs: string): string {
81
85
  const tokens = tokenize(rawArgs);
82
86
  let result = skill.body;
83
87
 
88
+ // Replace $<argName> placeholders first, longest names first so a `$start`
89
+ // arg can't truncate `$start_date`. Word-boundary tail prevents `$end`
90
+ // from clipping `$endpoint`.
91
+ const namedArgs = skill.arguments
92
+ .map((argDef, i) => ({
93
+ name: argDef.name,
94
+ value: tokens[i] ?? argDef.default ?? "",
95
+ }))
96
+ .filter((a) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(a.name))
97
+ .sort((a, b) => b.name.length - a.name.length);
98
+
99
+ for (const { name, value } of namedArgs) {
100
+ const re = new RegExp(`\\$${escapeRegex(name)}(?![A-Za-z0-9_])`, "g");
101
+ result = result.replace(re, value);
102
+ }
103
+
84
104
  result = result.replaceAll("$ARGUMENTS", rawArgs);
85
105
 
86
106
  // Replace $1-$9 with positional args or defaults
@@ -93,3 +113,23 @@ export function renderSkill(skill: SkillDefinition, rawArgs: string): string {
93
113
 
94
114
  return result;
95
115
  }
116
+
117
+ /**
118
+ * Identify required arguments that have neither a positional token
119
+ * nor a declared default. Used by the TUI to reject incomplete
120
+ * slash-command invocations before sending to the LLM.
121
+ */
122
+ export function validateSkillArgs(
123
+ skill: SkillDefinition,
124
+ rawArgs: string,
125
+ ): { missing: string[] } {
126
+ const tokens = tokenize(rawArgs);
127
+ const missing: string[] = [];
128
+ skill.arguments.forEach((argDef, i) => {
129
+ if (!argDef.required) return;
130
+ const hasToken = tokens[i] !== undefined;
131
+ const hasDefault = argDef.default !== undefined;
132
+ if (!hasToken && !hasDefault) missing.push(argDef.name);
133
+ });
134
+ return { missing };
135
+ }
@@ -33,6 +33,7 @@ import { listSchedulesTool } from "./schedule/list.ts";
33
33
  import { searchGrepTool } from "./search/grep.ts";
34
34
  import { searchSemanticTool } from "./search/semantic.ts";
35
35
  // Skill tools
36
+ import { skillDeleteTool } from "./skill/delete.ts";
36
37
  import { skillEditTool } from "./skill/edit.ts";
37
38
  import { skillListTool } from "./skill/list.ts";
38
39
  import { skillReadTool } from "./skill/read.ts";
@@ -102,6 +103,7 @@ export function registerAllTools(): void {
102
103
  registerTool(skillWriteTool);
103
104
  registerTool(skillEditTool);
104
105
  registerTool(skillSearchTool);
106
+ registerTool(skillDeleteTool);
105
107
 
106
108
  // Thread
107
109
  registerTool(listThreadsTool);
@@ -0,0 +1,56 @@
1
+ import { z } from "zod";
2
+ import { loadSkills } from "../../skills/loader.ts";
3
+ import type { ToolDefinition } from "../tool.ts";
4
+
5
+ const inputSchema = z.object({
6
+ name: z.string().describe("Skill name (case-insensitive)"),
7
+ });
8
+
9
+ const outputSchema = z.object({
10
+ name: z.string().nullable(),
11
+ path: z.string().nullable(),
12
+ deleted: z.boolean(),
13
+ is_error: z.boolean(),
14
+ error_type: z.string().optional(),
15
+ message: z.string().optional(),
16
+ next_action_hint: z.string().optional(),
17
+ });
18
+
19
+ export const skillDeleteTool = {
20
+ name: "skill_delete",
21
+ description:
22
+ "[[ bash equivalent command: rm ]] Delete a skill file (user-defined slash command) by name. The file is removed from .botholomew/skills/. Returns a not_found error with the list of available names when the skill doesn't exist.",
23
+ group: "skill",
24
+ inputSchema,
25
+ outputSchema,
26
+ execute: async (input, ctx) => {
27
+ const skills = await loadSkills(ctx.projectDir);
28
+ const skill = skills.get(input.name.toLowerCase());
29
+
30
+ if (!skill) {
31
+ const available = [...skills.keys()].sort();
32
+ const hint =
33
+ available.length > 0
34
+ ? `Available: ${available.join(", ")}. Use skill_list to browse.`
35
+ : "No skills exist yet. Use skill_write to create one.";
36
+ return {
37
+ name: input.name,
38
+ path: null,
39
+ deleted: false,
40
+ is_error: true,
41
+ error_type: "not_found",
42
+ message: `Skill not found: ${input.name}`,
43
+ next_action_hint: hint,
44
+ };
45
+ }
46
+
47
+ await Bun.file(skill.filePath).delete();
48
+
49
+ return {
50
+ name: skill.name,
51
+ path: skill.filePath,
52
+ deleted: true,
53
+ is_error: false,
54
+ };
55
+ },
56
+ } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
package/src/tui/App.tsx CHANGED
@@ -145,13 +145,13 @@ export function App({
145
145
  const [activeTab, setActiveTab] = useState<TabId>(1);
146
146
  const [workerRunning, setWorkerRunning] = useState(false);
147
147
  const [chatTitle, setChatTitle] = useState<string | undefined>(undefined);
148
- const queueRef = useRef<string[]>([]);
148
+ const queueRef = useRef<Array<{ display: string; content: string }>>([]);
149
149
  const processingRef = useRef(false);
150
150
  const [queuedMessages, setQueuedMessages] = useState<string[]>([]);
151
151
  const [selectedQueueIndex, setSelectedQueueIndex] = useState(0);
152
152
 
153
153
  const syncQueue = useCallback(() => {
154
- const snapshot = [...queueRef.current];
154
+ const snapshot = queueRef.current.map((e) => e.display);
155
155
  setQueuedMessages(snapshot);
156
156
  setSelectedQueueIndex((prev) =>
157
157
  snapshot.length === 0 ? 0 : Math.min(prev, snapshot.length - 1),
@@ -297,7 +297,7 @@ export function App({
297
297
  );
298
298
  syncQueue();
299
299
  if (msg) {
300
- setInputValue(msg);
300
+ setInputValue(msg.display);
301
301
  }
302
302
  return;
303
303
  }
@@ -327,9 +327,9 @@ export function App({
327
327
  processingRef.current = true;
328
328
 
329
329
  while (queueRef.current.length > 0) {
330
- const trimmed = queueRef.current.shift();
330
+ const entry = queueRef.current.shift();
331
331
  syncQueue();
332
- if (!trimmed) break;
332
+ if (!entry) break;
333
333
  setIsLoading(true);
334
334
  setStreamingText("");
335
335
  setActiveToolCalls([]);
@@ -338,7 +338,7 @@ export function App({
338
338
  const userMsg: ChatMessage = {
339
339
  id: msgId(),
340
340
  role: "user",
341
- content: trimmed,
341
+ content: entry.display,
342
342
  timestamp: new Date(),
343
343
  };
344
344
  setMessages((prev) => [...prev, userMsg]);
@@ -366,7 +366,7 @@ export function App({
366
366
 
367
367
  let lastStreamFlush = 0;
368
368
  try {
369
- await sendMessage(sessionRef.current, trimmed, {
369
+ await sendMessage(sessionRef.current, entry.content, {
370
370
  onToken: (token) => {
371
371
  currentText += token;
372
372
  const now = Date.now();
@@ -432,7 +432,10 @@ export function App({
432
432
  useEffect(() => {
433
433
  if (ready && initialPrompt && !initialPromptSent.current) {
434
434
  initialPromptSent.current = true;
435
- queueRef.current.push(initialPrompt);
435
+ queueRef.current.push({
436
+ display: initialPrompt,
437
+ content: initialPrompt,
438
+ });
436
439
  syncQueue();
437
440
  setInputHistory((prev) => [...prev, initialPrompt]);
438
441
  processQueue();
@@ -570,9 +573,12 @@ export function App({
570
573
  };
571
574
  setMessages((prev) => [...prev, msg]);
572
575
  },
573
- queueUserMessage: (content) => {
576
+ queueUserMessage: (content, opts) => {
574
577
  setInputHistory((prev) => [...prev, trimmed]);
575
- queueRef.current.push(content);
578
+ queueRef.current.push({
579
+ display: opts?.display ?? content,
580
+ content,
581
+ });
576
582
  syncQueue();
577
583
  processQueue();
578
584
  },
@@ -618,7 +624,7 @@ export function App({
618
624
  }
619
625
 
620
626
  setInputHistory((prev) => [...prev, trimmed]);
621
- queueRef.current.push(trimmed);
627
+ queueRef.current.push({ display: trimmed, content: trimmed });
622
628
  syncQueue();
623
629
  processQueue();
624
630
  },
@@ -1,7 +1,8 @@
1
1
  import { Box, Text, useInput, useStdout } from "ink";
2
- import { memo, useEffect, useState } from "react";
2
+ import { memo, useEffect, useMemo, useState } from "react";
3
3
  import { withDb } from "../../db/connection.ts";
4
4
  import { listWorkers, type Worker } from "../../db/workers.ts";
5
+ import { readLogTail } from "../../worker/log-reader.ts";
5
6
 
6
7
  interface WorkerPanelProps {
7
8
  dbPath: string;
@@ -15,6 +16,9 @@ const STATUS_FILTERS: readonly (Worker["status"] | null)[] = [
15
16
  "dead",
16
17
  ];
17
18
 
19
+ const PAGE_SCROLL_LINES = 10;
20
+ const LOG_POLL_MS = 1500;
21
+
18
22
  function statusColor(status: Worker["status"]): string {
19
23
  switch (status) {
20
24
  case "running":
@@ -36,6 +40,12 @@ function formatAge(from: Date, now: Date): string {
36
40
  return `${Math.floor(hours / 24)}d`;
37
41
  }
38
42
 
43
+ function formatBytes(n: number): string {
44
+ if (n < 1024) return `${n}B`;
45
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`;
46
+ return `${(n / (1024 * 1024)).toFixed(1)}MB`;
47
+ }
48
+
39
49
  export const WorkerPanel = memo(function WorkerPanel({
40
50
  dbPath,
41
51
  isActive,
@@ -46,6 +56,12 @@ export const WorkerPanel = memo(function WorkerPanel({
46
56
  const [selectedIndex, setSelectedIndex] = useState(0);
47
57
  const [filterIdx, setFilterIdx] = useState(0);
48
58
  const [now, setNow] = useState(() => new Date());
59
+ const [viewMode, setViewMode] = useState<"detail" | "log">("detail");
60
+ const [logContent, setLogContent] = useState("");
61
+ const [logSize, setLogSize] = useState(0);
62
+ const [logTruncated, setLogTruncated] = useState(false);
63
+ const [logScroll, setLogScroll] = useState(0);
64
+ const [logFollow, setLogFollow] = useState(true);
49
65
 
50
66
  useEffect(() => {
51
67
  let mounted = true;
@@ -72,17 +88,135 @@ export const WorkerPanel = memo(function WorkerPanel({
72
88
  };
73
89
  }, [dbPath, filterIdx]);
74
90
 
91
+ const selected = workers[selectedIndex];
92
+ const selectedLogPath = selected?.log_path ?? null;
93
+
94
+ useEffect(() => {
95
+ if (viewMode !== "log" || !selectedLogPath) return;
96
+ let mounted = true;
97
+
98
+ const refresh = async () => {
99
+ try {
100
+ const tail = await readLogTail(selectedLogPath);
101
+ if (!mounted) return;
102
+ setLogContent(tail.content);
103
+ setLogSize(tail.size);
104
+ setLogTruncated(tail.truncated);
105
+ } catch {
106
+ // Ignore transient read errors; next tick will retry.
107
+ }
108
+ };
109
+
110
+ refresh();
111
+ const interval = setInterval(refresh, LOG_POLL_MS);
112
+ return () => {
113
+ mounted = false;
114
+ clearInterval(interval);
115
+ };
116
+ }, [viewMode, selectedLogPath]);
117
+
118
+ // Reset log scroll + content when the selection or view mode changes.
119
+ // biome-ignore lint/correctness/useExhaustiveDependencies: intentional reset triggers
120
+ useEffect(() => {
121
+ setLogScroll(0);
122
+ setLogFollow(true);
123
+ setLogContent("");
124
+ setLogSize(0);
125
+ setLogTruncated(false);
126
+ }, [selected?.id, viewMode]);
127
+
128
+ const logLines = useMemo(() => {
129
+ if (logContent.length === 0) return [];
130
+ // Trim a single trailing newline so the rendered list doesn't end with a
131
+ // blank row, but preserve internal blank lines.
132
+ const trimmed = logContent.endsWith("\n")
133
+ ? logContent.slice(0, -1)
134
+ : logContent;
135
+ return trimmed.split("\n");
136
+ }, [logContent]);
137
+
138
+ const visibleRows = Math.max(4, termRows - 8);
139
+ const maxLogScroll = Math.max(0, logLines.length - visibleRows);
140
+
141
+ // When following, snap scroll to the bottom whenever new log content
142
+ // arrives. The user can break follow mode by scrolling up; pressing G or
143
+ // running off the end via j/J resumes it.
144
+ useEffect(() => {
145
+ if (viewMode === "log" && logFollow) {
146
+ setLogScroll(maxLogScroll);
147
+ }
148
+ }, [viewMode, logFollow, maxLogScroll]);
149
+
75
150
  useInput(
76
151
  (input, key) => {
77
152
  if (!isActive) return;
153
+
154
+ if (input === "l") {
155
+ setViewMode((m) => (m === "log" ? "detail" : "log"));
156
+ return;
157
+ }
158
+
78
159
  if (key.upArrow) {
160
+ if (viewMode === "log" && key.shift) {
161
+ setLogFollow(false);
162
+ setLogScroll((s) => Math.max(0, s - 1));
163
+ return;
164
+ }
79
165
  setSelectedIndex((i) => Math.max(0, i - 1));
80
166
  return;
81
167
  }
82
168
  if (key.downArrow) {
169
+ if (viewMode === "log" && key.shift) {
170
+ setLogScroll((s) => {
171
+ const next = Math.min(maxLogScroll, s + 1);
172
+ if (next >= maxLogScroll) setLogFollow(true);
173
+ return next;
174
+ });
175
+ return;
176
+ }
83
177
  setSelectedIndex((i) => Math.min(workers.length - 1, i + 1));
84
178
  return;
85
179
  }
180
+
181
+ if (viewMode === "log") {
182
+ if (input === "j") {
183
+ setLogScroll((s) => {
184
+ const next = Math.min(maxLogScroll, s + 1);
185
+ if (next >= maxLogScroll) setLogFollow(true);
186
+ return next;
187
+ });
188
+ return;
189
+ }
190
+ if (input === "k") {
191
+ setLogFollow(false);
192
+ setLogScroll((s) => Math.max(0, s - 1));
193
+ return;
194
+ }
195
+ if (input === "J") {
196
+ setLogScroll((s) => {
197
+ const next = Math.min(maxLogScroll, s + PAGE_SCROLL_LINES);
198
+ if (next >= maxLogScroll) setLogFollow(true);
199
+ return next;
200
+ });
201
+ return;
202
+ }
203
+ if (input === "K") {
204
+ setLogFollow(false);
205
+ setLogScroll((s) => Math.max(0, s - PAGE_SCROLL_LINES));
206
+ return;
207
+ }
208
+ if (input === "g") {
209
+ setLogFollow(false);
210
+ setLogScroll(0);
211
+ return;
212
+ }
213
+ if (input === "G") {
214
+ setLogFollow(true);
215
+ setLogScroll(maxLogScroll);
216
+ return;
217
+ }
218
+ }
219
+
86
220
  if (input === "f") {
87
221
  setFilterIdx((i) => (i + 1) % STATUS_FILTERS.length);
88
222
  return;
@@ -91,9 +225,8 @@ export const WorkerPanel = memo(function WorkerPanel({
91
225
  { isActive },
92
226
  );
93
227
 
94
- const selected = workers[selectedIndex];
95
228
  const filterLabel = STATUS_FILTERS[filterIdx] ?? "all";
96
- const visibleRows = Math.max(4, termRows - 10);
229
+ const visibleSidebarRows = Math.max(4, termRows - 10);
97
230
 
98
231
  return (
99
232
  <Box flexDirection="column" flexGrow={1} paddingX={1}>
@@ -103,7 +236,11 @@ export const WorkerPanel = memo(function WorkerPanel({
103
236
  </Text>
104
237
  <Text dimColor> · filter: </Text>
105
238
  <Text color="yellow">{filterLabel}</Text>
106
- <Text dimColor>{" · [f] cycle filter [↑↓] select"}</Text>
239
+ <Text dimColor>
240
+ {viewMode === "log"
241
+ ? " · [l] back [↑↓] select [j/k] scroll [g/G] top/bot [f] filter"
242
+ : " · [l] view log [f] cycle filter [↑↓] select"}
243
+ </Text>
107
244
  </Box>
108
245
 
109
246
  {workers.length === 0 ? (
@@ -121,7 +258,7 @@ export const WorkerPanel = memo(function WorkerPanel({
121
258
  marginRight={2}
122
259
  overflow="hidden"
123
260
  >
124
- {workers.slice(0, visibleRows).map((w, i) => {
261
+ {workers.slice(0, visibleSidebarRows).map((w, i) => {
125
262
  const active = i === selectedIndex;
126
263
  const short = w.id.slice(0, 8);
127
264
  return (
@@ -148,7 +285,21 @@ export const WorkerPanel = memo(function WorkerPanel({
148
285
  })}
149
286
  </Box>
150
287
  <Box flexDirection="column" flexGrow={1}>
151
- {selected ? <WorkerDetail worker={selected} now={now} /> : null}
288
+ {selected ? (
289
+ viewMode === "log" ? (
290
+ <WorkerLogView
291
+ worker={selected}
292
+ lines={logLines}
293
+ scroll={logScroll}
294
+ visibleRows={visibleRows}
295
+ truncated={logTruncated}
296
+ size={logSize}
297
+ follow={logFollow}
298
+ />
299
+ ) : (
300
+ <WorkerDetail worker={selected} now={now} />
301
+ )
302
+ ) : null}
152
303
  </Box>
153
304
  </Box>
154
305
  )}
@@ -201,6 +352,89 @@ function WorkerDetail({ worker, now }: { worker: Worker; now: Date }) {
201
352
  {worker.task_id}
202
353
  </Text>
203
354
  )}
355
+ {worker.log_path && (
356
+ <Text>
357
+ <Text dimColor>Log </Text>
358
+ <Text dimColor>{worker.log_path}</Text>
359
+ </Text>
360
+ )}
361
+ </Box>
362
+ </Box>
363
+ );
364
+ }
365
+
366
+ function WorkerLogView({
367
+ worker,
368
+ lines,
369
+ scroll,
370
+ visibleRows,
371
+ truncated,
372
+ size,
373
+ follow,
374
+ }: {
375
+ worker: Worker;
376
+ lines: string[];
377
+ scroll: number;
378
+ visibleRows: number;
379
+ truncated: boolean;
380
+ size: number;
381
+ follow: boolean;
382
+ }) {
383
+ if (!worker.log_path) {
384
+ return (
385
+ <Box flexDirection="column">
386
+ <Text bold color="blue">
387
+ {worker.id}
388
+ </Text>
389
+ <Box marginTop={1}>
390
+ <Text dimColor>
391
+ No log file (worker is running in foreground or was started before
392
+ per-worker logs existed).
393
+ </Text>
394
+ </Box>
395
+ </Box>
396
+ );
397
+ }
398
+
399
+ if (lines.length === 0) {
400
+ return (
401
+ <Box flexDirection="column">
402
+ <Text bold color="blue">
403
+ {worker.id}
404
+ </Text>
405
+ <Box marginTop={1}>
406
+ <Text dimColor>Log empty.</Text>
407
+ </Box>
408
+ </Box>
409
+ );
410
+ }
411
+
412
+ const visible = lines.slice(scroll, scroll + visibleRows);
413
+ const lastLine = Math.min(scroll + visibleRows, lines.length);
414
+
415
+ return (
416
+ <Box flexDirection="column" flexGrow={1}>
417
+ <Box>
418
+ <Text bold color="blue">
419
+ {worker.id.slice(0, 8)}
420
+ </Text>
421
+ <Text dimColor>
422
+ {" "}
423
+ · {formatBytes(size)}
424
+ {truncated ? " (tail only)" : ""} ·{" "}
425
+ </Text>
426
+ <Text color={follow ? "green" : "yellow"}>
427
+ {follow ? "following" : "paused"}
428
+ </Text>
429
+ <Text dimColor>
430
+ {" "}[{scroll + 1}–{lastLine} of {lines.length}]
431
+ </Text>
432
+ </Box>
433
+ <Box flexDirection="column" marginTop={1}>
434
+ {visible.map((line, i) => {
435
+ const lineNum = scroll + i;
436
+ return <Text key={lineNum}>{line || " "}</Text>;
437
+ })}
204
438
  </Box>
205
439
  </Box>
206
440
  );
@@ -24,6 +24,19 @@ export interface StartWorkerOptions {
24
24
  * When omitted, the worker claims the next eligible task from the queue.
25
25
  */
26
26
  taskId?: string;
27
+ /**
28
+ * Pre-allocated worker id from the spawn parent. When provided, the parent
29
+ * has already opened a per-worker log file at this id and we record both on
30
+ * the workers row. Foreground/in-process callers may omit this and a fresh
31
+ * id will be generated.
32
+ */
33
+ workerId?: string;
34
+ /**
35
+ * Path to the per-worker log file (set by the spawn parent when launching
36
+ * a detached worker). Stored on the workers row so the TUI can tail it.
37
+ * Null/undefined for foreground workers writing to stdout.
38
+ */
39
+ logPath?: string;
27
40
  /**
28
41
  * Whether to evaluate schedules as part of this run.
29
42
  * Defaults to `true` for one-shot workers without a taskId and for persist
@@ -86,7 +99,7 @@ export async function startWorker(
86
99
  logger.info("MCPX client initialized with external tools");
87
100
  }
88
101
 
89
- const workerId = uuidv7();
102
+ const workerId = options.workerId ?? uuidv7();
90
103
  await withDb(dbPath, (conn) =>
91
104
  registerWorker(conn, {
92
105
  id: workerId,
@@ -94,6 +107,7 @@ export async function startWorker(
94
107
  hostname: hostname(),
95
108
  mode,
96
109
  taskId: taskId ?? null,
110
+ logPath: options.logPath ?? null,
97
111
  }),
98
112
  );
99
113
 
@@ -0,0 +1,35 @@
1
+ export const DEFAULT_LOG_TAIL_BYTES = 128 * 1024;
2
+
3
+ export interface LogTail {
4
+ content: string;
5
+ truncated: boolean;
6
+ size: number;
7
+ }
8
+
9
+ /**
10
+ * Read the tail of a worker log file. Returns at most `maxBytes` from the end
11
+ * of the file; sets `truncated` when the file is larger than that.
12
+ *
13
+ * If the file doesn't exist (worker hasn't written anything yet), returns
14
+ * empty content rather than throwing — the caller renders an empty-state
15
+ * message instead of an error.
16
+ */
17
+ export async function readLogTail(
18
+ logPath: string,
19
+ maxBytes = DEFAULT_LOG_TAIL_BYTES,
20
+ ): Promise<LogTail> {
21
+ const file = Bun.file(logPath);
22
+ if (!(await file.exists())) {
23
+ return { content: "", truncated: false, size: 0 };
24
+ }
25
+ const size = file.size;
26
+ if (size === 0) {
27
+ return { content: "", truncated: false, size: 0 };
28
+ }
29
+ if (size <= maxBytes) {
30
+ return { content: await file.text(), truncated: false, size };
31
+ }
32
+ const start = size - maxBytes;
33
+ const content = await file.slice(start, size).text();
34
+ return { content, truncated: true, size };
35
+ }
@@ -13,6 +13,14 @@ const pkg = await Bun.file(
13
13
  new URL("../../package.json", import.meta.url),
14
14
  ).json();
15
15
 
16
+ export const STYLE_RULES = `## Style
17
+ - Open with the result, action, or next step. Skip preambles like "Great question", "You're absolutely right", "Let me…", "I'll go ahead and…".
18
+ - Don't flatter the user or their ideas. If a request is wrong, ambiguous, or risky, say so plainly with the reason.
19
+ - Hold your position when you have one. Don't capitulate to pushback that brings no new evidence.
20
+ - Be terse. Don't restate what you just did or are about to do — show it.
21
+ - Report failures and uncertainty directly. Don't paper over gaps with confident prose.
22
+ `;
23
+
16
24
  /**
17
25
  * Extract keyword set from free-form text: lowercase, split on whitespace,
18
26
  * keep words longer than 3 chars. Used to match `loading: contextual` files
@@ -160,5 +168,7 @@ Skip step 2 only if you already called \`mcp_info\` for that exact server+tool e
160
168
  `;
161
169
  }
162
170
 
171
+ prompt += `\n${STYLE_RULES}`;
172
+
163
173
  return prompt;
164
174
  }
package/src/worker/run.ts CHANGED
@@ -1,14 +1,14 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
3
  // Standalone entry point for a worker when spawned as a detached process.
4
- // Usage: bun run src/worker/run.ts <projectDir> [--persist] [--task-id=<uuid>] [--no-eval-schedules]
4
+ // Usage: bun run src/worker/run.ts <projectDir> [--worker-id=<uuid>] [--log-path=<path>] [--persist] [--task-id=<uuid>] [--no-eval-schedules]
5
5
 
6
6
  import { startWorker } from "./index.ts";
7
7
 
8
8
  const projectDir = process.argv[2];
9
9
  if (!projectDir) {
10
10
  console.error(
11
- "Usage: bun run src/worker/run.ts <projectDir> [--persist] [--task-id=<uuid>] [--no-eval-schedules]",
11
+ "Usage: bun run src/worker/run.ts <projectDir> [--worker-id=<uuid>] [--log-path=<path>] [--persist] [--task-id=<uuid>] [--no-eval-schedules]",
12
12
  );
13
13
  process.exit(1);
14
14
  }
@@ -18,9 +18,17 @@ const persist = args.includes("--persist");
18
18
  const noEvalSchedules = args.includes("--no-eval-schedules");
19
19
  const taskIdArg = args.find((a) => a.startsWith("--task-id="));
20
20
  const taskId = taskIdArg ? taskIdArg.slice("--task-id=".length) : undefined;
21
+ const workerIdArg = args.find((a) => a.startsWith("--worker-id="));
22
+ const workerId = workerIdArg
23
+ ? workerIdArg.slice("--worker-id=".length)
24
+ : undefined;
25
+ const logPathArg = args.find((a) => a.startsWith("--log-path="));
26
+ const logPath = logPathArg ? logPathArg.slice("--log-path=".length) : undefined;
21
27
 
22
28
  await startWorker(projectDir, {
23
29
  mode: persist ? "persist" : "once",
24
30
  taskId,
31
+ workerId,
32
+ logPath,
25
33
  evalSchedules: noEvalSchedules ? false : undefined,
26
34
  });