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.
- 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/context/locks.ts +146 -0
- package/src/context/reindex.ts +10 -1
- package/src/context/store.ts +120 -70
- package/src/fs/atomic.ts +28 -4
- package/src/init/index.ts +4 -4
- package/src/init/templates.ts +10 -16
- package/src/tools/file/copy.ts +3 -1
- package/src/tools/file/delete.ts +1 -0
- package/src/tools/file/edit.ts +14 -0
- package/src/tools/file/move.ts +7 -2
- package/src/tools/file/write.ts +1 -1
- 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/tools/tool.ts +9 -0
- package/src/tui/App.tsx +48 -8
- package/src/utils/frontmatter.ts +93 -4
- package/src/worker/heartbeat.ts +20 -0
- package/src/worker/llm.ts +4 -0
- package/src/worker/prompt.ts +29 -23
- package/src/worker/tick.ts +22 -8
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"],
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readdir, stat } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { CONTEXT_DIR, LOCKS_SUBDIR } from "../constants.ts";
|
|
5
|
+
import {
|
|
6
|
+
acquireLock,
|
|
7
|
+
LockHeldError,
|
|
8
|
+
readLockHolder,
|
|
9
|
+
releaseLock,
|
|
10
|
+
} from "../fs/atomic.ts";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Per-path mutex for `context/` mutations. Tasks/schedules already serialize
|
|
14
|
+
* their own writes via O_EXCL lockfiles; this gives the same guarantee for
|
|
15
|
+
* `context_write` / `context_edit` / `context_delete` / `context_mv` so two
|
|
16
|
+
* tools (worker + chat, or two workers on the same path) can't race on
|
|
17
|
+
* read-modify-write or rename ordering.
|
|
18
|
+
*
|
|
19
|
+
* Lockfiles live at `<projectDir>/context/.locks/<sha1(path)>.lock`. We hash
|
|
20
|
+
* the path so the lock filename is bounded-length and slash-free, and so a
|
|
21
|
+
* leading-dot path doesn't accidentally collide with `walk()`'s dotfile skip
|
|
22
|
+
* in `src/context/store.ts`. The `.locks/` dir itself is invisible to
|
|
23
|
+
* `context_list` (walk skips dot-prefixed names at every depth).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
// Retries are exponential-ish with jitter. Total worst-case wait is
|
|
27
|
+
// ~5 seconds — comfortable for a small herd of concurrent writers (the
|
|
28
|
+
// per-path critical section is just a stat + tmp write + rename, on the
|
|
29
|
+
// order of 1-10 ms each), and short enough that a stuck holder surfaces
|
|
30
|
+
// to the caller instead of hanging an LLM tool call indefinitely.
|
|
31
|
+
const ACQUIRE_RETRIES = 32;
|
|
32
|
+
const ACQUIRE_BASE_BACKOFF_MS = 10;
|
|
33
|
+
const ACQUIRE_MAX_BACKOFF_MS = 200;
|
|
34
|
+
|
|
35
|
+
export function getContextLocksDir(projectDir: string): string {
|
|
36
|
+
return join(projectDir, CONTEXT_DIR, LOCKS_SUBDIR);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function contextLockPath(
|
|
40
|
+
projectDir: string,
|
|
41
|
+
normalizedPath: string,
|
|
42
|
+
): string {
|
|
43
|
+
const hash = createHash("sha1").update(normalizedPath).digest("hex");
|
|
44
|
+
return join(getContextLocksDir(projectDir), `${hash}.lock`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Run `fn` while holding the per-path context lock. Retries a few times with
|
|
49
|
+
* a small backoff if another caller has the lock — concurrent context tools
|
|
50
|
+
* are expected to converge, not surface "try again" errors to the LLM.
|
|
51
|
+
*
|
|
52
|
+
* `holderId` is stored in the lockfile body so the reaper (and humans
|
|
53
|
+
* inspecting `context/.locks/`) can identify the owner. Pass the worker id
|
|
54
|
+
* when called from a worker; chat sessions pass `"chat:<sessionId>"` or
|
|
55
|
+
* just `"chat"` — anything stable for the duration of the operation.
|
|
56
|
+
*/
|
|
57
|
+
export async function withContextLock<T>(
|
|
58
|
+
projectDir: string,
|
|
59
|
+
normalizedPath: string,
|
|
60
|
+
holderId: string,
|
|
61
|
+
fn: () => Promise<T>,
|
|
62
|
+
): Promise<T> {
|
|
63
|
+
const lockPath = contextLockPath(projectDir, normalizedPath);
|
|
64
|
+
for (let attempt = 0; ; attempt++) {
|
|
65
|
+
try {
|
|
66
|
+
await acquireLock(lockPath, holderId);
|
|
67
|
+
try {
|
|
68
|
+
return await fn();
|
|
69
|
+
} finally {
|
|
70
|
+
await releaseLock(lockPath);
|
|
71
|
+
}
|
|
72
|
+
} catch (err) {
|
|
73
|
+
if (err instanceof LockHeldError && attempt < ACQUIRE_RETRIES) {
|
|
74
|
+
const exp = Math.min(
|
|
75
|
+
ACQUIRE_MAX_BACKOFF_MS,
|
|
76
|
+
ACQUIRE_BASE_BACKOFF_MS * 2 ** attempt,
|
|
77
|
+
);
|
|
78
|
+
const jittered = exp * (0.5 + Math.random());
|
|
79
|
+
await new Promise((res) => setTimeout(res, jittered));
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
throw err;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* True if `<projectDir>/context/.locks/<sha1(path)>.lock` currently exists.
|
|
89
|
+
* Used by the reindex orphan-prune to skip paths that a worker is mid-write
|
|
90
|
+
* on — without this guard the prune can drop the search-index rows of a
|
|
91
|
+
* file that's about to land on disk.
|
|
92
|
+
*/
|
|
93
|
+
export async function isContextPathLocked(
|
|
94
|
+
projectDir: string,
|
|
95
|
+
normalizedPath: string,
|
|
96
|
+
): Promise<boolean> {
|
|
97
|
+
try {
|
|
98
|
+
await stat(contextLockPath(projectDir, normalizedPath));
|
|
99
|
+
return true;
|
|
100
|
+
} catch (err) {
|
|
101
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
102
|
+
throw err;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Reaper: walk `context/.locks/`, drop any lockfile whose holder is no
|
|
108
|
+
* longer running per `isHolderAlive`. Mirrors `reapOrphanLocks` in
|
|
109
|
+
* `src/tasks/store.ts` so the worker reaper can clean stale context locks
|
|
110
|
+
* left behind by a crashed worker.
|
|
111
|
+
*
|
|
112
|
+
* `isHolderAlive` receives the raw holder id — the caller decides what
|
|
113
|
+
* counts as alive (typically: workers/<id>.json status === "running").
|
|
114
|
+
* Holders that don't match the worker convention (e.g. `"chat"` from a
|
|
115
|
+
* chat session) are conservatively treated as alive — not our business
|
|
116
|
+
* to expire those.
|
|
117
|
+
*/
|
|
118
|
+
export async function reapOrphanContextLocks(
|
|
119
|
+
projectDir: string,
|
|
120
|
+
isHolderAlive: (holderId: string) => Promise<boolean>,
|
|
121
|
+
): Promise<string[]> {
|
|
122
|
+
const dir = getContextLocksDir(projectDir);
|
|
123
|
+
let names: string[];
|
|
124
|
+
try {
|
|
125
|
+
names = await readdir(dir);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
128
|
+
throw err;
|
|
129
|
+
}
|
|
130
|
+
const released: string[] = [];
|
|
131
|
+
for (const name of names) {
|
|
132
|
+
if (!name.endsWith(".lock")) continue;
|
|
133
|
+
const lockPath = join(dir, name);
|
|
134
|
+
const holder = await readLockHolder(lockPath);
|
|
135
|
+
if (!holder) {
|
|
136
|
+
await releaseLock(lockPath);
|
|
137
|
+
released.push(name);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (!(await isHolderAlive(holder))) {
|
|
141
|
+
await releaseLock(lockPath);
|
|
142
|
+
released.push(name);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return released;
|
|
146
|
+
}
|
package/src/context/reindex.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
import { logger } from "../utils/logger.ts";
|
|
16
16
|
import { chunkByTextSplit } from "./chunker.ts";
|
|
17
17
|
import { embed as defaultEmbed } from "./embedder.ts";
|
|
18
|
+
import { isContextPathLocked } from "./locks.ts";
|
|
18
19
|
import { listContextDir } from "./store.ts";
|
|
19
20
|
|
|
20
21
|
/** Embed function shape — exported for tests that want to inject a fake. */
|
|
@@ -110,8 +111,16 @@ export async function reindexContext(
|
|
|
110
111
|
}
|
|
111
112
|
|
|
112
113
|
// 4. Anything left in indexedByPath is in the index but not on disk →
|
|
113
|
-
// delete its rows so search results don't surface ghost files.
|
|
114
|
+
// delete its rows so search results don't surface ghost files. Skip
|
|
115
|
+
// paths with an active per-path write lock: a worker may have just
|
|
116
|
+
// written the file *after* our `collectDiskFiles` walk snapshot, and
|
|
117
|
+
// pruning now would drop the index row for a real file. Best-effort —
|
|
118
|
+
// the next reindex will reconcile.
|
|
114
119
|
for (const orphan of indexedByPath.keys()) {
|
|
120
|
+
if (await isContextPathLocked(projectDir, orphan)) {
|
|
121
|
+
logger.debug(`reindex: skipping orphan-prune for in-flight ${orphan}`);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
115
124
|
await withDb(dbPath, (conn) => deleteIndexedPath(conn, orphan));
|
|
116
125
|
removed++;
|
|
117
126
|
}
|