botholomew 0.15.5 → 0.16.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.
@@ -0,0 +1,103 @@
1
+ import { unlink } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { z } from "zod";
4
+ import { getPromptsDir } from "../../constants.ts";
5
+ import {
6
+ PromptValidationError,
7
+ parsePromptFile,
8
+ } from "../../utils/frontmatter.ts";
9
+ import type { ToolDefinition } from "../tool.ts";
10
+
11
+ const inputSchema = z.object({
12
+ name: z
13
+ .string()
14
+ .describe("Prompt name without extension. Resolves to prompts/<name>.md."),
15
+ });
16
+
17
+ const outputSchema = z.object({
18
+ name: z.string(),
19
+ path: z.string().nullable(),
20
+ deleted: z.boolean(),
21
+ is_error: z.boolean(),
22
+ error_type: z.string().optional(),
23
+ message: z.string().optional(),
24
+ next_action_hint: z.string().optional(),
25
+ });
26
+
27
+ export const promptDeleteTool = {
28
+ name: "prompt_delete",
29
+ description:
30
+ "[[ bash equivalent command: rm ]] Delete a prompt file under prompts/. Files marked `agent-modification: false` are protected and will not be removed.",
31
+ group: "context",
32
+ inputSchema,
33
+ outputSchema,
34
+ execute: async (input, ctx) => {
35
+ if (input.name.includes("/") || input.name.includes("..")) {
36
+ return {
37
+ name: input.name,
38
+ path: null,
39
+ deleted: false,
40
+ is_error: true,
41
+ error_type: "invalid_name",
42
+ message: `Invalid prompt name: ${input.name}`,
43
+ };
44
+ }
45
+
46
+ const filePath = join(getPromptsDir(ctx.projectDir), `${input.name}.md`);
47
+ const file = Bun.file(filePath);
48
+ if (!(await file.exists())) {
49
+ return {
50
+ name: input.name,
51
+ path: null,
52
+ deleted: false,
53
+ is_error: true,
54
+ error_type: "not_found",
55
+ message: `Prompt not found: prompts/${input.name}.md`,
56
+ next_action_hint: "Use prompt_list to see available prompts.",
57
+ };
58
+ }
59
+
60
+ const raw = await file.text();
61
+ try {
62
+ const { meta } = parsePromptFile(filePath, raw);
63
+ if (!meta["agent-modification"]) {
64
+ return {
65
+ name: input.name,
66
+ path: filePath,
67
+ deleted: false,
68
+ is_error: true,
69
+ error_type: "agent_modification_disabled",
70
+ message: `Agent deletion not allowed for prompts/${input.name}.md`,
71
+ next_action_hint:
72
+ "Edit the file manually with `botholomew prompts delete` or your editor.",
73
+ };
74
+ }
75
+ } catch (err) {
76
+ // A malformed prompt is still a valid target for deletion — the agent
77
+ // shouldn't be locked out of cleaning up an unparseable file. Surface
78
+ // the parse error in the message but allow the unlink.
79
+ const reason =
80
+ err instanceof PromptValidationError
81
+ ? err.reason
82
+ : err instanceof Error
83
+ ? err.message
84
+ : String(err);
85
+ await unlink(filePath);
86
+ return {
87
+ name: input.name,
88
+ path: filePath,
89
+ deleted: true,
90
+ is_error: false,
91
+ message: `Deleted unparseable prompt (${reason})`,
92
+ };
93
+ }
94
+
95
+ await unlink(filePath);
96
+ return {
97
+ name: input.name,
98
+ path: filePath,
99
+ deleted: true,
100
+ is_error: false,
101
+ };
102
+ },
103
+ } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
@@ -8,8 +8,9 @@ import {
8
8
  } from "../../fs/atomic.ts";
9
9
  import { applyLinePatches, LinePatchSchema } from "../../fs/patches.ts";
