@oh-my-pi/pi-coding-agent 14.5.1 → 14.5.3
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/CHANGELOG.md +43 -0
- package/package.json +8 -8
- package/src/config/prompt-templates.ts +6 -3
- package/src/edit/block.ts +308 -0
- package/src/edit/indent.ts +150 -0
- package/src/edit/modes/atom.ts +341 -114
- package/src/lsp/utils.ts +6 -36
- package/src/modes/components/status-line.ts +36 -0
- package/src/modes/controllers/event-controller.ts +27 -2
- package/src/prompts/system/system-prompt.md +1 -1
- package/src/prompts/tools/atom.md +64 -86
- package/src/prompts/tools/read.md +1 -1
- package/src/session/agent-session.ts +28 -1
- package/src/tools/ast-edit.ts +23 -44
- package/src/tools/ast-grep.ts +18 -42
- package/src/tools/checkpoint.ts +2 -0
- package/src/tools/exit-plan-mode.ts +1 -0
- package/src/tools/grep.ts +11 -46
- package/src/tools/grouped-file-output.ts +96 -0
- package/src/tools/read.ts +6 -0
- package/src/tools/report-tool-issue.ts +1 -0
- package/src/tools/resolve.ts +2 -0
- package/src/tools/review.ts +1 -0
- package/src/tools/todo-write.ts +1 -1
- package/src/tools/yield.ts +1 -0
- package/src/utils/tool-choice.ts +6 -1
package/src/lsp/utils.ts
CHANGED
|
@@ -4,6 +4,7 @@ import * as fs from "node:fs/promises";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { isEnoent } from "@oh-my-pi/pi-utils";
|
|
6
6
|
import { type Theme, theme } from "../modes/theme/theme";
|
|
7
|
+
import { formatGroupedFiles } from "../tools/grouped-file-output";
|
|
7
8
|
import { resolveToCwd } from "../tools/path-utils";
|
|
8
9
|
import type {
|
|
9
10
|
CodeAction,
|
|
@@ -154,7 +155,7 @@ const DIAG_PATH_RE = /^(.+?):(\d+:\d+\s+.*)$/;
|
|
|
154
155
|
/**
|
|
155
156
|
* Reformat pre-formatted diagnostic messages into grep-style directory/file groups.
|
|
156
157
|
* Input: ["path:line:col [sev] msg", ...]
|
|
157
|
-
* Output: "# dir
|
|
158
|
+
* Output: "# dir/\n## file.ts\n line:col [sev] msg"
|
|
158
159
|
*
|
|
159
160
|
* Messages that don't match the expected format are appended ungrouped at the end.
|
|
160
161
|
*/
|
|
@@ -183,41 +184,10 @@ export function formatGroupedDiagnosticMessages(messages: string[]): string {
|
|
|
183
184
|
return ungrouped.join("\n");
|
|
184
185
|
}
|
|
185
186
|
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
filesByDirectory.set(directory, []);
|
|
191
|
-
}
|
|
192
|
-
filesByDirectory.get(directory)?.push(filePath);
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
const lines: string[] = [];
|
|
196
|
-
for (const [directory, directoryFiles] of filesByDirectory) {
|
|
197
|
-
if (directory === ".") {
|
|
198
|
-
for (const filePath of directoryFiles) {
|
|
199
|
-
if (lines.length > 0) {
|
|
200
|
-
lines.push("");
|
|
201
|
-
}
|
|
202
|
-
lines.push(`# ${path.basename(filePath)}`);
|
|
203
|
-
for (const diagnostic of diagnosticsByFile.get(filePath) ?? []) {
|
|
204
|
-
lines.push(` ${diagnostic}`);
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
continue;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
if (lines.length > 0) {
|
|
211
|
-
lines.push("");
|
|
212
|
-
}
|
|
213
|
-
lines.push(`# ${directory}`);
|
|
214
|
-
for (const filePath of directoryFiles) {
|
|
215
|
-
lines.push(`## └─ ${path.basename(filePath)}`);
|
|
216
|
-
for (const diagnostic of diagnosticsByFile.get(filePath) ?? []) {
|
|
217
|
-
lines.push(` ${diagnostic}`);
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
}
|
|
187
|
+
const grouped = formatGroupedFiles(fileOrder, filePath => ({
|
|
188
|
+
modelLines: (diagnosticsByFile.get(filePath) ?? []).map(diagnostic => ` ${diagnostic}`),
|
|
189
|
+
}));
|
|
190
|
+
const lines: string[] = grouped.model;
|
|
221
191
|
|
|
222
192
|
if (ungrouped.length > 0) {
|
|
223
193
|
lines.push("");
|
|
@@ -388,10 +388,12 @@ export class StatusLineComponent implements Component {
|
|
|
388
388
|
|
|
389
389
|
// Collect visible segment contents
|
|
390
390
|
const leftParts: string[] = [];
|
|
391
|
+
const leftSegIds: StatusLineSegmentId[] = [];
|
|
391
392
|
for (const segId of effectiveSettings.leftSegments) {
|
|
392
393
|
const rendered = renderSegment(segId, ctx);
|
|
393
394
|
if (rendered.visible && rendered.content) {
|
|
394
395
|
leftParts.push(rendered.content);
|
|
396
|
+
leftSegIds.push(segId);
|
|
395
397
|
}
|
|
396
398
|
}
|
|
397
399
|
|
|
@@ -434,8 +436,42 @@ export class StatusLineComponent implements Component {
|
|
|
434
436
|
right.pop();
|
|
435
437
|
rightWidth = groupWidth(right, rightCapWidth, rightSepWidth);
|
|
436
438
|
}
|
|
439
|
+
// Shrink path before dropping left segments — path is the only elastic segment
|
|
440
|
+
const pathIdx = leftSegIds.indexOf("path");
|
|
441
|
+
if (pathIdx >= 0 && totalWidth() > topFillWidth) {
|
|
442
|
+
const overflow = totalWidth() - topFillWidth;
|
|
443
|
+
const currentPathVW = visibleWidth(left[pathIdx]);
|
|
444
|
+
const minPathVW = 8; // icon + ellipsis + a few chars
|
|
445
|
+
const shrinkable = currentPathVW - minPathVW;
|
|
446
|
+
if (shrinkable > 0) {
|
|
447
|
+
const shrinkBy = Math.min(shrinkable, overflow);
|
|
448
|
+
const currentMaxLen = ctx.options.path?.maxLength ?? 40;
|
|
449
|
+
let newMaxLen = Math.max(4, Math.min(currentMaxLen, currentPathVW) - shrinkBy);
|
|
450
|
+
const pathCtx = (maxLen: number): SegmentContext => ({
|
|
451
|
+
...ctx,
|
|
452
|
+
options: { ...ctx.options, path: { ...ctx.options.path, maxLength: maxLen } },
|
|
453
|
+
});
|
|
454
|
+
let reRendered = renderSegment("path", pathCtx(newMaxLen));
|
|
455
|
+
if (reRendered.visible && reRendered.content) {
|
|
456
|
+
// maxLength governs path text, not icon prefix; iterate to compensate
|
|
457
|
+
for (let i = 0; i < 8; i++) {
|
|
458
|
+
const saved = currentPathVW - visibleWidth(reRendered.content);
|
|
459
|
+
if (saved >= shrinkBy) break;
|
|
460
|
+
const nextMaxLen = Math.max(4, newMaxLen - (shrinkBy - saved));
|
|
461
|
+
if (nextMaxLen >= newMaxLen) break; // no progress or hit floor
|
|
462
|
+
newMaxLen = nextMaxLen;
|
|
463
|
+
const adjusted = renderSegment("path", pathCtx(newMaxLen));
|
|
464
|
+
if (!adjusted.visible || !adjusted.content) break;
|
|
465
|
+
reRendered = adjusted;
|
|
466
|
+
}
|
|
467
|
+
left[pathIdx] = reRendered.content;
|
|
468
|
+
leftWidth = groupWidth(left, leftCapWidth, leftSepWidth);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
437
472
|
while (totalWidth() > topFillWidth && left.length > 0) {
|
|
438
473
|
left.pop();
|
|
474
|
+
leftSegIds.pop();
|
|
439
475
|
leftWidth = groupWidth(left, leftCapWidth, leftSepWidth);
|
|
440
476
|
}
|
|
441
477
|
}
|
|
@@ -52,6 +52,7 @@ export class EventController {
|
|
|
52
52
|
ttsr_triggered: e => this.#handleTtsrTriggered(e),
|
|
53
53
|
todo_reminder: e => this.#handleTodoReminder(e),
|
|
54
54
|
todo_auto_clear: e => this.#handleTodoAutoClear(e),
|
|
55
|
+
irc_message: e => this.#handleIrcMessage(e),
|
|
55
56
|
} satisfies AgentSessionEventHandlers;
|
|
56
57
|
}
|
|
57
58
|
|
|
@@ -203,6 +204,17 @@ export class EventController {
|
|
|
203
204
|
}
|
|
204
205
|
}
|
|
205
206
|
|
|
207
|
+
async #handleIrcMessage(event: Extract<AgentSessionEvent, { type: "irc_message" }>): Promise<void> {
|
|
208
|
+
const signature = `${event.message.role}:${event.message.customType}:${event.message.timestamp}`;
|
|
209
|
+
if (this.#renderedCustomMessages.has(signature)) {
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
this.#renderedCustomMessages.add(signature);
|
|
213
|
+
this.#resetReadGroup();
|
|
214
|
+
this.ctx.addMessageToChat(event.message);
|
|
215
|
+
this.ctx.ui.requestRender();
|
|
216
|
+
}
|
|
217
|
+
|
|
206
218
|
async #handleMessageUpdate(event: Extract<AgentSessionEvent, { type: "message_update" }>): Promise<void> {
|
|
207
219
|
if (this.ctx.streamingComponent && event.message.role === "assistant") {
|
|
208
220
|
this.ctx.streamingMessage = event.message;
|
|
@@ -269,8 +281,21 @@ export class EventController {
|
|
|
269
281
|
for (const content of this.ctx.streamingMessage.content) {
|
|
270
282
|
if (content.type !== "toolCall") continue;
|
|
271
283
|
const args = content.arguments;
|
|
272
|
-
if (!args || typeof args !== "object"
|
|
273
|
-
|
|
284
|
+
if (!args || typeof args !== "object") continue;
|
|
285
|
+
if (INTENT_FIELD in args) {
|
|
286
|
+
this.#updateWorkingMessageFromIntent(args[INTENT_FIELD] as string | undefined);
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
const tool = this.ctx.session.getToolByName(content.name);
|
|
290
|
+
if (typeof tool?.intent !== "function") continue;
|
|
291
|
+
try {
|
|
292
|
+
const derived = tool.intent(args as never)?.trim();
|
|
293
|
+
if (derived) {
|
|
294
|
+
this.#updateWorkingMessageFromIntent(derived);
|
|
295
|
+
}
|
|
296
|
+
} catch {
|
|
297
|
+
// intent function must never break the UI
|
|
298
|
+
}
|
|
274
299
|
}
|
|
275
300
|
|
|
276
301
|
this.ctx.ui.requestRender();
|
|
@@ -168,7 +168,7 @@ Tools:
|
|
|
168
168
|
|
|
169
169
|
{{#if intentTracing}}
|
|
170
170
|
<intent-field>
|
|
171
|
-
|
|
171
|
+
Most tools have a `{{intentField}}` parameter. Fill it with a concise intent in present participle form, 2-6 words, no period.
|
|
172
172
|
</intent-field>
|
|
173
173
|
{{/if}}
|
|
174
174
|
|
|
@@ -1,98 +1,76 @@
|
|
|
1
|
-
Applies precise file edits using
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
-
|
|
10
|
-
- `
|
|
11
|
-
- `
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
- `
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
- `
|
|
25
|
-
- `splice
|
|
26
|
-
|
|
1
|
+
Applies precise file edits using anchors (line+hash).
|
|
2
|
+
|
|
3
|
+
<ops>
|
|
4
|
+
Each call **MUST** have shape `{path:"a.ts",edits:[…]}`. `path` is the default file; you **MAY** override it per edit with `loc:"b.ts:160sr"`.
|
|
5
|
+
Each edit **MUST** have exactly one `loc` and **MUST** include one or more verbs.
|
|
6
|
+
|
|
7
|
+
# Locators
|
|
8
|
+
- `"A"` targets one anchored line. `"$"` targets the whole file: `pre` = BOF, `post` = EOF, `sed` = every line.
|
|
9
|
+
- Bracketed locators are **`splice` only** and select a balanced region around anchor `A`.
|
|
10
|
+
- `"(A)"` = block body. `"[A]"` = whole block/node.
|
|
11
|
+
- `"[A"` / `"(A"` = tail after/including anchor, closer excluded.
|
|
12
|
+
- `"A]"` / `"A)"` = head through/before anchor, opener excluded.
|
|
13
|
+
- Anchor bracketed forms on a body line of the intended block, not the opener line.
|
|
14
|
+
- Do not use bracketed locators on files that do not currently parse.
|
|
15
|
+
|
|
16
|
+
# Verbs
|
|
17
|
+
- `splice:[…]` replaces the anchored line, or the bracketed region. `[]` deletes; `[""]` makes a blank line.
|
|
18
|
+
- `pre:[…]` inserts before the anchor, or BOF with `loc:"$"`.
|
|
19
|
+
- `post:[…]` inserts after the anchor, or EOF with `loc:"$"`.
|
|
20
|
+
</ops>
|
|
21
|
+
|
|
22
|
+
<splice>
|
|
23
|
+
Replaces the anchored line, or the bracketed region.
|
|
24
|
+
- `[]` deletes. `[""]` leaves a blank line.
|
|
25
|
+
- For bracketed `splice`, write body at column 0, it will be re-indented.
|
|
26
|
+
- Do not use bracketed `splice` on broken files, or for single line edits.
|
|
27
|
+
</splice>
|
|
28
|
+
|
|
29
|
+
<sed>
|
|
30
|
+
Use for tiny inline edits: names, operators, literals.
|
|
31
|
+
- Keep `pat` as short as possible, it does not have to be unique.
|
|
32
|
+
- `g:false` by default; set to replace all instead of first.
|
|
33
|
+
</sed>
|
|
27
34
|
|
|
28
35
|
<examples>
|
|
29
|
-
All examples below reference the same file:
|
|
30
|
-
|
|
31
36
|
```ts title="a.ts"
|
|
32
|
-
{{hline 1 "const
|
|
37
|
+
{{hline 1 "const FALLBACK = \"guest\";"}}
|
|
33
38
|
{{hline 2 ""}}
|
|
34
|
-
{{hline 3 "function
|
|
35
|
-
{{hline 4 "\
|
|
36
|
-
{{hline 5 "\
|
|
37
|
-
{{hline 6 "
|
|
38
|
-
{{hline 7 "\treturn null;"}}
|
|
39
|
-
{{hline 8 "}"}}
|
|
39
|
+
{{hline 3 "export function label(name) {"}}
|
|
40
|
+
{{hline 4 "\tconst clean = name || FALLBACK;"}}
|
|
41
|
+
{{hline 5 "\treturn clean.trim().toLowerCase();"}}
|
|
42
|
+
{{hline 6 "}"}}
|
|
40
43
|
```
|
|
41
44
|
|
|
42
|
-
#
|
|
43
|
-
`{path:"a.ts",edits:[{loc:{{href 1 "const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
# Delete a line
|
|
49
|
-
`{path:"a.ts",edits:[{loc:{{href
|
|
50
|
-
|
|
51
|
-
# Preserve a blank line with `splice:[""]`
|
|
45
|
+
# Single-line replacement:
|
|
46
|
+
`{path:"a.ts",edits:[{loc:{{href 1 "const FALLBACK = \"guest\";"}},splice:["const FALLBACK = \"anonymous\";"]}]}`
|
|
47
|
+
# Small token edit: prefer `sed`:
|
|
48
|
+
`{path:"a.ts",edits:[{loc:{{href 5 "\treturn clean.trim().toLowerCase();"}},sed:{pat:"toLowerCase",rep:"toUpperCase"}}]}`
|
|
49
|
+
# Insert before / after an anchor:
|
|
50
|
+
`{path:"a.ts",edits:[{loc:{{href 5 "\treturn clean.trim().toLowerCase();"}},pre:["\tif (!clean) return FALLBACK;"],post:["\t// normalized label"]}]}`
|
|
51
|
+
# Delete a line vs make it blank:
|
|
52
|
+
`{path:"a.ts",edits:[{loc:{{href 2 ""}},splice:[]}]}`
|
|
52
53
|
`{path:"a.ts",edits:[{loc:{{href 2 ""}},splice:[""]}]}`
|
|
53
|
-
|
|
54
|
-
# Insert before / after a line
|
|
55
|
-
`{path:"a.ts",edits:[{loc:{{href 3 "function beta(x) {"}},pre:["function gamma() {","\tvalidate();","}",""]}]}`
|
|
56
|
-
|
|
57
|
-
# Substitute one token with `sed` (regex) — preferred for token-level edits
|
|
58
|
-
Use the smallest pattern that uniquely identifies the change.
|
|
59
|
-
`{path:"a.ts",edits:[{loc:{{href 5 "\t\treturn parse(data) || fallback;"}},sed:"s/\\|\\|/??/"}]}`
|
|
60
|
-
|
|
61
|
-
# Substitute every occurrence with `sed` (literal/fixed-string)
|
|
62
|
-
Use the `F` flag to disable regex; the delimiter can be any non-alphanumeric char.
|
|
63
|
-
`{path:"a.ts",edits:[{loc:{{href 5 "\t\treturn parse(data) || fallback;"}},sed:"s|data|input|gF"}]}`
|
|
64
|
-
|
|
65
|
-
# Prepend / append at file edges
|
|
54
|
+
# File edges:
|
|
66
55
|
`{path:"a.ts",edits:[{loc:"$",pre:["// Copyright (c) 2026",""]}]}`
|
|
67
|
-
`{path:"a.ts",edits:[{loc:"$",post:["","export
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
#
|
|
73
|
-
`{path:"a.ts",edits:[{loc:{{href
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
- `{path:"a.ts",edits:[{loc:{{href 4 "\tif (x) {"}},splice:["\tif (x && ready) {"]},{loc:{{href 5 "\t\treturn parse(data) ?? fallback;"}},splice:["\t\treturn parse(data) ?? fallback;","\t\t//unreachable"]}]}`
|
|
56
|
+
`{path:"a.ts",edits:[{loc:"$",post:["","export { FALLBACK };"]}]}`
|
|
57
|
+
# Cross-file override:
|
|
58
|
+
`{path:"a.ts",edits:[{loc:{{href 1 "const FALLBACK = \"guest\";" "config.ts:" ""}},splice:["const FALLBACK = \"anonymous\";"]}]}`
|
|
59
|
+
# Body replacement: use bracketed `splice`, write body at column 0:
|
|
60
|
+
`{path:"a.ts",edits:[{loc:{{href 4 "\tconst clean = name || FALLBACK;" "(" ")"}},splice:["if (name == null) return FALLBACK;","const clean = String(name).trim();","return clean || FALLBACK;"]}]}`
|
|
61
|
+
# Whole function replacement: anchor on a body line:
|
|
62
|
+
`{path:"a.ts",edits:[{loc:{{href 5 "\treturn clean.trim().toLowerCase();" "[" "]"}},splice:["export function label(name) {","\treturn String(name ?? FALLBACK).trim().toLowerCase();","}"]}]}`
|
|
63
|
+
# WRONG: bare-anchor `splice` does not own neighboring lines:
|
|
64
|
+
`{path:"a.ts",edits:[{loc:{{href 4 "\tconst clean = name || FALLBACK;"}},splice:["\tconst clean = String(name ?? FALLBACK).trim();","\treturn clean.toLowerCase();"]}]}`
|
|
65
|
+
This replaces only line 4. Original line 5 still shifts down, so the function now has two returns.
|
|
66
|
+
# RIGHT: use a body edit for that rewrite:
|
|
67
|
+
`{path:"a.ts",edits:[{loc:{{href 4 "\tconst clean = name || FALLBACK;" "(" ")"}},splice:["const clean = String(name ?? FALLBACK).trim();","return clean.toLowerCase();"]}]}`
|
|
80
68
|
</examples>
|
|
81
69
|
|
|
82
70
|
<critical>
|
|
83
|
-
-
|
|
84
|
-
-
|
|
85
|
-
-
|
|
86
|
-
-
|
|
87
|
-
-
|
|
88
|
-
- `splice: []` deletes the anchored line. `splice:[""]` preserves a blank line.
|
|
89
|
-
- Within a single request you may submit edits in any order — the runtime applies them bottom-up so they don't shift each other. After any request that mutates a file, anchors below the mutation are stale on disk; re-read before issuing more edits to that file.
|
|
90
|
-
- `splice` operations target the current file content only. Do not try to reference old line text after the file has changed.
|
|
91
|
-
- For **small** in-line edits (renaming a token, flipping an operator, tweaking a literal), prefer `sed` over `splice`. The `loc` anchor already pins the line — repeating the entire line in a `splice` array invites hallucinated content. Use the smallest `sed` pattern that uniquely identifies the change on that line; do not pad it with surrounding text just to feel safe. For multi-line restructuring (wrapping logic, adding new branches, inserting blocks), use `splice`/`pre`/`post` — do **not** stretch `sed` into a rewrite tool.
|
|
92
|
-
- When you do use `splice`, re-read the anchored line first and copy it verbatim, changing only the required token(s). Anchor identity does not verify line content, so a hallucinated replacement will silently corrupt the file.
|
|
93
|
-
- Anchors are pin points, not region markers. One anchor pins exactly one line. If your change touches N distinct source lines, that is N edits with N anchors — not one big `splice` array intended to cover the whole region. `splice` cannot "replace lines 4 through 7"; it can only splice content in at one anchor.
|
|
94
|
-
- You **MUST NOT** include lines in `splice`/`pre`/`post` that already exist immediately adjacent to the anchor in the current file. `splice` does not overwrite the lines below — they shift down — so any neighbor you re-type in your array becomes a duplicate. If your intended replacement contains content that is already on neighboring source lines, split into multiple edits at each real change site instead of one fat `splice`.
|
|
95
|
-
- Before issuing a multi-line `splice`, mentally diff each array element against the current file lines at and just below the anchor. Any element that matches a line within ~5 lines of the anchor will become a duplicate after the splice. If you find a match, drop that element and use a separate edit (or `pre`/`post`) at the real change point.
|
|
96
|
-
- Text content must be literal file content with matching indentation. If the file uses tabs, use real tabs.
|
|
97
|
-
- You **MUST NOT** use this tool to reformat or clean up unrelated code.
|
|
71
|
+
- You **MUST** copy full anchors exactly from a read op (e.g. `160sr`); you **MUST NOT** send only the 2-letter suffix.
|
|
72
|
+
- You **MUST** make the minimum exact edit; you **MUST NOT** reformat unrelated code.
|
|
73
|
+
- A bare anchor **MUST** target one line only; you **MUST** use bracketed `splice` for balanced block rewrites.
|
|
74
|
+
- You **MUST NOT** include unchanged adjacent lines in `splice`/`pre`/`post`; they shift and duplicate.
|
|
75
|
+
- For bracketed `splice`, replacement braces **MUST** be balanced for the selected region.
|
|
98
76
|
</critical>
|
|
@@ -23,7 +23,7 @@ The `read` tool is multi-purpose and more capable than it looks — inspects fil
|
|
|
23
23
|
# Filesystem
|
|
24
24
|
- Reading a directory path returns a list of dirents.
|
|
25
25
|
{{#if IS_HASHLINE_MODE}}
|
|
26
|
-
- Reading a file returns lines prefixed with anchors (line
|
|
26
|
+
- Reading a file returns lines prefixed with anchors (line+hash): `41th|def alpha():`
|
|
27
27
|
{{else}}
|
|
28
28
|
{{#if IS_LINE_NUMBER_MODE}}
|
|
29
29
|
- Reading a file returns lines prefixed with line numbers: `41|def alpha():`
|
|
@@ -196,7 +196,8 @@ export type AgentSessionEvent =
|
|
|
196
196
|
| { type: "retry_fallback_succeeded"; model: string; role: string }
|
|
197
197
|
| { type: "ttsr_triggered"; rules: Rule[] }
|
|
198
198
|
| { type: "todo_reminder"; todos: TodoItem[]; attempt: number; maxAttempts: number }
|
|
199
|
-
| { type: "todo_auto_clear" }
|
|
199
|
+
| { type: "todo_auto_clear" }
|
|
200
|
+
| { type: "irc_message"; message: CustomMessage };
|
|
200
201
|
|
|
201
202
|
/** Listener function for agent session events */
|
|
202
203
|
export type AgentSessionEventListener = (event: AgentSessionEvent) => void;
|
|
@@ -2970,6 +2971,30 @@ export class AgentSession {
|
|
|
2970
2971
|
attribution: "user",
|
|
2971
2972
|
timestamp: Date.now(),
|
|
2972
2973
|
});
|
|
2974
|
+
// When fully idle AND the session is in a resumable assistant-ended state,
|
|
2975
|
+
// schedule an immediate continue so the queued follow-up is delivered
|
|
2976
|
+
// without waiting for the next user turn. We gate on isStreaming (model
|
|
2977
|
+
// actively producing), isRetrying (auto-retry backoff is sleeping between
|
|
2978
|
+
// attempts, #retryPromise set), and the last message being assistant —
|
|
2979
|
+
// agent.continue() only dequeues follow-ups from an assistant-ended state;
|
|
2980
|
+
// resuming from user/toolResult state runs an extra model call on the
|
|
2981
|
+
// stale prompt before draining the queue.
|
|
2982
|
+
if (this.#canAutoContinueForFollowUp()) {
|
|
2983
|
+
this.#scheduleAgentContinue({
|
|
2984
|
+
shouldContinue: () => this.#canAutoContinueForFollowUp() && this.agent.hasQueuedMessages(),
|
|
2985
|
+
});
|
|
2986
|
+
}
|
|
2987
|
+
}
|
|
2988
|
+
|
|
2989
|
+
/**
|
|
2990
|
+
* Gate for idle-path follow-up auto-continue. See `#queueFollowUp` for rationale.
|
|
2991
|
+
*/
|
|
2992
|
+
#canAutoContinueForFollowUp(): boolean {
|
|
2993
|
+
if (this.isStreaming) return false;
|
|
2994
|
+
if (this.isRetrying) return false;
|
|
2995
|
+
const messages = this.agent.state.messages;
|
|
2996
|
+
const last = messages[messages.length - 1];
|
|
2997
|
+
return last?.role === "assistant";
|
|
2973
2998
|
}
|
|
2974
2999
|
|
|
2975
3000
|
queueDeferredMessage(message: CustomMessage): void {
|
|
@@ -5973,6 +5998,7 @@ export class AgentSession {
|
|
|
5973
5998
|
attribution: "agent",
|
|
5974
5999
|
timestamp: incomingTimestamp,
|
|
5975
6000
|
};
|
|
6001
|
+
void this.#emitSessionEvent({ type: "irc_message", message: incomingRecord });
|
|
5976
6002
|
|
|
5977
6003
|
if (!awaitReply) {
|
|
5978
6004
|
this.#queueBackgroundExchangeInjection([incomingRecord]);
|
|
@@ -5997,6 +6023,7 @@ export class AgentSession {
|
|
|
5997
6023
|
attribution: "agent",
|
|
5998
6024
|
timestamp: Date.now(),
|
|
5999
6025
|
};
|
|
6026
|
+
void this.#emitSessionEvent({ type: "irc_message", message: replyRecord });
|
|
6000
6027
|
this.#queueBackgroundExchangeInjection([incomingRecord, replyRecord]);
|
|
6001
6028
|
|
|
6002
6029
|
return { replyText };
|
package/src/tools/ast-edit.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, t
|
|
|
13
13
|
import { resolveFileDisplayMode } from "../utils/file-display-mode";
|
|
14
14
|
import type { ToolSession } from ".";
|
|
15
15
|
import { createFileRecorder, formatResultPath } from "./file-recorder";
|
|
16
|
+
import { formatGroupedFiles } from "./grouped-file-output";
|
|
16
17
|
import type { OutputMeta } from "./output-meta";
|
|
17
18
|
import {
|
|
18
19
|
hasGlobPathChars,
|
|
@@ -204,7 +205,9 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
|
|
|
204
205
|
const useHashLines = resolveFileDisplayMode(this.session).hashLines;
|
|
205
206
|
const outputLines: string[] = [];
|
|
206
207
|
const displayLines: string[] = [];
|
|
207
|
-
const renderChangesForFile = (relativePath: string) => {
|
|
208
|
+
const renderChangesForFile = (relativePath: string): { model: string[]; display: string[] } => {
|
|
209
|
+
const modelOut: string[] = [];
|
|
210
|
+
const displayOut: string[] = [];
|
|
208
211
|
const fileChanges = changesByFile.get(relativePath) ?? [];
|
|
209
212
|
const lineNumberWidth = fileChanges.reduce(
|
|
210
213
|
(width, change) => Math.max(width, String(change.startLine).length),
|
|
@@ -222,55 +225,31 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
|
|
|
222
225
|
? `${change.startLine}${computeLineHash(change.startLine, afterFirstLine)}`
|
|
223
226
|
: `${change.startLine}:${change.startColumn}`;
|
|
224
227
|
const lineSeparator = useHashLines ? HASHLINE_CONTENT_SEPARATOR : " ";
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
228
|
+
modelOut.push(`-${beforeRef}${lineSeparator}${beforeLine}`);
|
|
229
|
+
modelOut.push(`+${afterRef}${lineSeparator}${afterLine}`);
|
|
230
|
+
displayOut.push(formatCodeFrameLine("-", change.startLine, beforeLine, lineNumberWidth));
|
|
231
|
+
displayOut.push(formatCodeFrameLine("+", change.startLine, afterLine, lineNumberWidth));
|
|
229
232
|
}
|
|
233
|
+
return { model: modelOut, display: displayOut };
|
|
230
234
|
};
|
|
231
235
|
|
|
232
236
|
if (isDirectory) {
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
if (outputLines.length > 0) {
|
|
245
|
-
outputLines.push("");
|
|
246
|
-
displayLines.push("");
|
|
247
|
-
}
|
|
248
|
-
const count = fileReplacementCounts.get(relativePath) ?? 0;
|
|
249
|
-
const header = `# ${path.basename(relativePath)} (${formatCount("replacement", count)})`;
|
|
250
|
-
outputLines.push(header);
|
|
251
|
-
displayLines.push(header);
|
|
252
|
-
renderChangesForFile(relativePath);
|
|
253
|
-
}
|
|
254
|
-
continue;
|
|
255
|
-
}
|
|
256
|
-
if (outputLines.length > 0) {
|
|
257
|
-
outputLines.push("");
|
|
258
|
-
displayLines.push("");
|
|
259
|
-
}
|
|
260
|
-
const dirHeader = `# ${directory}`;
|
|
261
|
-
outputLines.push(dirHeader);
|
|
262
|
-
displayLines.push(dirHeader);
|
|
263
|
-
for (const relativePath of directoryFiles) {
|
|
264
|
-
const count = fileReplacementCounts.get(relativePath) ?? 0;
|
|
265
|
-
const fileHeader = `## └─ ${path.basename(relativePath)} (${formatCount("replacement", count)})`;
|
|
266
|
-
outputLines.push(fileHeader);
|
|
267
|
-
displayLines.push(fileHeader);
|
|
268
|
-
renderChangesForFile(relativePath);
|
|
269
|
-
}
|
|
270
|
-
}
|
|
237
|
+
const grouped = formatGroupedFiles(fileList, relativePath => {
|
|
238
|
+
const rendered = renderChangesForFile(relativePath);
|
|
239
|
+
const count = fileReplacementCounts.get(relativePath) ?? 0;
|
|
240
|
+
return {
|
|
241
|
+
headerSuffix: ` (${formatCount("replacement", count)})`,
|
|
242
|
+
modelLines: rendered.model,
|
|
243
|
+
displayLines: rendered.display,
|
|
244
|
+
};
|
|
245
|
+
});
|
|
246
|
+
outputLines.push(...grouped.model);
|
|
247
|
+
displayLines.push(...grouped.display);
|
|
271
248
|
} else {
|
|
272
249
|
for (const relativePath of fileList) {
|
|
273
|
-
renderChangesForFile(relativePath);
|
|
250
|
+
const rendered = renderChangesForFile(relativePath);
|
|
251
|
+
outputLines.push(...rendered.model);
|
|
252
|
+
displayLines.push(...rendered.display);
|
|
274
253
|
}
|
|
275
254
|
}
|
|
276
255
|
|
package/src/tools/ast-grep.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, t
|
|
|
12
12
|
import { resolveFileDisplayMode } from "../utils/file-display-mode";
|
|
13
13
|
import type { ToolSession } from ".";
|
|
14
14
|
import { createFileRecorder, formatResultPath } from "./file-recorder";
|
|
15
|
+
import { formatGroupedFiles } from "./grouped-file-output";
|
|
15
16
|
import { formatMatchLine } from "./match-line-format";
|
|
16
17
|
import type { OutputMeta } from "./output-meta";
|
|
17
18
|
import {
|
|
@@ -184,7 +185,9 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
|
|
|
184
185
|
const useHashLines = resolveFileDisplayMode(this.session).hashLines;
|
|
185
186
|
const outputLines: string[] = [];
|
|
186
187
|
const displayLines: string[] = [];
|
|
187
|
-
const renderMatchesForFile = (relativePath: string) => {
|
|
188
|
+
const renderMatchesForFile = (relativePath: string): { model: string[]; display: string[] } => {
|
|
189
|
+
const modelOut: string[] = [];
|
|
190
|
+
const displayOut: string[] = [];
|
|
188
191
|
const fileMatches = matchesByFile.get(relativePath) ?? [];
|
|
189
192
|
const lineNumberWidth = fileMatches.reduce((width, match) => {
|
|
190
193
|
const lineCount = match.text.split("\n").length;
|
|
@@ -197,61 +200,34 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
|
|
|
197
200
|
const lineNumber = match.startLine + index;
|
|
198
201
|
const isMatch = index === 0;
|
|
199
202
|
const line = matchLines[index] ?? "";
|
|
200
|
-
|
|
201
|
-
|
|
203
|
+
modelOut.push(formatMatchLine(lineNumber, line, isMatch, { useHashLines }));
|
|
204
|
+
displayOut.push(formatCodeFrameLine(isMatch ? "*" : " ", lineNumber, line, lineNumberWidth));
|
|
202
205
|
}
|
|
203
206
|
if (match.metaVariables && Object.keys(match.metaVariables).length > 0) {
|
|
204
207
|
const serializedMeta = Object.entries(match.metaVariables)
|
|
205
208
|
.sort(([left], [right]) => left.localeCompare(right))
|
|
206
209
|
.map(([key, value]) => `${key}=${value}`)
|
|
207
210
|
.join(", ");
|
|
208
|
-
|
|
209
|
-
|
|
211
|
+
modelOut.push(` meta: ${serializedMeta}`);
|
|
212
|
+
displayOut.push(` meta: ${serializedMeta}`);
|
|
210
213
|
}
|
|
211
214
|
fileMatchCounts.set(relativePath, (fileMatchCounts.get(relativePath) ?? 0) + 1);
|
|
212
215
|
}
|
|
216
|
+
return { model: modelOut, display: displayOut };
|
|
213
217
|
};
|
|
214
218
|
|
|
215
219
|
if (isDirectory) {
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
filesByDirectory.get(directory)!.push(relativePath);
|
|
223
|
-
}
|
|
224
|
-
for (const [directory, directoryFiles] of filesByDirectory) {
|
|
225
|
-
if (directory === ".") {
|
|
226
|
-
for (const relativePath of directoryFiles) {
|
|
227
|
-
if (outputLines.length > 0) {
|
|
228
|
-
outputLines.push("");
|
|
229
|
-
displayLines.push("");
|
|
230
|
-
}
|
|
231
|
-
const header = `# ${path.basename(relativePath)}`;
|
|
232
|
-
outputLines.push(header);
|
|
233
|
-
displayLines.push(header);
|
|
234
|
-
renderMatchesForFile(relativePath);
|
|
235
|
-
}
|
|
236
|
-
continue;
|
|
237
|
-
}
|
|
238
|
-
if (outputLines.length > 0) {
|
|
239
|
-
outputLines.push("");
|
|
240
|
-
displayLines.push("");
|
|
241
|
-
}
|
|
242
|
-
const dirHeader = `# ${directory}`;
|
|
243
|
-
outputLines.push(dirHeader);
|
|
244
|
-
displayLines.push(dirHeader);
|
|
245
|
-
for (const relativePath of directoryFiles) {
|
|
246
|
-
const fileHeader = `## └─ ${path.basename(relativePath)}`;
|
|
247
|
-
outputLines.push(fileHeader);
|
|
248
|
-
displayLines.push(fileHeader);
|
|
249
|
-
renderMatchesForFile(relativePath);
|
|
250
|
-
}
|
|
251
|
-
}
|
|
220
|
+
const grouped = formatGroupedFiles(fileList, relativePath => {
|
|
221
|
+
const rendered = renderMatchesForFile(relativePath);
|
|
222
|
+
return { modelLines: rendered.model, displayLines: rendered.display };
|
|
223
|
+
});
|
|
224
|
+
outputLines.push(...grouped.model);
|
|
225
|
+
displayLines.push(...grouped.display);
|
|
252
226
|
} else {
|
|
253
227
|
for (const relativePath of fileList) {
|
|
254
|
-
renderMatchesForFile(relativePath);
|
|
228
|
+
const rendered = renderMatchesForFile(relativePath);
|
|
229
|
+
outputLines.push(...rendered.model);
|
|
230
|
+
displayLines.push(...rendered.display);
|
|
255
231
|
}
|
|
256
232
|
}
|
|
257
233
|
|
package/src/tools/checkpoint.ts
CHANGED
|
@@ -52,6 +52,7 @@ export class CheckpointTool implements AgentTool<typeof checkpointSchema, Checkp
|
|
|
52
52
|
readonly description: string;
|
|
53
53
|
readonly parameters = checkpointSchema;
|
|
54
54
|
readonly strict = true;
|
|
55
|
+
readonly intent = (args: Partial<CheckpointParams>) => args.goal;
|
|
55
56
|
|
|
56
57
|
constructor(private readonly session: ToolSession) {
|
|
57
58
|
this.description = prompt.render(checkpointDescription);
|
|
@@ -94,6 +95,7 @@ export class RewindTool implements AgentTool<typeof rewindSchema, RewindToolDeta
|
|
|
94
95
|
readonly description: string;
|
|
95
96
|
readonly parameters = rewindSchema;
|
|
96
97
|
readonly strict = true;
|
|
98
|
+
readonly intent = (): string => "Rewinding to checkpoint";
|
|
97
99
|
|
|
98
100
|
constructor(private readonly session: ToolSession) {
|
|
99
101
|
this.description = prompt.render(rewindDescription);
|
|
@@ -46,6 +46,7 @@ export class ExitPlanModeTool implements AgentTool<typeof exitPlanModeSchema, Ex
|
|
|
46
46
|
readonly parameters = exitPlanModeSchema;
|
|
47
47
|
readonly strict = true;
|
|
48
48
|
readonly concurrency = "exclusive";
|
|
49
|
+
readonly intent = (): string => "Exiting plan mode";
|
|
49
50
|
|
|
50
51
|
constructor(private readonly session: ToolSession) {
|
|
51
52
|
this.description = prompt.render(exitPlanModeDescription);
|