botholomew 0.15.4 → 0.15.5
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/package.json +1 -1
- package/src/chat/agent.ts +6 -4
- package/src/context/store.ts +4 -22
- package/src/fs/patches.ts +39 -0
- package/src/schedules/store.ts +11 -5
- package/src/tasks/store.ts +6 -6
- package/src/tools/file/edit.ts +2 -11
- package/src/tools/prompt/edit.ts +148 -0
- package/src/tools/prompt/read.ts +70 -0
- package/src/tools/registry.ts +9 -4
- package/src/tools/schedule/edit.ts +126 -0
- package/src/tools/skill/edit.ts +3 -33
- package/src/tools/task/edit.ts +214 -0
- package/src/tui/App.tsx +47 -4
- package/src/tools/context/update-beliefs.ts +0 -64
- package/src/tools/context/update-goals.ts +0 -64
package/package.json
CHANGED
package/src/chat/agent.ts
CHANGED
|
@@ -52,9 +52,11 @@ const CHAT_TOOL_NAMES = new Set([
|
|
|
52
52
|
"view_thread",
|
|
53
53
|
"search_threads",
|
|
54
54
|
"create_schedule",
|
|
55
|
+
"schedule_edit",
|
|
55
56
|
"list_schedules",
|
|
56
|
-
"
|
|
57
|
-
"
|
|
57
|
+
"prompt_read",
|
|
58
|
+
"prompt_edit",
|
|
59
|
+
"task_edit",
|
|
58
60
|
"capabilities_refresh",
|
|
59
61
|
"mcp_list_tools",
|
|
60
62
|
"mcp_search",
|
|
@@ -100,7 +102,7 @@ You do NOT execute long-running work directly — enqueue tasks for a background
|
|
|
100
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.
|
|
101
103
|
Past conversations live in CSV files under \`threads/\`; use \`list_threads\`, \`search_threads\`, and \`view_thread\` to find and page through them.
|
|
102
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.
|
|
103
|
-
You can
|
|
105
|
+
You can read and edit the agent's prompt files (beliefs, goals, capabilities, soul) under \`prompts/\` via \`prompt_read\` and \`prompt_edit\` (line-range patches). Files marked \`agent-modification: false\` (e.g. soul.md) are read-only.
|
|
104
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.
|
|
105
107
|
Format your responses using Markdown. Use headings, bold, italic, lists, and code blocks to make your responses clear and well-structured.
|
|
106
108
|
`;
|
|
@@ -252,7 +254,7 @@ export async function runChatTurn(input: {
|
|
|
252
254
|
// Rebuild the system prompt every iteration so that:
|
|
253
255
|
// (1) `loading: contextual` files get matched against the latest user
|
|
254
256
|
// message, and
|
|
255
|
-
// (2) any
|
|
257
|
+
// (2) any prompt_edit tool call in the previous
|
|
256
258
|
// iteration is reflected in the next LLM call.
|
|
257
259
|
const keywordSource = findLastUserText(messages);
|
|
258
260
|
const systemPrompt = await buildChatSystemPrompt(projectDir, {
|
package/src/context/store.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
import { dirname, join, posix, relative, sep } from "node:path";
|
|
14
14
|
import { CONTEXT_DIR, PROTECTED_AREAS } from "../constants.ts";
|
|
15
15
|
import { atomicWrite } from "../fs/atomic.ts";
|
|
16
|
+
import { applyLinePatches, type LinePatch } from "../fs/patches.ts";
|
|
16
17
|
import {
|
|
17
18
|
getCanonicalRoot,
|
|
18
19
|
PathEscapeError,
|
|
@@ -59,11 +60,7 @@ export class PathConflictError extends Error {
|
|
|
59
60
|
}
|
|
60
61
|
}
|
|
61
62
|
|
|
62
|
-
export
|
|
63
|
-
start_line: number;
|
|
64
|
-
end_line: number;
|
|
65
|
-
content: string;
|
|
66
|
-
}
|
|
63
|
+
export type Patch = LinePatch;
|
|
67
64
|
|
|
68
65
|
export interface ContextEntry {
|
|
69
66
|
/** Project-relative path under context/, e.g. "notes/foo.md". Forward-slashes. */
|
|
@@ -775,26 +772,11 @@ export async function applyPatches(
|
|
|
775
772
|
patches: Patch[],
|
|
776
773
|
): Promise<{ applied: number; lines: number }> {
|
|
777
774
|
const content = await readContextFile(projectDir, path);
|
|
778
|
-
const
|
|
779
|
-
|
|
780
|
-
const sorted = [...patches].sort((a, b) => b.start_line - a.start_line);
|
|
781
|
-
|
|
782
|
-
for (const patch of sorted) {
|
|
783
|
-
if (patch.end_line === 0) {
|
|
784
|
-
const insertLines = patch.content === "" ? [] : patch.content.split("\n");
|
|
785
|
-
lines.splice(patch.start_line - 1, 0, ...insertLines);
|
|
786
|
-
} else {
|
|
787
|
-
const deleteCount = patch.end_line - patch.start_line + 1;
|
|
788
|
-
const insertLines = patch.content === "" ? [] : patch.content.split("\n");
|
|
789
|
-
lines.splice(patch.start_line - 1, deleteCount, ...insertLines);
|
|
790
|
-
}
|
|
791
|
-
}
|
|
792
|
-
|
|
793
|
-
const newContent = lines.join("\n");
|
|
775
|
+
const newContent = applyLinePatches(content, patches);
|
|
794
776
|
await writeContextFile(projectDir, path, newContent, {
|
|
795
777
|
onConflict: "overwrite",
|
|
796
778
|
});
|
|
797
|
-
return { applied: patches.length, lines:
|
|
779
|
+
return { applied: patches.length, lines: newContent.split("\n").length };
|
|
798
780
|
}
|
|
799
781
|
|
|
800
782
|
/**
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export interface LinePatch {
|
|
4
|
+
start_line: number;
|
|
5
|
+
end_line: number;
|
|
6
|
+
content: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const LinePatchSchema = z.object({
|
|
10
|
+
start_line: z.number().describe("1-based inclusive start line"),
|
|
11
|
+
end_line: z
|
|
12
|
+
.number()
|
|
13
|
+
.describe("1-based inclusive end line (0 to insert without replacing)"),
|
|
14
|
+
content: z
|
|
15
|
+
.string()
|
|
16
|
+
.describe("Replacement text (empty string to delete lines)"),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Apply git-style line-range patches to a string. Patches are applied
|
|
21
|
+
* bottom-up so earlier line numbers stay stable. `end_line === 0` is an
|
|
22
|
+
* insert that doesn't replace; an empty `content` deletes.
|
|
23
|
+
*/
|
|
24
|
+
export function applyLinePatches(raw: string, patches: LinePatch[]): string {
|
|
25
|
+
const lines = raw.split("\n");
|
|
26
|
+
const sorted = [...patches].sort((a, b) => b.start_line - a.start_line);
|
|
27
|
+
|
|
28
|
+
for (const patch of sorted) {
|
|
29
|
+
const insertLines = patch.content === "" ? [] : patch.content.split("\n");
|
|
30
|
+
if (patch.end_line === 0) {
|
|
31
|
+
lines.splice(patch.start_line - 1, 0, ...insertLines);
|
|
32
|
+
} else {
|
|
33
|
+
const deleteCount = patch.end_line - patch.start_line + 1;
|
|
34
|
+
lines.splice(patch.start_line - 1, deleteCount, ...insertLines);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return lines.join("\n");
|
|
39
|
+
}
|
package/src/schedules/store.ts
CHANGED
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
ScheduleFrontmatterSchema,
|
|
20
20
|
} from "./schema.ts";
|
|
21
21
|
|
|
22
|
-
function scheduleFilePath(projectDir: string, id: string): string {
|
|
22
|
+
export function scheduleFilePath(projectDir: string, id: string): string {
|
|
23
23
|
return join(getSchedulesDir(projectDir), `${id}.md`);
|
|
24
24
|
}
|
|
25
25
|
|
|
@@ -27,20 +27,26 @@ function scheduleLockPath(projectDir: string, id: string): string {
|
|
|
27
27
|
return join(getSchedulesLockDir(projectDir), `${id}.lock`);
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
function serializeSchedule(
|
|
30
|
+
export function serializeSchedule(
|
|
31
|
+
fm: ScheduleFrontmatter,
|
|
32
|
+
body: string,
|
|
33
|
+
): string {
|
|
31
34
|
return matter.stringify(`\n${body.trim()}\n`, fm as Record<string, unknown>);
|
|
32
35
|
}
|
|
33
36
|
|
|
34
|
-
interface
|
|
37
|
+
export interface ScheduleParseOk {
|
|
35
38
|
ok: true;
|
|
36
39
|
schedule: Schedule;
|
|
37
40
|
}
|
|
38
|
-
interface
|
|
41
|
+
export interface ScheduleParseFail {
|
|
39
42
|
ok: false;
|
|
40
43
|
reason: string;
|
|
41
44
|
}
|
|
42
45
|
|
|
43
|
-
function parseScheduleFile(
|
|
46
|
+
export function parseScheduleFile(
|
|
47
|
+
raw: string,
|
|
48
|
+
mtimeMs: number,
|
|
49
|
+
): ScheduleParseOk | ScheduleParseFail {
|
|
44
50
|
let parsed: matter.GrayMatterFile<string>;
|
|
45
51
|
try {
|
|
46
52
|
parsed = matter(raw);
|
package/src/tasks/store.ts
CHANGED
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
type TaskStatus,
|
|
22
22
|
} from "./schema.ts";
|
|
23
23
|
|
|
24
|
-
function taskFilePath(projectDir: string, id: string): string {
|
|
24
|
+
export function taskFilePath(projectDir: string, id: string): string {
|
|
25
25
|
return join(getTasksDir(projectDir), `${id}.md`);
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -33,23 +33,23 @@ function taskLockPath(projectDir: string, id: string): string {
|
|
|
33
33
|
* Render a Task to its on-disk markdown form. Frontmatter contains every
|
|
34
34
|
* field; the body is preserved as-is. Trailing newline keeps line count sane.
|
|
35
35
|
*/
|
|
36
|
-
function serializeTask(fm: TaskFrontmatter, body: string): string {
|
|
36
|
+
export function serializeTask(fm: TaskFrontmatter, body: string): string {
|
|
37
37
|
return matter.stringify(`\n${body.trim()}\n`, fm as Record<string, unknown>);
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
interface
|
|
40
|
+
export interface TaskParseOk {
|
|
41
41
|
ok: true;
|
|
42
42
|
task: Task;
|
|
43
43
|
}
|
|
44
|
-
interface
|
|
44
|
+
export interface TaskParseFail {
|
|
45
45
|
ok: false;
|
|
46
46
|
reason: string;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
function parseTaskFile(
|
|
49
|
+
export function parseTaskFile(
|
|
50
50
|
raw: string,
|
|
51
51
|
mtimeMs: number,
|
|
52
|
-
):
|
|
52
|
+
): TaskParseOk | TaskParseFail {
|
|
53
53
|
let parsed: matter.GrayMatterFile<string>;
|
|
54
54
|
try {
|
|
55
55
|
parsed = matter(raw);
|
package/src/tools/file/edit.ts
CHANGED
|
@@ -5,21 +5,12 @@ import {
|
|
|
5
5
|
NotFoundError,
|
|
6
6
|
readContextFile,
|
|
7
7
|
} from "../../context/store.ts";
|
|
8
|
+
import { LinePatchSchema } from "../../fs/patches.ts";
|
|
8
9
|
import type { ToolDefinition } from "../tool.ts";
|
|
9
10
|
|
|
10
|
-
const PatchSchema = z.object({
|
|
11
|
-
start_line: z.number().describe("1-based inclusive start line"),
|
|
12
|
-
end_line: z
|
|
13
|
-
.number()
|
|
14
|
-
.describe("1-based inclusive end line (0 to insert without replacing)"),
|
|
15
|
-
content: z
|
|
16
|
-
.string()
|
|
17
|
-
.describe("Replacement text (empty string to delete lines)"),
|
|
18
|
-
});
|
|
19
|
-
|
|
20
11
|
const inputSchema = z.object({
|
|
21
12
|
path: z.string().describe("Project-relative path under context/"),
|
|
22
|
-
patches: z.array(
|
|
13
|
+
patches: z.array(LinePatchSchema).describe("Patches to apply"),
|
|
23
14
|
});
|
|
24
15
|
|
|
25
16
|
const outputSchema = z.object({
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { getPromptsDir } from "../../constants.ts";
|
|
4
|
+
import {
|
|
5
|
+
atomicWriteIfUnchanged,
|
|
6
|
+
MtimeConflictError,
|
|
7
|
+
readWithMtime,
|
|
8
|
+
} from "../../fs/atomic.ts";
|
|
9
|
+
import { applyLinePatches, LinePatchSchema } from "../../fs/patches.ts";
|
|
10
|
+
import {
|
|
11
|
+
parseContextFile,
|
|
12
|
+
serializeContextFile,
|
|
13
|
+
} from "../../utils/frontmatter.ts";
|
|
14
|
+
import type { ToolDefinition } from "../tool.ts";
|
|
15
|
+
|
|
16
|
+
const inputSchema = z.object({
|
|
17
|
+
name: z
|
|
18
|
+
.string()
|
|
19
|
+
.describe(
|
|
20
|
+
"Prompt name without extension (e.g. 'beliefs', 'goals', 'capabilities'). Resolves to prompts/<name>.md.",
|
|
21
|
+
),
|
|
22
|
+
patches: z.array(LinePatchSchema).describe("Patches to apply"),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const outputSchema = z.object({
|
|
26
|
+
name: z.string(),
|
|
27
|
+
path: z.string().nullable(),
|
|
28
|
+
applied: z.number(),
|
|
29
|
+
content: z.string(),
|
|
30
|
+
is_error: z.boolean(),
|
|
31
|
+
error_type: z.string().optional(),
|
|
32
|
+
message: z.string().optional(),
|
|
33
|
+
next_action_hint: z.string().optional(),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export const promptEditTool = {
|
|
37
|
+
name: "prompt_edit",
|
|
38
|
+
description:
|
|
39
|
+
"[[ bash equivalent command: patch ]] Apply git-style line-range patches to a prompt file under prompts/. Operates on the whole file (frontmatter + body). Files marked `agent-modification: false` (e.g. soul.md) are protected. Use prompt_read first to inspect current line numbers.",
|
|
40
|
+
group: "context",
|
|
41
|
+
inputSchema,
|
|
42
|
+
outputSchema,
|
|
43
|
+
execute: async (input, ctx) => {
|
|
44
|
+
if (input.name.includes("/") || input.name.includes("..")) {
|
|
45
|
+
return {
|
|
46
|
+
name: input.name,
|
|
47
|
+
path: null,
|
|
48
|
+
applied: 0,
|
|
49
|
+
content: "",
|
|
50
|
+
is_error: true,
|
|
51
|
+
error_type: "invalid_name",
|
|
52
|
+
message: `Invalid prompt name: ${input.name}`,
|
|
53
|
+
next_action_hint:
|
|
54
|
+
"Use a basename without slashes or dots (e.g. 'beliefs').",
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
const filePath = join(getPromptsDir(ctx.projectDir), `${input.name}.md`);
|
|
58
|
+
const file = await readWithMtime(filePath);
|
|
59
|
+
if (!file) {
|
|
60
|
+
return {
|
|
61
|
+
name: input.name,
|
|
62
|
+
path: null,
|
|
63
|
+
applied: 0,
|
|
64
|
+
content: "",
|
|
65
|
+
is_error: true,
|
|
66
|
+
error_type: "not_found",
|
|
67
|
+
message: `Prompt not found: prompts/${input.name}.md`,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const original = file.content;
|
|
72
|
+
const preParsed = parseContextFile(original);
|
|
73
|
+
if (!preParsed.meta["agent-modification"]) {
|
|
74
|
+
return {
|
|
75
|
+
name: input.name,
|
|
76
|
+
path: filePath,
|
|
77
|
+
applied: 0,
|
|
78
|
+
content: original,
|
|
79
|
+
is_error: true,
|
|
80
|
+
error_type: "agent_modification_disabled",
|
|
81
|
+
message: `Agent modification not allowed for prompts/${input.name}.md`,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const updated = applyLinePatches(original, input.patches);
|
|
86
|
+
let postParsed: { meta: Record<string, unknown>; content: string };
|
|
87
|
+
try {
|
|
88
|
+
postParsed = parseContextFile(updated);
|
|
89
|
+
} catch (err) {
|
|
90
|
+
return {
|
|
91
|
+
name: input.name,
|
|
92
|
+
path: filePath,
|
|
93
|
+
applied: 0,
|
|
94
|
+
content: original,
|
|
95
|
+
is_error: true,
|
|
96
|
+
error_type: "invalid_frontmatter",
|
|
97
|
+
message: `Patched content failed to parse: ${err instanceof Error ? err.message : String(err)}`,
|
|
98
|
+
next_action_hint:
|
|
99
|
+
"Check that the frontmatter delimiters and YAML stay valid.",
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
if (!postParsed.meta["agent-modification"]) {
|
|
103
|
+
return {
|
|
104
|
+
name: input.name,
|
|
105
|
+
path: filePath,
|
|
106
|
+
applied: 0,
|
|
107
|
+
content: original,
|
|
108
|
+
is_error: true,
|
|
109
|
+
error_type: "agent_modification_disabled",
|
|
110
|
+
message: `Patch would clear agent-modification on prompts/${input.name}.md`,
|
|
111
|
+
next_action_hint:
|
|
112
|
+
"Don't change the agent-modification frontmatter flag.",
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const serialized = serializeContextFile(
|
|
117
|
+
postParsed.meta,
|
|
118
|
+
postParsed.content,
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
await atomicWriteIfUnchanged(filePath, serialized, file.mtimeMs);
|
|
123
|
+
} catch (err) {
|
|
124
|
+
if (err instanceof MtimeConflictError) {
|
|
125
|
+
return {
|
|
126
|
+
name: input.name,
|
|
127
|
+
path: filePath,
|
|
128
|
+
applied: 0,
|
|
129
|
+
content: original,
|
|
130
|
+
is_error: true,
|
|
131
|
+
error_type: "mtime_conflict",
|
|
132
|
+
message: `Prompt was modified concurrently: ${err.message}`,
|
|
133
|
+
next_action_hint:
|
|
134
|
+
"Re-read with prompt_read and recompute your patch line numbers before retrying.",
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
throw err;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
name: input.name,
|
|
142
|
+
path: filePath,
|
|
143
|
+
applied: input.patches.length,
|
|
144
|
+
content: serialized,
|
|
145
|
+
is_error: false,
|
|
146
|
+
};
|
|
147
|
+
},
|
|
148
|
+
} satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { getPromptsDir } from "../../constants.ts";
|
|
4
|
+
import type { ToolDefinition } from "../tool.ts";
|
|
5
|
+
|
|
6
|
+
const inputSchema = z.object({
|
|
7
|
+
name: z
|
|
8
|
+
.string()
|
|
9
|
+
.describe(
|
|
10
|
+
"Prompt name without extension (e.g. 'beliefs', 'goals', 'capabilities'). Resolves to prompts/<name>.md.",
|
|
11
|
+
),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const outputSchema = z.object({
|
|
15
|
+
name: z.string(),
|
|
16
|
+
path: z.string().nullable(),
|
|
17
|
+
content: z.string(),
|
|
18
|
+
agent_modification: z.boolean(),
|
|
19
|
+
is_error: z.boolean(),
|
|
20
|
+
error_type: z.string().optional(),
|
|
21
|
+
message: z.string().optional(),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export const promptReadTool = {
|
|
25
|
+
name: "prompt_read",
|
|
26
|
+
description:
|
|
27
|
+
"[[ bash equivalent command: cat ]] Read a prompt file under prompts/ (e.g. beliefs, goals, capabilities, soul). Returns the whole file (frontmatter + body) for use with prompt_edit.",
|
|
28
|
+
group: "context",
|
|
29
|
+
inputSchema,
|
|
30
|
+
outputSchema,
|
|
31
|
+
execute: async (input, ctx) => {
|
|
32
|
+
if (input.name.includes("/") || input.name.includes("..")) {
|
|
33
|
+
return {
|
|
34
|
+
name: input.name,
|
|
35
|
+
path: null,
|
|
36
|
+
content: "",
|
|
37
|
+
agent_modification: false,
|
|
38
|
+
is_error: true,
|
|
39
|
+
error_type: "invalid_name",
|
|
40
|
+
message: `Invalid prompt name: ${input.name}`,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
const filePath = join(getPromptsDir(ctx.projectDir), `${input.name}.md`);
|
|
44
|
+
const file = Bun.file(filePath);
|
|
45
|
+
if (!(await file.exists())) {
|
|
46
|
+
return {
|
|
47
|
+
name: input.name,
|
|
48
|
+
path: null,
|
|
49
|
+
content: "",
|
|
50
|
+
agent_modification: false,
|
|
51
|
+
is_error: true,
|
|
52
|
+
error_type: "not_found",
|
|
53
|
+
message: `Prompt not found: prompts/${input.name}.md`,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
const content = await file.text();
|
|
57
|
+
// Cheap header sniff so the agent knows whether prompt_edit will be
|
|
58
|
+
// accepted before it constructs patches.
|
|
59
|
+
const agent_modification = /agent-modification:\s*true/.test(
|
|
60
|
+
content.split("---", 3).slice(0, 3).join("---"),
|
|
61
|
+
);
|
|
62
|
+
return {
|
|
63
|
+
name: input.name,
|
|
64
|
+
path: filePath,
|
|
65
|
+
content,
|
|
66
|
+
agent_modification,
|
|
67
|
+
is_error: false,
|
|
68
|
+
};
|
|
69
|
+
},
|
|
70
|
+
} satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
|
package/src/tools/registry.ts
CHANGED
|
@@ -3,8 +3,6 @@ import { capabilitiesRefreshTool } from "./capabilities/refresh.ts";
|
|
|
3
3
|
// Context tools
|
|
4
4
|
import { pipeToContextTool } from "./context/pipe.ts";
|
|
5
5
|
import { readLargeResultTool } from "./context/read-large-result.ts";
|
|
6
|
-
import { updateBeliefsTool } from "./context/update-beliefs.ts";
|
|
7
|
-
import { updateGoalsTool } from "./context/update-goals.ts";
|
|
8
6
|
// Context — directory operations
|
|
9
7
|
import { contextCreateDirTool } from "./dir/create.ts";
|
|
10
8
|
import { contextDirSizeTool } from "./dir/size.ts";
|
|
@@ -24,8 +22,12 @@ import { mcpExecTool } from "./mcp/exec.ts";
|
|
|
24
22
|
import { mcpInfoTool } from "./mcp/info.ts";
|
|
25
23
|
import { mcpListToolsTool } from "./mcp/list-tools.ts";
|
|
26
24
|
import { mcpSearchTool } from "./mcp/search.ts";
|
|
25
|
+
// Prompt tools
|
|
26
|
+
import { promptEditTool } from "./prompt/edit.ts";
|
|
27
|
+
import { promptReadTool } from "./prompt/read.ts";
|
|
27
28
|
// Schedule tools
|
|
28
29
|
import { createScheduleTool } from "./schedule/create.ts";
|
|
30
|
+
import { scheduleEditTool } from "./schedule/edit.ts";
|
|
29
31
|
import { listSchedulesTool } from "./schedule/list.ts";
|
|
30
32
|
// Search tools
|
|
31
33
|
import { searchTool } from "./search/index.ts";
|
|
@@ -40,6 +42,7 @@ import { skillWriteTool } from "./skill/write.ts";
|
|
|
40
42
|
import { completeTaskTool } from "./task/complete.ts";
|
|
41
43
|
import { createTaskTool } from "./task/create.ts";
|
|
42
44
|
import { deleteTaskTool } from "./task/delete.ts";
|
|
45
|
+
import { taskEditTool } from "./task/edit.ts";
|
|
43
46
|
import { failTaskTool } from "./task/fail.ts";
|
|
44
47
|
import { listTasksTool } from "./task/list.ts";
|
|
45
48
|
import { updateTaskTool } from "./task/update.ts";
|
|
@@ -62,6 +65,7 @@ export function registerAllTools(): void {
|
|
|
62
65
|
registerTool(waitTaskTool);
|
|
63
66
|
registerTool(createTaskTool);
|
|
64
67
|
registerTool(updateTaskTool);
|
|
68
|
+
registerTool(taskEditTool);
|
|
65
69
|
registerTool(deleteTaskTool);
|
|
66
70
|
registerTool(listTasksTool);
|
|
67
71
|
registerTool(viewTaskTool);
|
|
@@ -79,8 +83,8 @@ export function registerAllTools(): void {
|
|
|
79
83
|
registerTool(contextInfoTool);
|
|
80
84
|
registerTool(contextExistsTool);
|
|
81
85
|
registerTool(contextCountLinesTool);
|
|
82
|
-
registerTool(
|
|
83
|
-
registerTool(
|
|
86
|
+
registerTool(promptReadTool);
|
|
87
|
+
registerTool(promptEditTool);
|
|
84
88
|
registerTool(readLargeResultTool);
|
|
85
89
|
registerTool(pipeToContextTool);
|
|
86
90
|
|
|
@@ -89,6 +93,7 @@ export function registerAllTools(): void {
|
|
|
89
93
|
|
|
90
94
|
// Schedule
|
|
91
95
|
registerTool(createScheduleTool);
|
|
96
|
+
registerTool(scheduleEditTool);
|
|
92
97
|
registerTool(listSchedulesTool);
|
|
93
98
|
|
|
94
99
|
// Search
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import {
|
|
3
|
+
atomicWriteIfUnchanged,
|
|
4
|
+
MtimeConflictError,
|
|
5
|
+
readWithMtime,
|
|
6
|
+
} from "../../fs/atomic.ts";
|
|
7
|
+
import { applyLinePatches, LinePatchSchema } from "../../fs/patches.ts";
|
|
8
|
+
import type { ScheduleFrontmatter } from "../../schedules/schema.ts";
|
|
9
|
+
import {
|
|
10
|
+
parseScheduleFile,
|
|
11
|
+
scheduleFilePath,
|
|
12
|
+
serializeSchedule,
|
|
13
|
+
} from "../../schedules/store.ts";
|
|
14
|
+
import type { ToolDefinition } from "../tool.ts";
|
|
15
|
+
|
|
16
|
+
const inputSchema = z.object({
|
|
17
|
+
id: z.string().describe("Schedule id"),
|
|
18
|
+
patches: z.array(LinePatchSchema).describe("Patches to apply"),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const outputSchema = z.object({
|
|
22
|
+
id: z.string(),
|
|
23
|
+
path: z.string().nullable(),
|
|
24
|
+
applied: z.number(),
|
|
25
|
+
content: z.string(),
|
|
26
|
+
is_error: z.boolean(),
|
|
27
|
+
error_type: z.string().optional(),
|
|
28
|
+
message: z.string().optional(),
|
|
29
|
+
next_action_hint: z.string().optional(),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export const scheduleEditTool = {
|
|
33
|
+
name: "schedule_edit",
|
|
34
|
+
description:
|
|
35
|
+
"[[ bash equivalent command: patch ]] Apply git-style line-range patches to a schedule file. Operates on the whole file (frontmatter + body). Patches whose result fails frontmatter validation are rejected without writing. Re-serializes to canonicalize YAML and bump updated_at. Use schedule_list to find ids.",
|
|
36
|
+
group: "schedule",
|
|
37
|
+
inputSchema,
|
|
38
|
+
outputSchema,
|
|
39
|
+
execute: async (input, ctx) => {
|
|
40
|
+
const filePath = scheduleFilePath(ctx.projectDir, input.id);
|
|
41
|
+
const file = await readWithMtime(filePath);
|
|
42
|
+
if (!file) {
|
|
43
|
+
return {
|
|
44
|
+
id: input.id,
|
|
45
|
+
path: null,
|
|
46
|
+
applied: 0,
|
|
47
|
+
content: "",
|
|
48
|
+
is_error: true,
|
|
49
|
+
error_type: "not_found",
|
|
50
|
+
message: `Schedule not found: ${input.id}`,
|
|
51
|
+
next_action_hint:
|
|
52
|
+
"Use schedule_list to see available schedules, or create_schedule to make one.",
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const original = file.content;
|
|
57
|
+
const updated = applyLinePatches(original, input.patches);
|
|
58
|
+
|
|
59
|
+
const parsed = parseScheduleFile(updated, file.mtimeMs);
|
|
60
|
+
if (!parsed.ok) {
|
|
61
|
+
return {
|
|
62
|
+
id: input.id,
|
|
63
|
+
path: filePath,
|
|
64
|
+
applied: 0,
|
|
65
|
+
content: original,
|
|
66
|
+
is_error: true,
|
|
67
|
+
error_type: "invalid_schedule",
|
|
68
|
+
message: `Patched content failed validation: ${parsed.reason}`,
|
|
69
|
+
next_action_hint:
|
|
70
|
+
"Check that frontmatter YAML stays valid and required fields (id, name, frequency, enabled, created_at, updated_at) are preserved.",
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
if (parsed.schedule.id !== input.id) {
|
|
74
|
+
return {
|
|
75
|
+
id: input.id,
|
|
76
|
+
path: filePath,
|
|
77
|
+
applied: 0,
|
|
78
|
+
content: original,
|
|
79
|
+
is_error: true,
|
|
80
|
+
error_type: "id_mismatch",
|
|
81
|
+
message: `frontmatter id '${parsed.schedule.id}' does not match the schedule id '${input.id}'`,
|
|
82
|
+
next_action_hint:
|
|
83
|
+
"Don't change the id frontmatter field; create a new schedule with create_schedule if you need a different id.",
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const fm: ScheduleFrontmatter = {
|
|
88
|
+
id: parsed.schedule.id,
|
|
89
|
+
name: parsed.schedule.name,
|
|
90
|
+
description: parsed.schedule.description,
|
|
91
|
+
frequency: parsed.schedule.frequency,
|
|
92
|
+
enabled: parsed.schedule.enabled,
|
|
93
|
+
last_run_at: parsed.schedule.last_run_at,
|
|
94
|
+
created_at: parsed.schedule.created_at,
|
|
95
|
+
updated_at: new Date().toISOString(),
|
|
96
|
+
};
|
|
97
|
+
const serialized = serializeSchedule(fm, parsed.schedule.body);
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
await atomicWriteIfUnchanged(filePath, serialized, file.mtimeMs);
|
|
101
|
+
} catch (err) {
|
|
102
|
+
if (err instanceof MtimeConflictError) {
|
|
103
|
+
return {
|
|
104
|
+
id: input.id,
|
|
105
|
+
path: filePath,
|
|
106
|
+
applied: 0,
|
|
107
|
+
content: original,
|
|
108
|
+
is_error: true,
|
|
109
|
+
error_type: "mtime_conflict",
|
|
110
|
+
message: `Schedule was modified concurrently: ${err.message}`,
|
|
111
|
+
next_action_hint:
|
|
112
|
+
"Re-read the schedule with schedule_list and recompute your patch line numbers before retrying.",
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
throw err;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
id: input.id,
|
|
120
|
+
path: filePath,
|
|
121
|
+
applied: input.patches.length,
|
|
122
|
+
content: serialized,
|
|
123
|
+
is_error: false,
|
|
124
|
+
};
|
|
125
|
+
},
|
|
126
|
+
} satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
|
package/src/tools/skill/edit.ts
CHANGED
|
@@ -1,22 +1,13 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { getSkillsDir } from "../../constants.ts";
|
|
4
|
+
import { applyLinePatches, LinePatchSchema } from "../../fs/patches.ts";
|
|
4
5
|
import { parseSkillFile } from "../../skills/parser.ts";
|
|
5
6
|
import type { ToolDefinition } from "../tool.ts";
|
|
6
7
|
|
|
7
|
-
const PatchSchema = z.object({
|
|
8
|
-
start_line: z.number().describe("1-based inclusive start line"),
|
|
9
|
-
end_line: z
|
|
10
|
-
.number()
|
|
11
|
-
.describe("1-based inclusive end line (0 to insert without replacing)"),
|
|
12
|
-
content: z
|
|
13
|
-
.string()
|
|
14
|
-
.describe("Replacement text (empty string to delete lines)"),
|
|
15
|
-
});
|
|
16
|
-
|
|
17
8
|
const inputSchema = z.object({
|
|
18
9
|
name: z.string().describe("Skill name (case-insensitive)"),
|
|
19
|
-
patches: z.array(
|
|
10
|
+
patches: z.array(LinePatchSchema).describe("Patches to apply"),
|
|
20
11
|
});
|
|
21
12
|
|
|
22
13
|
const outputSchema = z.object({
|
|
@@ -30,27 +21,6 @@ const outputSchema = z.object({
|
|
|
30
21
|
next_action_hint: z.string().optional(),
|
|
31
22
|
});
|
|
32
23
|
|
|
33
|
-
function applyPatches(
|
|
34
|
-
raw: string,
|
|
35
|
-
patches: Array<{ start_line: number; end_line: number; content: string }>,
|
|
36
|
-
): string {
|
|
37
|
-
const lines = raw.split("\n");
|
|
38
|
-
const sorted = [...patches].sort((a, b) => b.start_line - a.start_line);
|
|
39
|
-
|
|
40
|
-
for (const patch of sorted) {
|
|
41
|
-
if (patch.end_line === 0) {
|
|
42
|
-
const insertLines = patch.content === "" ? [] : patch.content.split("\n");
|
|
43
|
-
lines.splice(patch.start_line - 1, 0, ...insertLines);
|
|
44
|
-
} else {
|
|
45
|
-
const deleteCount = patch.end_line - patch.start_line + 1;
|
|
46
|
-
const insertLines = patch.content === "" ? [] : patch.content.split("\n");
|
|
47
|
-
lines.splice(patch.start_line - 1, deleteCount, ...insertLines);
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
return lines.join("\n");
|
|
52
|
-
}
|
|
53
|
-
|
|
54
24
|
export const skillEditTool = {
|
|
55
25
|
name: "skill_edit",
|
|
56
26
|
description:
|
|
@@ -78,7 +48,7 @@ export const skillEditTool = {
|
|
|
78
48
|
}
|
|
79
49
|
|
|
80
50
|
const original = await file.text();
|
|
81
|
-
const updated =
|
|
51
|
+
const updated = applyLinePatches(original, input.patches);
|
|
82
52
|
|
|
83
53
|
try {
|
|
84
54
|
const parsed = parseSkillFile(updated, filePath);
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import {
|
|
3
|
+
atomicWriteIfUnchanged,
|
|
4
|
+
MtimeConflictError,
|
|
5
|
+
readWithMtime,
|
|
6
|
+
} from "../../fs/atomic.ts";
|
|
7
|
+
import { applyLinePatches, LinePatchSchema } from "../../fs/patches.ts";
|
|
8
|
+
import type { TaskFrontmatter } from "../../tasks/schema.ts";
|
|
9
|
+
import {
|
|
10
|
+
CircularDependencyError,
|
|
11
|
+
parseTaskFile,
|
|
12
|
+
serializeTask,
|
|
13
|
+
taskFilePath,
|
|
14
|
+
validateBlockedBy,
|
|
15
|
+
} from "../../tasks/store.ts";
|
|
16
|
+
import type { ToolDefinition } from "../tool.ts";
|
|
17
|
+
|
|
18
|
+
const inputSchema = z.object({
|
|
19
|
+
id: z.string().describe("Task id"),
|
|
20
|
+
patches: z.array(LinePatchSchema).describe("Patches to apply"),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const outputSchema = z.object({
|
|
24
|
+
id: z.string(),
|
|
25
|
+
path: z.string().nullable(),
|
|
26
|
+
applied: z.number(),
|
|
27
|
+
content: z.string(),
|
|
28
|
+
is_error: z.boolean(),
|
|
29
|
+
error_type: z.string().optional(),
|
|
30
|
+
message: z.string().optional(),
|
|
31
|
+
next_action_hint: z.string().optional(),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export const taskEditTool = {
|
|
35
|
+
name: "task_edit",
|
|
36
|
+
description:
|
|
37
|
+
"[[ bash equivalent command: patch ]] Apply git-style line-range patches to a task file. Operates on the whole file (frontmatter + body). Only pending tasks may be edited. Patches that fail validation or introduce a circular dependency are rejected without writing. Re-serializes to canonicalize YAML and bump updated_at.",
|
|
38
|
+
group: "task",
|
|
39
|
+
inputSchema,
|
|
40
|
+
outputSchema,
|
|
41
|
+
execute: async (input, ctx) => {
|
|
42
|
+
const filePath = taskFilePath(ctx.projectDir, input.id);
|
|
43
|
+
const file = await readWithMtime(filePath);
|
|
44
|
+
if (!file) {
|
|
45
|
+
return {
|
|
46
|
+
id: input.id,
|
|
47
|
+
path: null,
|
|
48
|
+
applied: 0,
|
|
49
|
+
content: "",
|
|
50
|
+
is_error: true,
|
|
51
|
+
error_type: "not_found",
|
|
52
|
+
message: `Task not found: ${input.id}`,
|
|
53
|
+
next_action_hint:
|
|
54
|
+
"Use list_tasks to see available tasks, or create_task to make one.",
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const original = file.content;
|
|
59
|
+
const preParsed = parseTaskFile(original, file.mtimeMs);
|
|
60
|
+
if (!preParsed.ok) {
|
|
61
|
+
return {
|
|
62
|
+
id: input.id,
|
|
63
|
+
path: filePath,
|
|
64
|
+
applied: 0,
|
|
65
|
+
content: original,
|
|
66
|
+
is_error: true,
|
|
67
|
+
error_type: "invalid_task",
|
|
68
|
+
message: `Existing task file is malformed: ${preParsed.reason}`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
if (preParsed.task.status !== "pending") {
|
|
72
|
+
return {
|
|
73
|
+
id: input.id,
|
|
74
|
+
path: filePath,
|
|
75
|
+
applied: 0,
|
|
76
|
+
content: original,
|
|
77
|
+
is_error: true,
|
|
78
|
+
error_type: "not_pending",
|
|
79
|
+
message: `Cannot edit task ${input.id}: only pending tasks can be edited (current status: ${preParsed.task.status})`,
|
|
80
|
+
next_action_hint:
|
|
81
|
+
"Use complete_task / fail_task / wait_task for terminal status changes.",
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const updated = applyLinePatches(original, input.patches);
|
|
86
|
+
const parsed = parseTaskFile(updated, file.mtimeMs);
|
|
87
|
+
if (!parsed.ok) {
|
|
88
|
+
return {
|
|
89
|
+
id: input.id,
|
|
90
|
+
path: filePath,
|
|
91
|
+
applied: 0,
|
|
92
|
+
content: original,
|
|
93
|
+
is_error: true,
|
|
94
|
+
error_type: "invalid_task",
|
|
95
|
+
message: `Patched content failed validation: ${parsed.reason}`,
|
|
96
|
+
next_action_hint:
|
|
97
|
+
"Check that frontmatter YAML stays valid and required fields (id, name, status, priority, etc.) are preserved.",
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
if (parsed.task.id !== input.id) {
|
|
101
|
+
return {
|
|
102
|
+
id: input.id,
|
|
103
|
+
path: filePath,
|
|
104
|
+
applied: 0,
|
|
105
|
+
content: original,
|
|
106
|
+
is_error: true,
|
|
107
|
+
error_type: "id_mismatch",
|
|
108
|
+
message: `frontmatter id '${parsed.task.id}' does not match the task id '${input.id}'`,
|
|
109
|
+
next_action_hint:
|
|
110
|
+
"Don't change the id frontmatter field; create a new task with create_task if you need a different id.",
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
if (parsed.task.status !== "pending") {
|
|
114
|
+
return {
|
|
115
|
+
id: input.id,
|
|
116
|
+
path: filePath,
|
|
117
|
+
applied: 0,
|
|
118
|
+
content: original,
|
|
119
|
+
is_error: true,
|
|
120
|
+
error_type: "status_change_forbidden",
|
|
121
|
+
message: `Patch would change task status from 'pending' to '${parsed.task.status}'. Status transitions must go through complete_task / fail_task / wait_task so the terminal-tool loop and summary are recorded.`,
|
|
122
|
+
next_action_hint:
|
|
123
|
+
"Don't edit the status frontmatter field; use complete_task, fail_task, or wait_task to transition state.",
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
// Worker-managed fields: claim state is set by claimNextTask /
|
|
127
|
+
// releaseTaskLock, output by complete_task, waiting_reason by wait_task.
|
|
128
|
+
// A pending task should have all four null; refuse any patch that
|
|
129
|
+
// changes them so the agent can't backdoor a claim or fake an output.
|
|
130
|
+
const workerManaged: Array<keyof typeof parsed.task> = [
|
|
131
|
+
"claimed_by",
|
|
132
|
+
"claimed_at",
|
|
133
|
+
"output",
|
|
134
|
+
"waiting_reason",
|
|
135
|
+
];
|
|
136
|
+
for (const field of workerManaged) {
|
|
137
|
+
if (parsed.task[field] !== preParsed.task[field]) {
|
|
138
|
+
return {
|
|
139
|
+
id: input.id,
|
|
140
|
+
path: filePath,
|
|
141
|
+
applied: 0,
|
|
142
|
+
content: original,
|
|
143
|
+
is_error: true,
|
|
144
|
+
error_type: "worker_field_change_forbidden",
|
|
145
|
+
message: `Patch would change worker-managed field '${field}'. Only complete_task / fail_task / wait_task / the claim loop may set claimed_by, claimed_at, output, and waiting_reason.`,
|
|
146
|
+
next_action_hint: `Don't edit the ${field} frontmatter field.`,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
await validateBlockedBy(ctx.projectDir, input.id, parsed.task.blocked_by);
|
|
153
|
+
} catch (err) {
|
|
154
|
+
if (err instanceof CircularDependencyError) {
|
|
155
|
+
return {
|
|
156
|
+
id: input.id,
|
|
157
|
+
path: filePath,
|
|
158
|
+
applied: 0,
|
|
159
|
+
content: original,
|
|
160
|
+
is_error: true,
|
|
161
|
+
error_type: "circular_dependency",
|
|
162
|
+
message: err.message,
|
|
163
|
+
next_action_hint:
|
|
164
|
+
"Pick blockers that don't transitively depend on this task.",
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
throw err;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const fm: TaskFrontmatter = {
|
|
171
|
+
id: parsed.task.id,
|
|
172
|
+
name: parsed.task.name,
|
|
173
|
+
description: parsed.task.description,
|
|
174
|
+
priority: parsed.task.priority,
|
|
175
|
+
status: parsed.task.status,
|
|
176
|
+
blocked_by: parsed.task.blocked_by,
|
|
177
|
+
context_paths: parsed.task.context_paths,
|
|
178
|
+
output: parsed.task.output,
|
|
179
|
+
waiting_reason: parsed.task.waiting_reason,
|
|
180
|
+
claimed_by: parsed.task.claimed_by,
|
|
181
|
+
claimed_at: parsed.task.claimed_at,
|
|
182
|
+
created_at: parsed.task.created_at,
|
|
183
|
+
updated_at: new Date().toISOString(),
|
|
184
|
+
};
|
|
185
|
+
const serialized = serializeTask(fm, parsed.task.body);
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
await atomicWriteIfUnchanged(filePath, serialized, file.mtimeMs);
|
|
189
|
+
} catch (err) {
|
|
190
|
+
if (err instanceof MtimeConflictError) {
|
|
191
|
+
return {
|
|
192
|
+
id: input.id,
|
|
193
|
+
path: filePath,
|
|
194
|
+
applied: 0,
|
|
195
|
+
content: original,
|
|
196
|
+
is_error: true,
|
|
197
|
+
error_type: "mtime_conflict",
|
|
198
|
+
message: `Task was modified concurrently: ${err.message}`,
|
|
199
|
+
next_action_hint:
|
|
200
|
+
"Re-read the task with view_task and recompute your patch line numbers before retrying.",
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
throw err;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
id: input.id,
|
|
208
|
+
path: filePath,
|
|
209
|
+
applied: input.patches.length,
|
|
210
|
+
content: serialized,
|
|
211
|
+
is_error: false,
|
|
212
|
+
};
|
|
213
|
+
},
|
|
214
|
+
} satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
|
package/src/tui/App.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Box, Static, Text, useApp, useInput } from "ink";
|
|
1
|
+
import { Box, Static, Text, useApp, useInput, useStdout } from "ink";
|
|
2
2
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
3
|
import {
|
|
4
4
|
abortActiveStream,
|
|
@@ -174,9 +174,33 @@ function AppInner({
|
|
|
174
174
|
initialPrompt,
|
|
175
175
|
}: AppInnerProps) {
|
|
176
176
|
const { exit } = useApp();
|
|
177
|
+
const { stdout } = useStdout();
|
|
177
178
|
const { markActivity } = useIdle();
|
|
179
|
+
// Pin the root box to a known viewport height so the rendered frame size
|
|
180
|
+
// never crosses the viewport boundary. Ink 7's renderer wipes scrollback
|
|
181
|
+
// (`shouldClearTerminalForFrame` → `ansiEscapes.clearTerminal`) whenever
|
|
182
|
+
// the dynamic frame transitions in/out of fullscreen, so a fluctuating
|
|
183
|
+
// `outputHeight` (streaming text + tool boxes appearing/disappearing) used
|
|
184
|
+
// to delete the chat history on every turn. Ink doesn't pin a height on
|
|
185
|
+
// its internal root, so a `height="100%"` on our root collapses to `auto`
|
|
186
|
+
// — we have to pass the explicit row count and re-read it on resize.
|
|
187
|
+
const [rows, setRows] = useState(stdout?.rows ?? 24);
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
if (!stdout) return;
|
|
190
|
+
const onResize = () => setRows(stdout.rows ?? 24);
|
|
191
|
+
stdout.on("resize", onResize);
|
|
192
|
+
return () => {
|
|
193
|
+
stdout.off("resize", onResize);
|
|
194
|
+
};
|
|
195
|
+
}, [stdout]);
|
|
178
196
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
179
197
|
const [messagesEpoch, setMessagesEpoch] = useState(0);
|
|
198
|
+
// `clearing` gates new submissions while /clear's async work is in flight.
|
|
199
|
+
// Without it, a message submitted during the clearChatSession await runs
|
|
200
|
+
// sendMessage against the OLD thread id, then the IIFE's setMessages([sys])
|
|
201
|
+
// overwrites the user bubble it added — the message disappears.
|
|
202
|
+
const [clearing, setClearing] = useState(false);
|
|
203
|
+
const clearingRef = useRef(false);
|
|
180
204
|
const [usage, setUsage] = useState<ContextUsage | null>(null);
|
|
181
205
|
const [inputValue, setInputValue] = useState("");
|
|
182
206
|
const [inputHistory, setInputHistory] = useState<string[]>([]);
|
|
@@ -586,6 +610,8 @@ function AppInner({
|
|
|
586
610
|
async (text: string) => {
|
|
587
611
|
const trimmed = text.trim();
|
|
588
612
|
if (!trimmed || !sessionRef.current) return;
|
|
613
|
+
// /clear is mid-flight: don't queue against the old thread id.
|
|
614
|
+
if (clearingRef.current) return;
|
|
589
615
|
|
|
590
616
|
setInputValue("");
|
|
591
617
|
|
|
@@ -656,6 +682,12 @@ function AppInner({
|
|
|
656
682
|
// poll below immediately rather than waiting on the
|
|
657
683
|
// createThread/endThread round trip first.
|
|
658
684
|
abortActiveStream(session);
|
|
685
|
+
// Block new submissions until the new thread id is in place —
|
|
686
|
+
// otherwise the user's first post-/clear message races the
|
|
687
|
+
// async createThread, runs against the old thread id, and is
|
|
688
|
+
// then wiped by setMessages([sys]) below.
|
|
689
|
+
clearingRef.current = true;
|
|
690
|
+
setClearing(true);
|
|
659
691
|
void (async () => {
|
|
660
692
|
// Wait for any in-flight processQueue iteration to finish so
|
|
661
693
|
// its trailing `finalizeSegment` can't race our state reset
|
|
@@ -696,6 +728,9 @@ function AppInner({
|
|
|
696
728
|
timestamp: new Date(),
|
|
697
729
|
},
|
|
698
730
|
]);
|
|
731
|
+
} finally {
|
|
732
|
+
clearingRef.current = false;
|
|
733
|
+
setClearing(false);
|
|
699
734
|
}
|
|
700
735
|
})();
|
|
701
736
|
},
|
|
@@ -774,7 +809,7 @@ function AppInner({
|
|
|
774
809
|
const threadId = sessionRef.current.threadId;
|
|
775
810
|
|
|
776
811
|
return (
|
|
777
|
-
<Box flexDirection="column" height="
|
|
812
|
+
<Box flexDirection="column" height={rows} overflow="hidden">
|
|
778
813
|
{/* Completed messages — rendered once to terminal scrollback.
|
|
779
814
|
Must live outside the display="none" tab wrappers so the <Static>
|
|
780
815
|
node always has proper terminal width in its Yoga layout.
|
|
@@ -786,11 +821,19 @@ function AppInner({
|
|
|
786
821
|
|
|
787
822
|
{/* Tab content area — all panels stay mounted to avoid expensive
|
|
788
823
|
remount cycles. display="none" hides inactive panels from
|
|
789
|
-
layout without destroying them.
|
|
824
|
+
layout without destroying them.
|
|
825
|
+
The chat tab's flexGrow box is overflow-clipped so streaming
|
|
826
|
+
content can't push the rendered frame past the viewport — see
|
|
827
|
+
the comment on the `rows` state for why that matters.
|
|
828
|
+
`justifyContent="flex-end"` keeps active streaming content + the
|
|
829
|
+
tool-call card pinned to the bottom of the chat area (just
|
|
830
|
+
above the input bar) instead of leaving a tall gap below them. */}
|
|
790
831
|
<Box
|
|
791
832
|
display={activeTab === 1 ? "flex" : "none"}
|
|
792
833
|
flexDirection="column"
|
|
793
834
|
flexGrow={1}
|
|
835
|
+
overflow="hidden"
|
|
836
|
+
justifyContent="flex-end"
|
|
794
837
|
>
|
|
795
838
|
<MessageList
|
|
796
839
|
streamingText={streamingText}
|
|
@@ -871,7 +914,7 @@ function AppInner({
|
|
|
871
914
|
value={inputValue}
|
|
872
915
|
onChange={setInputValue}
|
|
873
916
|
onSubmit={handleSubmit}
|
|
874
|
-
disabled={activeTab !== 1}
|
|
917
|
+
disabled={activeTab !== 1 || clearing}
|
|
875
918
|
history={inputHistory}
|
|
876
919
|
header={inputBarHeader}
|
|
877
920
|
slashCommands={slashCommands}
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
import { join } from "node:path";
|
|
2
|
-
import { z } from "zod";
|
|
3
|
-
import { getPromptsDir } from "../../constants.ts";
|
|
4
|
-
import {
|
|
5
|
-
type ContextFileMeta,
|
|
6
|
-
parseContextFile,
|
|
7
|
-
serializeContextFile,
|
|
8
|
-
} from "../../utils/frontmatter.ts";
|
|
9
|
-
import type { ToolDefinition } from "../tool.ts";
|
|
10
|
-
|
|
11
|
-
const inputSchema = z.object({
|
|
12
|
-
content: z
|
|
13
|
-
.string()
|
|
14
|
-
.describe(
|
|
15
|
-
"The new beliefs content (replaces existing body, frontmatter is preserved)",
|
|
16
|
-
),
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
const outputSchema = z.object({
|
|
20
|
-
message: z.string(),
|
|
21
|
-
path: z.string(),
|
|
22
|
-
is_error: z.boolean(),
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
export const updateBeliefsTool = {
|
|
26
|
-
name: "update_beliefs",
|
|
27
|
-
description:
|
|
28
|
-
"Update the agent's beliefs file (prompts/beliefs.md). Preserves frontmatter, replaces content body.",
|
|
29
|
-
group: "context",
|
|
30
|
-
inputSchema,
|
|
31
|
-
outputSchema,
|
|
32
|
-
execute: async (input, ctx) => {
|
|
33
|
-
const filePath = join(getPromptsDir(ctx.projectDir), "beliefs.md");
|
|
34
|
-
const file = Bun.file(filePath);
|
|
35
|
-
|
|
36
|
-
let meta: ContextFileMeta = {
|
|
37
|
-
loading: "always",
|
|
38
|
-
"agent-modification": true,
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
if (await file.exists()) {
|
|
42
|
-
const raw = await file.text();
|
|
43
|
-
const parsed = parseContextFile(raw);
|
|
44
|
-
meta = parsed.meta;
|
|
45
|
-
|
|
46
|
-
if (!meta["agent-modification"]) {
|
|
47
|
-
return {
|
|
48
|
-
message: "Agent modification not allowed for this file",
|
|
49
|
-
path: filePath,
|
|
50
|
-
is_error: true,
|
|
51
|
-
};
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const serialized = serializeContextFile(meta, input.content);
|
|
56
|
-
await Bun.write(filePath, serialized);
|
|
57
|
-
|
|
58
|
-
return {
|
|
59
|
-
message: "Updated beliefs.md",
|
|
60
|
-
path: filePath,
|
|
61
|
-
is_error: false,
|
|
62
|
-
};
|
|
63
|
-
},
|
|
64
|
-
} satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
import { join } from "node:path";
|
|
2
|
-
import { z } from "zod";
|
|
3
|
-
import { getPromptsDir } from "../../constants.ts";
|
|
4
|
-
import {
|
|
5
|
-
type ContextFileMeta,
|
|
6
|
-
parseContextFile,
|
|
7
|
-
serializeContextFile,
|
|
8
|
-
} from "../../utils/frontmatter.ts";
|
|
9
|
-
import type { ToolDefinition } from "../tool.ts";
|
|
10
|
-
|
|
11
|
-
const inputSchema = z.object({
|
|
12
|
-
content: z
|
|
13
|
-
.string()
|
|
14
|
-
.describe(
|
|
15
|
-
"The new goals content (replaces existing body, frontmatter is preserved)",
|
|
16
|
-
),
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
const outputSchema = z.object({
|
|
20
|
-
message: z.string(),
|
|
21
|
-
path: z.string(),
|
|
22
|
-
is_error: z.boolean(),
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
export const updateGoalsTool = {
|
|
26
|
-
name: "update_goals",
|
|
27
|
-
description:
|
|
28
|
-
"Update the agent's goals file (prompts/goals.md). Preserves frontmatter, replaces content body.",
|
|
29
|
-
group: "context",
|
|
30
|
-
inputSchema,
|
|
31
|
-
outputSchema,
|
|
32
|
-
execute: async (input, ctx) => {
|
|
33
|
-
const filePath = join(getPromptsDir(ctx.projectDir), "goals.md");
|
|
34
|
-
const file = Bun.file(filePath);
|
|
35
|
-
|
|
36
|
-
let meta: ContextFileMeta = {
|
|
37
|
-
loading: "always",
|
|
38
|
-
"agent-modification": true,
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
if (await file.exists()) {
|
|
42
|
-
const raw = await file.text();
|
|
43
|
-
const parsed = parseContextFile(raw);
|
|
44
|
-
meta = parsed.meta;
|
|
45
|
-
|
|
46
|
-
if (!meta["agent-modification"]) {
|
|
47
|
-
return {
|
|
48
|
-
message: "Agent modification not allowed for this file",
|
|
49
|
-
path: filePath,
|
|
50
|
-
is_error: true,
|
|
51
|
-
};
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const serialized = serializeContextFile(meta, input.content);
|
|
56
|
-
await Bun.write(filePath, serialized);
|
|
57
|
-
|
|
58
|
-
return {
|
|
59
|
-
message: "Updated goals.md",
|
|
60
|
-
path: filePath,
|
|
61
|
-
is_error: false,
|
|
62
|
-
};
|
|
63
|
-
},
|
|
64
|
-
} satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
|