@oh-my-pi/pi-coding-agent 13.14.0 → 13.15.2
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 +140 -0
- package/package.json +10 -8
- package/src/autoresearch/command-initialize.md +34 -0
- package/src/autoresearch/command-resume.md +17 -0
- package/src/autoresearch/contract.ts +332 -0
- package/src/autoresearch/dashboard.ts +447 -0
- package/src/autoresearch/git.ts +243 -0
- package/src/autoresearch/helpers.ts +458 -0
- package/src/autoresearch/index.ts +693 -0
- package/src/autoresearch/prompt.md +227 -0
- package/src/autoresearch/resume-message.md +16 -0
- package/src/autoresearch/state.ts +386 -0
- package/src/autoresearch/tools/init-experiment.ts +310 -0
- package/src/autoresearch/tools/log-experiment.ts +833 -0
- package/src/autoresearch/tools/run-experiment.ts +640 -0
- package/src/autoresearch/types.ts +218 -0
- package/src/cli/args.ts +8 -2
- package/src/cli/initial-message.ts +58 -0
- package/src/config/keybindings.ts +417 -212
- package/src/config/model-registry.ts +1 -0
- package/src/config/model-resolver.ts +57 -9
- package/src/config/settings-schema.ts +38 -10
- package/src/config/settings.ts +1 -4
- package/src/exec/bash-executor.ts +7 -5
- package/src/export/html/template.css +43 -13
- package/src/export/html/template.generated.ts +1 -1
- package/src/export/html/template.html +1 -0
- package/src/export/html/template.js +107 -0
- package/src/extensibility/extensions/types.ts +31 -8
- package/src/internal-urls/docs-index.generated.ts +1 -1
- package/src/lsp/index.ts +1 -1
- package/src/main.ts +44 -44
- package/src/mcp/oauth-discovery.ts +1 -1
- package/src/modes/acp/acp-agent.ts +957 -0
- package/src/modes/acp/acp-event-mapper.ts +531 -0
- package/src/modes/acp/acp-mode.ts +13 -0
- package/src/modes/acp/index.ts +2 -0
- package/src/modes/components/agent-dashboard.ts +5 -4
- package/src/modes/components/bash-execution.ts +40 -11
- package/src/modes/components/custom-editor.ts +47 -47
- package/src/modes/components/extensions/extension-dashboard.ts +2 -1
- package/src/modes/components/history-search.ts +2 -1
- package/src/modes/components/hook-editor.ts +2 -1
- package/src/modes/components/hook-input.ts +8 -7
- package/src/modes/components/hook-selector.ts +15 -10
- package/src/modes/components/keybinding-hints.ts +9 -9
- package/src/modes/components/login-dialog.ts +3 -3
- package/src/modes/components/mcp-add-wizard.ts +2 -1
- package/src/modes/components/model-selector.ts +14 -3
- package/src/modes/components/oauth-selector.ts +2 -1
- package/src/modes/components/python-execution.ts +2 -3
- package/src/modes/components/session-selector.ts +2 -1
- package/src/modes/components/settings-selector.ts +2 -1
- package/src/modes/components/status-line-segment-editor.ts +2 -1
- package/src/modes/components/tool-execution.ts +4 -5
- package/src/modes/components/tree-selector.ts +3 -2
- package/src/modes/components/user-message-selector.ts +3 -8
- package/src/modes/components/user-message.ts +16 -0
- package/src/modes/controllers/command-controller.ts +0 -2
- package/src/modes/controllers/extension-ui-controller.ts +89 -4
- package/src/modes/controllers/input-controller.ts +29 -23
- package/src/modes/controllers/mcp-command-controller.ts +1 -1
- package/src/modes/index.ts +1 -0
- package/src/modes/interactive-mode.ts +17 -5
- package/src/modes/print-mode.ts +1 -1
- package/src/modes/prompt-action-autocomplete.ts +7 -7
- package/src/modes/rpc/rpc-mode.ts +7 -2
- package/src/modes/rpc/rpc-types.ts +1 -0
- package/src/modes/theme/theme.ts +53 -44
- package/src/modes/types.ts +9 -2
- package/src/modes/utils/hotkeys-markdown.ts +19 -19
- package/src/modes/utils/keybinding-matchers.ts +21 -0
- package/src/modes/utils/ui-helpers.ts +1 -1
- package/src/patch/hashline.ts +139 -127
- package/src/patch/index.ts +77 -59
- package/src/patch/shared.ts +19 -11
- package/src/prompts/tools/hashline.md +43 -116
- package/src/sdk.ts +34 -17
- package/src/session/agent-session.ts +123 -30
- package/src/session/session-manager.ts +32 -31
- package/src/session/streaming-output.ts +87 -37
- package/src/tools/ask.ts +56 -30
- package/src/tools/bash-interactive.ts +2 -6
- package/src/tools/bash-interceptor.ts +1 -39
- package/src/tools/bash-skill-urls.ts +1 -1
- package/src/tools/browser.ts +1 -1
- package/src/tools/gemini-image.ts +1 -1
- package/src/tools/python.ts +2 -2
- package/src/tools/resolve.ts +1 -1
- package/src/utils/child-process.ts +88 -0
package/src/modes/types.ts
CHANGED
|
@@ -3,7 +3,12 @@ import type { AssistantMessage, ImageContent, Message, UsageReport } from "@oh-m
|
|
|
3
3
|
import type { Component, Container, Loader, Spacer, Text, TUI } from "@oh-my-pi/pi-tui";
|
|
4
4
|
import type { KeybindingsManager } from "../config/keybindings";
|
|
5
5
|
import type { Settings } from "../config/settings";
|
|
6
|
-
import type {
|
|
6
|
+
import type {
|
|
7
|
+
ExtensionUIContext,
|
|
8
|
+
ExtensionUIDialogOptions,
|
|
9
|
+
ExtensionWidgetContent,
|
|
10
|
+
ExtensionWidgetOptions,
|
|
11
|
+
} from "../extensibility/extensions";
|
|
7
12
|
import type { CompactOptions } from "../extensibility/extensions/types";
|
|
8
13
|
import type { MCPManager } from "../mcp";
|
|
9
14
|
import type { AgentSession, AgentSessionEvent } from "../session/agent-session";
|
|
@@ -59,6 +64,8 @@ export interface InteractiveModeContext {
|
|
|
59
64
|
btwContainer: Container;
|
|
60
65
|
editor: CustomEditor;
|
|
61
66
|
editorContainer: Container;
|
|
67
|
+
hookWidgetContainerAbove: Container;
|
|
68
|
+
hookWidgetContainerBelow: Container;
|
|
62
69
|
statusLine: StatusLineComponent;
|
|
63
70
|
|
|
64
71
|
// Session access
|
|
@@ -226,7 +233,7 @@ export interface InteractiveModeContext {
|
|
|
226
233
|
reason: "start" | "switch" | "branch" | "tree" | "shutdown",
|
|
227
234
|
previousSessionFile?: string,
|
|
228
235
|
): Promise<void>;
|
|
229
|
-
setHookWidget(key: string, content:
|
|
236
|
+
setHookWidget(key: string, content: ExtensionWidgetContent, options?: ExtensionWidgetOptions): void;
|
|
230
237
|
setHookStatus(key: string, text: string | undefined): void;
|
|
231
238
|
showHookSelector(
|
|
232
239
|
title: string,
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { AppKeybinding, KeybindingsManager } from "../../config/keybindings";
|
|
2
2
|
|
|
3
3
|
export interface HotkeysMarkdownBindings {
|
|
4
4
|
keybindings: Pick<KeybindingsManager, "getDisplayString">;
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
-
function appKey(bindings: HotkeysMarkdownBindings, action:
|
|
7
|
+
function appKey(bindings: HotkeysMarkdownBindings, action: AppKeybinding): string {
|
|
8
8
|
return bindings.keybindings.getDisplayString(action) || "Disabled";
|
|
9
9
|
}
|
|
10
10
|
|
|
@@ -26,29 +26,29 @@ export function buildHotkeysMarkdown(bindings: HotkeysMarkdownBindings): string
|
|
|
26
26
|
"| `Ctrl+W` / `Option+Backspace` | Delete word backwards |",
|
|
27
27
|
"| `Ctrl+U` | Delete to start of line |",
|
|
28
28
|
"| `Ctrl+K` | Delete to end of line |",
|
|
29
|
-
`| \`${appKey(bindings, "copyLine")}\` | Copy current line |`,
|
|
30
|
-
`| \`${appKey(bindings, "copyPrompt")}\` | Copy whole prompt |`,
|
|
29
|
+
`| \`${appKey(bindings, "app.clipboard.copyLine")}\` | Copy current line |`,
|
|
30
|
+
`| \`${appKey(bindings, "app.clipboard.copyPrompt")}\` | Copy whole prompt |`,
|
|
31
31
|
"",
|
|
32
32
|
"**Other**",
|
|
33
33
|
"| Key | Action |",
|
|
34
34
|
"|-----|--------|",
|
|
35
35
|
"| `Tab` | Path completion / accept autocomplete |",
|
|
36
|
-
`| \`${appKey(bindings, "interrupt")}\` | Cancel autocomplete / interrupt active work |`,
|
|
37
|
-
`| \`${appKey(bindings, "clear")}\` | Clear editor (first) / exit (second) |`,
|
|
38
|
-
`| \`${appKey(bindings, "exit")}\` | Exit (when editor is empty) |`,
|
|
39
|
-
`| \`${appKey(bindings, "suspend")}\` | Suspend to background |`,
|
|
40
|
-
`| \`${appKey(bindings, "
|
|
41
|
-
`| \`${appKey(bindings, "
|
|
42
|
-
`| \`${appKey(bindings, "
|
|
36
|
+
`| \`${appKey(bindings, "app.interrupt")}\` | Cancel autocomplete / interrupt active work |`,
|
|
37
|
+
`| \`${appKey(bindings, "app.clear")}\` | Clear editor (first) / exit (second) |`,
|
|
38
|
+
`| \`${appKey(bindings, "app.exit")}\` | Exit (when editor is empty) |`,
|
|
39
|
+
`| \`${appKey(bindings, "app.suspend")}\` | Suspend to background |`,
|
|
40
|
+
`| \`${appKey(bindings, "app.thinking.cycle")}\` | Cycle thinking level |`,
|
|
41
|
+
`| \`${appKey(bindings, "app.model.cycleForward")}\` | Cycle role models (slow/default/smol) |`,
|
|
42
|
+
`| \`${appKey(bindings, "app.model.cycleBackward")}\` | Cycle role models (temporary) |`,
|
|
43
43
|
"| `Alt+P` | Select model (temporary) |",
|
|
44
|
-
`| \`${appKey(bindings, "
|
|
45
|
-
`| \`${appKey(bindings, "
|
|
46
|
-
`| \`${appKey(bindings, "
|
|
47
|
-
`| \`${appKey(bindings, "
|
|
48
|
-
`| \`${appKey(bindings, "
|
|
49
|
-
`| \`${appKey(bindings, "
|
|
50
|
-
`| \`${appKey(bindings, "pasteImage")}\` | Paste image from clipboard |`,
|
|
51
|
-
`| \`${appKey(bindings, "
|
|
44
|
+
`| \`${appKey(bindings, "app.model.select")}\` | Select model (set roles) |`,
|
|
45
|
+
`| \`${appKey(bindings, "app.plan.toggle")}\` | Toggle plan mode |`,
|
|
46
|
+
`| \`${appKey(bindings, "app.history.search")}\` | Search prompt history |`,
|
|
47
|
+
`| \`${appKey(bindings, "app.tools.expand")}\` | Toggle tool output expansion |`,
|
|
48
|
+
`| \`${appKey(bindings, "app.thinking.toggle")}\` | Toggle thinking block visibility |`,
|
|
49
|
+
`| \`${appKey(bindings, "app.editor.external")}\` | Edit message in external editor |`,
|
|
50
|
+
`| \`${appKey(bindings, "app.clipboard.pasteImage")}\` | Paste image from clipboard |`,
|
|
51
|
+
`| \`${appKey(bindings, "app.stt.toggle")}\` | Toggle speech-to-text recording |`,
|
|
52
52
|
"| `#` | Open prompt actions |",
|
|
53
53
|
"| `/` | Slash commands |",
|
|
54
54
|
"| `!` | Run bash command |",
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { getKeybindings, matchesKey } from "@oh-my-pi/pi-tui";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Match the coding-agent interrupt key.
|
|
5
|
+
*
|
|
6
|
+
* Interactive mode installs a keybinding manager that exposes `app.interrupt`
|
|
7
|
+
* globally, but some isolated component tests still run with only TUI
|
|
8
|
+
* keybindings registered. In that case, fall back to raw Escape matching.
|
|
9
|
+
*/
|
|
10
|
+
export function matchesAppInterrupt(data: string): boolean {
|
|
11
|
+
const keybindings = getKeybindings();
|
|
12
|
+
const interruptKeys = keybindings.getKeys("app.interrupt");
|
|
13
|
+
if (interruptKeys.length > 0) {
|
|
14
|
+
return keybindings.matches(data, "app.interrupt");
|
|
15
|
+
}
|
|
16
|
+
return matchesKey(data, "escape") || matchesKey(data, "esc");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function matchesSelectCancel(data: string): boolean {
|
|
20
|
+
return getKeybindings().matches(data, "tui.select.cancel");
|
|
21
|
+
}
|
|
@@ -474,7 +474,7 @@ export class UiHelpers {
|
|
|
474
474
|
const queuedText = theme.fg("dim", `${entry.label}: ${entry.message}`);
|
|
475
475
|
this.ctx.pendingMessagesContainer.addChild(new TruncatedText(queuedText, 1, 0));
|
|
476
476
|
}
|
|
477
|
-
const dequeueKey = this.ctx.keybindings.getDisplayString("dequeue") || "Alt+Up";
|
|
477
|
+
const dequeueKey = this.ctx.keybindings.getDisplayString("app.message.dequeue") || "Alt+Up";
|
|
478
478
|
const hintText = theme.fg("dim", `${theme.tree.hook} ${dequeueKey} to edit`);
|
|
479
479
|
this.ctx.pendingMessagesContainer.addChild(new TruncatedText(hintText, 1, 0));
|
|
480
480
|
}
|
package/src/patch/hashline.ts
CHANGED
|
@@ -16,9 +16,12 @@ import type { HashMismatch } from "./types";
|
|
|
16
16
|
|
|
17
17
|
export type Anchor = { line: number; hash: string };
|
|
18
18
|
export type HashlineEdit =
|
|
19
|
-
| { op: "
|
|
20
|
-
| { op: "
|
|
21
|
-
| { op: "
|
|
19
|
+
| { op: "replace_line"; pos: Anchor; lines: string[] }
|
|
20
|
+
| { op: "replace_range"; pos: Anchor; end: Anchor; lines: string[] }
|
|
21
|
+
| { op: "append_at"; pos: Anchor; lines: string[] }
|
|
22
|
+
| { op: "prepend_at"; pos: Anchor; lines: string[] }
|
|
23
|
+
| { op: "append_file"; lines: string[] }
|
|
24
|
+
| { op: "prepend_file"; lines: string[] };
|
|
22
25
|
|
|
23
26
|
const NIBBLE_STR = "ZPMQVRWSNKTXJBYH";
|
|
24
27
|
|
|
@@ -455,18 +458,6 @@ function maybeWarnSuspiciousUnicodeEscapePlaceholder(edits: HashlineEdit[], warn
|
|
|
455
458
|
// Edit Application
|
|
456
459
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
457
460
|
|
|
458
|
-
const MIN_AUTOCORRECT_LENGTH = 2;
|
|
459
|
-
|
|
460
|
-
function shouldAutocorrect(line: string, otherLine: string): boolean {
|
|
461
|
-
if (!line || line !== otherLine) return false;
|
|
462
|
-
line = line.trim();
|
|
463
|
-
if (line.length < MIN_AUTOCORRECT_LENGTH) {
|
|
464
|
-
// if brace, we allow
|
|
465
|
-
return line.endsWith("}") || line.endsWith(")");
|
|
466
|
-
}
|
|
467
|
-
return true;
|
|
468
|
-
}
|
|
469
|
-
|
|
470
461
|
/**
|
|
471
462
|
* Apply an array of hashline edits to file content.
|
|
472
463
|
*
|
|
@@ -513,28 +504,29 @@ export function applyHashlineEdits(
|
|
|
513
504
|
}
|
|
514
505
|
for (const edit of edits) {
|
|
515
506
|
switch (edit.op) {
|
|
516
|
-
case "
|
|
517
|
-
if (edit.
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
507
|
+
case "replace_line": {
|
|
508
|
+
if (!validateRef(edit.pos)) continue;
|
|
509
|
+
break;
|
|
510
|
+
}
|
|
511
|
+
case "replace_range": {
|
|
512
|
+
const startValid = validateRef(edit.pos);
|
|
513
|
+
const endValid = validateRef(edit.end);
|
|
514
|
+
if (!startValid || !endValid) continue;
|
|
515
|
+
if (edit.pos.line > edit.end.line) {
|
|
516
|
+
throw new Error(`Range start line ${edit.pos.line} must be <= end line ${edit.end.line}`);
|
|
526
517
|
}
|
|
527
518
|
break;
|
|
528
519
|
}
|
|
529
|
-
case "
|
|
530
|
-
|
|
520
|
+
case "append_at":
|
|
521
|
+
case "prepend_at": {
|
|
522
|
+
if (!validateRef(edit.pos)) continue;
|
|
531
523
|
if (edit.lines.length === 0) {
|
|
532
524
|
edit.lines = [""]; // insert an empty line
|
|
533
525
|
}
|
|
534
526
|
break;
|
|
535
527
|
}
|
|
536
|
-
case "
|
|
537
|
-
|
|
528
|
+
case "append_file":
|
|
529
|
+
case "prepend_file": {
|
|
538
530
|
if (edit.lines.length === 0) {
|
|
539
531
|
edit.lines = [""]; // insert an empty line
|
|
540
532
|
}
|
|
@@ -547,6 +539,38 @@ export function applyHashlineEdits(
|
|
|
547
539
|
}
|
|
548
540
|
maybeAutocorrectEscapedTabIndentation(edits, warnings);
|
|
549
541
|
maybeWarnSuspiciousUnicodeEscapePlaceholder(edits, warnings);
|
|
542
|
+
|
|
543
|
+
// Warn when a replace_range/replace_line's last inserted line duplicates the next surviving line.
|
|
544
|
+
// This catches the common boundary-overreach pattern where the agent includes a closing delimiter
|
|
545
|
+
// in the replacement but sets `end` to the line before the delimiter, causing duplication.
|
|
546
|
+
for (const edit of edits) {
|
|
547
|
+
let endLine: number;
|
|
548
|
+
switch (edit.op) {
|
|
549
|
+
case "replace_line":
|
|
550
|
+
endLine = edit.pos.line;
|
|
551
|
+
break;
|
|
552
|
+
case "replace_range":
|
|
553
|
+
endLine = edit.end.line;
|
|
554
|
+
break;
|
|
555
|
+
default:
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
if (edit.lines.length === 0) continue;
|
|
559
|
+
const nextSurvivingIdx = endLine; // 0-indexed: endLine (1-indexed) is the next line after `end`
|
|
560
|
+
if (nextSurvivingIdx >= originalFileLines.length) continue;
|
|
561
|
+
const nextSurvivingLine = originalFileLines[nextSurvivingIdx];
|
|
562
|
+
const lastInsertedLine = edit.lines[edit.lines.length - 1];
|
|
563
|
+
const trimmedNext = nextSurvivingLine.trim();
|
|
564
|
+
const trimmedLast = lastInsertedLine.trim();
|
|
565
|
+
// Only warn for non-trivial lines to avoid false positives on blank lines or bare punctuation
|
|
566
|
+
if (trimmedLast.length > 0 && trimmedLast === trimmedNext) {
|
|
567
|
+
const tag = formatLineTag(endLine + 1, nextSurvivingLine);
|
|
568
|
+
warnings.push(
|
|
569
|
+
`Possible boundary duplication: your last replacement line \`${trimmedLast}\` is identical to the next surviving line ${tag}. ` +
|
|
570
|
+
`If you meant to replace the entire block, set \`end\` to ${tag} instead.`,
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
550
574
|
// Deduplicate identical edits targeting the same line(s)
|
|
551
575
|
const seenEditKeys = new Map<string, number>();
|
|
552
576
|
const dedupIndices = new Set<number>();
|
|
@@ -554,25 +578,22 @@ export function applyHashlineEdits(
|
|
|
554
578
|
const edit = edits[i];
|
|
555
579
|
let lineKey: string;
|
|
556
580
|
switch (edit.op) {
|
|
557
|
-
case "
|
|
558
|
-
|
|
559
|
-
lineKey = `s:${edit.pos.line}`;
|
|
560
|
-
} else {
|
|
561
|
-
lineKey = `r:${edit.pos.line}:${edit.end.line}`;
|
|
562
|
-
}
|
|
581
|
+
case "replace_line":
|
|
582
|
+
lineKey = `s:${edit.pos.line}`;
|
|
563
583
|
break;
|
|
564
|
-
case "
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
}
|
|
584
|
+
case "replace_range":
|
|
585
|
+
lineKey = `r:${edit.pos.line}:${edit.end.line}`;
|
|
586
|
+
break;
|
|
587
|
+
case "append_at":
|
|
588
|
+
lineKey = `i:${edit.pos.line}`;
|
|
589
|
+
break;
|
|
590
|
+
case "prepend_at":
|
|
591
|
+
lineKey = `ib:${edit.pos.line}`;
|
|
592
|
+
break;
|
|
593
|
+
case "append_file":
|
|
569
594
|
lineKey = "ieof";
|
|
570
595
|
break;
|
|
571
|
-
case "
|
|
572
|
-
if (edit.pos) {
|
|
573
|
-
lineKey = `ib:${edit.pos.line}`;
|
|
574
|
-
break;
|
|
575
|
-
}
|
|
596
|
+
case "prepend_file":
|
|
576
597
|
lineKey = "ibef";
|
|
577
598
|
break;
|
|
578
599
|
}
|
|
@@ -594,20 +615,28 @@ export function applyHashlineEdits(
|
|
|
594
615
|
let sortLine: number;
|
|
595
616
|
let precedence: number;
|
|
596
617
|
switch (edit.op) {
|
|
597
|
-
case "
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
618
|
+
case "replace_line":
|
|
619
|
+
sortLine = edit.pos.line;
|
|
620
|
+
precedence = 0;
|
|
621
|
+
break;
|
|
622
|
+
case "replace_range":
|
|
623
|
+
sortLine = edit.end.line;
|
|
603
624
|
precedence = 0;
|
|
604
625
|
break;
|
|
605
|
-
case "
|
|
606
|
-
sortLine = edit.pos
|
|
626
|
+
case "append_at":
|
|
627
|
+
sortLine = edit.pos.line;
|
|
607
628
|
precedence = 1;
|
|
608
629
|
break;
|
|
609
|
-
case "
|
|
610
|
-
sortLine = edit.pos
|
|
630
|
+
case "prepend_at":
|
|
631
|
+
sortLine = edit.pos.line;
|
|
632
|
+
precedence = 2;
|
|
633
|
+
break;
|
|
634
|
+
case "append_file":
|
|
635
|
+
sortLine = fileLines.length + 1;
|
|
636
|
+
precedence = 1;
|
|
637
|
+
break;
|
|
638
|
+
case "prepend_file":
|
|
639
|
+
sortLine = 0;
|
|
611
640
|
precedence = 2;
|
|
612
641
|
break;
|
|
613
642
|
}
|
|
@@ -619,99 +648,82 @@ export function applyHashlineEdits(
|
|
|
619
648
|
// Apply edits bottom-up
|
|
620
649
|
for (const { edit, idx } of annotated) {
|
|
621
650
|
switch (edit.op) {
|
|
622
|
-
case "
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
break;
|
|
633
|
-
}
|
|
634
|
-
fileLines.splice(edit.pos.line - 1, 1, ...newLines);
|
|
635
|
-
trackFirstChanged(edit.pos.line);
|
|
636
|
-
} else {
|
|
637
|
-
const count = edit.end.line - edit.pos.line + 1;
|
|
638
|
-
const newLines = [...edit.lines];
|
|
639
|
-
const trailingReplacementLine = newLines[newLines.length - 1]?.trimEnd();
|
|
640
|
-
const nextSurvivingLine = fileLines[edit.end.line]?.trimEnd();
|
|
641
|
-
if (
|
|
642
|
-
shouldAutocorrect(trailingReplacementLine, nextSurvivingLine) &&
|
|
643
|
-
// Safety: only correct when end-line content differs from the duplicate.
|
|
644
|
-
// If end already points to the boundary, matching next line is coincidence.
|
|
645
|
-
fileLines[edit.end.line - 1]?.trimEnd() !== trailingReplacementLine
|
|
646
|
-
) {
|
|
647
|
-
newLines.pop();
|
|
648
|
-
warnings.push(
|
|
649
|
-
`Auto-corrected range replace ${edit.pos.line}#${edit.pos.hash}-${edit.end.line}#${edit.end.hash}: removed trailing replacement line "${trailingReplacementLine}" that duplicated next surviving line`,
|
|
650
|
-
);
|
|
651
|
-
}
|
|
652
|
-
const leadingReplacementLine = newLines[0]?.trimEnd();
|
|
653
|
-
const prevSurvivingLine = fileLines[edit.pos.line - 2]?.trimEnd();
|
|
654
|
-
if (
|
|
655
|
-
shouldAutocorrect(leadingReplacementLine, prevSurvivingLine) &&
|
|
656
|
-
// Safety: only correct when pos-line content differs from the duplicate.
|
|
657
|
-
// If pos already points to the boundary, matching prev line is coincidence.
|
|
658
|
-
fileLines[edit.pos.line - 1]?.trimEnd() !== leadingReplacementLine
|
|
659
|
-
) {
|
|
660
|
-
newLines.shift();
|
|
661
|
-
warnings.push(
|
|
662
|
-
`Auto-corrected range replace ${edit.pos.line}#${edit.pos.hash}-${edit.end.line}#${edit.end.hash}: removed leading replacement line "${leadingReplacementLine}" that duplicated preceding surviving line`,
|
|
663
|
-
);
|
|
664
|
-
}
|
|
665
|
-
fileLines.splice(edit.pos.line - 1, count, ...newLines);
|
|
666
|
-
trackFirstChanged(edit.pos.line);
|
|
651
|
+
case "replace_line": {
|
|
652
|
+
const origLines = originalFileLines.slice(edit.pos.line - 1, edit.pos.line);
|
|
653
|
+
const newLines = edit.lines;
|
|
654
|
+
if (origLines.length === newLines.length && origLines.every((line, i) => line === newLines[i])) {
|
|
655
|
+
noopEdits.push({
|
|
656
|
+
editIndex: idx,
|
|
657
|
+
loc: `${edit.pos.line}#${edit.pos.hash}`,
|
|
658
|
+
current: origLines.join("\n"),
|
|
659
|
+
});
|
|
660
|
+
break;
|
|
667
661
|
}
|
|
662
|
+
fileLines.splice(edit.pos.line - 1, 1, ...newLines);
|
|
663
|
+
trackFirstChanged(edit.pos.line);
|
|
664
|
+
break;
|
|
665
|
+
}
|
|
666
|
+
case "replace_range": {
|
|
667
|
+
const count = edit.end.line - edit.pos.line + 1;
|
|
668
|
+
fileLines.splice(edit.pos.line - 1, count, ...edit.lines);
|
|
669
|
+
trackFirstChanged(edit.pos.line);
|
|
668
670
|
break;
|
|
669
671
|
}
|
|
670
|
-
case "
|
|
672
|
+
case "append_at": {
|
|
671
673
|
const inserted = edit.lines;
|
|
672
674
|
if (inserted.length === 0) {
|
|
673
675
|
noopEdits.push({
|
|
674
676
|
editIndex: idx,
|
|
675
|
-
loc:
|
|
676
|
-
current:
|
|
677
|
+
loc: `${edit.pos.line}#${edit.pos.hash}`,
|
|
678
|
+
current: originalFileLines[edit.pos.line - 1],
|
|
677
679
|
});
|
|
678
680
|
break;
|
|
679
681
|
}
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
trackFirstChanged(edit.pos.line + 1);
|
|
683
|
-
} else {
|
|
684
|
-
if (fileLines.length === 1 && fileLines[0] === "") {
|
|
685
|
-
fileLines.splice(0, 1, ...inserted);
|
|
686
|
-
trackFirstChanged(1);
|
|
687
|
-
} else {
|
|
688
|
-
fileLines.splice(fileLines.length, 0, ...inserted);
|
|
689
|
-
trackFirstChanged(fileLines.length - inserted.length + 1);
|
|
690
|
-
}
|
|
691
|
-
}
|
|
682
|
+
fileLines.splice(edit.pos.line, 0, ...inserted);
|
|
683
|
+
trackFirstChanged(edit.pos.line + 1);
|
|
692
684
|
break;
|
|
693
685
|
}
|
|
694
|
-
case "
|
|
686
|
+
case "prepend_at": {
|
|
695
687
|
const inserted = edit.lines;
|
|
696
688
|
if (inserted.length === 0) {
|
|
697
689
|
noopEdits.push({
|
|
698
690
|
editIndex: idx,
|
|
699
|
-
loc:
|
|
700
|
-
current:
|
|
691
|
+
loc: `${edit.pos.line}#${edit.pos.hash}`,
|
|
692
|
+
current: originalFileLines[edit.pos.line - 1],
|
|
701
693
|
});
|
|
702
694
|
break;
|
|
703
695
|
}
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
696
|
+
fileLines.splice(edit.pos.line - 1, 0, ...inserted);
|
|
697
|
+
trackFirstChanged(edit.pos.line);
|
|
698
|
+
break;
|
|
699
|
+
}
|
|
700
|
+
case "append_file": {
|
|
701
|
+
const inserted = edit.lines;
|
|
702
|
+
if (inserted.length === 0) {
|
|
703
|
+
noopEdits.push({ editIndex: idx, loc: "EOF", current: "" });
|
|
704
|
+
break;
|
|
705
|
+
}
|
|
706
|
+
if (fileLines.length === 1 && fileLines[0] === "") {
|
|
707
|
+
fileLines.splice(0, 1, ...inserted);
|
|
713
708
|
trackFirstChanged(1);
|
|
709
|
+
} else {
|
|
710
|
+
fileLines.splice(fileLines.length, 0, ...inserted);
|
|
711
|
+
trackFirstChanged(fileLines.length - inserted.length + 1);
|
|
712
|
+
}
|
|
713
|
+
break;
|
|
714
|
+
}
|
|
715
|
+
case "prepend_file": {
|
|
716
|
+
const inserted = edit.lines;
|
|
717
|
+
if (inserted.length === 0) {
|
|
718
|
+
noopEdits.push({ editIndex: idx, loc: "BOF", current: "" });
|
|
719
|
+
break;
|
|
720
|
+
}
|
|
721
|
+
if (fileLines.length === 1 && fileLines[0] === "") {
|
|
722
|
+
fileLines.splice(0, 1, ...inserted);
|
|
723
|
+
} else {
|
|
724
|
+
fileLines.splice(0, 0, ...inserted);
|
|
714
725
|
}
|
|
726
|
+
trackFirstChanged(1);
|
|
715
727
|
break;
|
|
716
728
|
}
|
|
717
729
|
}
|
package/src/patch/index.ts
CHANGED
|
@@ -174,16 +174,35 @@ export function hashlineParseText(edit: string[] | string | null): string[] {
|
|
|
174
174
|
return stripNewLinePrefixes(edit);
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
+
const linesSchema = Type.Union([
|
|
178
|
+
Type.Array(Type.String(), { description: "content (preferred format)" }),
|
|
179
|
+
Type.String(),
|
|
180
|
+
Type.Null(),
|
|
181
|
+
]);
|
|
182
|
+
|
|
183
|
+
const locSchema = Type.Union(
|
|
184
|
+
[
|
|
185
|
+
Type.Literal("append"),
|
|
186
|
+
Type.Literal("prepend"),
|
|
187
|
+
Type.Object({ append: Type.String({ description: "anchor" }) }),
|
|
188
|
+
Type.Object({ prepend: Type.String({ description: "anchor" }) }),
|
|
189
|
+
Type.Object({
|
|
190
|
+
line: Type.String({ description: "anchor" }),
|
|
191
|
+
}),
|
|
192
|
+
Type.Object({
|
|
193
|
+
block: Type.Object({
|
|
194
|
+
pos: Type.String({ description: "anchor" }),
|
|
195
|
+
end: Type.String({ description: "limit position" }),
|
|
196
|
+
}),
|
|
197
|
+
}),
|
|
198
|
+
],
|
|
199
|
+
{ description: "insert location" },
|
|
200
|
+
);
|
|
201
|
+
|
|
177
202
|
const hashlineEditSchema = Type.Object(
|
|
178
203
|
{
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
end: Type.Optional(Type.String({ description: "limit position" })),
|
|
182
|
-
lines: Type.Union([
|
|
183
|
-
Type.Array(Type.String(), { description: "content (preferred format)" }),
|
|
184
|
-
Type.String(),
|
|
185
|
-
Type.Null(),
|
|
186
|
-
]),
|
|
204
|
+
loc: locSchema,
|
|
205
|
+
content: linesSchema,
|
|
187
206
|
},
|
|
188
207
|
{ additionalProperties: false },
|
|
189
208
|
);
|
|
@@ -206,45 +225,48 @@ export type HashlineParams = Static<typeof hashlineEditParamsSchema>;
|
|
|
206
225
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
207
226
|
|
|
208
227
|
/**
|
|
209
|
-
* Map
|
|
228
|
+
* Map loc/content tool-schema edits into typed HashlineEdit objects.
|
|
210
229
|
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
* Unknown ops default to "replace".
|
|
230
|
+
* Each edit entry has a `loc` (where to edit) and `content` (what to insert/replace).
|
|
231
|
+
* loc can be:
|
|
232
|
+
* - "append" / "prepend" — file-level insert
|
|
233
|
+
* - { append: anchor } / { prepend: anchor } — insert relative to anchor
|
|
234
|
+
* - { replace_line: anchor } — replace one line
|
|
235
|
+
* - { replace_block: { pos, end } } — replace inclusive range
|
|
219
236
|
*/
|
|
220
237
|
function resolveEditAnchors(edits: HashlineToolEdit[]): HashlineEdit[] {
|
|
221
238
|
const result: HashlineEdit[] = [];
|
|
222
239
|
for (const edit of edits) {
|
|
223
|
-
const lines = hashlineParseText(edit.
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
240
|
+
const lines = hashlineParseText(edit.content);
|
|
241
|
+
const loc = edit.loc;
|
|
242
|
+
|
|
243
|
+
if (loc === "append") {
|
|
244
|
+
result.push({ op: "append_file", lines });
|
|
245
|
+
} else if (loc === "prepend") {
|
|
246
|
+
result.push({ op: "prepend_file", lines });
|
|
247
|
+
} else if (typeof loc === "object") {
|
|
248
|
+
if ("append" in loc) {
|
|
249
|
+
const anchor = tryParseTag(loc.append);
|
|
250
|
+
if (!anchor) throw new Error("append requires a valid anchor.");
|
|
251
|
+
result.push({ op: "append_at", pos: anchor, lines });
|
|
252
|
+
} else if ("prepend" in loc) {
|
|
253
|
+
const anchor = tryParseTag(loc.prepend);
|
|
254
|
+
if (!anchor) throw new Error("prepend requires a valid anchor.");
|
|
255
|
+
result.push({ op: "prepend_at", pos: anchor, lines });
|
|
256
|
+
} else if ("line" in loc) {
|
|
257
|
+
const anchor = tryParseTag(loc.line);
|
|
258
|
+
if (!anchor) throw new Error("line requires a valid anchor.");
|
|
259
|
+
result.push({ op: "replace_line", pos: anchor, lines });
|
|
260
|
+
} else if ("block" in loc) {
|
|
261
|
+
const posAnchor = tryParseTag(loc.block.pos);
|
|
262
|
+
const endAnchor = tryParseTag(loc.block.end);
|
|
263
|
+
if (!posAnchor || !endAnchor) throw new Error("block requires valid pos and end anchors.");
|
|
264
|
+
result.push({ op: "replace_range", pos: posAnchor, end: endAnchor, lines });
|
|
265
|
+
} else {
|
|
266
|
+
throw new Error("Unknown loc shape. Expected append, prepend, line, or block.");
|
|
247
267
|
}
|
|
268
|
+
} else {
|
|
269
|
+
throw new Error(`Invalid loc value: ${JSON.stringify(loc)}`);
|
|
248
270
|
}
|
|
249
271
|
}
|
|
250
272
|
return result;
|
|
@@ -552,12 +574,10 @@ export class EditTool implements AgentTool<TInput> {
|
|
|
552
574
|
const lines: string[] = [];
|
|
553
575
|
for (const edit of edits) {
|
|
554
576
|
// For file creation, only anchorless appends/prepends are valid
|
|
555
|
-
if (
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
lines.push(...hashlineParseText(edit.lines));
|
|
560
|
-
}
|
|
577
|
+
if (edit.loc === "append") {
|
|
578
|
+
lines.push(...hashlineParseText(edit.content));
|
|
579
|
+
} else if (edit.loc === "prepend") {
|
|
580
|
+
lines.unshift(...hashlineParseText(edit.content));
|
|
561
581
|
} else {
|
|
562
582
|
throw new Error(`File not found: ${path}`);
|
|
563
583
|
}
|
|
@@ -582,7 +602,7 @@ export class EditTool implements AgentTool<TInput> {
|
|
|
582
602
|
const originalNormalized = normalizeToLF(text);
|
|
583
603
|
let normalizedText = originalNormalized;
|
|
584
604
|
|
|
585
|
-
// Apply anchor-based edits first (replace,
|
|
605
|
+
// Apply anchor-based edits first (replace, append_at, prepend_at)
|
|
586
606
|
const anchorResult = applyHashlineEdits(normalizedText, anchorEdits);
|
|
587
607
|
normalizedText = anchorResult.lines;
|
|
588
608
|
|
|
@@ -612,20 +632,18 @@ export class EditTool implements AgentTool<TInput> {
|
|
|
612
632
|
for (const edit of anchorEdits) {
|
|
613
633
|
refs.length = 0;
|
|
614
634
|
switch (edit.op) {
|
|
615
|
-
case "
|
|
616
|
-
|
|
617
|
-
refs.push(edit.end, edit.pos);
|
|
618
|
-
} else {
|
|
619
|
-
refs.push(edit.pos);
|
|
620
|
-
}
|
|
635
|
+
case "replace_line":
|
|
636
|
+
refs.push(edit.pos);
|
|
621
637
|
break;
|
|
622
|
-
case "
|
|
623
|
-
|
|
638
|
+
case "replace_range":
|
|
639
|
+
refs.push(edit.end, edit.pos);
|
|
624
640
|
break;
|
|
625
|
-
case "
|
|
626
|
-
|
|
641
|
+
case "append_at":
|
|
642
|
+
case "prepend_at":
|
|
643
|
+
refs.push(edit.pos);
|
|
627
644
|
break;
|
|
628
|
-
|
|
645
|
+
case "append_file":
|
|
646
|
+
case "prepend_file":
|
|
629
647
|
break;
|
|
630
648
|
}
|
|
631
649
|
|