botholomew 0.15.6 → 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.
- package/README.md +6 -6
- package/package.json +1 -1
- package/src/chat/agent.ts +1 -1
- package/src/chat/usage.ts +1 -1
- package/src/cli.ts +2 -0
- package/src/commands/prompts.ts +333 -0
- package/src/constants.ts +1 -1
- package/src/context/capabilities.ts +4 -0
- package/src/init/index.ts +4 -4
- package/src/init/templates.ts +10 -16
- package/src/tools/prompt/create.ts +136 -0
- package/src/tools/prompt/delete.ts +103 -0
- package/src/tools/prompt/edit.ts +34 -13
- package/src/tools/prompt/list.ts +109 -0
- package/src/tools/prompt/read.ts +46 -14
- package/src/tools/registry.ts +6 -0
- package/src/utils/frontmatter.ts +93 -4
- package/src/worker/prompt.ts +29 -23
- package/src/worker/tick.ts +16 -7
package/README.md
CHANGED
|
@@ -124,11 +124,10 @@ the agent or worker touches is a real file you can `vim`, `grep`, and
|
|
|
124
124
|
```
|
|
125
125
|
my-project/
|
|
126
126
|
config/config.json # models, tick interval, API keys
|
|
127
|
-
prompts/ #
|
|
128
|
-
|
|
127
|
+
prompts/ # markdown files loaded into every system prompt (or keyword-loaded)
|
|
128
|
+
goals.md # identity + current goals (agent-editable)
|
|
129
129
|
beliefs.md # agent-editable priors
|
|
130
|
-
|
|
131
|
-
capabilities.md # agent-editable tool inventory
|
|
130
|
+
capabilities.md # auto-generated tool inventory
|
|
132
131
|
skills/ # slash commands (built-ins + user-defined)
|
|
133
132
|
summarize.md
|
|
134
133
|
standup.md
|
|
@@ -168,6 +167,7 @@ from `context/`.
|
|
|
168
167
|
| `botholomew schedule list\|add\|view\|enable\|disable\|trigger\|delete` | Recurring work (markdown files in `schedules/`) |
|
|
169
168
|
| `botholomew context add\|import\|tree\|stats\|reindex\|search\|read\|write\|edit\|move\|delete\|…` | Bring files/URLs into `context/`; rebuild the search index; expose the agent's file/dir tools as CLI subcommands |
|
|
170
169
|
| `botholomew capabilities` | Rescan built-in + MCPX tools and rewrite `prompts/capabilities.md` |
|
|
170
|
+
| `botholomew prompts list\|show\|create\|edit\|delete\|validate` | CRUD over the markdown files in `prompts/` (with strict frontmatter validation) |
|
|
171
171
|
| `botholomew mcpx servers\|list\|add\|remove\|info\|search\|exec\|ping\|auth\|deauth\|import-global\|…` | Configure external MCP servers (passthrough to `mcpx`) |
|
|
172
172
|
| `botholomew skill list\|show\|create\|validate` | Manage slash-command skills |
|
|
173
173
|
| `botholomew thread list\|view` | Browse the agent's conversation history (CSVs in `threads/`) |
|
|
@@ -238,8 +238,8 @@ Topics worth understanding in detail:
|
|
|
238
238
|
validation, and natural-language recurring schedules.
|
|
239
239
|
- **[The Tool class](docs/tools.md)** — one Zod definition, three consumers
|
|
240
240
|
(Anthropic tool-use, Commander CLI, tests).
|
|
241
|
-
- **[Prompts](docs/prompts.md)** —
|
|
242
|
-
frontmatter
|
|
241
|
+
- **[Prompts](docs/prompts.md)** — generic markdown files in `prompts/`,
|
|
242
|
+
strict frontmatter validation, and full CRUD via CLI + agent tools.
|
|
243
243
|
- **[Skills (slash commands)](docs/skills.md)** — reusable prompt templates
|
|
244
244
|
with positional arguments and tab completion; the chat agent can also
|
|
245
245
|
create, edit, and search them at runtime.
|
package/package.json
CHANGED
package/src/chat/agent.ts
CHANGED
|
@@ -102,7 +102,7 @@ You do NOT execute long-running work directly — enqueue tasks for a background
|
|
|
102
102
|
Use the available tools to look up tasks, threads, schedules, and context when the user asks about them. Files the agent can read and write live under \`context/\` as project-relative paths (e.g. \`notes/foo.md\`). Use \`context_tree\` to see what's there, \`search\` (hybrid regexp + semantic) to find content, then \`context_read\` / \`context_info\` to drill in.
|
|
103
103
|
Past conversations live in CSV files under \`threads/\`; use \`list_threads\`, \`search_threads\`, and \`view_thread\` to find and page through them.
|
|
104
104
|
When multiple tool calls are independent of each other (i.e., one does not depend on the result of another), call them all in a single response. They will be executed in parallel, which is faster than calling them one at a time.
|
|
105
|
-
You can
|
|
105
|
+
You can manage the agent's prompt files (always-on or keyword-loaded notes the agent sees in every turn) under \`prompts/\` via \`prompt_list\`, \`prompt_read\`, \`prompt_create\`, \`prompt_edit\` (git-style line-range patches), and \`prompt_delete\`. Files marked \`agent-modification: false\` are read-only — \`prompt_edit\` and \`prompt_delete\` will refuse them.
|
|
106
106
|
You can author and refine slash-command skills (reusable prompt templates stored in \`skills/\`) via \`skill_list\`, \`skill_search\`, \`skill_read\`, \`skill_write\`, \`skill_edit\`, and \`skill_delete\`. New or edited skills are usable as \`/<name>\` on the user's next message.
|
|
107
107
|
Format your responses using Markdown. Use headings, bold, italic, lists, and code blocks to make your responses clear and well-structured.
|
|
108
108
|
`;
|
package/src/chat/usage.ts
CHANGED
|
@@ -10,7 +10,7 @@ const CHARS_PER_TOKEN = 4;
|
|
|
10
10
|
* line up exactly with the API's count.
|
|
11
11
|
*/
|
|
12
12
|
export interface ContextBreakdown {
|
|
13
|
-
/**
|
|
13
|
+
/** Files loaded from `prompts/` (always-on plus any contextual matches). */
|
|
14
14
|
prompts: number;
|
|
15
15
|
/** Chat instructions block + MCP guidance + style rules + meta header. */
|
|
16
16
|
instructions: number;
|
package/src/cli.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { registerInitCommand } from "./commands/init.ts";
|
|
|
11
11
|
import { registerMcpxCommand } from "./commands/mcpx.ts";
|
|
12
12
|
import { registerNukeCommand } from "./commands/nuke.ts";
|
|
13
13
|
import { registerPrepareCommand } from "./commands/prepare.ts";
|
|
14
|
+
import { registerPromptsCommand } from "./commands/prompts.ts";
|
|
14
15
|
import { registerScheduleCommand } from "./commands/schedule.ts";
|
|
15
16
|
import { registerSkillCommand } from "./commands/skill.ts";
|
|
16
17
|
import { registerTaskCommand } from "./commands/task.ts";
|
|
@@ -43,6 +44,7 @@ registerChatCommand(program);
|
|
|
43
44
|
registerContextCommand(program);
|
|
44
45
|
registerDbCommand(program);
|
|
45
46
|
registerCapabilitiesCommand(program);
|
|
47
|
+
registerPromptsCommand(program);
|
|
46
48
|
registerMcpxCommand(program);
|
|
47
49
|
registerSkillCommand(program);
|
|
48
50
|
registerNukeCommand(program);
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { mkdir, readdir, stat, unlink } from "node:fs/promises";
|
|
3
|
+
import { join, relative } from "node:path";
|
|
4
|
+
import ansis from "ansis";
|
|
5
|
+
import type { Command } from "commander";
|
|
6
|
+
import { getPromptsDir } from "../constants.ts";
|
|
7
|
+
import { atomicWrite } from "../fs/atomic.ts";
|
|
8
|
+
import {
|
|
9
|
+
PromptValidationError,
|
|
10
|
+
parsePromptFile,
|
|
11
|
+
serializePromptFile,
|
|
12
|
+
} from "../utils/frontmatter.ts";
|
|
13
|
+
import { logger } from "../utils/logger.ts";
|
|
14
|
+
|
|
15
|
+
const VALID_NAME = /^[a-zA-Z0-9._-]+$/;
|
|
16
|
+
|
|
17
|
+
export function registerPromptsCommand(program: Command) {
|
|
18
|
+
const prompts = program
|
|
19
|
+
.command("prompts")
|
|
20
|
+
.description(
|
|
21
|
+
"Manage prompt files (always-on or contextual notes for the agent)",
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
prompts
|
|
25
|
+
.command("list")
|
|
26
|
+
.description("List prompts under prompts/")
|
|
27
|
+
.option("-l, --limit <n>", "max number of prompts", Number.parseInt)
|
|
28
|
+
.option("-o, --offset <n>", "skip first N prompts", Number.parseInt)
|
|
29
|
+
.action(async (opts: { limit?: number; offset?: number }) => {
|
|
30
|
+
const dir = program.opts().dir as string;
|
|
31
|
+
const promptsDir = getPromptsDir(dir);
|
|
32
|
+
const files = await listPromptFiles(promptsDir);
|
|
33
|
+
|
|
34
|
+
if (files.length === 0) {
|
|
35
|
+
logger.dim("No prompt files found.");
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const start = opts.offset ?? 0;
|
|
40
|
+
const end = opts.limit ? start + opts.limit : undefined;
|
|
41
|
+
const page = files.slice(start, end);
|
|
42
|
+
|
|
43
|
+
const header = `${ansis.bold("Name".padEnd(20))} ${ansis.bold("Title".padEnd(28))} ${ansis.bold("Loading".padEnd(12))} ${ansis.bold("Editable".padEnd(10))} ${ansis.bold("Size".padEnd(8))} ${ansis.bold("Status")}`;
|
|
44
|
+
console.log(header);
|
|
45
|
+
console.log("-".repeat(header.length));
|
|
46
|
+
|
|
47
|
+
for (const filename of page) {
|
|
48
|
+
const filePath = join(promptsDir, filename);
|
|
49
|
+
const name = filename.replace(/\.md$/, "");
|
|
50
|
+
const [raw, st] = await Promise.all([
|
|
51
|
+
Bun.file(filePath).text(),
|
|
52
|
+
stat(filePath),
|
|
53
|
+
]);
|
|
54
|
+
try {
|
|
55
|
+
const { meta } = parsePromptFile(filePath, raw);
|
|
56
|
+
console.log(
|
|
57
|
+
[
|
|
58
|
+
name.padEnd(20),
|
|
59
|
+
meta.title.slice(0, 27).padEnd(28),
|
|
60
|
+
meta.loading.padEnd(12),
|
|
61
|
+
(meta["agent-modification"] ? "yes" : "no").padEnd(10),
|
|
62
|
+
`${st.size}`.padEnd(8),
|
|
63
|
+
ansis.green("ok"),
|
|
64
|
+
].join(" "),
|
|
65
|
+
);
|
|
66
|
+
} catch (err) {
|
|
67
|
+
const reason =
|
|
68
|
+
err instanceof PromptValidationError
|
|
69
|
+
? err.reason
|
|
70
|
+
: err instanceof Error
|
|
71
|
+
? err.message
|
|
72
|
+
: String(err);
|
|
73
|
+
console.log(
|
|
74
|
+
[
|
|
75
|
+
name.padEnd(20),
|
|
76
|
+
ansis.dim("—".padEnd(28)),
|
|
77
|
+
ansis.dim("—".padEnd(12)),
|
|
78
|
+
ansis.dim("—".padEnd(10)),
|
|
79
|
+
`${st.size}`.padEnd(8),
|
|
80
|
+
ansis.red(`invalid: ${reason}`),
|
|
81
|
+
].join(" "),
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const footer =
|
|
87
|
+
page.length === files.length
|
|
88
|
+
? `${files.length} prompt(s)`
|
|
89
|
+
: `showing ${page.length} of ${files.length} prompt(s)`;
|
|
90
|
+
console.log(`\n${ansis.dim(footer)}`);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
prompts
|
|
94
|
+
.command("show <name>")
|
|
95
|
+
.description("Print the raw contents of a prompt file")
|
|
96
|
+
.action(async (name: string) => {
|
|
97
|
+
const dir = program.opts().dir as string;
|
|
98
|
+
const filePath = resolvePromptPath(dir, name);
|
|
99
|
+
if (!filePath) {
|
|
100
|
+
logger.error(`Invalid prompt name: ${name}`);
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
const file = Bun.file(filePath);
|
|
104
|
+
if (!(await file.exists())) {
|
|
105
|
+
logger.error(`Prompt not found: ${relative(dir, filePath)}`);
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
process.stdout.write(await file.text());
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
prompts
|
|
112
|
+
.command("create <name>")
|
|
113
|
+
.description("Create a new prompt file")
|
|
114
|
+
.option("--title <title>", "human-readable title (defaults to <name>)")
|
|
115
|
+
.option(
|
|
116
|
+
"--loading <mode>",
|
|
117
|
+
"'always' or 'contextual' (default: always)",
|
|
118
|
+
"always",
|
|
119
|
+
)
|
|
120
|
+
.option(
|
|
121
|
+
"--no-agent-modification",
|
|
122
|
+
"make this prompt read-only to the agent",
|
|
123
|
+
)
|
|
124
|
+
.option("--from-file <path>", "read body from a file (use '-' for stdin)")
|
|
125
|
+
.option("--force", "overwrite if a prompt with this name exists")
|
|
126
|
+
.action(
|
|
127
|
+
async (
|
|
128
|
+
name: string,
|
|
129
|
+
opts: {
|
|
130
|
+
title?: string;
|
|
131
|
+
loading: string;
|
|
132
|
+
agentModification: boolean;
|
|
133
|
+
fromFile?: string;
|
|
134
|
+
force?: boolean;
|
|
135
|
+
},
|
|
136
|
+
) => {
|
|
137
|
+
const dir = program.opts().dir as string;
|
|
138
|
+
if (!VALID_NAME.test(name) || name.includes("..")) {
|
|
139
|
+
logger.error(`Invalid prompt name: ${name}`);
|
|
140
|
+
logger.dim("Use [a-zA-Z0-9._-] only — no slashes, no '..'.");
|
|
141
|
+
process.exit(1);
|
|
142
|
+
}
|
|
143
|
+
if (opts.loading !== "always" && opts.loading !== "contextual") {
|
|
144
|
+
logger.error(`--loading must be 'always' or 'contextual'`);
|
|
145
|
+
process.exit(1);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const promptsDir = getPromptsDir(dir);
|
|
149
|
+
const filePath = join(promptsDir, `${name}.md`);
|
|
150
|
+
if (!opts.force && (await Bun.file(filePath).exists())) {
|
|
151
|
+
logger.error(`Prompt already exists: ${relative(dir, filePath)}`);
|
|
152
|
+
logger.dim("Use --force to overwrite.");
|
|
153
|
+
process.exit(1);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let body: string;
|
|
157
|
+
if (opts.fromFile === "-") {
|
|
158
|
+
body = await readStdin();
|
|
159
|
+
} else if (opts.fromFile) {
|
|
160
|
+
body = await Bun.file(opts.fromFile).text();
|
|
161
|
+
} else {
|
|
162
|
+
body = `# ${opts.title ?? name}\n`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const meta = {
|
|
166
|
+
title: opts.title ?? name,
|
|
167
|
+
loading: opts.loading as "always" | "contextual",
|
|
168
|
+
"agent-modification": opts.agentModification,
|
|
169
|
+
};
|
|
170
|
+
const serialized = serializePromptFile(meta, body);
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
parsePromptFile(filePath, serialized);
|
|
174
|
+
} catch (err) {
|
|
175
|
+
logger.error(
|
|
176
|
+
err instanceof PromptValidationError
|
|
177
|
+
? err.message
|
|
178
|
+
: `Generated content failed validation: ${err instanceof Error ? err.message : String(err)}`,
|
|
179
|
+
);
|
|
180
|
+
process.exit(1);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
await mkdir(promptsDir, { recursive: true });
|
|
184
|
+
await atomicWrite(filePath, serialized);
|
|
185
|
+
logger.success(`Created prompt: ${relative(dir, filePath)}`);
|
|
186
|
+
},
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
prompts
|
|
190
|
+
.command("edit <name>")
|
|
191
|
+
.description("Open a prompt in $EDITOR; refuse to keep invalid output")
|
|
192
|
+
.action(async (name: string) => {
|
|
193
|
+
const dir = program.opts().dir as string;
|
|
194
|
+
const filePath = resolvePromptPath(dir, name);
|
|
195
|
+
if (!filePath) {
|
|
196
|
+
logger.error(`Invalid prompt name: ${name}`);
|
|
197
|
+
process.exit(1);
|
|
198
|
+
}
|
|
199
|
+
if (!(await Bun.file(filePath).exists())) {
|
|
200
|
+
logger.error(`Prompt not found: ${relative(dir, filePath)}`);
|
|
201
|
+
process.exit(1);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const editor = process.env.EDITOR || process.env.VISUAL || "nano";
|
|
205
|
+
await new Promise<void>((resolve, reject) => {
|
|
206
|
+
const child = spawn(editor, [filePath], { stdio: "inherit" });
|
|
207
|
+
child.on("exit", (code) => {
|
|
208
|
+
if (code === 0) resolve();
|
|
209
|
+
else reject(new Error(`${editor} exited with code ${code}`));
|
|
210
|
+
});
|
|
211
|
+
child.on("error", reject);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const raw = await Bun.file(filePath).text();
|
|
215
|
+
try {
|
|
216
|
+
parsePromptFile(filePath, raw);
|
|
217
|
+
logger.success(`Saved: ${relative(dir, filePath)}`);
|
|
218
|
+
} catch (err) {
|
|
219
|
+
const reason =
|
|
220
|
+
err instanceof PromptValidationError
|
|
221
|
+
? err.message
|
|
222
|
+
: err instanceof Error
|
|
223
|
+
? err.message
|
|
224
|
+
: String(err);
|
|
225
|
+
const quarantine = `${filePath}.tmp.invalid`;
|
|
226
|
+
await atomicWrite(quarantine, raw);
|
|
227
|
+
logger.error(`Validation failed: ${reason}`);
|
|
228
|
+
logger.dim(
|
|
229
|
+
`Wrote your edits to ${relative(dir, quarantine)} so you can recover them. The original file is unchanged.`,
|
|
230
|
+
);
|
|
231
|
+
process.exit(1);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
prompts
|
|
236
|
+
.command("delete <name>")
|
|
237
|
+
.description("Delete a prompt file")
|
|
238
|
+
.option("--force", "delete even if marked agent-modification: false")
|
|
239
|
+
.action(async (name: string, opts: { force?: boolean }) => {
|
|
240
|
+
const dir = program.opts().dir as string;
|
|
241
|
+
const filePath = resolvePromptPath(dir, name);
|
|
242
|
+
if (!filePath) {
|
|
243
|
+
logger.error(`Invalid prompt name: ${name}`);
|
|
244
|
+
process.exit(1);
|
|
245
|
+
}
|
|
246
|
+
const file = Bun.file(filePath);
|
|
247
|
+
if (!(await file.exists())) {
|
|
248
|
+
logger.error(`Prompt not found: ${relative(dir, filePath)}`);
|
|
249
|
+
process.exit(1);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (!opts.force) {
|
|
253
|
+
const raw = await file.text();
|
|
254
|
+
try {
|
|
255
|
+
const { meta } = parsePromptFile(filePath, raw);
|
|
256
|
+
if (!meta["agent-modification"]) {
|
|
257
|
+
logger.error(
|
|
258
|
+
`${relative(dir, filePath)} is marked agent-modification: false`,
|
|
259
|
+
);
|
|
260
|
+
logger.dim("Use --force to delete anyway.");
|
|
261
|
+
process.exit(1);
|
|
262
|
+
}
|
|
263
|
+
} catch {
|
|
264
|
+
// Malformed — let the user delete it; that's why they're here.
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
await unlink(filePath);
|
|
269
|
+
logger.success(`Deleted prompt: ${relative(dir, filePath)}`);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
prompts
|
|
273
|
+
.command("validate")
|
|
274
|
+
.description("Validate every prompt file under prompts/")
|
|
275
|
+
.action(async () => {
|
|
276
|
+
const dir = program.opts().dir as string;
|
|
277
|
+
const promptsDir = getPromptsDir(dir);
|
|
278
|
+
const files = await listPromptFiles(promptsDir);
|
|
279
|
+
|
|
280
|
+
if (files.length === 0) {
|
|
281
|
+
logger.dim("No prompt files found.");
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
let hasErrors = false;
|
|
286
|
+
for (const filename of files) {
|
|
287
|
+
const filePath = join(promptsDir, filename);
|
|
288
|
+
const raw = await Bun.file(filePath).text();
|
|
289
|
+
try {
|
|
290
|
+
parsePromptFile(filePath, raw);
|
|
291
|
+
logger.success(
|
|
292
|
+
`${ansis.bold(filename.padEnd(24))} ${ansis.green("ok")}`,
|
|
293
|
+
);
|
|
294
|
+
} catch (err) {
|
|
295
|
+
hasErrors = true;
|
|
296
|
+
const reason =
|
|
297
|
+
err instanceof PromptValidationError
|
|
298
|
+
? err.reason
|
|
299
|
+
: err instanceof Error
|
|
300
|
+
? err.message
|
|
301
|
+
: String(err);
|
|
302
|
+
logger.error(
|
|
303
|
+
`${ansis.bold(filename.padEnd(24))} ${ansis.red(reason)}`,
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (hasErrors) process.exit(1);
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function resolvePromptPath(projectDir: string, name: string): string | null {
|
|
313
|
+
if (!VALID_NAME.test(name) || name.includes("..")) return null;
|
|
314
|
+
return join(getPromptsDir(projectDir), `${name}.md`);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function listPromptFiles(promptsDir: string): Promise<string[]> {
|
|
318
|
+
try {
|
|
319
|
+
const entries = await readdir(promptsDir);
|
|
320
|
+
return entries.filter((f) => f.endsWith(".md")).sort();
|
|
321
|
+
} catch (err) {
|
|
322
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
323
|
+
throw err;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function readStdin(): Promise<string> {
|
|
328
|
+
const chunks: Buffer[] = [];
|
|
329
|
+
for await (const chunk of process.stdin) {
|
|
330
|
+
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
331
|
+
}
|
|
332
|
+
return Buffer.concat(chunks).toString("utf-8");
|
|
333
|
+
}
|
package/src/constants.ts
CHANGED
|
@@ -475,6 +475,7 @@ export async function writeCapabilitiesFile(
|
|
|
475
475
|
const file = Bun.file(filePath);
|
|
476
476
|
|
|
477
477
|
let meta: ContextFileMeta = {
|
|
478
|
+
title: "Capabilities",
|
|
478
479
|
loading: "always",
|
|
479
480
|
"agent-modification": true,
|
|
480
481
|
};
|
|
@@ -485,6 +486,9 @@ export async function writeCapabilitiesFile(
|
|
|
485
486
|
const parsed = parseContextFile(raw);
|
|
486
487
|
if (parsed.meta && typeof parsed.meta === "object") {
|
|
487
488
|
meta = {
|
|
489
|
+
title:
|
|
490
|
+
(typeof parsed.meta.title === "string" && parsed.meta.title) ||
|
|
491
|
+
meta.title,
|
|
488
492
|
loading: parsed.meta.loading ?? meta.loading,
|
|
489
493
|
"agent-modification":
|
|
490
494
|
parsed.meta["agent-modification"] ?? meta["agent-modification"],
|
package/src/init/index.ts
CHANGED
|
@@ -36,7 +36,6 @@ import {
|
|
|
36
36
|
DEFAULT_CONFIG,
|
|
37
37
|
DEFAULT_MCPX_SERVERS,
|
|
38
38
|
GOALS_MD,
|
|
39
|
-
SOUL_MD,
|
|
40
39
|
STANDUP_SKILL,
|
|
41
40
|
SUMMARIZE_SKILL,
|
|
42
41
|
} from "./templates.ts";
|
|
@@ -74,9 +73,8 @@ export async function initProject(
|
|
|
74
73
|
|
|
75
74
|
// Persistent-context template files
|
|
76
75
|
const pcDir = getPromptsDir(projectDir);
|
|
77
|
-
await Bun.write(join(pcDir, "soul.md"), SOUL_MD);
|
|
78
|
-
await Bun.write(join(pcDir, "beliefs.md"), BELIEFS_MD);
|
|
79
76
|
await Bun.write(join(pcDir, "goals.md"), GOALS_MD);
|
|
77
|
+
await Bun.write(join(pcDir, "beliefs.md"), BELIEFS_MD);
|
|
80
78
|
await Bun.write(join(pcDir, "capabilities.md"), CAPABILITIES_MD);
|
|
81
79
|
|
|
82
80
|
// Default skills
|
|
@@ -117,7 +115,9 @@ export async function initProject(
|
|
|
117
115
|
logger.dim("");
|
|
118
116
|
logger.dim("Layout:");
|
|
119
117
|
logger.dim(` ${CONFIG_DIR}/ settings`);
|
|
120
|
-
logger.dim(
|
|
118
|
+
logger.dim(
|
|
119
|
+
` prompts/ goals, beliefs, capabilities (and any you add)`,
|
|
120
|
+
);
|
|
121
121
|
logger.dim(` ${CONTEXT_DIR}/ agent-writable knowledge tree`);
|
|
122
122
|
logger.dim(` ${TASKS_DIR}/ one markdown file per task`);
|
|
123
123
|
logger.dim(` ${LOCKS_SUBDIR}/ worker claim lockfiles`);
|
package/src/init/templates.ts
CHANGED
|
@@ -1,20 +1,26 @@
|
|
|
1
1
|
import { DEFAULT_CONFIG as SCHEMA_DEFAULT_CONFIG } from "../config/schemas.ts";
|
|
2
2
|
|
|
3
|
-
export const
|
|
3
|
+
export const GOALS_MD = `---
|
|
4
|
+
title: Goals
|
|
4
5
|
loading: always
|
|
5
|
-
agent-modification:
|
|
6
|
+
agent-modification: true
|
|
6
7
|
---
|
|
7
8
|
|
|
8
|
-
#
|
|
9
|
+
# Goals
|
|
9
10
|
|
|
10
11
|
You are Botholomew, an AI agent for knowledge work, personified by a wise owl. You help humans manage information, research topics, organize knowledge, and complete intellectual tasks.
|
|
11
12
|
|
|
12
13
|
You are thoughtful, thorough, and proactive. You work through your task queue methodically, prioritizing appropriately and asking for clarification when needed.
|
|
13
14
|
|
|
14
15
|
You are direct: lead with the answer, skip preambles, disagree when you have reason to, and never flatter.
|
|
16
|
+
|
|
17
|
+
*The list below is the current set of goals for this project. Update it as goals are completed or new ones are added.*
|
|
18
|
+
|
|
19
|
+
- Get set up and ready to help.
|
|
15
20
|
`;
|
|
16
21
|
|
|
17
22
|
export const BELIEFS_MD = `---
|
|
23
|
+
title: Beliefs
|
|
18
24
|
loading: always
|
|
19
25
|
agent-modification: true
|
|
20
26
|
---
|
|
@@ -28,20 +34,8 @@ agent-modification: true
|
|
|
28
34
|
- I should ask for help when I'm stuck rather than guessing.
|
|
29
35
|
`;
|
|
30
36
|
|
|
31
|
-
export const GOALS_MD = `---
|
|
32
|
-
loading: always
|
|
33
|
-
agent-modification: true
|
|
34
|
-
---
|
|
35
|
-
|
|
36
|
-
# Goals
|
|
37
|
-
|
|
38
|
-
*These are the current goals for this project.*
|
|
39
|
-
*Botholomew updates this file as goals are completed or new ones are added.*
|
|
40
|
-
|
|
41
|
-
- Get set up and ready to help.
|
|
42
|
-
`;
|
|
43
|
-
|
|
44
37
|
export const CAPABILITIES_MD = `---
|
|
38
|
+
title: Capabilities
|
|
45
39
|
loading: always
|
|
46
40
|
agent-modification: true
|
|
47
41
|
---
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { mkdir } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { getPromptsDir } from "../../constants.ts";
|
|
5
|
+
import { atomicWrite } from "../../fs/atomic.ts";
|
|
6
|
+
import {
|
|
7
|
+
PromptValidationError,
|
|
8
|
+
parsePromptFile,
|
|
9
|
+
serializePromptFile,
|
|
10
|
+
} from "../../utils/frontmatter.ts";
|
|
11
|
+
import type { ToolDefinition } from "../tool.ts";
|
|
12
|
+
|
|
13
|
+
const inputSchema = z.object({
|
|
14
|
+
name: z
|
|
15
|
+
.string()
|
|
16
|
+
.min(1)
|
|
17
|
+
.describe(
|
|
18
|
+
"Prompt name without extension (e.g. 'style-notes'). Resolves to prompts/<name>.md.",
|
|
19
|
+
),
|
|
20
|
+
title: z
|
|
21
|
+
.string()
|
|
22
|
+
.min(1)
|
|
23
|
+
.describe("Human-readable title shown in prompt_list output."),
|
|
24
|
+
loading: z
|
|
25
|
+
.enum(["always", "contextual"])
|
|
26
|
+
.describe(
|
|
27
|
+
"'always' includes the prompt in every system prompt. 'contextual' includes it only when the latest user/task text shares keywords with the body.",
|
|
28
|
+
),
|
|
29
|
+
agent_modification: z
|
|
30
|
+
.boolean()
|
|
31
|
+
.describe(
|
|
32
|
+
"If true, prompt_edit and prompt_delete may modify or remove this file. If false, the file is read-only to the agent.",
|
|
33
|
+
),
|
|
34
|
+
body: z
|
|
35
|
+
.string()
|
|
36
|
+
.describe("Markdown body (everything after the frontmatter)."),
|
|
37
|
+
on_conflict: z
|
|
38
|
+
.enum(["error", "overwrite"])
|
|
39
|
+
.optional()
|
|
40
|
+
.default("error")
|
|
41
|
+
.describe(
|
|
42
|
+
"What to do if a prompt with this name already exists. Defaults to 'error'.",
|
|
43
|
+
),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const outputSchema = z.object({
|
|
47
|
+
name: z.string().nullable(),
|
|
48
|
+
path: z.string().nullable(),
|
|
49
|
+
created: z.boolean(),
|
|
50
|
+
content: z.string(),
|
|
51
|
+
is_error: z.boolean(),
|
|
52
|
+
error_type: z.string().optional(),
|
|
53
|
+
message: z.string().optional(),
|
|
54
|
+
next_action_hint: z.string().optional(),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const VALID_NAME = /^[a-zA-Z0-9._-]+$/;
|
|
58
|
+
|
|
59
|
+
export const promptCreateTool = {
|
|
60
|
+
name: "prompt_create",
|
|
61
|
+
description:
|
|
62
|
+
"[[ bash equivalent command: touch ]] Create a new prompt file under prompts/. Frontmatter (title, loading, agent-modification) is set from the arguments and re-validated before the file is committed. Fails with path_conflict if a prompt with this name exists unless on_conflict='overwrite'.",
|
|
63
|
+
group: "context",
|
|
64
|
+
inputSchema,
|
|
65
|
+
outputSchema,
|
|
66
|
+
execute: async (input, ctx) => {
|
|
67
|
+
if (!VALID_NAME.test(input.name) || input.name.includes("..")) {
|
|
68
|
+
return {
|
|
69
|
+
name: null,
|
|
70
|
+
path: null,
|
|
71
|
+
created: false,
|
|
72
|
+
content: "",
|
|
73
|
+
is_error: true,
|
|
74
|
+
error_type: "invalid_name",
|
|
75
|
+
message: `Invalid prompt name: ${input.name}`,
|
|
76
|
+
next_action_hint:
|
|
77
|
+
"Use [a-zA-Z0-9._-] only — no slashes, no '..', no extension.",
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const dir = getPromptsDir(ctx.projectDir);
|
|
82
|
+
const filePath = join(dir, `${input.name}.md`);
|
|
83
|
+
const exists = await Bun.file(filePath).exists();
|
|
84
|
+
if (exists && input.on_conflict !== "overwrite") {
|
|
85
|
+
return {
|
|
86
|
+
name: input.name,
|
|
87
|
+
path: filePath,
|
|
88
|
+
created: false,
|
|
89
|
+
content: "",
|
|
90
|
+
is_error: true,
|
|
91
|
+
error_type: "path_conflict",
|
|
92
|
+
message: `Prompt already exists: prompts/${input.name}.md`,
|
|
93
|
+
next_action_hint:
|
|
94
|
+
"Pass on_conflict='overwrite' to replace, or use prompt_edit for a partial change.",
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const meta = {
|
|
99
|
+
title: input.title,
|
|
100
|
+
loading: input.loading,
|
|
101
|
+
"agent-modification": input.agent_modification,
|
|
102
|
+
};
|
|
103
|
+
const serialized = serializePromptFile(meta, input.body);
|
|
104
|
+
|
|
105
|
+
// Round-trip validation: refuse to write content that wouldn't load back.
|
|
106
|
+
try {
|
|
107
|
+
parsePromptFile(filePath, serialized);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
return {
|
|
110
|
+
name: input.name,
|
|
111
|
+
path: filePath,
|
|
112
|
+
created: false,
|
|
113
|
+
content: serialized,
|
|
114
|
+
is_error: true,
|
|
115
|
+
error_type: "invalid_frontmatter",
|
|
116
|
+
message:
|
|
117
|
+
err instanceof PromptValidationError
|
|
118
|
+
? err.message
|
|
119
|
+
: `Generated content failed validation: ${err instanceof Error ? err.message : String(err)}`,
|
|
120
|
+
next_action_hint:
|
|
121
|
+
"Pick a title without unusual characters that break YAML.",
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
await mkdir(dir, { recursive: true });
|
|
126
|
+
await atomicWrite(filePath, serialized);
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
name: input.name,
|
|
130
|
+
path: filePath,
|
|
131
|
+
created: !exists,
|
|
132
|
+
content: serialized,
|
|
133
|
+
is_error: false,
|
|
134
|
+
};
|
|
135
|
+
},
|
|
136
|
+
} satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
|
|
@@ -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>;
|
package/src/tools/prompt/edit.ts
CHANGED
|
@@ -8,8 +8,9 @@ import {
|
|
|
8
8
|
} from "../../fs/atomic.ts";
|
|
9
9
|
import { applyLinePatches, LinePatchSchema } from "../../fs/patches.ts";
|
|
10
10
|
import {
|
|
11
|
-
|
|
12
|
-
|
|
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'
|
|
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`
|
|
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
|
-
|
|
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:
|
|
107
|
+
let postParsed: ReturnType<typeof parsePromptFile>;
|
|
87
108
|
try {
|
|
88
|
-
postParsed =
|
|
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:
|
|
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 =
|
|
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>;
|
package/src/tools/prompt/read.ts
CHANGED
|
@@ -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'
|
|
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
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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>;
|
package/src/tools/registry.ts
CHANGED
|
@@ -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/utils/frontmatter.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/worker/prompt.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
46
|
-
|
|
50
|
+
let files: string[];
|
|
47
51
|
try {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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;
|
package/src/worker/tick.ts
CHANGED
|
@@ -149,13 +149,22 @@ async function runClaimedTask(opts: {
|
|
|
149
149
|
`Working: ${task.name}`,
|
|
150
150
|
);
|
|
151
151
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
task,
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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({
|