@oh-my-pi/pi-coding-agent 16.3.0 → 16.3.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 +53 -0
- package/dist/cli.js +3610 -3566
- package/dist/types/config/settings-schema.d.ts +10 -0
- package/dist/types/discovery/helpers.d.ts +2 -0
- package/dist/types/irc/bus.d.ts +5 -4
- package/dist/types/session/agent-session.d.ts +13 -13
- package/dist/types/task/discovery.d.ts +5 -2
- package/dist/types/task/renderer.d.ts +1 -0
- package/dist/types/tools/ast-grep.d.ts +4 -2
- package/dist/types/tools/browser/render.d.ts +2 -0
- package/dist/types/tools/debug.d.ts +1 -0
- package/dist/types/tools/eval-render.d.ts +2 -0
- package/dist/types/tools/glob.d.ts +4 -2
- package/dist/types/tools/grep.d.ts +4 -3
- package/dist/types/tools/path-utils.d.ts +7 -0
- package/dist/types/tools/renderers.d.ts +11 -0
- package/dist/types/tools/ssh.d.ts +2 -1
- package/dist/types/tts/index.d.ts +2 -0
- package/dist/types/tts/speakable.d.ts +47 -0
- package/dist/types/tts/speech-enhancer.d.ts +46 -0
- package/dist/types/tts/streaming-player.d.ts +1 -2
- package/dist/types/tts/tts-client.d.ts +11 -10
- package/dist/types/tts/tts-protocol.d.ts +7 -0
- package/dist/types/tts/vocalizer.d.ts +15 -8
- package/dist/types/utils/git.d.ts +2 -0
- package/package.json +12 -12
- package/src/advisor/__tests__/advisor.test.ts +1 -1
- package/src/cli/gallery-fixtures/fs.ts +2 -2
- package/src/cli/gallery-fixtures/search.ts +2 -2
- package/src/cli.ts +27 -5
- package/src/config/model-resolver.ts +31 -10
- package/src/config/settings-schema.ts +11 -0
- package/src/cursor.ts +1 -1
- package/src/discovery/helpers.ts +8 -0
- package/src/export/html/tool-views.generated.js +34 -34
- package/src/extensibility/custom-tools/loader.ts +3 -3
- package/src/extensibility/extensions/loader.ts +10 -3
- package/src/extensibility/legacy-pi-coding-agent-shim.ts +2 -2
- package/src/extensibility/plugins/legacy-pi-compat.ts +87 -11
- package/src/extensibility/plugins/loader.ts +30 -1
- package/src/irc/bus.ts +5 -4
- package/src/modes/components/__tests__/move-overlay.test.ts +72 -1
- package/src/modes/components/assistant-message.ts +1 -1
- package/src/modes/components/move-overlay.ts +35 -23
- package/src/modes/components/tool-execution.ts +45 -12
- package/src/modes/components/tree-selector.ts +10 -3
- package/src/modes/controllers/event-controller.ts +16 -0
- package/src/prompts/goals/goal-mode-context.md +4 -0
- package/src/prompts/goals/goal-todo-context.md +10 -2
- package/src/prompts/system/speech-rewrite.md +15 -0
- package/src/prompts/system/tiny-title-system.md +1 -1
- package/src/prompts/system/title-system-marker.md +2 -1
- package/src/prompts/system/title-system.md +2 -1
- package/src/prompts/tools/ast-grep.md +3 -3
- package/src/prompts/tools/glob.md +1 -1
- package/src/prompts/tools/grep.md +1 -1
- package/src/sdk.ts +8 -5
- package/src/session/agent-session.ts +163 -49
- package/src/session/session-history-format.ts +6 -2
- package/src/task/discovery.ts +25 -2
- package/src/task/executor.ts +24 -4
- package/src/task/render.ts +26 -12
- package/src/task/renderer.ts +1 -0
- package/src/task/worktree.ts +144 -16
- package/src/tiny/text.ts +109 -17
- package/src/tools/ast-grep.ts +20 -17
- package/src/tools/bash.ts +16 -8
- package/src/tools/browser/render.ts +2 -0
- package/src/tools/debug.ts +1 -0
- package/src/tools/eval-render.ts +5 -2
- package/src/tools/gh-renderer.ts +3 -0
- package/src/tools/glob.ts +24 -20
- package/src/tools/grep.ts +18 -36
- package/src/tools/path-utils.ts +55 -10
- package/src/tools/read.ts +9 -2
- package/src/tools/renderers.ts +11 -0
- package/src/tools/ssh.ts +17 -7
- package/src/tts/index.ts +2 -0
- package/src/tts/speakable.ts +382 -0
- package/src/tts/speech-enhancer.ts +204 -0
- package/src/tts/streaming-player.ts +71 -16
- package/src/tts/tts-client.ts +11 -10
- package/src/tts/tts-protocol.ts +14 -5
- package/src/tts/tts-worker.ts +52 -49
- package/src/tts/vocalizer.ts +277 -46
- package/src/utils/git.ts +8 -0
package/src/task/executor.ts
CHANGED
|
@@ -882,6 +882,7 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor {
|
|
|
882
882
|
const finalOutputChunks: string[] = [];
|
|
883
883
|
const RECENT_OUTPUT_TAIL_BYTES = 8 * 1024;
|
|
884
884
|
let recentOutputTail = "";
|
|
885
|
+
let tailLastLineRepresentable = false;
|
|
885
886
|
let resolved = false;
|
|
886
887
|
let abortSent = false;
|
|
887
888
|
let abortReason: AbortReason | undefined;
|
|
@@ -1060,17 +1061,35 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor {
|
|
|
1060
1061
|
};
|
|
1061
1062
|
|
|
1062
1063
|
const updateRecentOutputLines = () => {
|
|
1063
|
-
const lines = recentOutputTail.split("\n")
|
|
1064
|
-
|
|
1064
|
+
const lines = recentOutputTail.split("\n");
|
|
1065
|
+
const filtered = lines.filter(line => line.trim());
|
|
1066
|
+
progress.recentOutput = filtered.slice(-8).reverse();
|
|
1067
|
+
// The tail's last raw segment (after its final newline) is "represented"
|
|
1068
|
+
// in recentOutput only when it trims non-empty — an empty/whitespace-only
|
|
1069
|
+
// trailing segment is filtered out, so recentOutput[0] is then the line
|
|
1070
|
+
// before it, not the tail's true last line.
|
|
1071
|
+
tailLastLineRepresentable = lines[lines.length - 1].trim().length > 0;
|
|
1065
1072
|
};
|
|
1066
1073
|
|
|
1067
1074
|
const appendRecentOutputTail = (text: string) => {
|
|
1068
1075
|
if (!text) return;
|
|
1069
1076
|
recentOutputTail += text;
|
|
1070
|
-
|
|
1077
|
+
const truncated = recentOutputTail.length > RECENT_OUTPUT_TAIL_BYTES;
|
|
1078
|
+
if (truncated) {
|
|
1071
1079
|
recentOutputTail = recentOutputTail.slice(-RECENT_OUTPUT_TAIL_BYTES);
|
|
1072
1080
|
}
|
|
1073
|
-
|
|
1081
|
+
// Fast path: a token without a newline only extends the current last line.
|
|
1082
|
+
// This runs on every text_delta token (hundreds/thousands per second while
|
|
1083
|
+
// streaming), so skip re-splitting the whole (up to 8KB) tail unless the line
|
|
1084
|
+
// structure actually changed. Requires no truncation AND the tail's last line
|
|
1085
|
+
// already represented (trims non-empty) — otherwise boundaries shift and a
|
|
1086
|
+
// full recompute is required. Appending to a non-empty line keeps it non-empty,
|
|
1087
|
+
// so the flag stays valid across consecutive fast-path tokens.
|
|
1088
|
+
if (truncated || text.includes("\n") || !tailLastLineRepresentable || progress.recentOutput.length === 0) {
|
|
1089
|
+
updateRecentOutputLines();
|
|
1090
|
+
} else {
|
|
1091
|
+
progress.recentOutput = [progress.recentOutput[0] + text, ...progress.recentOutput.slice(1)];
|
|
1092
|
+
}
|
|
1074
1093
|
};
|
|
1075
1094
|
|
|
1076
1095
|
const replaceRecentOutputFromContent = (content: unknown[]) => {
|
|
@@ -1090,6 +1109,7 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor {
|
|
|
1090
1109
|
|
|
1091
1110
|
const resetRecentOutput = () => {
|
|
1092
1111
|
recentOutputTail = "";
|
|
1112
|
+
tailLastLineRepresentable = false;
|
|
1093
1113
|
progress.recentOutput = [];
|
|
1094
1114
|
};
|
|
1095
1115
|
|
package/src/task/render.ts
CHANGED
|
@@ -151,9 +151,9 @@ function normalizeReportFindings(value: unknown): ReportFindingDetails[] {
|
|
|
151
151
|
const REVIEWER_ARRAY_LABELS: ReadonlySet<string> = new Set(["findings"]);
|
|
152
152
|
|
|
153
153
|
function extractIncrementalReviewResult(
|
|
154
|
-
|
|
154
|
+
items: RenderYieldItem[],
|
|
155
155
|
): { summary: SubmitReviewDetails; findings: ReportFindingDetails[] } | undefined {
|
|
156
|
-
const yieldItems: YieldItem[] =
|
|
156
|
+
const yieldItems: YieldItem[] = items.map(item => ({
|
|
157
157
|
data: item.data,
|
|
158
158
|
type: item.type,
|
|
159
159
|
status: item.status === "aborted" ? "aborted" : item.status === "success" ? "success" : undefined,
|
|
@@ -962,7 +962,7 @@ function renderAgentProgress(
|
|
|
962
962
|
// yield sections. Fall back to the legacy `report_finding` side-channel.
|
|
963
963
|
if (progress.status === "completed") {
|
|
964
964
|
const completeData = normalizeYieldData(progress.extractedToolData.yield);
|
|
965
|
-
const incrementalReview = extractIncrementalReviewResult(
|
|
965
|
+
const incrementalReview = extractIncrementalReviewResult(completeData);
|
|
966
966
|
const reportFindingData = normalizeReportFindings(progress.extractedToolData.report_finding);
|
|
967
967
|
if (incrementalReview) {
|
|
968
968
|
lines.push(
|
|
@@ -1253,7 +1253,7 @@ function renderAgentResult(
|
|
|
1253
1253
|
// is not a function` when the slot is a plain object (see issue #1987).
|
|
1254
1254
|
const completeData = normalizeYieldData(result.extractedToolData?.yield);
|
|
1255
1255
|
const reportFindingData = normalizeReportFindings(result.extractedToolData?.report_finding);
|
|
1256
|
-
const incrementalReview = extractIncrementalReviewResult(
|
|
1256
|
+
const incrementalReview = extractIncrementalReviewResult(completeData);
|
|
1257
1257
|
|
|
1258
1258
|
if (incrementalReview) {
|
|
1259
1259
|
lines.push(
|
|
@@ -1491,9 +1491,27 @@ export function renderResult(
|
|
|
1491
1491
|
}
|
|
1492
1492
|
|
|
1493
1493
|
const hasResults = Boolean(details.results && details.results.length > 0);
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1494
|
+
// Single pass over details.results derives the header booleans AND the footer
|
|
1495
|
+
// counts/totals. This block re-runs ~30×/sec via the 33ms spinner render; the
|
|
1496
|
+
// previous form did 3× `.some()` here plus 3× `.filter()` + `.reduce()` again
|
|
1497
|
+
// inside the frame below (7+ full passes per tick).
|
|
1498
|
+
let abortedCount = 0;
|
|
1499
|
+
let failCount = 0;
|
|
1500
|
+
let mergeFailedCount = 0;
|
|
1501
|
+
let successCount = 0;
|
|
1502
|
+
let requestTotal = 0;
|
|
1503
|
+
if (hasResults) {
|
|
1504
|
+
for (const r of details.results) {
|
|
1505
|
+
requestTotal += r.requests ?? 0;
|
|
1506
|
+
if (r.aborted) abortedCount++;
|
|
1507
|
+
else if (r.exitCode !== 0) failCount++;
|
|
1508
|
+
else if (r.error) mergeFailedCount++;
|
|
1509
|
+
else successCount++;
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
const aborted = abortedCount > 0;
|
|
1513
|
+
const failed = failCount > 0;
|
|
1514
|
+
const mergeFailed = mergeFailedCount > 0;
|
|
1497
1515
|
const isError = aborted || failed;
|
|
1498
1516
|
const agentCount = hasResults ? details.results.length : (details.progress?.length ?? 0);
|
|
1499
1517
|
const icon: ToolUIStatus = options.isPartial ? "running" : isError ? "error" : mergeFailed ? "warning" : "success";
|
|
@@ -1552,16 +1570,12 @@ export function renderResult(
|
|
|
1552
1570
|
);
|
|
1553
1571
|
}
|
|
1554
1572
|
|
|
1555
|
-
const abortedCount = details.results.filter(r => r.aborted).length;
|
|
1556
|
-
const mergeFailedCount = details.results.filter(r => !r.aborted && r.exitCode === 0 && r.error).length;
|
|
1557
|
-
const successCount = details.results.filter(r => !r.aborted && r.exitCode === 0 && !r.error).length;
|
|
1558
|
-
const failCount = details.results.length - successCount - mergeFailedCount - abortedCount;
|
|
1559
1573
|
const summaryParts: string[] = [];
|
|
1560
1574
|
if (abortedCount > 0) summaryParts.push(theme.fg("error", `${abortedCount} aborted`));
|
|
1561
1575
|
if (successCount > 0) summaryParts.push(theme.fg("success", `${successCount} succeeded`));
|
|
1562
1576
|
if (mergeFailedCount > 0) summaryParts.push(theme.fg("warning", `${mergeFailedCount} merge failed`));
|
|
1563
1577
|
if (failCount > 0) summaryParts.push(theme.fg("error", `${failCount} failed`));
|
|
1564
|
-
const totalRequests =
|
|
1578
|
+
const totalRequests = requestTotal;
|
|
1565
1579
|
if (totalRequests > 0) summaryParts.push(theme.fg("dim", `${formatNumber(totalRequests)} req`));
|
|
1566
1580
|
summaryParts.push(theme.fg("dim", formatDuration(details.totalDurationMs)));
|
|
1567
1581
|
// Wrap the run summary in the theme's bracket glyphs (dim chrome, colored
|
package/src/task/renderer.ts
CHANGED
package/src/task/worktree.ts
CHANGED
|
@@ -481,41 +481,148 @@ function baselineHasRootWip(baseline: RepoBaseline): boolean {
|
|
|
481
481
|
return !!(baseline.staged.trim() || baseline.unstaged.trim() || baseline.untrackedPatch.trim());
|
|
482
482
|
}
|
|
483
483
|
|
|
484
|
+
/**
|
|
485
|
+
* Baseline WIP context needed to safely apply a delta patch whose hunks were
|
|
486
|
+
* captured against `HEAD + WIP` (see {@link captureRepoDeltaPatch}). Passed
|
|
487
|
+
* whenever {@link baselineHasRootWip} is true so
|
|
488
|
+
* {@link commitPatchToBranchWorktree} can replay the WIP into the temp
|
|
489
|
+
* worktree first, then rewind WIP-only files after applying the delta.
|
|
490
|
+
*/
|
|
491
|
+
interface BaselineWipContext {
|
|
492
|
+
readonly staged: string;
|
|
493
|
+
readonly unstaged: string;
|
|
494
|
+
readonly untrackedPatch: string;
|
|
495
|
+
/** Untracked file paths present in the baseline (never in HEAD). */
|
|
496
|
+
readonly untracked: readonly string[];
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function collectWipPatches(wip: BaselineWipContext | undefined): string[] {
|
|
500
|
+
if (!wip) return [];
|
|
501
|
+
return [wip.staged, wip.unstaged, wip.untrackedPatch].filter(p => p.trim());
|
|
502
|
+
}
|
|
503
|
+
|
|
484
504
|
async function commitPatchToBranchWorktree(
|
|
485
505
|
tmpDir: string,
|
|
486
506
|
taskId: string,
|
|
487
507
|
patchText: string,
|
|
488
508
|
message: string,
|
|
489
509
|
author?: git.CommitAuthor,
|
|
510
|
+
baselineWip?: BaselineWipContext,
|
|
490
511
|
): Promise<void> {
|
|
512
|
+
// Try the two clean paths first — they yield an agent-only commit and are
|
|
513
|
+
// the happy case when the temp worktree can resolve the patch against
|
|
514
|
+
// HEAD directly:
|
|
515
|
+
//
|
|
516
|
+
// 1. Plain apply — works when WIP context happens to match HEAD (e.g.
|
|
517
|
+
// WIP-touched files that the delta patch doesn't reference).
|
|
518
|
+
// 2. `--3way` — works when the WIP-side blob is tracked in HEAD and
|
|
519
|
+
// lives in the shared ODB (captureDeltaPatch seeded it while writing
|
|
520
|
+
// the synthetic baseline tree). The 3-way merge subtracts WIP,
|
|
521
|
+
// producing an agent-only commit even when WIP and agent modify the
|
|
522
|
+
// same tracked file at unrelated lines.
|
|
523
|
+
//
|
|
524
|
+
// If both fail (untracked WIP files, staged-new WIP files, or overlap that
|
|
525
|
+
// --3way can't resolve — see #4136), replay the WIP into the worktree
|
|
526
|
+
// first so the delta's context lines match, then rewind WIP-only files so
|
|
527
|
+
// they don't leak into the commit. Files touched by BOTH WIP and delta
|
|
528
|
+
// keep their combined state; the parent's stash-pop reconciles the WIP
|
|
529
|
+
// side via 3-way merge on merge-back.
|
|
530
|
+
let plainErr: git.GitCommandError | undefined;
|
|
491
531
|
try {
|
|
492
532
|
await git.patch.applyText(tmpDir, patchText);
|
|
493
533
|
} catch (err) {
|
|
494
534
|
if (!(err instanceof git.GitCommandError)) throw err;
|
|
495
|
-
|
|
496
|
-
|
|
535
|
+
plainErr = err;
|
|
536
|
+
}
|
|
537
|
+
if (plainErr) {
|
|
538
|
+
let threeWayErr: git.GitCommandError | undefined;
|
|
497
539
|
try {
|
|
498
540
|
await git.patch.applyText(tmpDir, patchText, { threeWay: true });
|
|
499
|
-
} catch (
|
|
500
|
-
if (
|
|
541
|
+
} catch (err) {
|
|
542
|
+
if (!(err instanceof git.GitCommandError)) throw err;
|
|
543
|
+
threeWayErr = err;
|
|
544
|
+
}
|
|
545
|
+
if (threeWayErr) {
|
|
546
|
+
const wipPatches = collectWipPatches(baselineWip);
|
|
547
|
+
if (wipPatches.length === 0 || !baselineWip) {
|
|
501
548
|
const stderr = threeWayErr.result.stderr.slice(0, 2000);
|
|
502
549
|
logger.error("commitToBranch: git apply --3way failed", {
|
|
503
550
|
taskId,
|
|
504
551
|
exitCode: threeWayErr.result.exitCode,
|
|
505
552
|
stderr,
|
|
506
|
-
initialStderr:
|
|
553
|
+
initialStderr: plainErr.result.stderr.slice(0, 2000),
|
|
507
554
|
patchSize: patchText.length,
|
|
508
555
|
patchHead: patchText.slice(0, 500),
|
|
509
556
|
});
|
|
510
557
|
throw new Error(`git apply --3way failed for task ${taskId}: ${stderr}`);
|
|
511
558
|
}
|
|
512
|
-
|
|
559
|
+
try {
|
|
560
|
+
// `git apply --3way` leaves conflict markers in `U` files when
|
|
561
|
+
// it can't resolve; reset the worktree so the WIP-seeded retry
|
|
562
|
+
// starts from a clean HEAD tree.
|
|
563
|
+
await git.reset(tmpDir, { hard: true, target: "HEAD" });
|
|
564
|
+
await applyDeltaOverBaselineWip(tmpDir, taskId, patchText, wipPatches, baselineWip);
|
|
565
|
+
} catch (wipErr) {
|
|
566
|
+
if (!(wipErr instanceof git.GitCommandError)) throw wipErr;
|
|
567
|
+
const stderr = wipErr.result.stderr.slice(0, 2000);
|
|
568
|
+
logger.error("commitToBranch: git apply with baseline WIP failed", {
|
|
569
|
+
taskId,
|
|
570
|
+
exitCode: wipErr.result.exitCode,
|
|
571
|
+
stderr,
|
|
572
|
+
threeWayStderr: threeWayErr.result.stderr.slice(0, 2000),
|
|
573
|
+
initialStderr: plainErr.result.stderr.slice(0, 2000),
|
|
574
|
+
patchSize: patchText.length,
|
|
575
|
+
patchHead: patchText.slice(0, 500),
|
|
576
|
+
});
|
|
577
|
+
throw new Error(`git apply with baseline WIP failed for task ${taskId}: ${stderr}`);
|
|
578
|
+
}
|
|
513
579
|
}
|
|
514
580
|
}
|
|
581
|
+
|
|
515
582
|
await git.stage.files(tmpDir);
|
|
516
583
|
await git.commit(tmpDir, message, author ? { author } : {});
|
|
517
584
|
}
|
|
518
585
|
|
|
586
|
+
/**
|
|
587
|
+
* Replay baseline WIP into the temp worktree so the delta patch's HEAD+WIP
|
|
588
|
+
* context matches, apply the delta, then rewind files WIP touched but the
|
|
589
|
+
* delta didn't — HEAD-tracked files are restored via `git restore`, untracked
|
|
590
|
+
* or staged-new WIP files are removed from the worktree. The commit that
|
|
591
|
+
* follows reflects agent's delta plus any overlap with WIP; parent's
|
|
592
|
+
* stash-pop reconciles the WIP side on merge-back.
|
|
593
|
+
*/
|
|
594
|
+
async function applyDeltaOverBaselineWip(
|
|
595
|
+
tmpDir: string,
|
|
596
|
+
_taskId: string,
|
|
597
|
+
patchText: string,
|
|
598
|
+
wipPatches: readonly string[],
|
|
599
|
+
baselineWip: BaselineWipContext,
|
|
600
|
+
): Promise<void> {
|
|
601
|
+
for (const wip of wipPatches) {
|
|
602
|
+
await git.patch.applyText(tmpDir, wip);
|
|
603
|
+
}
|
|
604
|
+
await git.patch.applyText(tmpDir, patchText);
|
|
605
|
+
|
|
606
|
+
const wipFiles = new Set(wipPatches.flatMap(patchTouchedFiles));
|
|
607
|
+
const deltaFiles = new Set(patchTouchedFiles(patchText));
|
|
608
|
+
const wipOnly = [...wipFiles].filter(f => !deltaFiles.has(f));
|
|
609
|
+
if (wipOnly.length === 0) return;
|
|
610
|
+
|
|
611
|
+
// Any wipOnly file baselined as untracked cannot be in HEAD.
|
|
612
|
+
// Everything else may or may not — verify against HEAD's tree.
|
|
613
|
+
const untrackedSet = new Set(baselineWip.untracked);
|
|
614
|
+
const candidates = wipOnly.filter(f => !untrackedSet.has(f));
|
|
615
|
+
const inHead = candidates.length > 0 ? new Set(await git.ls.tree(tmpDir, "HEAD", candidates)) : new Set<string>();
|
|
616
|
+
const toRestore = candidates.filter(f => inHead.has(f));
|
|
617
|
+
const toRemove = wipOnly.filter(f => !toRestore.includes(f));
|
|
618
|
+
if (toRestore.length > 0) {
|
|
619
|
+
await git.restore(tmpDir, { source: "HEAD", staged: true, worktree: true, files: toRestore });
|
|
620
|
+
}
|
|
621
|
+
for (const rel of toRemove) {
|
|
622
|
+
await fs.rm(path.join(tmpDir, rel), { force: true });
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
519
626
|
interface FilteredAgentReplayOptions {
|
|
520
627
|
baseline: WorktreeBaseline;
|
|
521
628
|
branchName: string;
|
|
@@ -542,6 +649,7 @@ async function replayFilteredAgentCommits(opts: FilteredAgentReplayOptions): Pro
|
|
|
542
649
|
opts.baseline.root.untrackedPatch,
|
|
543
650
|
]);
|
|
544
651
|
let previousFilteredTree = baselineSha;
|
|
652
|
+
let filteredCommitsApplied = 0;
|
|
545
653
|
|
|
546
654
|
for (const commitSha of agentCommits) {
|
|
547
655
|
const taskStatePatch = await git.diff.tree(opts.isolationDir, dirtyBaselineTree, `${commitSha}^{tree}`, {
|
|
@@ -562,18 +670,37 @@ async function replayFilteredAgentCommits(opts: FilteredAgentReplayOptions): Pro
|
|
|
562
670
|
details.message || commitSha,
|
|
563
671
|
details.author,
|
|
564
672
|
);
|
|
673
|
+
filteredCommitsApplied++;
|
|
565
674
|
}
|
|
566
675
|
previousFilteredTree = currentFilteredTree;
|
|
567
676
|
}
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
677
|
+
if (filteredCommitsApplied === 0) {
|
|
678
|
+
// No filtered commit landed — tmpDir is still pinned at baselineSha.
|
|
679
|
+
// The `finalFilteredTree = writeSyntheticTree(HEAD, [rootPatch])`
|
|
680
|
+
// path here fails hard whenever rootPatch's WIP-context can't be
|
|
681
|
+
// applied to a HEAD-only index (untracked WIP + agent modifies,
|
|
682
|
+
// staged-new WIP + agent modifies — see #4136). Bypass the synthesis
|
|
683
|
+
// entirely and collapse the isolation output onto a single commit
|
|
684
|
+
// with WIP seed, matching the no-agent-commit path in commitToBranch.
|
|
685
|
+
// This also handles the "agent committed only baseline WIP" corner
|
|
686
|
+
// case where every filtered patch collapsed to empty.
|
|
687
|
+
if (opts.rootPatch.trim()) {
|
|
688
|
+
const msg = (opts.commitMessage && (await opts.commitMessage(opts.rootPatch))) || opts.fallbackMessage;
|
|
689
|
+
await commitPatchToBranchWorktree(tmpDir, opts.taskId, opts.rootPatch, msg, undefined, opts.baseline.root);
|
|
690
|
+
}
|
|
691
|
+
} else {
|
|
692
|
+
// A filtered commit landed; tmpDir has advanced past baselineSha and
|
|
693
|
+
// previousFilteredTree is HEAD-derived, so writeSyntheticTree +
|
|
694
|
+
// leftoverPatch stay HEAD-based and no WIP seed is needed.
|
|
695
|
+
const finalFilteredTree = await writeSyntheticTree(opts.repoRoot, baselineSha, [opts.rootPatch]);
|
|
696
|
+
const leftoverPatch = await git.diff.tree(opts.repoRoot, previousFilteredTree, finalFilteredTree, {
|
|
697
|
+
allowFailure: true,
|
|
698
|
+
binary: true,
|
|
699
|
+
});
|
|
700
|
+
if (leftoverPatch.trim()) {
|
|
701
|
+
const msg = (opts.commitMessage && (await opts.commitMessage(leftoverPatch))) || opts.fallbackMessage;
|
|
702
|
+
await commitPatchToBranchWorktree(tmpDir, opts.taskId, leftoverPatch, msg);
|
|
703
|
+
}
|
|
577
704
|
}
|
|
578
705
|
} finally {
|
|
579
706
|
await git.worktree.tryRemove(opts.repoRoot, tmpDir);
|
|
@@ -674,7 +801,8 @@ export async function commitToBranch(
|
|
|
674
801
|
await git.worktree.add(repoRoot, tmpDir, branchName);
|
|
675
802
|
|
|
676
803
|
const msg = (commitMessage && (await commitMessage(rootPatch))) || fallbackMessage;
|
|
677
|
-
|
|
804
|
+
const wip = baselineHasRootWip(baseline.root) ? baseline.root : undefined;
|
|
805
|
+
await commitPatchToBranchWorktree(tmpDir, taskId, rootPatch, msg, undefined, wip);
|
|
678
806
|
} finally {
|
|
679
807
|
await git.worktree.tryRemove(repoRoot, tmpDir);
|
|
680
808
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
package/src/tiny/text.ts
CHANGED
|
@@ -152,6 +152,34 @@ const FILLER_TITLE_TOKENS = new Set<string>([
|
|
|
152
152
|
]);
|
|
153
153
|
|
|
154
154
|
const TITLE_WORD = /[\p{L}\p{N}]+/gu;
|
|
155
|
+
const COMMON_TITLE_ACRONYMS = new Set<string>([
|
|
156
|
+
"API",
|
|
157
|
+
"CLI",
|
|
158
|
+
"CPU",
|
|
159
|
+
"CRUD",
|
|
160
|
+
"CSS",
|
|
161
|
+
"DNS",
|
|
162
|
+
"ETL",
|
|
163
|
+
"GPU",
|
|
164
|
+
"HTML",
|
|
165
|
+
"HTTP",
|
|
166
|
+
"HTTPS",
|
|
167
|
+
"ID",
|
|
168
|
+
"JSON",
|
|
169
|
+
"LLM",
|
|
170
|
+
"REST",
|
|
171
|
+
"SDK",
|
|
172
|
+
"SSH",
|
|
173
|
+
"TCP",
|
|
174
|
+
"TLS",
|
|
175
|
+
"TUI",
|
|
176
|
+
"UI",
|
|
177
|
+
"URI",
|
|
178
|
+
"URL",
|
|
179
|
+
"UX",
|
|
180
|
+
"XML",
|
|
181
|
+
"YAML",
|
|
182
|
+
]);
|
|
155
183
|
|
|
156
184
|
/**
|
|
157
185
|
* True when a first user message is too low-signal to title (greeting, ack,
|
|
@@ -191,51 +219,115 @@ export function normalizeGeneratedTitle(value: string | null | undefined, source
|
|
|
191
219
|
* Reconcile a generated title's casing against the user's own message.
|
|
192
220
|
*
|
|
193
221
|
* The title prompt asks for sentence case, but small title models still mangle
|
|
194
|
-
* casing
|
|
195
|
-
* (`daemon` → `dAemon`)
|
|
196
|
-
* (`TinyVMM` → `tinyvmm`)
|
|
197
|
-
*
|
|
222
|
+
* casing three ways: they sprout stray interior capitals on ordinary words
|
|
223
|
+
* (`daemon` → `dAemon`), they flatten proper nouns the user cased distinctively
|
|
224
|
+
* (`TinyVMM` → `tinyvmm`), and they title-case ALL-CAPS acronyms as if they
|
|
225
|
+
* were sentence words (`CNPG` → `Cnpg`). The user's message is the source of
|
|
226
|
+
* truth, so per title token:
|
|
198
227
|
* 1. typed verbatim in the message → keep it (the user established the casing);
|
|
199
228
|
* 2. else the message has the same word with *distinctive* mixed casing
|
|
200
229
|
* (`TinyVMM`, `iOS`, `IDs`) → adopt the user's casing (restoration);
|
|
201
|
-
* 3. else
|
|
230
|
+
* 3. else the model produced a plain title-cased artifact (`Cnpg`) whose
|
|
231
|
+
* lowercased form is a likely ALL-CAPS acronym in a non-shouty source
|
|
232
|
+
* (`CNPG`, `API`, `ETL`) → restore the source acronym;
|
|
233
|
+
* 4. else it's a camelCase artifact (lowercase word + stray interior capital,
|
|
202
234
|
* `dAemon`) the user never wrote → lowercase it;
|
|
203
|
-
*
|
|
235
|
+
* 5. else leave it — preserves model-cased proper nouns like `GitHub`, `OAuth`.
|
|
204
236
|
*
|
|
205
|
-
* Restoration is limited to
|
|
206
|
-
*
|
|
207
|
-
* emphatic all-caps (`ALL ERROR
|
|
237
|
+
* Restoration is limited to avoid three failure modes: a sentence that merely
|
|
238
|
+
* *starts* with `For` can't force a mid-title `for` to `For` (distinctive
|
|
239
|
+
* requires interior mixed casing); emphatic all-caps input (`ALL ERROR
|
|
240
|
+
* HANDLING`, `FIX the BUG NOW`) is never re-shouted — see {@link isShoutySource};
|
|
241
|
+
* and ordinary all-caps English words (`FIX`, `WORK`, `BUG`) are not treated as
|
|
242
|
+
* restorable acronyms unless they carry a stronger acronym signal.
|
|
208
243
|
*/
|
|
209
244
|
function reconcileTitleCasing(title: string, sourceText: string): string {
|
|
210
245
|
const verbatim = new Set<string>();
|
|
211
246
|
const distinctive = new Map<string, string>();
|
|
247
|
+
const acronyms = new Map<string, string>();
|
|
248
|
+
const shouty = isShoutySource(sourceText);
|
|
212
249
|
for (const [token] of sourceText.matchAll(TITLE_WORD)) {
|
|
213
250
|
verbatim.add(token);
|
|
214
251
|
if (isDistinctiveCasing(token)) {
|
|
215
252
|
const lower = token.toLowerCase();
|
|
216
253
|
if (!distinctive.has(lower)) distinctive.set(lower, token);
|
|
254
|
+
} else if (!shouty && isAllCapsAcronym(token)) {
|
|
255
|
+
const lower = token.toLowerCase();
|
|
256
|
+
if (!acronyms.has(lower)) acronyms.set(lower, token);
|
|
217
257
|
}
|
|
218
258
|
}
|
|
219
259
|
return title.replace(TITLE_WORD, token => {
|
|
220
260
|
if (verbatim.has(token)) return token;
|
|
221
|
-
const
|
|
261
|
+
const lower = token.toLowerCase();
|
|
262
|
+
const restored = distinctive.get(lower);
|
|
222
263
|
if (restored) return restored;
|
|
223
|
-
|
|
264
|
+
if (isTitleCasedArtifact(token)) {
|
|
265
|
+
const acronym = acronyms.get(lower);
|
|
266
|
+
if (acronym) return acronym;
|
|
267
|
+
}
|
|
268
|
+
return isCamelArtifact(token) ? lower : token;
|
|
224
269
|
});
|
|
225
270
|
}
|
|
226
271
|
|
|
227
272
|
/** Mixed-case identifier the user cased deliberately (`TinyVMM`, `iOS`, `IDs`):
|
|
228
273
|
* an interior/repeated capital plus at least one lowercase letter. Only these
|
|
229
|
-
* are restored when the model flattens them.
|
|
230
|
-
*
|
|
231
|
-
* Pure all-caps is intentionally excluded. The model preserves its own acronyms
|
|
232
|
-
* verbatim regardless, so restoring all-caps from the source would only ever
|
|
233
|
-
* re-shout emphatic input (`ALL ERROR HANDLING`, `FIX THE BUG`) over the
|
|
234
|
-
* sentence case the prompt asks for. */
|
|
274
|
+
* are restored when the model flattens them. */
|
|
235
275
|
function isDistinctiveCasing(token: string): boolean {
|
|
236
276
|
return /\p{Ll}/u.test(token) && /\p{L}\p{Lu}/u.test(token);
|
|
237
277
|
}
|
|
238
278
|
|
|
279
|
+
/** Multi-letter ALL-CAPS source token with a stronger acronym signal than a
|
|
280
|
+
* plain emphasized word. Consonant-only tokens (`CNPG`, `SQL`, `JWT`) are
|
|
281
|
+
* restored, digit-bearing identifiers are restored, and common technical
|
|
282
|
+
* acronyms (`API`, `JSON`, `URL`) are allowlisted. Ordinary emphasized words
|
|
283
|
+
* (`FIX`, `WORK`, `BUG`) contain vowels and are not restored from source. */
|
|
284
|
+
function isAllCapsAcronym(token: string): boolean {
|
|
285
|
+
if (!isAllCapsWord(token)) return false;
|
|
286
|
+
const upper = token.toUpperCase();
|
|
287
|
+
if (COMMON_TITLE_ACRONYMS.has(upper)) return true;
|
|
288
|
+
if (/\p{N}/u.test(token)) return true;
|
|
289
|
+
return !/[AEIOU]/.test(upper);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Multi-letter ALL-CAPS word in the source. Used for shout detection, not for
|
|
293
|
+
* acronym restoration — shouted English words (`FIX`, `WORK`) still count as
|
|
294
|
+
* shouty even though they are not restorable acronyms. Requires an actual
|
|
295
|
+
* uppercase letter so caseless scripts (CJK) never register as shouting and
|
|
296
|
+
* disable acronym restoration for those messages. */
|
|
297
|
+
function isAllCapsWord(token: string): boolean {
|
|
298
|
+
const letters = token.match(/\p{L}/gu);
|
|
299
|
+
if (!letters || letters.length < 2) return false;
|
|
300
|
+
return /\p{Lu}/u.test(token) && !/\p{Ll}/u.test(token);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Plain title-cased word (`Cnpg`, `Etl`): starts uppercase, has one-or-more
|
|
304
|
+
* lowercase letters, no interior uppercase. This is the artifact a title model
|
|
305
|
+
* produces when it sentence-cases an unfamiliar ALL-CAPS acronym; PascalCase
|
|
306
|
+
* proper nouns like `GitHub`/`OAuth` have an interior capital and are
|
|
307
|
+
* excluded so we don't misidentify them. */
|
|
308
|
+
function isTitleCasedArtifact(token: string): boolean {
|
|
309
|
+
if (!/^\p{Lu}/u.test(token)) return false;
|
|
310
|
+
if (!/\p{Ll}/u.test(token)) return false;
|
|
311
|
+
return !/\p{Lu}/u.test(token.slice(1));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** True when the source text is shouting — ≥2 consecutive multi-letter
|
|
315
|
+
* ALL-CAPS tokens (`FIX the BUG NOW` has `BUG NOW`; `ALL ERROR HANDLING`
|
|
316
|
+
* has all three adjacent). Acronym restoration is disabled for shouty input
|
|
317
|
+
* so we don't re-shout emphatic prose the model correctly de-shouted. */
|
|
318
|
+
function isShoutySource(sourceText: string): boolean {
|
|
319
|
+
let run = 0;
|
|
320
|
+
for (const [token] of sourceText.matchAll(TITLE_WORD)) {
|
|
321
|
+
if (isAllCapsWord(token)) {
|
|
322
|
+
run += 1;
|
|
323
|
+
if (run >= 2) return true;
|
|
324
|
+
} else {
|
|
325
|
+
run = 0;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
|
|
239
331
|
/** A lowercase word carrying a stray interior capital (`dAemon`, `cReate`): the
|
|
240
332
|
* model-mangled shape we flatten when the user never wrote it. PascalCase proper
|
|
241
333
|
* nouns (`GitHub`, `OAuth`) start uppercase and are left untouched. */
|
package/src/tools/ast-grep.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { createFileRecorder, formatResultPath } from "./file-recorder";
|
|
|
19
19
|
import { classifyGroupedLines, formatGroupedFiles, groupLineIndicesByBlank } from "./grouped-file-output";
|
|
20
20
|
import { formatMatchLine } from "./match-line-format";
|
|
21
21
|
import type { OutputMeta } from "./output-meta";
|
|
22
|
-
import { resolveToolSearchScope } from "./path-utils";
|
|
22
|
+
import { resolveToolSearchScope, toPathList } from "./path-utils";
|
|
23
23
|
import {
|
|
24
24
|
appendParseErrorsBulletList,
|
|
25
25
|
capParseErrors,
|
|
@@ -37,11 +37,9 @@ import { toolResult } from "./tool-result";
|
|
|
37
37
|
|
|
38
38
|
const astGrepSchema = type({
|
|
39
39
|
pat: type("string").describe("ast pattern"),
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
.atLeastLength(1)
|
|
44
|
-
.describe("files, directories, globs, or internal URLs to search"),
|
|
40
|
+
"path?": type("string").describe(
|
|
41
|
+
'file, directory, glob, or internal URL to search; pass several as a semicolon-delimited list ("src; tests"). Omitted -> searches the workspace root (".")',
|
|
42
|
+
),
|
|
45
43
|
"skip?": type("number").describe("matches to skip"),
|
|
46
44
|
});
|
|
47
45
|
|
|
@@ -159,23 +157,23 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
|
|
|
159
157
|
readonly examples: readonly ToolExample<typeof astGrepSchema.inferIn>[] = [
|
|
160
158
|
{
|
|
161
159
|
caption: "Search TypeScript files under src",
|
|
162
|
-
call: { pat: "console.log($$$)",
|
|
160
|
+
call: { pat: "console.log($$$)", path: "src/**/*.ts" },
|
|
163
161
|
},
|
|
164
162
|
{
|
|
165
163
|
caption: "Named imports from a specific package",
|
|
166
|
-
call: { pat: 'import { $$$IMPORTS } from "react"',
|
|
164
|
+
call: { pat: 'import { $$$IMPORTS } from "react"', path: "src/**/*.ts" },
|
|
167
165
|
},
|
|
168
166
|
{
|
|
169
167
|
caption: "Arrow functions assigned to a const",
|
|
170
|
-
call: { pat: "const $NAME = ($$$ARGS) => $BODY",
|
|
168
|
+
call: { pat: "const $NAME = ($$$ARGS) => $BODY", path: "src/utils/**/*.ts" },
|
|
171
169
|
},
|
|
172
170
|
{
|
|
173
171
|
caption: "Method call on any object, ignoring method name with `$_`",
|
|
174
|
-
call: { pat: "logger.$_($$$ARGS)",
|
|
172
|
+
call: { pat: "logger.$_($$$ARGS)", path: "src/**/*.ts" },
|
|
175
173
|
},
|
|
176
174
|
{
|
|
177
175
|
caption: "Loosest existence check for a symbol in one file",
|
|
178
|
-
call: { pat: "processItems",
|
|
176
|
+
call: { pat: "processItems", path: "src/worker.ts" },
|
|
179
177
|
},
|
|
180
178
|
];
|
|
181
179
|
readonly loadMode = "discoverable";
|
|
@@ -201,8 +199,10 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
|
|
|
201
199
|
if (!Number.isFinite(skip) || skip < 0) {
|
|
202
200
|
throw new ToolError("skip must be a non-negative number");
|
|
203
201
|
}
|
|
202
|
+
const scopedPaths = toPathList(params.path);
|
|
203
|
+
const rawPaths = scopedPaths.length > 0 ? scopedPaths : ["."];
|
|
204
204
|
const scope = await resolveToolSearchScope({
|
|
205
|
-
rawPaths
|
|
205
|
+
rawPaths,
|
|
206
206
|
cwd: this.session.cwd,
|
|
207
207
|
internalUrlAction: "search",
|
|
208
208
|
settings: this.session.settings,
|
|
@@ -275,7 +275,7 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
|
|
|
275
275
|
|
|
276
276
|
if (result.matches.length === 0) {
|
|
277
277
|
const noMatchMessage = cappedParseErrors.length
|
|
278
|
-
? "No matches found. Parse issues mean the query may be mis-scoped; narrow `
|
|
278
|
+
? "No matches found. Parse issues mean the query may be mis-scoped; narrow `path` before concluding absence."
|
|
279
279
|
: "No matches found";
|
|
280
280
|
const parseMessage = cappedParseErrors.length
|
|
281
281
|
? `\n${formatParseErrors(cappedParseErrors, parseErrorsTotal).join("\n")}`
|
|
@@ -375,7 +375,7 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
|
|
|
375
375
|
displayContent: displayLines.join("\n"),
|
|
376
376
|
};
|
|
377
377
|
if (result.limitReached) {
|
|
378
|
-
outputLines.push("", "Result limit reached; narrow
|
|
378
|
+
outputLines.push("", "Result limit reached; narrow path or increase limit.");
|
|
379
379
|
}
|
|
380
380
|
if (cappedParseErrors.length) {
|
|
381
381
|
outputLines.push("", ...formatParseErrors(cappedParseErrors, parseErrorsTotal));
|
|
@@ -392,6 +392,8 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
|
|
|
392
392
|
|
|
393
393
|
interface AstGrepRenderArgs {
|
|
394
394
|
pat?: string;
|
|
395
|
+
path?: string | string[];
|
|
396
|
+
/** Legacy pre-`path` argument name; kept so historical transcripts still render a scope. */
|
|
395
397
|
paths?: string[];
|
|
396
398
|
skip?: number;
|
|
397
399
|
}
|
|
@@ -402,7 +404,8 @@ export const astGrepToolRenderer = {
|
|
|
402
404
|
inline: true,
|
|
403
405
|
renderCall(args: AstGrepRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
|
|
404
406
|
const meta: string[] = [];
|
|
405
|
-
|
|
407
|
+
const scopePaths = toPathList(args.path ?? args.paths);
|
|
408
|
+
if (scopePaths.length) meta.push(`in ${scopePaths.join(", ")}`);
|
|
406
409
|
if (args.skip !== undefined && args.skip > 0) meta.push(`skip:${args.skip}`);
|
|
407
410
|
|
|
408
411
|
const description = args.pat ?? "?";
|
|
@@ -436,7 +439,7 @@ export const astGrepToolRenderer = {
|
|
|
436
439
|
const header = renderStatusLine({ icon: "warning", title: "AST Grep", description, meta }, uiTheme);
|
|
437
440
|
const lines = [header, formatEmptyMessage("No matches found", uiTheme)];
|
|
438
441
|
if (details?.parseErrors?.length) {
|
|
439
|
-
lines.push(uiTheme.fg("warning", "Query may be mis-scoped; narrow `
|
|
442
|
+
lines.push(uiTheme.fg("warning", "Query may be mis-scoped; narrow `path` before concluding absence"));
|
|
440
443
|
appendParseErrorsBulletList(lines, details.parseErrors, uiTheme, details.parseErrorsTotal);
|
|
441
444
|
}
|
|
442
445
|
return new Text(lines.join("\n"), 0, 0);
|
|
@@ -487,7 +490,7 @@ export const astGrepToolRenderer = {
|
|
|
487
490
|
|
|
488
491
|
const extraLines: string[] = [];
|
|
489
492
|
if (limitReached) {
|
|
490
|
-
extraLines.push(uiTheme.fg("warning", "limit reached; narrow
|
|
493
|
+
extraLines.push(uiTheme.fg("warning", "limit reached; narrow path or increase limit"));
|
|
491
494
|
}
|
|
492
495
|
if (details?.parseErrors?.length) {
|
|
493
496
|
extraLines.push(
|