botholomew 0.15.6 → 0.16.2

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.
@@ -0,0 +1,106 @@
1
+ import type { Interaction } from "../threads/store.ts";
2
+ import { MAX_INLINE_CHARS, PAGE_SIZE_CHARS } from "../worker/large-results.ts";
3
+ import type { ChatMessage } from "./components/MessageList.tsx";
4
+ import type { ToolCallData } from "./components/ToolCall.tsx";
5
+
6
+ let nextRestoreId = 0;
7
+ function restoreMsgId(): string {
8
+ return `restore-msg-${++nextRestoreId}`;
9
+ }
10
+
11
+ function detectToolError(output: string | undefined): boolean {
12
+ if (!output) return false;
13
+ try {
14
+ const parsed = JSON.parse(output);
15
+ if (typeof parsed === "object" && parsed?.is_error === true) return true;
16
+ } catch {
17
+ /* not JSON */
18
+ }
19
+ return false;
20
+ }
21
+
22
+ /**
23
+ * Reconstruct `ChatMessage[]` from a thread's interaction log so the TUI can
24
+ * hydrate chat history (plus the Tools tab) when resuming a session.
25
+ *
26
+ * Tools attach to the assistant message that *issued* them, not the next one.
27
+ * `runChatTurn` logs in the order: assistant text → tool_use(s) → tool_result(s)
28
+ * → next assistant text, so we track the most recent assistant message and
29
+ * append tool calls there until a user message resets the cursor.
30
+ */
31
+ export function restoreMessagesFromInteractions(
32
+ interactions: Interaction[],
33
+ ): ChatMessage[] {
34
+ const result: ChatMessage[] = [];
35
+ let currentAssistant: ChatMessage | null = null;
36
+ let orphanTools: ToolCallData[] = [];
37
+ let restoredIdx = 0;
38
+
39
+ const makeToolCall = (ix: Interaction): ToolCallData => ({
40
+ id: `restored-${restoredIdx++}`,
41
+ name: ix.tool_name ?? "unknown",
42
+ input: ix.tool_input ?? "{}",
43
+ running: false,
44
+ timestamp: ix.created_at,
45
+ });
46
+
47
+ for (const ix of interactions) {
48
+ if (ix.kind === "tool_use") {
49
+ const tc = makeToolCall(ix);
50
+ if (currentAssistant) {
51
+ const list = currentAssistant.toolCalls ?? [];
52
+ list.push(tc);
53
+ currentAssistant.toolCalls = list;
54
+ } else {
55
+ orphanTools.push(tc);
56
+ }
57
+ } else if (ix.kind === "tool_result") {
58
+ const pool = currentAssistant?.toolCalls ?? orphanTools;
59
+ const tc = pool.find((t) => t.name === ix.tool_name && !t.output);
60
+ if (tc) {
61
+ tc.output = ix.content;
62
+ tc.isError = detectToolError(ix.content);
63
+ if (ix.content.length > MAX_INLINE_CHARS) {
64
+ tc.largeResult = {
65
+ id: "(restored)",
66
+ chars: ix.content.length,
67
+ pages: Math.ceil(ix.content.length / PAGE_SIZE_CHARS),
68
+ };
69
+ }
70
+ }
71
+ } else if (ix.kind === "message" && ix.role === "user") {
72
+ result.push({
73
+ id: restoreMsgId(),
74
+ role: "user",
75
+ content: ix.content,
76
+ timestamp: ix.created_at,
77
+ });
78
+ currentAssistant = null;
79
+ } else if (ix.kind === "message" && ix.role === "assistant") {
80
+ const msg: ChatMessage = {
81
+ id: restoreMsgId(),
82
+ role: "assistant",
83
+ content: ix.content,
84
+ timestamp: ix.created_at,
85
+ };
86
+ if (orphanTools.length > 0) {
87
+ msg.toolCalls = [...orphanTools];
88
+ orphanTools = [];
89
+ }
90
+ result.push(msg);
91
+ currentAssistant = msg;
92
+ }
93
+ }
94
+
95
+ if (orphanTools.length > 0) {
96
+ result.push({
97
+ id: restoreMsgId(),
98
+ role: "assistant",
99
+ content: "",
100
+ timestamp: orphanTools[0]?.timestamp ?? new Date(),
101
+ toolCalls: [...orphanTools],
102
+ });
103
+ }
104
+
105
+ return result;
106
+ }
@@ -0,0 +1,26 @@
1
+ import { useStdout } from "ink";
2
+ import { useEffect, useState } from "react";
3
+
4
+ /**
5
+ * Track terminal columns + rows. Ink's `useStdout` doesn't re-render on
6
+ * resize, so panels that compute layout from terminal width (e.g. detail
7
+ * panes that wrap long lines) need this hook to stay accurate.
8
+ */
9
+ export function useTerminalSize(): { cols: number; rows: number } {
10
+ const { stdout } = useStdout();
11
+ const [size, setSize] = useState(() => ({
12
+ cols: stdout?.columns ?? 80,
13
+ rows: stdout?.rows ?? 24,
14
+ }));
15
+ useEffect(() => {
16
+ if (!stdout) return;
17
+ const onResize = () => {
18
+ setSize({ cols: stdout.columns ?? 80, rows: stdout.rows ?? 24 });
19
+ };
20
+ stdout.on("resize", onResize);
21
+ return () => {
22
+ stdout.off("resize", onResize);
23
+ };
24
+ }, [stdout]);
25
+ return size;
26
+ }
@@ -0,0 +1,15 @@
1
+ import wrapAnsi from "wrap-ansi";
2
+
3
+ /**
4
+ * Wrap an ANSI-colored body string to a column width and return one entry
5
+ * per visual line. Used by the right-pane detail views in every list/detail
6
+ * panel (Tools, Tasks, Threads, Schedules, Context) so long lines wrap
7
+ * instead of getting truncated by `<Text wrap="truncate-end">`.
8
+ *
9
+ * `wrap-ansi` preserves SGR state across wrap boundaries, so colorized
10
+ * JSON / markdown stays intact.
11
+ */
12
+ export function wrapDetailLines(text: string, width: number): string[] {
13
+ if (width <= 0) return text.split("\n");
14
+ return wrapAnsi(text, width, { hard: true, trim: false }).split("\n");
15
+ }
@@ -1,12 +1,18 @@
1
1
  import matter from "gray-matter";