10
10
  import {
11
- parseContextFile,
12
- serializeContextFile,
11
+ PromptValidationError,
12
+ parsePromptFile,
13
+ serializePromptFile,
13
14
  } from "../../utils/frontmatter.ts";
14
15
  import type { ToolDefinition } from "../tool.ts";
15
16
 
@@ -17,7 +18,7 @@ const inputSchema = z.object({
17
18
  name: z
18
19
  .string()
19
20
  .describe(
20
- "Prompt name without extension (e.g. 'beliefs', 'goals', 'capabilities'). Resolves to prompts/<name>.md.",
21
+ "Prompt name without extension (e.g. 'beliefs', 'goals'). Resolves to prompts/<name>.md.",
21
22
  ),
22
23
  patches: z.array(LinePatchSchema).describe("Patches to apply"),
23
24
  });
@@ -36,7 +37,7 @@ const outputSchema = z.object({
36
37
  export const promptEditTool = {
37
38
  name: "prompt_edit",
38
39
  description:
39
- "[[ bash equivalent command: patch ]] Apply git-style line-range patches to a prompt file under prompts/. Operates on the whole file (frontmatter + body). Files marked `agent-modification: false` (e.g. soul.md) are protected. Use prompt_read first to inspect current line numbers.",
40
+ "[[ bash equivalent command: patch ]] Apply git-style line-range patches to a prompt file under prompts/. Operates on the whole file (frontmatter + body). Files marked `agent-modification: false` are protected. Use prompt_read first to inspect current line numbers.",
40
41
  group: "context",
41
42
  inputSchema,
42
43
  outputSchema,
@@ -65,11 +66,31 @@ export const promptEditTool = {
65
66
  is_error: true,
66
67
  error_type: "not_found",
67
68
  message: `Prompt not found: prompts/${input.name}.md`,
69
+ next_action_hint:
70
+ "Use prompt_list to see available prompts, or prompt_create to add a new one.",
68
71
  };
69
72
  }
70
73
 
71
74
  const original = file.content;
72
- const preParsed = parseContextFile(original);
75
+ let preParsed: ReturnType<typeof parsePromptFile>;
76
+ try {
77
+ preParsed = parsePromptFile(filePath, original);
78
+ } catch (err) {
79
+ return {
80
+ name: input.name,
81
+ path: filePath,
82
+ applied: 0,
83
+ content: original,
84
+ is_error: true,
85
+ error_type: "invalid_frontmatter",
86
+ message:
87
+ err instanceof PromptValidationError
88
+ ? err.message
89
+ : `Existing prompt failed to parse: ${err instanceof Error ? err.message : String(err)}`,
90
+ next_action_hint:
91
+ "Fix the file's frontmatter directly before patching. Required keys: title, loading, agent-modification.",
92
+ };
93
+ }
73
94
  if (!preParsed.meta["agent-modification"]) {
74
95
  return {
75
96
  name: input.name,
@@ -83,9 +104,9 @@ export const promptEditTool = {
83
104
  }
84
105
 
85
106
  const updated = applyLinePatches(original, input.patches);
86
- let postParsed: { meta: Record<string, unknown>; content: string };
107
+ let postParsed: ReturnType<typeof parsePromptFile>;
87
108
  try {
88
- postParsed = parseContextFile(updated);
109
+ postParsed = parsePromptFile(filePath, updated);
89
110
  } catch (err) {
90
111
  return {
91
112
  name: input.name,
@@ -94,9 +115,12 @@ export const promptEditTool = {
94
115
  content: original,
95
116
  is_error: true,
96
117
  error_type: "invalid_frontmatter",
97
- message: `Patched content failed to parse: ${err instanceof Error ? err.message : String(err)}`,
118
+ message:
119
+ err instanceof PromptValidationError
120
+ ? `Patched content failed to parse — ${err.reason}`
121
+ : `Patched content failed to parse: ${err instanceof Error ? err.message : String(err)}`,
98
122
  next_action_hint:
99
- "Check that the frontmatter delimiters and YAML stay valid.",
123
+ "Check that the frontmatter delimiters and YAML stay valid (title, loading, agent-modification all required).",
100
124
  };
101
125
  }
102
126
  if (!postParsed.meta["agent-modification"]) {
@@ -113,10 +137,7 @@ export const promptEditTool = {
113
137
  };
114
138
  }
115
139
 
116
- const serialized = serializeContextFile(
117
- postParsed.meta,
118
- postParsed.content,
119
- );
140
+ const serialized = serializePromptFile(postParsed.meta, postParsed.content);
120
141
 
121
142
  try {
122
143
  await atomicWriteIfUnchanged(filePath, serialized, file.mtimeMs);
@@ -0,0 +1,109 @@
1
+ import { readdir, stat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { z } from "zod";
4
+ import { getPromptsDir } from "../../constants.ts";
5
+ import {
6
+ PromptValidationError,
7
+ parsePromptFile,
8
+ } from "../../utils/frontmatter.ts";
9
+ import type { ToolDefinition } from "../tool.ts";
10
+
11
+ const inputSchema = z.object({
12
+ limit: z
13
+ .number()
14
+ .optional()
15
+ .default(100)
16
+ .describe("Max number of prompts to return (default 100)"),
17
+ offset: z
18
+ .number()
19
+ .optional()
20
+ .default(0)
21
+ .describe("Skip the first N prompts (default 0)"),
22
+ });
23
+
24
+ const outputSchema = z.object({
25
+ prompts: z.array(
26
+ z.object({
27
+ name: z.string(),
28
+ title: z.string().nullable(),
29
+ loading: z.string().nullable(),
30
+ agent_modification: z.boolean(),
31
+ size_bytes: z.number(),
32
+ path: z.string(),
33
+ valid: z.boolean(),
34
+ error: z.string().nullable(),
35
+ }),
36
+ ),
37
+ total: z.number(),
38
+ is_error: z.boolean(),
39
+ });
40
+
41
+ export const promptListTool = {
42
+ name: "prompt_list",
43
+ description:
44
+ "[[ bash equivalent command: ls ]] List prompt files under prompts/. Returns name, title, loading mode, agent_modification flag, file size, and a valid/error pair per file (so you can see at a glance which prompts have broken frontmatter).",
45
+ group: "context",
46
+ inputSchema,
47
+ outputSchema,
48
+ execute: async (input, ctx) => {
49
+ const dir = getPromptsDir(ctx.projectDir);
50
+ let entries: string[];
51
+ try {
52
+ entries = await readdir(dir);
53
+ } catch (err) {
54
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
55
+ return { prompts: [], total: 0, is_error: false };
56
+ }
57
+ throw err;
58
+ }
59
+
60
+ const mdFiles = entries.filter((f) => f.endsWith(".md")).sort();
61
+ const total = mdFiles.length;
62
+ const offset = input.offset ?? 0;
63
+ const limit = input.limit ?? 100;
64
+ const page = mdFiles.slice(offset, offset + limit);
65
+
66
+ const rows = await Promise.all(
67
+ page.map(async (filename) => {
68
+ const filePath = join(dir, filename);
69
+ const name = filename.replace(/\.md$/, "");
70
+ const [raw, st] = await Promise.all([
71
+ Bun.file(filePath).text(),
72
+ stat(filePath),
73
+ ]);
74
+ try {
75
+ const { meta } = parsePromptFile(filePath, raw);
76
+ return {
77
+ name,
78
+ title: meta.title,
79
+ loading: meta.loading,
80
+ agent_modification: meta["agent-modification"],
81
+ size_bytes: st.size,
82
+ path: filePath,
83
+ valid: true,
84
+ error: null,
85
+ };
86
+ } catch (err) {
87
+ const reason =
88
+ err instanceof PromptValidationError
89
+ ? err.reason
90
+ : err instanceof Error
91
+ ? err.message
92
+ : String(err);
93
+ return {
94
+ name,
95
+ title: null,
96
+ loading: null,
97
+ agent_modification: false,
98
+ size_bytes: st.size,
99
+ path: filePath,
100
+ valid: false,
101
+ error: reason,
102
+ };
103
+ }
104
+ }),
105
+ );
106
+
107
+ return { prompts: rows, total, is_error: false };
108
+ },
109
+ } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
@@ -1,13 +1,17 @@
1
1
  import { join } from "node:path";
2
2
  import { z } from "zod";
3
3
  import { getPromptsDir } from "../../constants.ts";
4
+ import {
5
+ PromptValidationError,
6
+ parsePromptFile,
7
+ } from "../../utils/frontmatter.ts";
4
8
  import type { ToolDefinition } from "../tool.ts";
5
9
 
6
10
  const inputSchema = z.object({
7
11
  name: z
8
12
  .string()
9
13
  .describe(
10
- "Prompt name without extension (e.g. 'beliefs', 'goals', 'capabilities'). Resolves to prompts/<name>.md.",
14
+ "Prompt name without extension (e.g. 'beliefs', 'goals'). Resolves to prompts/<name>.md.",
11
15
  ),
12
16
  });
13
17
 
@@ -15,16 +19,19 @@ const outputSchema = z.object({
15
19
  name: z.string(),
16
20
  path: z.string().nullable(),
17
21
  content: z.string(),
22
+ title: z.string().nullable(),
23
+ loading: z.string().nullable(),
18
24
  agent_modification: z.boolean(),
19
25
  is_error: z.boolean(),
20
26
  error_type: z.string().optional(),
21
27
  message: z.string().optional(),
28
+ next_action_hint: z.string().optional(),
22
29
  });
23
30
 
24
31
  export const promptReadTool = {
25
32
  name: "prompt_read",
26
33
  description:
27
- "[[ bash equivalent command: cat ]] Read a prompt file under prompts/ (e.g. beliefs, goals, capabilities, soul). Returns the whole file (frontmatter + body) for use with prompt_edit.",
34
+ "[[ bash equivalent command: cat ]] Read a prompt file under prompts/. Returns the whole file (frontmatter + body) plus the parsed title / loading / agent_modification flags so you can decide whether prompt_edit will be accepted.",
28
35
  group: "context",
29
36
  inputSchema,
30
37
  outputSchema,
@@ -34,6 +41,8 @@ export const promptReadTool = {
34
41
  name: input.name,
35
42
  path: null,
36
43
  content: "",
44
+ title: null,
45
+ loading: null,
37
46
  agent_modification: false,
38
47
  is_error: true,
39
48
  error_type: "invalid_name",
@@ -47,24 +56,47 @@ export const promptReadTool = {
47
56
  name: input.name,
48
57
  path: null,
49
58
  content: "",
59
+ title: null,
60
+ loading: null,
50
61
  agent_modification: false,
51
62
  is_error: true,
52
63
  error_type: "not_found",
53
64
  message: `Prompt not found: prompts/${input.name}.md`,
65
+ next_action_hint: "Use prompt_list to see available prompts.",
54
66
  };
55
67
  }
56
68
  const content = await file.text();
57
- // Cheap header sniff so the agent knows whether prompt_edit will be
58
- // accepted before it constructs patches.
59
- const agent_modification = /agent-modification:\s*true/.test(
60
- content.split("---", 3).slice(0, 3).join("---"),
61
- );
62
- return {
63
- name: input.name,
64
- path: filePath,
65
- content,
66
- agent_modification,
67
- is_error: false,
68
- };
69
+ try {
70
+ const { meta } = parsePromptFile(filePath, content);
71
+ return {
72
+ name: input.name,
73
+ path: filePath,
74
+ content,
75
+ title: meta.title,
76
+ loading: meta.loading,
77
+ agent_modification: meta["agent-modification"],
78
+ is_error: false,
79
+ };
80
+ } catch (err) {
81
+ const message =
82
+ err instanceof PromptValidationError
83
+ ? err.message
84
+ : err instanceof Error
85
+ ? err.message
86
+ : String(err);
87
+ return {
88
+ name: input.name,
89
+ path: filePath,
90
+ content,
91
+ title: null,
92
+ loading: null,
93
+ agent_modification: false,
94
+ is_error: true,
95
+ error_type: "invalid_frontmatter",
96
+ message,
97
+ next_action_hint:
98
+ "Fix the file's frontmatter (required: title, loading, agent-modification). The raw content is returned for inspection.",
99
+ };
100
+ }
69
101
  },
70
102
  } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
@@ -23,7 +23,10 @@ import { mcpInfoTool } from "./mcp/info.ts";
23
23
  import { mcpListToolsTool } from "./mcp/list-tools.ts";
24
24
  import { mcpSearchTool } from "./mcp/search.ts";
25
25
  // Prompt tools
26
+ import { promptCreateTool } from "./prompt/create.ts";
27
+ import { promptDeleteTool } from "./prompt/delete.ts";
26
28
  import { promptEditTool } from "./prompt/edit.ts";
29
+ import { promptListTool } from "./prompt/list.ts";
27
30
  import { promptReadTool } from "./prompt/read.ts";
28
31
  // Schedule tools
29
32
  import { createScheduleTool } from "./schedule/create.ts";
@@ -83,8 +86,11 @@ export function registerAllTools(): void {
83
86
  registerTool(contextInfoTool);
84
87
  registerTool(contextExistsTool);
85
88
  registerTool(contextCountLinesTool);
89
+ registerTool(promptListTool);
86
90
  registerTool(promptReadTool);
91
+ registerTool(promptCreateTool);
87
92
  registerTool(promptEditTool);
93
+ registerTool(promptDeleteTool);
88
94
  registerTool(readLargeResultTool);
89
95
  registerTool(pipeToContextTool);
90
96
 
package/src/tools/tool.ts CHANGED
@@ -17,6 +17,15 @@ export interface ToolContext {
17
17
  projectDir: string;
18
18
  config: Required<BotholomewConfig>;
19
19
  mcpxClient: McpxClient | null;
20
+ /**
21
+ * Identifier of the agent process running this tool, used as the holder
22
+ * id for per-path context locks (`src/context/locks.ts`) so the worker
23
+ * reaper can identify and release locks abandoned by a crashed worker.
24
+ * Workers pass their `workerId`; chat sessions pass a `chat:` prefixed
25
+ * id; tests and one-off CLI calls leave it `undefined` (the store falls
26
+ * back to `pid:<n>`).
27
+ */
28
+ workerId?: string;
20
29
  /**
21
30
  * Chat-mode only. Lets long-running tools (e.g. `sleep`) poll for
22
31
  * Esc-to-abort by reading `session.aborted`. Workers leave this `undefined`.
package/src/tui/App.tsx CHANGED
@@ -216,6 +216,7 @@ function AppInner({
216
216
  const [splashDone, setSplashDone] = useState(skipSplash);
217
217
  const [error, setError] = useState<string | null>(null);
218
218
  const sessionRef = useRef<ChatSession | null>(null);
219
+ const shuttingDownRef = useRef(false);
219
220
  const [activeTab, setActiveTab] = useState<TabId>(1);
220
221
  const [workerRunning, setWorkerRunning] = useState(false);
221
222
  const [chatTitle, setChatTitle] = useState<string | undefined>(undefined);
@@ -275,16 +276,52 @@ function AppInner({
275
276
 
276
277
  return () => {
277
278
  cancelled = true;
279
+ // Fire-and-forget safety net: only triggers when unmount happens via a
280
+ // path that didn't go through performShutdown (which nulls sessionRef
281
+ // first). React doesn't await unmount cleanups, so the goodbye lands
282
+ // before mcpx finishes closing — that's fine for non-Ctrl-C paths.
278
283
  if (sessionRef.current) {
279
- const threadId = sessionRef.current.threadId;
280
- endChatSession(sessionRef.current);
284
+ const session = sessionRef.current;
285
+ const threadId = session.threadId;
286
+ abortActiveStream(session);
287
+ void endChatSession(session);
281
288
  process.stderr.write(
282
- `\nThread: ${threadId}\nResume with: ${ansi.success}botholomew chat --thread-id ${threadId}${ansi.reset}\n`,
289
+ `\nThread: ${threadId}\nResume with: ${ansi.success}botholomew chat --thread-id ${threadId}${ansi.reset}\nBye!\n`,
283
290
  );
284
291
  }
285
292
  };
286
293
  }, [projectDir, resumeThreadId]);
287
294
 
295
+ const performShutdown = useCallback(async () => {
296
+ if (shuttingDownRef.current) {
297
+ // Second Ctrl-C while cleanup is in flight — give the user an escape
298
+ // hatch. 130 = standard SIGINT exit code.
299
+ process.exit(130);
300
+ }
301
+ shuttingDownRef.current = true;
302
+
303
+ const session = sessionRef.current;
304
+ // Null the ref so the useEffect cleanup that runs on Ink unmount becomes
305
+ // a no-op — otherwise it would double-print the goodbye and double-close
306
+ // the mcpx client.
307
+ sessionRef.current = null;
308
+
309
+ if (session) {
310
+ const threadId = session.threadId;
311
+ abortActiveStream(session);
312
+ try {
313
+ await endChatSession(session);
314
+ } catch {
315
+ // Best-effort: the user pressed Ctrl-C, surfacing a stack trace here
316
+ // would just hide the goodbye line.
317
+ }
318
+ process.stderr.write(
319
+ `\nThread: ${threadId}\nResume with: ${ansi.success}botholomew chat --thread-id ${threadId}${ansi.reset}\nBye!\n`,
320
+ );
321
+ }
322
+ exit();
323
+ }, [exit]);
324
+
288
325
  // Minimum splash screen duration
289
326
  useEffect(() => {
290
327
  const timer = setTimeout(() => setSplashDone(true), 2000);
@@ -333,9 +370,12 @@ function AppInner({
333
370
  (input: string, key: any) => {
334
371
  markActivityRef.current();
335
372
 
336
- // Ctrl+C exits
373
+ // Ctrl+C exits. Routed through performShutdown so the in-flight LLM
374
+ // stream is aborted and mcpx is closed before we unmount Ink — without
375
+ // that, one Ctrl-C prints the goodbye but the process stays pinned by
376
+ // the open HTTPS socket and a second Ctrl-C is needed.
337
377
  if (input === "c" && key.ctrl) {
338
- exit();
378
+ void performShutdown();
339
379
  return;
340
380
  }
341
381
 
@@ -417,7 +457,7 @@ function AppInner({
417
457
  }
418
458
  }
419
459
  },
420
- [exit, syncQueue],
460
+ [performShutdown, syncQueue],
421
461
  );
422
462
 
423
463
  useInput(stableAppHandler);
@@ -669,7 +709,7 @@ function AppInner({
669
709
  syncQueue();
670
710
  processQueue();
671
711
  },
672
- exit,
712
+ exit: () => void performShutdown(),
673
713
  clearChat: () => {
674
714
  const session = sessionRef.current;
675
715
  if (!session) return;
@@ -743,7 +783,7 @@ function AppInner({
743
783
  syncQueue();
744
784
  processQueue();
745
785
  },
746
- [exit, processQueue, syncQueue],
786
+ [performShutdown, processQueue, syncQueue],
747
787
  );
748
788
 
749
789
  const sessionDbPath = sessionRef.current?.dbPath;
@@ -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
+ }