@aaroncql/pim-agent 0.0.1
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/LICENSE +21 -0
- package/README.md +212 -0
- package/bin/pim.ts +109 -0
- package/package.json +49 -0
- package/src/extensions/_init/index.ts +109 -0
- package/src/extensions/bash/capture.test.ts +126 -0
- package/src/extensions/bash/capture.ts +80 -0
- package/src/extensions/bash/format.test.ts +240 -0
- package/src/extensions/bash/format.ts +76 -0
- package/src/extensions/bash/index.ts +86 -0
- package/src/extensions/bash/run.test.ts +262 -0
- package/src/extensions/bash/run.ts +207 -0
- package/src/extensions/bash/schema.ts +54 -0
- package/src/extensions/command-picker/index.ts +52 -0
- package/src/extensions/command-picker/ranker.test.ts +46 -0
- package/src/extensions/command-picker/ranker.ts +17 -0
- package/src/extensions/edit/edit.test.ts +285 -0
- package/src/extensions/edit/edit.ts +382 -0
- package/src/extensions/edit/index.ts +54 -0
- package/src/extensions/edit/schema.ts +37 -0
- package/src/extensions/file-picker/catalog.test.ts +263 -0
- package/src/extensions/file-picker/catalog.ts +219 -0
- package/src/extensions/file-picker/index.test.ts +168 -0
- package/src/extensions/file-picker/index.ts +119 -0
- package/src/extensions/file-picker/ranker.test.ts +94 -0
- package/src/extensions/file-picker/ranker.ts +76 -0
- package/src/extensions/footer/git.test.ts +76 -0
- package/src/extensions/footer/git.ts +87 -0
- package/src/extensions/footer/index.test.ts +161 -0
- package/src/extensions/footer/index.ts +148 -0
- package/src/extensions/footer/powerline.ts +87 -0
- package/src/extensions/footer/segments.test.ts +164 -0
- package/src/extensions/footer/segments.ts +234 -0
- package/src/extensions/glob/glob.test.ts +171 -0
- package/src/extensions/glob/glob.ts +34 -0
- package/src/extensions/glob/index.test.ts +68 -0
- package/src/extensions/glob/index.ts +136 -0
- package/src/extensions/glob/render.test.ts +126 -0
- package/src/extensions/glob/render.ts +74 -0
- package/src/extensions/glob/schema.ts +52 -0
- package/src/extensions/grep/grep.test.ts +387 -0
- package/src/extensions/grep/grep.ts +215 -0
- package/src/extensions/grep/index.test.ts +68 -0
- package/src/extensions/grep/index.ts +158 -0
- package/src/extensions/grep/render.test.ts +269 -0
- package/src/extensions/grep/render.ts +243 -0
- package/src/extensions/grep/schema.ts +92 -0
- package/src/extensions/read/index.ts +84 -0
- package/src/extensions/read/read.test.ts +177 -0
- package/src/extensions/read/read.ts +206 -0
- package/src/extensions/read/render.test.ts +61 -0
- package/src/extensions/read/render.ts +33 -0
- package/src/extensions/read/schema.ts +27 -0
- package/src/extensions/subagent/index.test.ts +44 -0
- package/src/extensions/subagent/index.ts +30 -0
- package/src/extensions/subagent/render.test.ts +292 -0
- package/src/extensions/subagent/render.ts +359 -0
- package/src/extensions/subagent/schema.ts +9 -0
- package/src/extensions/subagent/subagent.test.ts +315 -0
- package/src/extensions/subagent/subagent.ts +418 -0
- package/src/extensions/system-prompt/index.ts +28 -0
- package/src/extensions/system-prompt/prompt.test.ts +64 -0
- package/src/extensions/system-prompt/prompt.ts +213 -0
- package/src/extensions/todo/index.test.ts +244 -0
- package/src/extensions/todo/index.ts +122 -0
- package/src/extensions/todo/render.test.ts +180 -0
- package/src/extensions/todo/render.ts +172 -0
- package/src/extensions/todo/schema.ts +24 -0
- package/src/extensions/todo/todo.test.ts +222 -0
- package/src/extensions/todo/todo.ts +188 -0
- package/src/extensions/tps/index.test.ts +254 -0
- package/src/extensions/tps/index.ts +136 -0
- package/src/extensions/web-fetch/JinaReaderClient.ts +230 -0
- package/src/extensions/web-fetch/WebViewFetchClient.ts +186 -0
- package/src/extensions/web-fetch/WebViewMarkdownSnapshot.test.ts +119 -0
- package/src/extensions/web-fetch/WebViewMarkdownSnapshot.ts +511 -0
- package/src/extensions/web-fetch/fetch.test.ts +244 -0
- package/src/extensions/web-fetch/fetch.ts +249 -0
- package/src/extensions/web-fetch/index.ts +107 -0
- package/src/extensions/web-fetch/render.test.ts +56 -0
- package/src/extensions/web-fetch/render.ts +39 -0
- package/src/extensions/web-fetch/schema.ts +23 -0
- package/src/extensions/web-search/ExaMcpClient.test.ts +143 -0
- package/src/extensions/web-search/ExaMcpClient.ts +258 -0
- package/src/extensions/web-search/index.ts +118 -0
- package/src/extensions/web-search/render.test.ts +21 -0
- package/src/extensions/web-search/render.ts +9 -0
- package/src/extensions/web-search/schema.ts +21 -0
- package/src/extensions/web-search/search.test.ts +53 -0
- package/src/extensions/web-search/search.ts +23 -0
- package/src/extensions/working-indicator/index.test.ts +21 -0
- package/src/extensions/working-indicator/index.ts +77 -0
- package/src/extensions/write/index.ts +76 -0
- package/src/extensions/write/render.test.ts +64 -0
- package/src/extensions/write/schema.ts +14 -0
- package/src/extensions/write/write.test.ts +108 -0
- package/src/extensions/write/write.ts +104 -0
- package/src/shared/DiffLines.test.ts +193 -0
- package/src/shared/DiffLines.ts +307 -0
- package/src/shared/DiffRenderer.test.ts +206 -0
- package/src/shared/DiffRenderer.ts +396 -0
- package/src/shared/DiffView.ts +199 -0
- package/src/shared/EditMatcher.test.ts +123 -0
- package/src/shared/EditMatcher.ts +826 -0
- package/src/shared/FileScanner.test.ts +158 -0
- package/src/shared/FileScanner.ts +41 -0
- package/src/shared/Fs.ts +46 -0
- package/src/shared/FsErrors.ts +72 -0
- package/src/shared/FuzzyMatcher.test.ts +114 -0
- package/src/shared/FuzzyMatcher.ts +73 -0
- package/src/shared/GitignoreFilter.test.ts +64 -0
- package/src/shared/GitignoreFilter.ts +142 -0
- package/src/shared/GlobExclusions.ts +23 -0
- package/src/shared/Levenshtein.ts +33 -0
- package/src/shared/Lines.test.ts +25 -0
- package/src/shared/Lines.ts +77 -0
- package/src/shared/McpClient.test.ts +235 -0
- package/src/shared/McpClient.ts +406 -0
- package/src/shared/OutputBudget.test.ts +99 -0
- package/src/shared/OutputBudget.ts +79 -0
- package/src/shared/Paths.test.ts +51 -0
- package/src/shared/Paths.ts +52 -0
- package/src/shared/PimSettings.test.ts +90 -0
- package/src/shared/PimSettings.ts +124 -0
- package/src/shared/Renderer.test.ts +190 -0
- package/src/shared/Renderer.ts +256 -0
- package/src/shared/SpillCache.test.ts +94 -0
- package/src/shared/SpillCache.ts +89 -0
- package/src/shared/Tools.test.ts +392 -0
- package/src/shared/Tools.ts +636 -0
- package/src/telegram/Bot.ts +198 -0
- package/src/telegram/Commands.ts +721 -0
- package/src/telegram/Config.test.ts +275 -0
- package/src/telegram/Config.ts +162 -0
- package/src/telegram/Markdown.test.ts +143 -0
- package/src/telegram/Markdown.ts +177 -0
- package/src/telegram/Message.ts +211 -0
- package/src/telegram/Renderer.test.ts +216 -0
- package/src/telegram/Renderer.ts +713 -0
- package/src/telegram/SendFileSchema.ts +19 -0
- package/src/telegram/SendFileTool.ts +94 -0
- package/src/telegram/Session.ts +579 -0
- package/src/telegram/SessionRegistry.test.ts +89 -0
- package/src/telegram/SessionRegistry.ts +170 -0
- package/src/telegram/Supervisor.ts +357 -0
- package/src/telegram/TaskScheduler.test.ts +278 -0
- package/src/telegram/TaskScheduler.ts +293 -0
- package/src/telegram/TaskSchema.ts +88 -0
- package/src/telegram/TaskStore.ts +73 -0
- package/src/telegram/TaskTool.test.ts +179 -0
- package/src/telegram/TaskTool.ts +159 -0
- package/src/telegram/TypingIndicator.ts +43 -0
- package/src/telegram/index.ts +32 -0
- package/src/themes/pim-dark.json +84 -0
- package/src/themes/pim-light.json +84 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { FsErrors } from "../../shared/FsErrors";
|
|
2
|
+
import { Lines } from "../../shared/Lines";
|
|
3
|
+
import { OutputBudget } from "../../shared/OutputBudget";
|
|
4
|
+
import type { ReadRange } from "./schema";
|
|
5
|
+
|
|
6
|
+
type RenderedLine = {
|
|
7
|
+
readonly lineNumber: number;
|
|
8
|
+
readonly text: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type ReadOutcome = {
|
|
12
|
+
readonly body: string;
|
|
13
|
+
readonly totalLines: number;
|
|
14
|
+
readonly visibleStart: number;
|
|
15
|
+
readonly visibleEnd: number;
|
|
16
|
+
readonly truncatedByByteCap: boolean;
|
|
17
|
+
readonly truncatedByEnd: boolean;
|
|
18
|
+
readonly hadBom: boolean;
|
|
19
|
+
readonly nextStart?: number;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function buildReadRange(
|
|
23
|
+
start: number | undefined,
|
|
24
|
+
end: number | undefined
|
|
25
|
+
): ReadRange {
|
|
26
|
+
const startLine = start ?? 1;
|
|
27
|
+
|
|
28
|
+
if (start !== undefined && (!Number.isInteger(start) || start <= 0)) {
|
|
29
|
+
throw new Error(`Read start ${start} must be a positive integer.`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (end !== undefined && (!Number.isInteger(end) || end <= 0)) {
|
|
33
|
+
throw new Error(`Read end ${end} must be a positive integer.`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (end !== undefined && end < startLine) {
|
|
37
|
+
throw new Error(`Read end line ${end} must be >= start line ${startLine}.`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
start: startLine,
|
|
42
|
+
...(end === undefined ? {} : { end }),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function readFile(
|
|
47
|
+
path: string,
|
|
48
|
+
range: ReadRange
|
|
49
|
+
): Promise<ReadOutcome> {
|
|
50
|
+
const metadata = await FsErrors.statOrThrow(path);
|
|
51
|
+
|
|
52
|
+
if (metadata.isDirectory()) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`Path is a directory: ${path}. Use grep or glob to inspect directories.`
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const file = Bun.file(path);
|
|
59
|
+
|
|
60
|
+
let head: Uint8Array;
|
|
61
|
+
try {
|
|
62
|
+
head = new Uint8Array(await file.slice(0, 8192).arrayBuffer());
|
|
63
|
+
} catch (error) {
|
|
64
|
+
rethrowFsError(error, path, "read");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (head.includes(0)) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`Read only supports UTF-8 text files but given path is a binary file. Use bash with 'file' or 'xxd' to inspect binary contents.`
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let bytes: Uint8Array;
|
|
74
|
+
try {
|
|
75
|
+
bytes = await file.bytes();
|
|
76
|
+
} catch (error) {
|
|
77
|
+
rethrowFsError(error, path, "read");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const hadBom = Lines.hasUtf8Bom(bytes);
|
|
81
|
+
const text = new TextDecoder("utf-8").decode(bytes);
|
|
82
|
+
|
|
83
|
+
return renderText(Lines.stripUtf8Bom(text), range, path, hadBom);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function renderText(
|
|
87
|
+
content: string,
|
|
88
|
+
range: ReadRange,
|
|
89
|
+
path: string,
|
|
90
|
+
hadBom: boolean
|
|
91
|
+
): ReadOutcome {
|
|
92
|
+
const lines = Lines.split(content);
|
|
93
|
+
const totalLines = lines.length;
|
|
94
|
+
|
|
95
|
+
if (totalLines === 0) {
|
|
96
|
+
throw new Error("File is empty. Use the write tool to create content.");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (range.start > totalLines) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
`Start ${range.start} is beyond end of file (${totalLines} lines total). Use start=1 to read from the beginning, or start=${totalLines} to read the last line.`
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const lastLine = Math.min(range.end ?? totalLines, totalLines);
|
|
106
|
+
const rendered = renderLines(lines, range.start, lastLine);
|
|
107
|
+
const { visible, firstLineTooBig } = applyByteCap(rendered);
|
|
108
|
+
|
|
109
|
+
if (firstLineTooBig !== undefined) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
`Line ${firstLineTooBig.line} is ${formatBytes(firstLineTooBig.bytes)}, exceeds the ${formatBytes(OutputBudget.maxBytes)} read cap. Use bash: sed -n '${firstLineTooBig.line}p' ${path} | head -c ${OutputBudget.maxBytes}${range.start < totalLines ? `, or call read again with start=${range.start + 1} to skip this line.` : "."}`
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const lastVisibleLine = visible.at(-1)?.lineNumber ?? range.start;
|
|
116
|
+
const body = visible.map((line) => line.text).join("\n");
|
|
117
|
+
const truncatedByByteCap = lastVisibleLine < lastLine;
|
|
118
|
+
const truncatedByEnd = lastVisibleLine < totalLines;
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
body,
|
|
122
|
+
totalLines,
|
|
123
|
+
visibleStart: range.start,
|
|
124
|
+
visibleEnd: lastVisibleLine,
|
|
125
|
+
truncatedByByteCap,
|
|
126
|
+
truncatedByEnd,
|
|
127
|
+
hadBom,
|
|
128
|
+
...(truncatedByEnd ? { nextStart: lastVisibleLine + 1 } : {}),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function renderLines(
|
|
133
|
+
lines: readonly string[],
|
|
134
|
+
start: number,
|
|
135
|
+
end: number
|
|
136
|
+
): readonly RenderedLine[] {
|
|
137
|
+
const rendered: RenderedLine[] = [];
|
|
138
|
+
|
|
139
|
+
for (let lineNumber = start; lineNumber <= end; lineNumber += 1) {
|
|
140
|
+
const line = OutputBudget.truncateLine(lines[lineNumber - 1] ?? "");
|
|
141
|
+
rendered.push({
|
|
142
|
+
lineNumber,
|
|
143
|
+
text: `${lineNumber}:${line}`,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return rendered;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function applyByteCap(lines: readonly RenderedLine[]): {
|
|
151
|
+
readonly visible: readonly RenderedLine[];
|
|
152
|
+
readonly firstLineTooBig:
|
|
153
|
+
| { readonly line: number; readonly bytes: number }
|
|
154
|
+
| undefined;
|
|
155
|
+
} {
|
|
156
|
+
const visible: RenderedLine[] = [];
|
|
157
|
+
let bytes = 0;
|
|
158
|
+
|
|
159
|
+
for (const line of lines) {
|
|
160
|
+
const separatorBytes = visible.length === 0 ? 0 : 1;
|
|
161
|
+
const lineBytes = Buffer.byteLength(line.text, "utf8");
|
|
162
|
+
|
|
163
|
+
if (visible.length === 0 && lineBytes > OutputBudget.maxBytes) {
|
|
164
|
+
return {
|
|
165
|
+
visible,
|
|
166
|
+
firstLineTooBig: { line: line.lineNumber, bytes: lineBytes },
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (
|
|
171
|
+
visible.length > 0 &&
|
|
172
|
+
bytes + separatorBytes + lineBytes > OutputBudget.maxBytes
|
|
173
|
+
) {
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
visible.push(line);
|
|
178
|
+
bytes += separatorBytes + lineBytes;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return { visible, firstLineTooBig: undefined };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function formatBytes(bytes: number): string {
|
|
185
|
+
if (bytes < 1024) {
|
|
186
|
+
return `${bytes} B`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (bytes < 1024 * 1024) {
|
|
190
|
+
return `${(bytes / 1024).toFixed(1)} KiB`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function rethrowFsError(error: unknown, path: string, action: string): never {
|
|
197
|
+
const code = FsErrors.code(error);
|
|
198
|
+
|
|
199
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
200
|
+
throw new Error(`Permission denied reading ${path}.`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
throw new Error(
|
|
204
|
+
`Cannot ${action} ${path}: ${code ?? (error instanceof Error ? error.message : "unknown error")}.`
|
|
205
|
+
);
|
|
206
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { formatTitlePath } from "./render";
|
|
3
|
+
|
|
4
|
+
describe("formatTitlePath", () => {
|
|
5
|
+
const cwd = "/work/repo";
|
|
6
|
+
|
|
7
|
+
test("renders relative path without format suffix", () => {
|
|
8
|
+
expect(
|
|
9
|
+
formatTitlePath({
|
|
10
|
+
path: "/work/repo/src/foo.ts",
|
|
11
|
+
cwd,
|
|
12
|
+
start: undefined,
|
|
13
|
+
end: undefined,
|
|
14
|
+
})
|
|
15
|
+
).toBe("src/foo.ts");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("renders explicit start-end range", () => {
|
|
19
|
+
expect(
|
|
20
|
+
formatTitlePath({
|
|
21
|
+
path: "/work/repo/src/foo.ts",
|
|
22
|
+
cwd,
|
|
23
|
+
start: 40,
|
|
24
|
+
end: 80,
|
|
25
|
+
})
|
|
26
|
+
).toBe("src/foo.ts:40-80");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("renders start-only range", () => {
|
|
30
|
+
expect(
|
|
31
|
+
formatTitlePath({
|
|
32
|
+
path: "/work/repo/src/foo.ts",
|
|
33
|
+
cwd,
|
|
34
|
+
start: 40,
|
|
35
|
+
end: undefined,
|
|
36
|
+
})
|
|
37
|
+
).toBe("src/foo.ts:40");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("falls back to absolute path when outside cwd", () => {
|
|
41
|
+
expect(
|
|
42
|
+
formatTitlePath({
|
|
43
|
+
path: "/etc/hosts",
|
|
44
|
+
cwd,
|
|
45
|
+
start: undefined,
|
|
46
|
+
end: undefined,
|
|
47
|
+
})
|
|
48
|
+
).toBe("/etc/hosts");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("placeholder when path is missing", () => {
|
|
52
|
+
expect(
|
|
53
|
+
formatTitlePath({
|
|
54
|
+
path: undefined,
|
|
55
|
+
cwd,
|
|
56
|
+
start: undefined,
|
|
57
|
+
end: undefined,
|
|
58
|
+
})
|
|
59
|
+
).toBe("...");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Paths } from "../../shared/Paths";
|
|
2
|
+
|
|
3
|
+
export type TitlePathOptions = {
|
|
4
|
+
readonly path: string | undefined;
|
|
5
|
+
readonly cwd: string;
|
|
6
|
+
readonly start: number | undefined;
|
|
7
|
+
readonly end: number | undefined;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function formatTitlePath(options: TitlePathOptions): string {
|
|
11
|
+
const path = options.path
|
|
12
|
+
? Paths.displayRelative(options.path, options.cwd)
|
|
13
|
+
: "...";
|
|
14
|
+
const range = formatRange(options.start, options.end);
|
|
15
|
+
return `${path}${range}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function formatRange(
|
|
19
|
+
start: number | undefined,
|
|
20
|
+
end: number | undefined
|
|
21
|
+
): string {
|
|
22
|
+
if (start === undefined && end === undefined) {
|
|
23
|
+
return "";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const startLine = start ?? 1;
|
|
27
|
+
|
|
28
|
+
if (end === undefined) {
|
|
29
|
+
return `:${startLine}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return `:${startLine}-${end}`;
|
|
33
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type Static, Type } from "typebox";
|
|
2
|
+
|
|
3
|
+
export const readSchema = Type.Object({
|
|
4
|
+
path: Type.String({
|
|
5
|
+
description: "Absolute or relative path to file (resolved against cwd).",
|
|
6
|
+
}),
|
|
7
|
+
start: Type.Optional(
|
|
8
|
+
Type.Integer({
|
|
9
|
+
minimum: 1,
|
|
10
|
+
description: "First line to return, 1-indexed. Defaults to 1.",
|
|
11
|
+
})
|
|
12
|
+
),
|
|
13
|
+
end: Type.Optional(
|
|
14
|
+
Type.Integer({
|
|
15
|
+
minimum: 1,
|
|
16
|
+
description:
|
|
17
|
+
"Last line to return, 1-indexed and inclusive. Defaults to EOF or the byte cap.",
|
|
18
|
+
})
|
|
19
|
+
),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export type ReadInput = Static<typeof readSchema>;
|
|
23
|
+
|
|
24
|
+
export type ReadRange = {
|
|
25
|
+
readonly start: number;
|
|
26
|
+
readonly end?: number;
|
|
27
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { validateToolArguments } from "@earendil-works/pi-ai";
|
|
4
|
+
import registerSubagent from "./index";
|
|
5
|
+
import { subagentSchema } from "./schema";
|
|
6
|
+
|
|
7
|
+
function validate(args: unknown): void {
|
|
8
|
+
validateToolArguments(
|
|
9
|
+
{ name: "subagent", parameters: subagentSchema } as never,
|
|
10
|
+
{
|
|
11
|
+
type: "toolCall",
|
|
12
|
+
id: "1",
|
|
13
|
+
name: "subagent",
|
|
14
|
+
arguments: args as Record<string, unknown>,
|
|
15
|
+
}
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("subagent extension registration", () => {
|
|
20
|
+
test("schema rejects an empty prompt", () => {
|
|
21
|
+
expect(() => validate({ prompt: "" })).toThrow();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("registers one parallel tool without a prompt snippet", () => {
|
|
25
|
+
let tool:
|
|
26
|
+
| {
|
|
27
|
+
readonly name: string;
|
|
28
|
+
readonly executionMode?: string;
|
|
29
|
+
readonly promptSnippet?: string;
|
|
30
|
+
readonly parameters: unknown;
|
|
31
|
+
}
|
|
32
|
+
| undefined;
|
|
33
|
+
registerSubagent({
|
|
34
|
+
registerTool(def) {
|
|
35
|
+
tool = def;
|
|
36
|
+
},
|
|
37
|
+
} as ExtensionAPI);
|
|
38
|
+
|
|
39
|
+
expect(tool?.name).toBe("subagent");
|
|
40
|
+
expect(tool?.executionMode).toBe("parallel");
|
|
41
|
+
expect(tool?.promptSnippet).toBeUndefined();
|
|
42
|
+
expect(tool?.parameters).toBe(subagentSchema);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Tools } from "../../shared/Tools";
|
|
3
|
+
import { renderCall, renderResult } from "./render";
|
|
4
|
+
import { subagentSchema, type SubagentInput } from "./schema";
|
|
5
|
+
import { runSubagent, type SubagentDetails } from "./subagent";
|
|
6
|
+
|
|
7
|
+
export default function (pi: ExtensionAPI): void {
|
|
8
|
+
Tools.register<typeof subagentSchema, SubagentDetails>(pi, {
|
|
9
|
+
name: "subagent",
|
|
10
|
+
label: "subagent",
|
|
11
|
+
description:
|
|
12
|
+
"Delegate a task to an isolated subagent to keep your main context clean. The subagent inherits your currently active tools (except subagent itself) and runs in a fresh in-memory session. Multiple subagent calls in one turn run in parallel. Subagent responses are capped at 32KB; the full output is preserved in tool details.",
|
|
13
|
+
parameters: subagentSchema,
|
|
14
|
+
renderShell: "self",
|
|
15
|
+
executionMode: "parallel",
|
|
16
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
17
|
+
const input = params as SubagentInput;
|
|
18
|
+
return runSubagent(
|
|
19
|
+
input.prompt,
|
|
20
|
+
ctx,
|
|
21
|
+
signal,
|
|
22
|
+
onUpdate,
|
|
23
|
+
undefined,
|
|
24
|
+
pi.getActiveTools()
|
|
25
|
+
);
|
|
26
|
+
},
|
|
27
|
+
renderCall,
|
|
28
|
+
renderResult,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type {
|
|
3
|
+
AgentToolResult,
|
|
4
|
+
Theme,
|
|
5
|
+
ThemeColor,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import type { SubagentDetails } from "./subagent";
|
|
8
|
+
import {
|
|
9
|
+
formatCallTitle,
|
|
10
|
+
formatTopLine,
|
|
11
|
+
renderCall,
|
|
12
|
+
renderResult,
|
|
13
|
+
} from "./render";
|
|
14
|
+
|
|
15
|
+
const stubTheme = {
|
|
16
|
+
bold: (text: string) => text,
|
|
17
|
+
italic: (text: string) => text,
|
|
18
|
+
strikethrough: (text: string) => text,
|
|
19
|
+
underline: (text: string) => text,
|
|
20
|
+
fg: (_color: string, text: string) => text,
|
|
21
|
+
} as unknown as Theme;
|
|
22
|
+
|
|
23
|
+
type ColorCall = {
|
|
24
|
+
readonly color: ThemeColor;
|
|
25
|
+
readonly text: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function tracingTheme(): {
|
|
29
|
+
readonly theme: Theme;
|
|
30
|
+
readonly calls: ColorCall[];
|
|
31
|
+
} {
|
|
32
|
+
const calls: ColorCall[] = [];
|
|
33
|
+
return {
|
|
34
|
+
calls,
|
|
35
|
+
theme: {
|
|
36
|
+
bold: (text: string) => text,
|
|
37
|
+
italic: (text: string) => text,
|
|
38
|
+
strikethrough: (text: string) => text,
|
|
39
|
+
underline: (text: string) => text,
|
|
40
|
+
fg: (color: ThemeColor, text: string) => {
|
|
41
|
+
calls.push({ color, text });
|
|
42
|
+
return text;
|
|
43
|
+
},
|
|
44
|
+
} as unknown as Theme,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const baseDetails: SubagentDetails = {
|
|
49
|
+
returnedOutput: "body",
|
|
50
|
+
fullOutput: "body",
|
|
51
|
+
outputTruncated: false,
|
|
52
|
+
omittedBytes: 0,
|
|
53
|
+
usage: {
|
|
54
|
+
input: 10,
|
|
55
|
+
output: 5,
|
|
56
|
+
cacheRead: 2,
|
|
57
|
+
cacheWrite: 0,
|
|
58
|
+
cost: 0.23,
|
|
59
|
+
turns: 3,
|
|
60
|
+
contextTokens: 4000,
|
|
61
|
+
},
|
|
62
|
+
toolCalls: [{ name: "read", isError: false }],
|
|
63
|
+
activeToolNames: [],
|
|
64
|
+
lastToolName: "read",
|
|
65
|
+
stopReason: "stop",
|
|
66
|
+
errorMessage: undefined,
|
|
67
|
+
model: "deepseek-v4-flash",
|
|
68
|
+
contextWindow: 1_000_000,
|
|
69
|
+
topLine: "$0.23 ⬝ 0.4%/1.0M ⬝ deepseek-v4-flash ⬝ 3 turns ⬝ 1 tool",
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
function result(text: string): AgentToolResult<SubagentDetails> {
|
|
73
|
+
return { content: [{ type: "text", text }], details: baseDetails };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe("subagent render formatting", () => {
|
|
77
|
+
test("call title uses the first line without truncating", () => {
|
|
78
|
+
const long = `${"x".repeat(140)}\nsecond`;
|
|
79
|
+
|
|
80
|
+
expect(formatCallTitle(long)).toBe("x".repeat(140));
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("top line includes cost, context, model, and activity", () => {
|
|
84
|
+
expect(formatTopLine(baseDetails)).toBe(
|
|
85
|
+
"$0.23 ⬝ 0.4%/1.0M ⬝ deepseek-v4-flash ⬝ 3 turns ⬝ 1 tool"
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("call title renders prompt markdown", () => {
|
|
90
|
+
const component = renderCall(
|
|
91
|
+
{ prompt: "Review **bold** and `code`" },
|
|
92
|
+
stubTheme,
|
|
93
|
+
{
|
|
94
|
+
lastComponent: undefined,
|
|
95
|
+
isPartial: false,
|
|
96
|
+
isError: false,
|
|
97
|
+
}
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
expect(component.render(80)[0]?.trimEnd()).toBe(
|
|
101
|
+
" ▪ Subagent: Review bold and code"
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("call title uses the default color for prompt text", () => {
|
|
106
|
+
const rendered = tracingTheme();
|
|
107
|
+
renderCall({ prompt: "plain prompt" }, rendered.theme, {
|
|
108
|
+
lastComponent: undefined,
|
|
109
|
+
isPartial: false,
|
|
110
|
+
isError: false,
|
|
111
|
+
}).render(80);
|
|
112
|
+
|
|
113
|
+
expect(rendered.calls).not.toContainEqual({
|
|
114
|
+
color: "toolTitle",
|
|
115
|
+
text: "plain prompt",
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("call title colors the Subagent label by running status", () => {
|
|
120
|
+
const pending = tracingTheme();
|
|
121
|
+
renderCall({ prompt: "investigate" }, pending.theme, {
|
|
122
|
+
lastComponent: undefined,
|
|
123
|
+
isPartial: true,
|
|
124
|
+
isError: false,
|
|
125
|
+
}).render(80);
|
|
126
|
+
|
|
127
|
+
expect(pending.calls).toContainEqual({
|
|
128
|
+
color: "warning",
|
|
129
|
+
text: "Subagent",
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const done = tracingTheme();
|
|
133
|
+
renderCall({ prompt: "investigate" }, done.theme, {
|
|
134
|
+
lastComponent: undefined,
|
|
135
|
+
isPartial: false,
|
|
136
|
+
isError: false,
|
|
137
|
+
}).render(80);
|
|
138
|
+
|
|
139
|
+
expect(done.calls).toContainEqual({ color: "accent", text: "Subagent" });
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("top line uses muted dots with accent or warning content", () => {
|
|
143
|
+
const done = tracingTheme();
|
|
144
|
+
renderResult(
|
|
145
|
+
result("body"),
|
|
146
|
+
{ expanded: false, isPartial: false },
|
|
147
|
+
done.theme,
|
|
148
|
+
{ lastComponent: undefined, isPartial: false, isError: false }
|
|
149
|
+
).render(80);
|
|
150
|
+
|
|
151
|
+
expect(done.calls).toContainEqual({ color: "accent", text: "$0.23 " });
|
|
152
|
+
expect(done.calls).toContainEqual({ color: "muted", text: "⬝" });
|
|
153
|
+
|
|
154
|
+
const running = tracingTheme();
|
|
155
|
+
renderResult(
|
|
156
|
+
{
|
|
157
|
+
content: [{ type: "text", text: "ignored body" }],
|
|
158
|
+
details: { ...baseDetails, stopReason: undefined },
|
|
159
|
+
},
|
|
160
|
+
{ expanded: false, isPartial: true },
|
|
161
|
+
running.theme,
|
|
162
|
+
{ lastComponent: undefined, isPartial: true, isError: false }
|
|
163
|
+
).render(80);
|
|
164
|
+
|
|
165
|
+
expect(running.calls).toContainEqual({ color: "warning", text: "$0.23 " });
|
|
166
|
+
expect(running.calls).toContainEqual({ color: "muted", text: "⬝" });
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("partial render displays only the running top line", () => {
|
|
170
|
+
const runningDetails: SubagentDetails = {
|
|
171
|
+
...baseDetails,
|
|
172
|
+
toolCalls: [],
|
|
173
|
+
activeToolNames: ["grep"],
|
|
174
|
+
stopReason: undefined,
|
|
175
|
+
topLine: "$0.23 ⬝ 0.4%/1.0M ⬝ deepseek-v4-flash ⬝ 3 turns ⬝ grep",
|
|
176
|
+
};
|
|
177
|
+
const component = renderResult(
|
|
178
|
+
{
|
|
179
|
+
content: [{ type: "text", text: "ignored body" }],
|
|
180
|
+
details: runningDetails,
|
|
181
|
+
},
|
|
182
|
+
{ expanded: false, isPartial: true },
|
|
183
|
+
stubTheme,
|
|
184
|
+
{ lastComponent: undefined, isPartial: true, isError: false }
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
expect(component.render(80)).toEqual([` │ ${runningDetails.topLine}`]);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("collapsed done render hides the final message", () => {
|
|
191
|
+
const body = Array.from({ length: 12 }, (_, i) => `line ${i + 1}`).join(
|
|
192
|
+
"\n"
|
|
193
|
+
);
|
|
194
|
+
const component = renderResult(
|
|
195
|
+
result(body),
|
|
196
|
+
{ expanded: false, isPartial: false },
|
|
197
|
+
stubTheme,
|
|
198
|
+
{ lastComponent: undefined, isPartial: false, isError: false }
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
expect(component.render(80)).toEqual([` │ ${baseDetails.topLine}`]);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("expanded done render keeps the top line above the final message", () => {
|
|
205
|
+
const component = renderResult(
|
|
206
|
+
result("line 1\nline 2"),
|
|
207
|
+
{ expanded: true, isPartial: false },
|
|
208
|
+
stubTheme,
|
|
209
|
+
{ lastComponent: undefined, isPartial: false, isError: false }
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
expect(component.render(80)).toEqual([
|
|
213
|
+
` │ ${baseDetails.topLine}`,
|
|
214
|
+
" │ line 1",
|
|
215
|
+
" │ line 2",
|
|
216
|
+
]);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("expanded done render renders final message markdown", () => {
|
|
220
|
+
const component = renderResult(
|
|
221
|
+
result("Final **answer** and `code`"),
|
|
222
|
+
{ expanded: true, isPartial: false },
|
|
223
|
+
stubTheme,
|
|
224
|
+
{ lastComponent: undefined, isPartial: false, isError: false }
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
expect(component.render(80)).toEqual([
|
|
228
|
+
` │ ${baseDetails.topLine}`,
|
|
229
|
+
" │ Final answer and code",
|
|
230
|
+
]);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("expanded done render uses configured markdown theme tokens", () => {
|
|
234
|
+
const rendered = tracingTheme();
|
|
235
|
+
renderResult(
|
|
236
|
+
result(
|
|
237
|
+
[
|
|
238
|
+
"# Heading",
|
|
239
|
+
"",
|
|
240
|
+
"[docs](https://example.test)",
|
|
241
|
+
"",
|
|
242
|
+
"`inline`",
|
|
243
|
+
"",
|
|
244
|
+
"> quoted",
|
|
245
|
+
"",
|
|
246
|
+
"- item",
|
|
247
|
+
"",
|
|
248
|
+
"```",
|
|
249
|
+
"plain code",
|
|
250
|
+
"```",
|
|
251
|
+
"",
|
|
252
|
+
"---",
|
|
253
|
+
].join("\n")
|
|
254
|
+
),
|
|
255
|
+
{ expanded: true, isPartial: false },
|
|
256
|
+
rendered.theme,
|
|
257
|
+
{ lastComponent: undefined, isPartial: false, isError: false }
|
|
258
|
+
).render(120);
|
|
259
|
+
|
|
260
|
+
const colors = new Set(rendered.calls.map((call) => call.color));
|
|
261
|
+
const expectedColors = [
|
|
262
|
+
"mdHeading",
|
|
263
|
+
"mdLink",
|
|
264
|
+
"mdCode",
|
|
265
|
+
"mdQuote",
|
|
266
|
+
"mdQuoteBorder",
|
|
267
|
+
"mdListBullet",
|
|
268
|
+
"mdCodeBlock",
|
|
269
|
+
"mdCodeBlockBorder",
|
|
270
|
+
"mdHr",
|
|
271
|
+
] satisfies readonly ThemeColor[];
|
|
272
|
+
|
|
273
|
+
for (const color of expectedColors) {
|
|
274
|
+
expect(colors.has(color)).toBe(true);
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("expanded done render uses the default color for final message text", () => {
|
|
279
|
+
const rendered = tracingTheme();
|
|
280
|
+
renderResult(
|
|
281
|
+
result("plain final"),
|
|
282
|
+
{ expanded: true, isPartial: false },
|
|
283
|
+
rendered.theme,
|
|
284
|
+
{ lastComponent: undefined, isPartial: false, isError: false }
|
|
285
|
+
).render(80);
|
|
286
|
+
|
|
287
|
+
expect(rendered.calls).not.toContainEqual({
|
|
288
|
+
color: "toolOutput",
|
|
289
|
+
text: "plain final",
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
});
|