2
+ import { z } from "zod";
3
+
4
+ // --------------------------------------------------------------------------
5
+ // Loose context-file metadata
6
+ //
7
+ // Used for files under `context/` (URL imports, agent-authored notes) and the
8
+ // auto-generated `prompts/capabilities.md`. Frontmatter is permissive here:
9
+ // imported pages may carry source_url / imported_at; agent notes may have
10
+ // nothing at all.
11
+ // --------------------------------------------------------------------------
2
12
 
3
13
  export interface ContextFileMeta {
4
14
  loading?: "always" | "contextual";
5
15
  "agent-modification"?: boolean;
6
- // Set by `botholomew context import <url>` so the saved file remembers
7
- // where it came from. Optional so files written by other paths
8
- // (prompts/, beliefs/, agent-authored notes) aren't required to
9
- // carry import metadata.
10
16
  source_url?: string;
11
17
  imported_at?: string;
12
18
  title?: string;
@@ -30,3 +36,86 @@ export function serializeContextFile(
30
36
  ): string {
31
37
  return matter.stringify(`\n${content}\n`, meta);
32
38
  }
39
+
40
+ // --------------------------------------------------------------------------
41
+ // Strict prompt-file schema
42
+ //
43
+ // Every file under `prompts/*.md` must conform. Validation runs at load time
44
+ // (worker + chat) and on every CRUD operation; failures throw
45
+ // PromptValidationError with the offending path so the user can fix it.
46
+ // --------------------------------------------------------------------------
47
+
48
+ export const PromptFrontmatterSchema = z
49
+ .object({
50
+ title: z.string().min(1),
51
+ loading: z.enum(["always", "contextual"]),
52
+ "agent-modification": z.boolean(),
53
+ })
54
+ .strict();
55
+
56
+ export type PromptFrontmatter = z.infer<typeof PromptFrontmatterSchema>;
57
+
58
+ export class PromptValidationError extends Error {
59
+ constructor(
60
+ readonly path: string,
61
+ readonly reason: string,
62
+ ) {
63
+ super(`${path}: ${reason}`);
64
+ this.name = "PromptValidationError";
65
+ }
66
+ }
67
+
68
+ export function parsePromptFile(
69
+ path: string,
70
+ raw: string,
71
+ ): { meta: PromptFrontmatter; content: string } {
72
+ let parsed: { data: Record<string, unknown>; content: string };
73
+ try {
74
+ const m = matter(raw);
75
+ parsed = {
76
+ data: (m.data ?? {}) as Record<string, unknown>,
77
+ content: m.content,
78
+ };
79
+ } catch (err) {
80
+ throw new PromptValidationError(
81
+ path,
82
+ `invalid YAML frontmatter — ${err instanceof Error ? err.message : String(err)}`,
83
+ );
84
+ }
85
+
86
+ if (Object.keys(parsed.data).length === 0) {
87
+ throw new PromptValidationError(
88
+ path,
89
+ "missing frontmatter (required: title, loading, agent-modification)",
90
+ );
91
+ }
92
+
93
+ const result = PromptFrontmatterSchema.safeParse(parsed.data);
94
+ if (!result.success) {
95
+ throw new PromptValidationError(path, formatZodIssues(result.error.issues));
96
+ }
97
+
98
+ return { meta: result.data, content: parsed.content.trim() };
99
+ }
100
+
101
+ export function serializePromptFile(
102
+ meta: PromptFrontmatter,
103
+ content: string,
104
+ ): string {
105
+ return matter.stringify(`\n${content}\n`, meta);
106
+ }
107
+
108
+ function formatZodIssues(issues: z.ZodIssue[]): string {
109
+ return issues
110
+ .map((issue) => {
111
+ if (issue.code === "unrecognized_keys") {
112
+ const keys = (issue as z.ZodIssue & { keys?: string[] }).keys ?? [];
113
+ return `unrecognized frontmatter key(s): ${keys.join(", ")}`;
114
+ }
115
+ const field = issue.path.join(".");
116
+ return field
117
+ ? `frontmatter field '${field}': ${issue.message}`
118
+ : issue.message;
119
+ })
120
+ .join("; ");
121
+ }
@@ -3,7 +3,7 @@ import { join } from "node:path";
3
3
  import type { BotholomewConfig } from "../config/schemas.ts";
4
4
  import { getPromptsDir } from "../constants.ts";
5
5
  import type { Task } from "../tasks/schema.ts";
6
- import { parseContextFile } from "../utils/frontmatter.ts";
6
+ import { parsePromptFile } from "../utils/frontmatter.ts";
7
7
 
8
8
  const pkg = await Bun.file(
9
9
  new URL("../../package.json", import.meta.url),
@@ -36,37 +36,43 @@ export function extractKeywords(text: string): Set<string> {
36
36
  * Load persistent context files from prompts/ as a single formatted
37
37
  * string. Includes "always" files unconditionally and "contextual" files
38
38
  * whose content overlaps the provided taskKeywords.
39
+ *
40
+ * Validation is strict: any *.md file under prompts/ that fails the prompt
41
+ * frontmatter schema throws PromptValidationError naming the offending file.
42
+ * The only swallowed error is a missing prompts/ directory (e.g. fresh
43
+ * working dir before `botholomew init`).
39
44
  */
40
45
  export async function loadPersistentContext(
41
46
  projectDir: string,
42
47
  taskKeywords?: Set<string> | null,
43
48
  ): Promise<string> {
44
49
  const dir = getPromptsDir(projectDir);
45
- let out = "";
46
-
50
+ let files: string[];
47
51
  try {
48
- const files = await readdir(dir);
49
- const mdFiles = files.filter((f) => f.endsWith(".md"));
50
-
51
- for (const filename of mdFiles) {
52
- const filePath = join(dir, filename);
53
- const raw = await Bun.file(filePath).text();
54
- const { meta, content } = parseContextFile(raw);
55
-
56
- if (meta.loading === "always") {
57
- out += `## ${filename}\n${content}\n\n`;
58
- } else if (meta.loading === "contextual" && taskKeywords) {
59
- const contentLower = content.toLowerCase();
60
- const hasOverlap = [...taskKeywords].some((kw) =>
61
- contentLower.includes(kw),
62
- );
63
- if (hasOverlap) {
64
- out += `## ${filename} (contextual)\n${content}\n\n`;
65
- }
52
+ files = await readdir(dir);
53
+ } catch (err) {
54
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return "";
55
+ throw err;
56
+ }
57
+ const mdFiles = files.filter((f) => f.endsWith(".md")).sort();
58
+
59
+ let out = "";
60
+ for (const filename of mdFiles) {
61
+ const filePath = join(dir, filename);
62
+ const raw = await Bun.file(filePath).text();
63
+ const { meta, content } = parsePromptFile(filePath, raw);
64
+
65
+ if (meta.loading === "always") {
66
+ out += `## ${filename}\n${content}\n\n`;
67
+ } else if (meta.loading === "contextual" && taskKeywords) {
68
+ const contentLower = content.toLowerCase();
69
+ const hasOverlap = [...taskKeywords].some((kw) =>
70
+ contentLower.includes(kw),
71
+ );
72
+ if (hasOverlap) {
73
+ out += `## ${filename} (contextual)\n${content}\n\n`;
66
74
  }
67
75
  }
68
- } catch {
69
- // prompts/ might not have md files yet
70
76
  }
71
77
 
72
78
  return out;
@@ -149,13 +149,22 @@ async function runClaimedTask(opts: {
149
149
  `Working: ${task.name}`,
150
150
  );
151
151
 
152
- const systemPrompt = await buildSystemPrompt(
153
- projectDir,
154
- task,
155
- dbPath,
156
- config,
157
- { hasMcpTools: mcpxClient != null },
158
- );
152
+ let systemPrompt: string;
153
+ try {
154
+ systemPrompt = await buildSystemPrompt(projectDir, task, dbPath, config, {
155
+ hasMcpTools: mcpxClient != null,
156
+ });
157
+ } catch (err) {
158
+ const reason = err instanceof Error ? err.message : String(err);
159
+ await updateTaskStatus(projectDir, task.id, "failed", reason, null);
160
+ await logInteraction(projectDir, threadId, {
161
+ role: "system",
162
+ kind: "status_change",
163
+ content: `Task ${task.id} failed during prompt load: ${reason}`,
164
+ });
165
+ logger.error(`Task ${task.id} failed during prompt load: ${reason}`);
166
+ return;
167
+ }
159
168
 
160
169
  try {
161
170
  const result = await runAgentLoop({