@harms-haus/pi-subagents 0.1.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/LICENSE +21 -0
- package/README.md +362 -0
- package/docs/architecture.md +554 -0
- package/docs/changelog.md +61 -0
- package/docs/profiles.md +546 -0
- package/docs/settings.md +52 -0
- package/docs/tools-reference.md +519 -0
- package/package.json +59 -0
- package/src/cache.ts +24 -0
- package/src/commands/profile.ts +176 -0
- package/src/format-tool-call.ts +597 -0
- package/src/format-transcript.ts +151 -0
- package/src/index.ts +117 -0
- package/src/profile-editor.ts +356 -0
- package/src/profile-formatting.ts +178 -0
- package/src/profile-types.ts +73 -0
- package/src/profiles.ts +577 -0
- package/src/schemas.ts +65 -0
- package/src/settings.ts +155 -0
- package/src/skill-discovery.ts +30 -0
- package/src/spawner.ts +523 -0
- package/src/tools/delegate-render.ts +285 -0
- package/src/tools/delegate.ts +867 -0
- package/src/tools/retrieval.ts +287 -0
- package/src/types.ts +232 -0
- package/src/utils.ts +168 -0
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool Call Formatting & Path/Bash Utilities
|
|
3
|
+
*
|
|
4
|
+
* Functions for formatting tool call previews and shortening paths/collapsing
|
|
5
|
+
* cd commands in sub-agent output.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { relative } from "node:path";
|
|
10
|
+
|
|
11
|
+
const HOME = homedir();
|
|
12
|
+
|
|
13
|
+
const TRUNCATION_SUFFIX_LENGTH = 3;
|
|
14
|
+
const BASH_PREFIX_WIDTH = 12;
|
|
15
|
+
const BASH_CONT_PREFIX_WIDTH = 5;
|
|
16
|
+
|
|
17
|
+
// ── Path Shortening ──────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Shortens a single absolute file path relative to the given cwd.
|
|
21
|
+
* - Replaces home directory prefix with `~`
|
|
22
|
+
* - Uses relative path from cwd if shorter
|
|
23
|
+
*/
|
|
24
|
+
export function shortenPath(absolutePath: string, cwd: string): string {
|
|
25
|
+
if (absolutePath === cwd) {
|
|
26
|
+
return ".";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let displayPath = absolutePath;
|
|
30
|
+
if (absolutePath.startsWith(`${HOME}/`)) {
|
|
31
|
+
displayPath = `~${absolutePath.slice(HOME.length)}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const rel = relative(cwd, absolutePath);
|
|
35
|
+
|
|
36
|
+
if (rel !== "" && rel !== "." && rel.length < displayPath.length) {
|
|
37
|
+
// For ascending paths (..), only use if significantly shorter to avoid confusing output
|
|
38
|
+
if (rel.startsWith("..")) {
|
|
39
|
+
const savings = displayPath.length - rel.length;
|
|
40
|
+
if (savings < 10) {
|
|
41
|
+
return displayPath;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return rel;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return displayPath;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Regex to match candidate absolute paths (at least 2 segments, starting with /) */
|
|
51
|
+
const ABSOLUTE_PATH_REGEX = /(?:^|[^:\w/])((?:\/[a-zA-Z0-9._-]+){2,})/g;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Finds absolute paths in arbitrary text and shortens them.
|
|
55
|
+
* Excludes URLs (preceded by `://`).
|
|
56
|
+
*/
|
|
57
|
+
export function shortenPathsInText(text: string, cwd: string): string {
|
|
58
|
+
const matches: Array<{ match: string; index: number }> = [];
|
|
59
|
+
ABSOLUTE_PATH_REGEX.lastIndex = 0;
|
|
60
|
+
let m: RegExpExecArray | null = ABSOLUTE_PATH_REGEX.exec(text);
|
|
61
|
+
while (m !== null) {
|
|
62
|
+
if (m[1]) {
|
|
63
|
+
matches.push({ match: m[1], index: m.index + (m[0].length - m[1].length) });
|
|
64
|
+
}
|
|
65
|
+
m = ABSOLUTE_PATH_REGEX.exec(text);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (matches.length === 0) {
|
|
69
|
+
return text;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Build result by replacing each match
|
|
73
|
+
let result = "";
|
|
74
|
+
let lastEnd = 0;
|
|
75
|
+
|
|
76
|
+
for (const { match, index } of matches) {
|
|
77
|
+
result += text.slice(lastEnd, index);
|
|
78
|
+
result += shortenPath(match, cwd);
|
|
79
|
+
lastEnd = index + match.length;
|
|
80
|
+
}
|
|
81
|
+
result += text.slice(lastEnd);
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Handles the common pattern where the cwd appears in cd commands.
|
|
87
|
+
* - `cd <cwd> && ...` → strips the `cd <cwd> &&` prefix
|
|
88
|
+
* - `cd <cwd>` exactly → returns `.`
|
|
89
|
+
* - `cd <cwd> &&` with nothing after → returns empty string
|
|
90
|
+
*/
|
|
91
|
+
let _cdCwd = "";
|
|
92
|
+
let _cdPattern: RegExp | null = null;
|
|
93
|
+
|
|
94
|
+
function getCdPattern(cwd: string): RegExp {
|
|
95
|
+
if (cwd !== _cdCwd) {
|
|
96
|
+
_cdCwd = cwd;
|
|
97
|
+
const escapedCwd = cwd.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
98
|
+
_cdPattern = new RegExp(`^cd\\s+${escapedCwd}(\\s+&&\\s*(.*))?$`);
|
|
99
|
+
}
|
|
100
|
+
return _cdPattern as RegExp;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function collapseCdDot(command: string, cwd: string): string {
|
|
104
|
+
const match = command.match(getCdPattern(cwd));
|
|
105
|
+
|
|
106
|
+
if (!match) {
|
|
107
|
+
return command;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (!command.includes("&&")) {
|
|
111
|
+
// Exact match: `cd <cwd>` with nothing after → return "."
|
|
112
|
+
return ".";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Has `&&` part — match[2] is the text after `&&\s*`, which may be empty
|
|
116
|
+
const after = match[2];
|
|
117
|
+
if (!after || after.trim() === "") {
|
|
118
|
+
// `cd <cwd> &&` with nothing after → return empty string
|
|
119
|
+
return "";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// `cd <cwd> && ...` → strip prefix, return the rest
|
|
123
|
+
return after.trimStart();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── Bash Command Formatting ────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
function flushTruncatedSegment(
|
|
129
|
+
seg: string,
|
|
130
|
+
budget: number,
|
|
131
|
+
isLast: boolean,
|
|
132
|
+
isFirstLine: boolean,
|
|
133
|
+
lines: string[],
|
|
134
|
+
contPrefix: string,
|
|
135
|
+
): { isFirstLine: boolean } {
|
|
136
|
+
const truncated = `${seg.slice(0, budget - TRUNCATION_SUFFIX_LENGTH)}...`;
|
|
137
|
+
const prefix = isFirstLine ? "" : contPrefix;
|
|
138
|
+
const suffix = isLast ? "" : " &&";
|
|
139
|
+
lines.push(`${prefix}${truncated}${suffix}`);
|
|
140
|
+
return { isFirstLine: false };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function formatBashSegments(
|
|
144
|
+
segments: string[],
|
|
145
|
+
firstLineBudget: number,
|
|
146
|
+
contLineBudget: number,
|
|
147
|
+
contPrefix: string,
|
|
148
|
+
separator: string,
|
|
149
|
+
): string {
|
|
150
|
+
const lines: string[] = [];
|
|
151
|
+
let currentLine = "";
|
|
152
|
+
let isFirstLine = true;
|
|
153
|
+
|
|
154
|
+
for (let i = 0; i < segments.length; i++) {
|
|
155
|
+
const budget = isFirstLine && currentLine.length === 0 ? firstLineBudget : contLineBudget;
|
|
156
|
+
const seg = segments[i];
|
|
157
|
+
if (!seg) continue;
|
|
158
|
+
const isLast = i === segments.length - 1;
|
|
159
|
+
|
|
160
|
+
if (currentLine.length === 0) {
|
|
161
|
+
if (seg.length <= budget) {
|
|
162
|
+
currentLine = `${isFirstLine ? "" : contPrefix}${seg}`;
|
|
163
|
+
} else {
|
|
164
|
+
const result = flushTruncatedSegment(seg, budget, isLast, isFirstLine, lines, contPrefix);
|
|
165
|
+
isFirstLine = result.isFirstLine;
|
|
166
|
+
currentLine = "";
|
|
167
|
+
}
|
|
168
|
+
} else {
|
|
169
|
+
const withSeg = `${currentLine} && ${seg}`;
|
|
170
|
+
if (withSeg.length <= budget) {
|
|
171
|
+
currentLine = withSeg;
|
|
172
|
+
} else {
|
|
173
|
+
lines.push(`${currentLine}${separator}`);
|
|
174
|
+
isFirstLine = false;
|
|
175
|
+
|
|
176
|
+
if (seg.length <= contLineBudget) {
|
|
177
|
+
currentLine = `${contPrefix}${seg}`;
|
|
178
|
+
} else {
|
|
179
|
+
const result = flushTruncatedSegment(
|
|
180
|
+
seg,
|
|
181
|
+
contLineBudget,
|
|
182
|
+
isLast,
|
|
183
|
+
false,
|
|
184
|
+
lines,
|
|
185
|
+
contPrefix,
|
|
186
|
+
);
|
|
187
|
+
isFirstLine = result.isFirstLine;
|
|
188
|
+
currentLine = "";
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (currentLine.length > 0) {
|
|
195
|
+
lines.push(currentLine);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return lines.join("\n");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Format a bash command with smart && splitting for display.
|
|
203
|
+
*
|
|
204
|
+
* Splits on ` && ` boundaries, greedily fits segments into the width budget.
|
|
205
|
+
* When the next segment won't fit in the remaining width, starts a new line.
|
|
206
|
+
* When a single segment is too long, truncates with `...`.
|
|
207
|
+
* Continuation lines are prefixed with `│ ` for visual grouping.
|
|
208
|
+
*
|
|
209
|
+
* @param cmd - The bash command string (already collapsed/stripped of cd prefix)
|
|
210
|
+
* @param firstLineBudget - Maximum characters for the first line of command content
|
|
211
|
+
* @param contBudget - Maximum characters for continuation lines (command text only,
|
|
212
|
+
* excluding the `│ ` prefix). Defaults to `firstLineBudget`.
|
|
213
|
+
* @returns Formatted multi-line string (continuation lines include `│ ` prefix)
|
|
214
|
+
*/
|
|
215
|
+
export function formatBashCommand(
|
|
216
|
+
cmd: string,
|
|
217
|
+
firstLineBudget: number,
|
|
218
|
+
contBudget?: number,
|
|
219
|
+
): string {
|
|
220
|
+
const contLineBudget = contBudget ?? firstLineBudget;
|
|
221
|
+
|
|
222
|
+
if (cmd.length <= firstLineBudget) {
|
|
223
|
+
return cmd;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Split on " && " boundaries
|
|
227
|
+
const segments = cmd.split(" && ");
|
|
228
|
+
|
|
229
|
+
if (segments.length === 1) {
|
|
230
|
+
// Single segment, just truncate
|
|
231
|
+
return `${cmd.slice(0, firstLineBudget - TRUNCATION_SUFFIX_LENGTH)}...`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const separator = " &&"; // goes at end of line when wrapping
|
|
235
|
+
const contPrefix = "\u2502 "; // \u2502 = │ prefix for continuation lines
|
|
236
|
+
|
|
237
|
+
return formatBashSegments(segments, firstLineBudget, contLineBudget, contPrefix, separator);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ── Tool Call Formatting ────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Count non-empty lines in a text without allocating intermediate arrays.
|
|
244
|
+
*/
|
|
245
|
+
export function countNonEmptyLines(text: string): number {
|
|
246
|
+
let count = 0;
|
|
247
|
+
let lineStart = 0;
|
|
248
|
+
for (let i = 0; i <= text.length; i++) {
|
|
249
|
+
if (i === text.length || text.charCodeAt(i) === 10) {
|
|
250
|
+
// newline
|
|
251
|
+
let empty = true;
|
|
252
|
+
for (let j = lineStart; j < i; j++) {
|
|
253
|
+
const c = text.charCodeAt(j);
|
|
254
|
+
if (c !== 32 && c !== 9 && c !== 13) {
|
|
255
|
+
// not space/tab/CR
|
|
256
|
+
empty = false;
|
|
257
|
+
break;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (!empty) {
|
|
261
|
+
count++;
|
|
262
|
+
}
|
|
263
|
+
lineStart = i + 1;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return count;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function countOutputEntries(text: string): number {
|
|
270
|
+
let count = 0;
|
|
271
|
+
let lineStart = 0;
|
|
272
|
+
for (let i = 0; i <= text.length; i++) {
|
|
273
|
+
if (i === text.length || text.charCodeAt(i) === 10) {
|
|
274
|
+
if (i > lineStart && text.charCodeAt(lineStart) !== 91) count++;
|
|
275
|
+
lineStart = i + 1;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return count;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function countDirsInOutput(text: string): number {
|
|
282
|
+
let dirs = 0;
|
|
283
|
+
let lineStart = 0;
|
|
284
|
+
for (let i = 0; i <= text.length; i++) {
|
|
285
|
+
if (i === text.length || text.charCodeAt(i) === 10) {
|
|
286
|
+
if (i > lineStart && text.charCodeAt(lineStart) !== 91 && text.charCodeAt(i - 1) === 47) {
|
|
287
|
+
dirs++;
|
|
288
|
+
}
|
|
289
|
+
lineStart = i + 1;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return dirs;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function isEmptyLsOutput(text: string): boolean {
|
|
296
|
+
return !text || text === "(empty directory)" || text === "(empty directory)\n";
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function formatLsResultText(
|
|
300
|
+
text: string,
|
|
301
|
+
details?: { entryLimitReached?: number },
|
|
302
|
+
inline?: boolean,
|
|
303
|
+
): string {
|
|
304
|
+
const prefix = inline ? "" : " ";
|
|
305
|
+
if (isEmptyLsOutput(text)) {
|
|
306
|
+
return `${prefix}(empty)`;
|
|
307
|
+
}
|
|
308
|
+
const dirs = countDirsInOutput(text);
|
|
309
|
+
const total = countOutputEntries(text);
|
|
310
|
+
const files = total - dirs;
|
|
311
|
+
if (dirs === 0 && files === 0) {
|
|
312
|
+
return `${prefix}(empty)`;
|
|
313
|
+
}
|
|
314
|
+
const parts: string[] = [];
|
|
315
|
+
if (files > 0) parts.push(`${files} file${files !== 1 ? "s" : ""}`);
|
|
316
|
+
if (dirs > 0) parts.push(`${dirs} dir${dirs !== 1 ? "s" : ""}`);
|
|
317
|
+
const truncationIndicator = details?.entryLimitReached ? "+" : "";
|
|
318
|
+
return `${prefix}${parts.join(", ")}${truncationIndicator}`;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function formatFindResultText(
|
|
322
|
+
text: string,
|
|
323
|
+
details?: { resultLimitReached?: number },
|
|
324
|
+
inline?: boolean,
|
|
325
|
+
): string {
|
|
326
|
+
const prefix = inline ? "" : " ";
|
|
327
|
+
if (
|
|
328
|
+
!text ||
|
|
329
|
+
text === "No files found matching pattern" ||
|
|
330
|
+
text === "No files found matching pattern\n"
|
|
331
|
+
) {
|
|
332
|
+
return `${prefix}0 matches`;
|
|
333
|
+
}
|
|
334
|
+
const count = countOutputEntries(text);
|
|
335
|
+
const truncationIndicator = details?.resultLimitReached ? "+" : "";
|
|
336
|
+
return `${prefix}${count} match${count !== 1 ? "es" : ""}${truncationIndicator}`;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export function formatToolResult(
|
|
340
|
+
toolName: string,
|
|
341
|
+
resultText: string,
|
|
342
|
+
details?: Record<string, unknown>,
|
|
343
|
+
): string | null {
|
|
344
|
+
if (toolName === "ls") {
|
|
345
|
+
return formatLsResultText(resultText, details);
|
|
346
|
+
}
|
|
347
|
+
if (toolName === "find") {
|
|
348
|
+
return formatFindResultText(resultText, details);
|
|
349
|
+
}
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Like formatToolResult but returns the summary WITHOUT leading spaces.
|
|
355
|
+
* Returns null for unsupported tool names.
|
|
356
|
+
*/
|
|
357
|
+
export function formatToolResultInline(
|
|
358
|
+
toolName: string,
|
|
359
|
+
resultText: string,
|
|
360
|
+
details?: Record<string, unknown>,
|
|
361
|
+
): string | null {
|
|
362
|
+
if (toolName === "ls") {
|
|
363
|
+
return formatLsResultText(resultText, details, true);
|
|
364
|
+
}
|
|
365
|
+
if (toolName === "find") {
|
|
366
|
+
return formatFindResultText(resultText, details, true);
|
|
367
|
+
}
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ── formatToolCall case helpers ────────────────────────────────────
|
|
372
|
+
|
|
373
|
+
function formatEditCall(
|
|
374
|
+
a: Record<string, string>,
|
|
375
|
+
args: Record<string, unknown>,
|
|
376
|
+
cwd: string,
|
|
377
|
+
): string {
|
|
378
|
+
const path = shortenPath(a.path || a.filePath || "...", cwd);
|
|
379
|
+
const edits = (args.edits as Array<{ oldText?: string; newText?: string }> | undefined) ?? [];
|
|
380
|
+
const count = edits.length;
|
|
381
|
+
const suffix = count ? ` (${count} edit${count > 1 ? "s" : ""})` : "";
|
|
382
|
+
let added = 0;
|
|
383
|
+
let removed = 0;
|
|
384
|
+
for (const edit of edits) {
|
|
385
|
+
removed += countNonEmptyLines(edit.oldText ?? "");
|
|
386
|
+
added += countNonEmptyLines(edit.newText ?? "");
|
|
387
|
+
}
|
|
388
|
+
const diffStats = count > 0 ? ` +${added}/-${removed}` : "";
|
|
389
|
+
return `edit → ${path}${suffix}${diffStats}`;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function formatWriteCall(a: Record<string, string>, cwd: string): string {
|
|
393
|
+
const path = shortenPath(a.path || a.filePath || "...", cwd);
|
|
394
|
+
const content = a.content || "";
|
|
395
|
+
const lines = countNonEmptyLines(content);
|
|
396
|
+
return `write → ${path} +${lines}`;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function formatGrepCall(a: Record<string, string>, cwd: string): string {
|
|
400
|
+
const pattern = a.pattern || "...";
|
|
401
|
+
if (a.glob) {
|
|
402
|
+
return `grep → /${pattern}/ → ${a.glob}`;
|
|
403
|
+
} else if (a.path) {
|
|
404
|
+
return `grep → /${pattern}/ → ${shortenPath(a.path, cwd)}`;
|
|
405
|
+
}
|
|
406
|
+
return `grep → /${pattern}/`;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function formatBashCall(a: Record<string, string>, cwd: string, widthBudget: number): string {
|
|
410
|
+
let cmd = (a.command || "...").split("\n")[0] ?? "...";
|
|
411
|
+
cmd = collapseCdDot(cmd, cwd);
|
|
412
|
+
if (cmd === "." || cmd === "") {
|
|
413
|
+
return `bash → cd .`;
|
|
414
|
+
}
|
|
415
|
+
cmd = shortenPathsInText(cmd, cwd);
|
|
416
|
+
return `bash → ${formatBashCommand(cmd, widthBudget - BASH_PREFIX_WIDTH, widthBudget - BASH_CONT_PREFIX_WIDTH)}`;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function formatReadCall(a: Record<string, string>, cwd: string): string {
|
|
420
|
+
const path = shortenPath(a.path || "...", cwd);
|
|
421
|
+
const parts = [path];
|
|
422
|
+
if (a.offset) {
|
|
423
|
+
parts.push(`:${a.offset}`);
|
|
424
|
+
}
|
|
425
|
+
if (a.limit) {
|
|
426
|
+
parts.push(`+${a.limit}`);
|
|
427
|
+
}
|
|
428
|
+
const lineCount = a.limit ? ` (${a.limit} lines)` : "";
|
|
429
|
+
return `read → ${parts.join("")}${lineCount}`;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function formatDelegateCall(a: Record<string, string>, args: Record<string, unknown>): string {
|
|
433
|
+
const tasks = (args.tasks || []) as { profile?: string }[];
|
|
434
|
+
const profiles = tasks.map((t) => t.profile).filter(Boolean);
|
|
435
|
+
const profileStr =
|
|
436
|
+
profiles.length > 0 ? ` [${profiles.join(", ")}]` : a.profile ? ` [${a.profile}]` : "";
|
|
437
|
+
return `delegate_to_subagents → ${tasks.length} task${tasks.length !== 1 ? "s" : ""}${profileStr}`;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function formatWriteTodosCall(args: Record<string, unknown>): string {
|
|
441
|
+
const n = (args.todos as unknown[] | undefined)?.length ?? 0;
|
|
442
|
+
return `write_todos → ${n} todos written`;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function formatLspRefactorSymbolCall(a: Record<string, string>, cwd: string): string {
|
|
446
|
+
const file = shortenPath(a.file || "...", cwd);
|
|
447
|
+
return `lsp_refactor_symbol → ${file}:${a.line}:${a.column} → ${a.newName || "..."}`;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function formatFetchRepoCall(a: Record<string, string>): string {
|
|
451
|
+
return `fetch_repo → ${a.url || "..."}`;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function formatSessionCall(toolName: string, args: Record<string, unknown>): string {
|
|
455
|
+
const sessionId = (args.sessionId as string) || "...";
|
|
456
|
+
return `${toolName} → ${sessionId}`;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function formatWorkflowStepCall(args: Record<string, unknown>): string {
|
|
460
|
+
const action = (args.action as string) || "?";
|
|
461
|
+
return `workflow_step → ${action}`;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function formatLsCall(a: Record<string, string>, cwd: string): string {
|
|
465
|
+
const path = a.path ? shortenPath(a.path, cwd) : ".";
|
|
466
|
+
return `ls → ${path}`;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function formatFindCall(a: Record<string, string>, cwd: string): string {
|
|
470
|
+
const pattern = a.pattern || "...";
|
|
471
|
+
if (a.path) {
|
|
472
|
+
return `find → ${pattern} in ${shortenPath(a.path, cwd)}`;
|
|
473
|
+
}
|
|
474
|
+
return `find → ${pattern}`;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function formatLspFindSymbolCall(a: Record<string, string>): string {
|
|
478
|
+
return `lsp_find_symbol → ${a.query || "..."}`;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function formatEditTodosCall(args: Record<string, unknown>): string {
|
|
482
|
+
const action = (args.action as string) || "?";
|
|
483
|
+
const indices = (args.indices as number[] | undefined) ?? [];
|
|
484
|
+
const todos = args.todos as Array<{ text?: string }> | undefined;
|
|
485
|
+
let desc: string;
|
|
486
|
+
if (todos && todos.length > 0) {
|
|
487
|
+
desc = todos.map((t) => t.text ?? "").join(", ");
|
|
488
|
+
if (desc.length > 48) {
|
|
489
|
+
desc = `${desc.slice(0, 45)}...`;
|
|
490
|
+
}
|
|
491
|
+
} else {
|
|
492
|
+
desc = `${action} [${indices.join(",")}]`;
|
|
493
|
+
}
|
|
494
|
+
return `edit_todos → ${desc}`;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function formatLspFileCall(toolName: string, a: Record<string, string>, cwd: string): string {
|
|
498
|
+
const file = shortenPath(a.file || "...", cwd);
|
|
499
|
+
return `${toolName} → ${file}`;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function formatLspPositionCall(toolName: string, a: Record<string, string>, cwd: string): string {
|
|
503
|
+
const file = shortenPath(a.file || "...", cwd);
|
|
504
|
+
return `${toolName} → ${file}:${a.line}:${a.column}`;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function formatLintFilesCall(args: Record<string, unknown>, cwd: string): string {
|
|
508
|
+
const files = args.files as string[] | undefined;
|
|
509
|
+
if (files && files.length > 0) {
|
|
510
|
+
const shortened = files.map((f) => shortenPath(f, cwd));
|
|
511
|
+
const preview =
|
|
512
|
+
shortened.length <= 3
|
|
513
|
+
? shortened.join(", ")
|
|
514
|
+
: `${shortened.slice(0, 2).join(", ")}, ... +${shortened.length - 2} more`;
|
|
515
|
+
return `lint → ${preview}`;
|
|
516
|
+
}
|
|
517
|
+
return "lint → (all)";
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function formatFetchCall(toolName: string, a: Record<string, string>, widthBudget: number): string {
|
|
521
|
+
const url = a.url || a.query || "...";
|
|
522
|
+
const urlBudget = widthBudget - toolName.length - 4;
|
|
523
|
+
const truncated = url.length > urlBudget ? `${url.slice(0, urlBudget - 3)}...` : url;
|
|
524
|
+
return `${toolName} → ${truncated}`;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function formatDefaultCall(
|
|
528
|
+
toolName: string,
|
|
529
|
+
args: Record<string, unknown>,
|
|
530
|
+
widthBudget: number,
|
|
531
|
+
): string {
|
|
532
|
+
const argsStr = JSON.stringify(args);
|
|
533
|
+
const budget = widthBudget - toolName.length - 1;
|
|
534
|
+
if (argsStr === "{}") {
|
|
535
|
+
return toolName;
|
|
536
|
+
}
|
|
537
|
+
if (argsStr.length > budget) {
|
|
538
|
+
return `${toolName} ${argsStr.slice(0, Math.max(0, budget - 3))}...`;
|
|
539
|
+
}
|
|
540
|
+
return `${toolName} ${argsStr}`;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const TOOL_FORMATTERS: Partial<
|
|
544
|
+
Record<
|
|
545
|
+
string,
|
|
546
|
+
(
|
|
547
|
+
a: Record<string, string>,
|
|
548
|
+
args: Record<string, unknown>,
|
|
549
|
+
cwd: string,
|
|
550
|
+
widthBudget: number,
|
|
551
|
+
) => string
|
|
552
|
+
>
|
|
553
|
+
> = {
|
|
554
|
+
edit: (a, args, cwd) => formatEditCall(a, args, cwd),
|
|
555
|
+
write: (a, _args, cwd) => formatWriteCall(a, cwd),
|
|
556
|
+
grep: (a, _args, cwd) => formatGrepCall(a, cwd),
|
|
557
|
+
bash: (a, _args, cwd, widthBudget) => formatBashCall(a, cwd, widthBudget),
|
|
558
|
+
read: (a, _args, cwd) => formatReadCall(a, cwd),
|
|
559
|
+
delegate_to_subagents: (a, args) => formatDelegateCall(a, args),
|
|
560
|
+
write_todos: (_a, args) => formatWriteTodosCall(args),
|
|
561
|
+
edit_todos: (_a, args) => formatEditTodosCall(args),
|
|
562
|
+
list_todos: () => "list_todos",
|
|
563
|
+
lsp_diagnostics: (a, _args, cwd) => formatLspFileCall("lsp_diagnostics", a, cwd),
|
|
564
|
+
lsp_find_references: (a, _args, cwd) => formatLspPositionCall("lsp_find_references", a, cwd),
|
|
565
|
+
lsp_goto_definition: (a, _args, cwd) => formatLspPositionCall("lsp_goto_definition", a, cwd),
|
|
566
|
+
lsp_find_symbol: (a) => formatLspFindSymbolCall(a),
|
|
567
|
+
lsp_call_hierarchy: (a, _args, cwd) => formatLspPositionCall("lsp_call_hierarchy", a, cwd),
|
|
568
|
+
lsp_refactor_symbol: (a, _args, cwd) => formatLspRefactorSymbolCall(a, cwd),
|
|
569
|
+
lint_files: (_a, args, cwd) => formatLintFilesCall(args, cwd),
|
|
570
|
+
fetch_content: (a, _args, _cwd, widthBudget) => formatFetchCall("fetch_content", a, widthBudget),
|
|
571
|
+
web_search: (a, _args, _cwd, widthBudget) => formatFetchCall("web_search", a, widthBudget),
|
|
572
|
+
fetch_repo: (a) => formatFetchRepoCall(a),
|
|
573
|
+
get_subagent_output: (_a, args) => formatSessionCall("get_subagent_output", args),
|
|
574
|
+
get_subagent_session: (_a, args) => formatSessionCall("get_subagent_session", args),
|
|
575
|
+
list_subagent_profiles: () => "list_subagent_profiles",
|
|
576
|
+
workflow_step: (_a, args) => formatWorkflowStepCall(args),
|
|
577
|
+
ls: (a, _args, cwd) => formatLsCall(a, cwd),
|
|
578
|
+
find: (a, _args, cwd) => formatFindCall(a, cwd),
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Format a tool call as a concise one-liner for the sub-agent rolling window.
|
|
583
|
+
* Avoids dumping full JSON arguments for common tools.
|
|
584
|
+
*/
|
|
585
|
+
export function formatToolCall(
|
|
586
|
+
toolName: string,
|
|
587
|
+
args: Record<string, unknown>,
|
|
588
|
+
cwd: string,
|
|
589
|
+
widthBudget: number,
|
|
590
|
+
): string {
|
|
591
|
+
const a = args as Record<string, string>;
|
|
592
|
+
const formatter = TOOL_FORMATTERS[toolName];
|
|
593
|
+
if (formatter) {
|
|
594
|
+
return formatter(a, args, cwd, widthBudget);
|
|
595
|
+
}
|
|
596
|
+
return formatDefaultCall(toolName, args, widthBudget);
|
|
597
|
+
}
|