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
|
@@ -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/tools/tool.ts
CHANGED
|
@@ -17,6 +17,15 @@ export interface ToolContext {
|
|
|
17
17
|
projectDir: string;
|
|
18
18
|
config: Required<BotholomewConfig>;
|
|
19
19
|
mcpxClient: McpxClient | null;
|
|
20
|
+
/**
|
|
21
|
+
* Identifier of the agent process running this tool, used as the holder
|
|
22
|
+
* id for per-path context locks (`src/context/locks.ts`) so the worker
|
|
23
|
+
* reaper can identify and release locks abandoned by a crashed worker.
|
|
24
|
+
* Workers pass their `workerId`; chat sessions pass a `chat:` prefixed
|
|
25
|
+
* id; tests and one-off CLI calls leave it `undefined` (the store falls
|
|
26
|
+
* back to `pid:<n>`).
|
|
27
|
+
*/
|
|
28
|
+
workerId?: string;
|
|
20
29
|
/**
|
|
21
30
|
* Chat-mode only. Lets long-running tools (e.g. `sleep`) poll for
|
|
22
31
|
* Esc-to-abort by reading `session.aborted`. Workers leave this `undefined`.
|
package/src/tui/App.tsx
CHANGED
|
@@ -216,6 +216,7 @@ function AppInner({
|
|
|
216
216
|
const [splashDone, setSplashDone] = useState(skipSplash);
|
|
217
217
|
const [error, setError] = useState<string | null>(null);
|
|
218
218
|
const sessionRef = useRef<ChatSession | null>(null);
|
|
219
|
+
const shuttingDownRef = useRef(false);
|
|
219
220
|
const [activeTab, setActiveTab] = useState<TabId>(1);
|
|
220
221
|
const [workerRunning, setWorkerRunning] = useState(false);
|
|
221
222
|
const [chatTitle, setChatTitle] = useState<string | undefined>(undefined);
|
|
@@ -275,16 +276,52 @@ function AppInner({
|
|
|
275
276
|
|
|
276
277
|
return () => {
|
|
277
278
|
cancelled = true;
|
|
279
|
+
// Fire-and-forget safety net: only triggers when unmount happens via a
|
|
280
|
+
// path that didn't go through performShutdown (which nulls sessionRef
|
|
281
|
+
// first). React doesn't await unmount cleanups, so the goodbye lands
|
|
282
|
+
// before mcpx finishes closing — that's fine for non-Ctrl-C paths.
|
|
278
283
|
if (sessionRef.current) {
|
|
279
|
-
const
|
|
280
|
-
|
|
284
|
+
const session = sessionRef.current;
|
|
285
|
+
const threadId = session.threadId;
|
|
286
|
+
abortActiveStream(session);
|
|
287
|
+
void endChatSession(session);
|
|
281
288
|
process.stderr.write(
|
|
282
|
-
`\nThread: ${threadId}\nResume with: ${ansi.success}botholomew chat --thread-id ${threadId}${ansi.reset}\n`,
|
|
289
|
+
`\nThread: ${threadId}\nResume with: ${ansi.success}botholomew chat --thread-id ${threadId}${ansi.reset}\nBye!\n`,
|
|
283
290
|
);
|
|
284
291
|
}
|
|
285
292
|
};
|
|
286
293
|
}, [projectDir, resumeThreadId]);
|
|
287
294
|
|
|
295
|
+
const performShutdown = useCallback(async () => {
|
|
296
|
+
if (shuttingDownRef.current) {
|
|
297
|
+
// Second Ctrl-C while cleanup is in flight — give the user an escape
|
|
298
|
+
// hatch. 130 = standard SIGINT exit code.
|
|
299
|
+
process.exit(130);
|
|
300
|
+
}
|
|
301
|
+
shuttingDownRef.current = true;
|
|
302
|
+
|
|
303
|
+
const session = sessionRef.current;
|
|
304
|
+
// Null the ref so the useEffect cleanup that runs on Ink unmount becomes
|
|
305
|
+
// a no-op — otherwise it would double-print the goodbye and double-close
|
|
306
|
+
// the mcpx client.
|
|
307
|
+
sessionRef.current = null;
|
|
308
|
+
|
|
309
|
+
if (session) {
|
|
310
|
+
const threadId = session.threadId;
|
|
311
|
+
abortActiveStream(session);
|
|
312
|
+
try {
|
|
313
|
+
await endChatSession(session);
|
|
314
|
+
} catch {
|
|
315
|
+
// Best-effort: the user pressed Ctrl-C, surfacing a stack trace here
|
|
316
|
+
// would just hide the goodbye line.
|
|
317
|
+
}
|
|
318
|
+
process.stderr.write(
|
|
319
|
+
`\nThread: ${threadId}\nResume with: ${ansi.success}botholomew chat --thread-id ${threadId}${ansi.reset}\nBye!\n`,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
exit();
|
|
323
|
+
}, [exit]);
|
|
324
|
+
|
|
288
325
|
// Minimum splash screen duration
|
|
289
326
|
useEffect(() => {
|
|
290
327
|
const timer = setTimeout(() => setSplashDone(true), 2000);
|
|
@@ -333,9 +370,12 @@ function AppInner({
|
|
|
333
370
|
(input: string, key: any) => {
|
|
334
371
|
markActivityRef.current();
|
|
335
372
|
|
|
336
|
-
// Ctrl+C exits
|
|
373
|
+
// Ctrl+C exits. Routed through performShutdown so the in-flight LLM
|
|
374
|
+
// stream is aborted and mcpx is closed before we unmount Ink — without
|
|
375
|
+
// that, one Ctrl-C prints the goodbye but the process stays pinned by
|
|
376
|
+
// the open HTTPS socket and a second Ctrl-C is needed.
|
|
337
377
|
if (input === "c" && key.ctrl) {
|
|
338
|
-
|
|
378
|
+
void performShutdown();
|
|
339
379
|
return;
|
|
340
380
|
}
|
|
341
381
|
|
|
@@ -417,7 +457,7 @@ function AppInner({
|
|
|
417
457
|
}
|
|
418
458
|
}
|
|
419
459
|
},
|
|
420
|
-
[
|
|
460
|
+
[performShutdown, syncQueue],
|
|
421
461
|
);
|
|
422
462
|
|
|
423
463
|
useInput(stableAppHandler);
|
|
@@ -669,7 +709,7 @@ function AppInner({
|
|
|
669
709
|
syncQueue();
|
|
670
710
|
processQueue();
|
|
671
711
|
},
|
|
672
|
-
exit,
|
|
712
|
+
exit: () => void performShutdown(),
|
|
673
713
|
clearChat: () => {
|
|
674
714
|
const session = sessionRef.current;
|
|
675
715
|
if (!session) return;
|
|
@@ -743,7 +783,7 @@ function AppInner({
|
|
|
743
783
|
syncQueue();
|
|
744
784
|
processQueue();
|
|
745
785
|
},
|
|
746
|
-
[
|
|
786
|
+
[performShutdown, processQueue, syncQueue],
|
|
747
787
|
);
|
|
748
788
|
|
|
749
789
|
const sessionDbPath = sessionRef.current?.dbPath;
|
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
|
+
}
|