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/src/worker/heartbeat.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { reapOrphanContextLocks } from "../context/locks.ts";
|
|
1
2
|
import { reapOrphanScheduleLocks } from "../schedules/store.ts";
|
|
2
3
|
import { reapOrphanLocks as reapOrphanTaskLocks } from "../tasks/store.ts";
|
|
3
4
|
import { logger } from "../utils/logger.ts";
|
|
@@ -81,6 +82,25 @@ export function startReaper(
|
|
|
81
82
|
logger.warn(`schedule lock reap failed: ${err}`);
|
|
82
83
|
}
|
|
83
84
|
|
|
85
|
+
try {
|
|
86
|
+
// Context locks store either a `workerId` (worker holders) or a
|
|
87
|
+
// free-form id like `chat` / `pid:<n>` (chat sessions, CLI). Only
|
|
88
|
+
// expire holders that look like worker ids; conservatively treat
|
|
89
|
+
// any other holder as alive — we don't manage the chat session's
|
|
90
|
+
// lifecycle here.
|
|
91
|
+
const released = await reapOrphanContextLocks(projectDir, async (id) => {
|
|
92
|
+
if (id.startsWith("pid:") || id.startsWith("chat")) return true;
|
|
93
|
+
return await isAlive(id);
|
|
94
|
+
});
|
|
95
|
+
if (released.length > 0) {
|
|
96
|
+
logger.warn(
|
|
97
|
+
`released ${released.length} orphan context lock(s): ${released.join(", ")}`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
} catch (err) {
|
|
101
|
+
logger.warn(`context lock reap failed: ${err}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
84
104
|
try {
|
|
85
105
|
const pruned = await pruneStoppedWorkers(
|
|
86
106
|
projectDir,
|
package/src/worker/llm.ts
CHANGED
|
@@ -53,6 +53,7 @@ export async function runAgentLoop(input: {
|
|
|
53
53
|
dbPath: string;
|
|
54
54
|
threadId: string;
|
|
55
55
|
projectDir: string;
|
|
56
|
+
workerId?: string;
|
|
56
57
|
mcpxClient?: McpxClient | null;
|
|
57
58
|
callbacks?: WorkerStreamCallbacks;
|
|
58
59
|
}): Promise<AgentLoopResult> {
|
|
@@ -63,6 +64,7 @@ export async function runAgentLoop(input: {
|
|
|
63
64
|
dbPath,
|
|
64
65
|
threadId,
|
|
65
66
|
projectDir,
|
|
67
|
+
workerId,
|
|
66
68
|
callbacks,
|
|
67
69
|
} = input;
|
|
68
70
|
|
|
@@ -207,6 +209,7 @@ export async function runAgentLoop(input: {
|
|
|
207
209
|
projectDir,
|
|
208
210
|
config,
|
|
209
211
|
mcpxClient: input.mcpxClient ?? null,
|
|
212
|
+
workerId,
|
|
210
213
|
});
|
|
211
214
|
const elapsed = Date.now() - start;
|
|
212
215
|
callbacks?.onToolEnd(
|
|
@@ -265,6 +268,7 @@ interface ToolCallCtx {
|
|
|
265
268
|
projectDir: string;
|
|
266
269
|
config: Required<BotholomewConfig>;
|
|
267
270
|
mcpxClient: McpxClient | null;
|
|
271
|
+
workerId?: string;
|
|
268
272
|
}
|
|
269
273
|
|
|
270
274
|
async function executeToolCall(
|
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
|
@@ -77,6 +77,7 @@ export async function tick(opts: TickOptions): Promise<boolean> {
|
|
|
77
77
|
projectDir,
|
|
78
78
|
dbPath,
|
|
79
79
|
config,
|
|
80
|
+
workerId,
|
|
80
81
|
mcpxClient,
|
|
81
82
|
callbacks,
|
|
82
83
|
task,
|
|
@@ -115,6 +116,7 @@ export async function runSpecificTask(opts: {
|
|
|
115
116
|
projectDir: opts.projectDir,
|
|
116
117
|
dbPath: opts.dbPath,
|
|
117
118
|
config: opts.config,
|
|
119
|
+
workerId: opts.workerId,
|
|
118
120
|
mcpxClient: opts.mcpxClient,
|
|
119
121
|
callbacks: opts.callbacks,
|
|
120
122
|
task,
|
|
@@ -126,11 +128,13 @@ async function runClaimedTask(opts: {
|
|
|
126
128
|
projectDir: string;
|
|
127
129
|
dbPath: string;
|
|
128
130
|
config: Required<BotholomewConfig>;
|
|
131
|
+
workerId: string;
|
|
129
132
|
mcpxClient?: McpxClient | null;
|
|
130
133
|
callbacks?: WorkerStreamCallbacks;
|
|
131
134
|
task: Task;
|
|
132
135
|
}): Promise<void> {
|
|
133
|
-
const { projectDir, dbPath, config, mcpxClient, callbacks, task } =
|
|
136
|
+
const { projectDir, dbPath, config, workerId, mcpxClient, callbacks, task } =
|
|
137
|
+
opts;
|
|
134
138
|
|
|
135
139
|
logger.info(`Claimed task: ${task.name} (${task.id})`);
|
|
136
140
|
if (!callbacks && task.description) {
|
|
@@ -145,13 +149,22 @@ async function runClaimedTask(opts: {
|
|
|
145
149
|
`Working: ${task.name}`,
|
|
146
150
|
);
|
|
147
151
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
task,
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
+
}
|
|
155
168
|
|
|
156
169
|
try {
|
|
157
170
|
const result = await runAgentLoop({
|
|
@@ -161,6 +174,7 @@ async function runClaimedTask(opts: {
|
|
|
161
174
|
dbPath,
|
|
162
175
|
threadId,
|
|
163
176
|
projectDir,
|
|
177
|
+
workerId,
|
|
164
178
|
mcpxClient,
|
|
165
179
|
callbacks,
|
|
166
180
|
});
|