@oh-my-pi/pi-coding-agent 14.6.6 → 14.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -0
- package/examples/hooks/handoff.ts +1 -1
- package/examples/hooks/qna.ts +1 -1
- package/examples/sdk/03-custom-prompt.ts +7 -4
- package/examples/sdk/README.md +1 -1
- package/package.json +12 -12
- package/src/autoresearch/index.ts +48 -44
- package/src/cli/grep-cli.ts +1 -1
- package/src/cli/read-cli.ts +58 -0
- package/src/cli.ts +1 -0
- package/src/commands/read.ts +40 -0
- package/src/commit/agentic/agent.ts +1 -1
- package/src/commit/analysis/conventional.ts +1 -1
- package/src/commit/analysis/summary.ts +1 -1
- package/src/commit/changelog/generate.ts +1 -1
- package/src/commit/map-reduce/map-phase.ts +1 -1
- package/src/commit/map-reduce/reduce-phase.ts +1 -1
- package/src/config/settings-schema.ts +49 -0
- package/src/config/settings.ts +71 -1
- package/src/dap/client.ts +1 -0
- package/src/discovery/builtin.ts +34 -9
- package/src/edit/line-hash.ts +34 -4
- package/src/edit/modes/hashline.ts +352 -8
- package/src/edit/streaming.ts +4 -1
- package/src/export/html/index.ts +1 -1
- package/src/extensibility/extensions/runner.ts +3 -3
- package/src/extensibility/extensions/types.ts +4 -4
- package/src/internal-urls/docs-index.generated.ts +1 -1
- package/src/main.ts +13 -18
- package/src/memories/index.ts +1 -1
- package/src/modes/components/agent-dashboard.ts +1 -1
- package/src/modes/components/read-tool-group.ts +4 -9
- package/src/modes/components/tool-execution.ts +4 -0
- package/src/modes/controllers/event-controller.ts +2 -0
- package/src/modes/interactive-mode.ts +19 -12
- package/src/modes/rpc/rpc-types.ts +1 -1
- package/src/modes/utils/context-usage.ts +12 -5
- package/src/modes/utils/ui-helpers.ts +1 -0
- package/src/prompts/system/plan-mode-active.md +7 -3
- package/src/prompts/system/plan-mode-approved.md +5 -0
- package/src/prompts/system/project-prompt.md +36 -0
- package/src/prompts/system/system-prompt.md +0 -29
- package/src/prompts/tools/github.md +1 -0
- package/src/prompts/tools/read.md +15 -14
- package/src/sdk.ts +29 -28
- package/src/session/agent-session.ts +20 -12
- package/src/session/compaction/branch-summarization.ts +1 -1
- package/src/session/compaction/compaction.ts +3 -3
- package/src/session/session-dump-format.ts +10 -5
- package/src/session/streaming-output.ts +1 -1
- package/src/slash-commands/builtin-registry.ts +2 -2
- package/src/system-prompt.ts +35 -3
- package/src/task/executor.ts +4 -3
- package/src/task/isolation-backend.ts +22 -0
- package/src/tools/fetch.ts +4 -4
- package/src/tools/gh.ts +187 -0
- package/src/tools/inspect-image.ts +1 -1
- package/src/tools/output-meta.ts +1 -1
- package/src/tools/path-utils.ts +11 -0
- package/src/tools/read.ts +393 -204
- package/src/tools/search.ts +1 -1
- package/src/tools/sqlite-reader.ts +1 -1
- package/src/utils/commit-message-generator.ts +1 -1
- package/src/utils/title-generator.ts +1 -1
- package/src/web/search/providers/anthropic.ts +1 -1
- package/src/workspace-tree.ts +396 -0
package/src/discovery/builtin.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import * as path from "node:path";
|
|
7
7
|
import { logger, parseFrontmatter, tryParseJson } from "@oh-my-pi/pi-utils";
|
|
8
|
+
import { YAML } from "bun";
|
|
8
9
|
import { registerProvider } from "../capability";
|
|
9
10
|
import { type ContextFile, contextFileCapability } from "../capability/context-file";
|
|
10
11
|
import { type Extension, type ExtensionManifest, extensionCapability } from "../capability/extension";
|
|
@@ -778,22 +779,46 @@ async function loadSettings(ctx: LoadContext): Promise<LoadResult<Settings>> {
|
|
|
778
779
|
const items: Settings[] = [];
|
|
779
780
|
const warnings: string[] = [];
|
|
780
781
|
|
|
782
|
+
const parseYamlSettings = (content: string, filePath: string): Record<string, unknown> | null => {
|
|
783
|
+
try {
|
|
784
|
+
const data = YAML.parse(content);
|
|
785
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) return {};
|
|
786
|
+
return data as Record<string, unknown>;
|
|
787
|
+
} catch {
|
|
788
|
+
warnings.push(`Failed to parse ${filePath}`);
|
|
789
|
+
return null;
|
|
790
|
+
}
|
|
791
|
+
};
|
|
792
|
+
|
|
781
793
|
for (const { dir, level } of await getConfigDirs(ctx)) {
|
|
782
794
|
const settingsPath = path.join(dir, "settings.json");
|
|
783
|
-
const
|
|
784
|
-
if (
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
795
|
+
const settingsContent = await readFile(settingsPath);
|
|
796
|
+
if (settingsContent) {
|
|
797
|
+
const data = tryParseJson<Record<string, unknown>>(settingsContent);
|
|
798
|
+
if (data) {
|
|
799
|
+
items.push({
|
|
800
|
+
path: settingsPath,
|
|
801
|
+
data,
|
|
802
|
+
level,
|
|
803
|
+
_source: createSourceMeta(PROVIDER_ID, settingsPath, level),
|
|
804
|
+
});
|
|
805
|
+
} else {
|
|
806
|
+
warnings.push(`Failed to parse ${settingsPath}`);
|
|
807
|
+
}
|
|
790
808
|
}
|
|
791
809
|
|
|
810
|
+
const configPath = path.join(dir, "config.yml");
|
|
811
|
+
const configContent = await readFile(configPath);
|
|
812
|
+
if (!configContent) continue;
|
|
813
|
+
|
|
814
|
+
const data = parseYamlSettings(configContent, configPath);
|
|
815
|
+
if (!data) continue;
|
|
816
|
+
|
|
792
817
|
items.push({
|
|
793
|
-
path:
|
|
818
|
+
path: configPath,
|
|
794
819
|
data,
|
|
795
820
|
level,
|
|
796
|
-
_source: createSourceMeta(PROVIDER_ID,
|
|
821
|
+
_source: createSourceMeta(PROVIDER_ID, configPath, level),
|
|
797
822
|
});
|
|
798
823
|
}
|
|
799
824
|
|
package/src/edit/line-hash.ts
CHANGED
|
@@ -783,18 +783,48 @@ export const HL_BODY_SEP = "|";
|
|
|
783
783
|
export const HL_BODY_SEP_RE_RAW = regexEscape(HL_BODY_SEP);
|
|
784
784
|
|
|
785
785
|
const RE_SIGNIFICANT = /[\p{L}\p{N}]/u;
|
|
786
|
+
const RE_STRUCTURAL_STRIP = /[\s{}]/g;
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* Bigram returned for lines that contain only whitespace and `{`/`}`.
|
|
790
|
+
* Picks the English ordinal suffix for the line number (`1` → `st`,
|
|
791
|
+
* `2` → `nd`, `3` → `rd`, `11`/`12`/`13` → `th`, else `th`) so the
|
|
792
|
+
* line digits + bigram BPE-merge into a single ordinal token (`1st`, `42nd`,
|
|
793
|
+
* `100th`, …). Brace-only lines therefore cost one token for the whole
|
|
794
|
+
* `LINE+ID` anchor instead of two.
|
|
795
|
+
*/
|
|
796
|
+
function structuralBigram(line: number): string {
|
|
797
|
+
const mod100 = line % 100;
|
|
798
|
+
if (mod100 >= 11 && mod100 <= 13) return "th";
|
|
799
|
+
switch (line % 10) {
|
|
800
|
+
case 1:
|
|
801
|
+
return "st";
|
|
802
|
+
case 2:
|
|
803
|
+
return "nd";
|
|
804
|
+
case 3:
|
|
805
|
+
return "rd";
|
|
806
|
+
default:
|
|
807
|
+
return "th";
|
|
808
|
+
}
|
|
809
|
+
}
|
|
786
810
|
|
|
787
811
|
/**
|
|
788
812
|
* Compute a 2-character hash of a single line via xxHash32 mod 647 over
|
|
789
|
-
* {@link HL_BIGRAMS}. Lines
|
|
790
|
-
*
|
|
791
|
-
*
|
|
792
|
-
*
|
|
813
|
+
* {@link HL_BIGRAMS}. Lines that contain only whitespace and `{`/`}` collapse
|
|
814
|
+
* to an ordinal-suffix bigram (see {@link structuralBigram}) so brace-only
|
|
815
|
+
* structure shares one merged ordinal token (`1st`, `42nd`, `100th`, …).
|
|
816
|
+
* Other lines with no letter or digit mix the line number into the seed so
|
|
817
|
+
* adjacent identical punctuation-only lines get distinct hashes; lines with
|
|
818
|
+
* significant content stay line-number-independent so a line is identifiable
|
|
819
|
+
* across small shifts.
|
|
793
820
|
*
|
|
794
821
|
* The line input should not include a trailing newline.
|
|
795
822
|
*/
|
|
796
823
|
export function computeLineHash(idx: number, line: string): string {
|
|
797
824
|
line = line.replace(/\r/g, "").trimEnd();
|
|
825
|
+
if (line.replace(RE_STRUCTURAL_STRIP, "").length === 0) {
|
|
826
|
+
return structuralBigram(idx);
|
|
827
|
+
}
|
|
798
828
|
const seed = RE_SIGNIFICANT.test(line) ? 0 : idx;
|
|
799
829
|
return HL_BIGRAMS[Bun.hash.xxHash32(line, seed) % HL_BIGRAMS_COUNT];
|
|
800
830
|
}
|
|
@@ -103,6 +103,9 @@ export interface CompactHashlineDiffOptions {
|
|
|
103
103
|
/** Maximum entries kept on each side of an unchanged-context truncation (default: 2). */
|
|
104
104
|
maxUnchangedRun?: number;
|
|
105
105
|
}
|
|
106
|
+
export interface HashlineApplyOptions {
|
|
107
|
+
autoDropPureInsertDuplicates?: boolean;
|
|
108
|
+
}
|
|
106
109
|
|
|
107
110
|
export interface SplitHashlineOptions {
|
|
108
111
|
cwd?: string;
|
|
@@ -140,7 +143,7 @@ const HL_OUTPUT_PREFIX_SEPARATOR_RE = `[:${HL_BODY_SEP_RE_RAW}]`;
|
|
|
140
143
|
const HL_PREFIX_RE = new RegExp(`^\\s*(?:>>>|>>)?\\s*(?:[+*]\\s*)?\\d+[a-z]{2}${HL_OUTPUT_PREFIX_SEPARATOR_RE}`);
|
|
141
144
|
const HL_PREFIX_PLUS_RE = new RegExp(`^\\s*(?:>>>|>>)?\\s*\\+\\s*\\d+[a-z]{2}${HL_OUTPUT_PREFIX_SEPARATOR_RE}`);
|
|
142
145
|
const DIFF_PLUS_RE = /^[+](?![+])/;
|
|
143
|
-
const READ_TRUNCATION_NOTICE_RE = /^\[(?:Showing lines \d+-\d+ of \d+|\d+ more lines? in (?:file|\S+))\b.*\
|
|
146
|
+
const READ_TRUNCATION_NOTICE_RE = /^\[(?:Showing lines \d+-\d+ of \d+|\d+ more lines? in (?:file|\S+))\b.*\bUse :L?\d+/;
|
|
144
147
|
|
|
145
148
|
const HL_HASH_HINT_RE = /^[a-z]{2}$/i;
|
|
146
149
|
const HL_ANCHOR_EXAMPLES = describeAnchorExamples("160");
|
|
@@ -978,6 +981,110 @@ function countMatchingSuffixBlock(fileLines: string[], endLine: number, replacem
|
|
|
978
981
|
return 0;
|
|
979
982
|
}
|
|
980
983
|
|
|
984
|
+
// Single-line duplicate absorption is limited to structural closing delimiters.
|
|
985
|
+
// General one-line context is too easy to delete incorrectly, but duplicated
|
|
986
|
+
// `};` / `)` / `]` boundaries usually indicate a replacement range stopped one
|
|
987
|
+
// line early and would otherwise produce a syntax error.
|
|
988
|
+
const STRUCTURAL_CLOSING_BOUNDARY_RE = /^\s*[\])}]+[;,]?\s*$/;
|
|
989
|
+
|
|
990
|
+
function isStructuralClosingBoundaryLine(line: string): boolean {
|
|
991
|
+
return STRUCTURAL_CLOSING_BOUNDARY_RE.test(line);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
interface DelimiterBalance {
|
|
995
|
+
paren: number;
|
|
996
|
+
bracket: number;
|
|
997
|
+
brace: number;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
const ZERO_DELIMITER_BALANCE: DelimiterBalance = { paren: 0, bracket: 0, brace: 0 };
|
|
1001
|
+
|
|
1002
|
+
/**
|
|
1003
|
+
* Naive bracket counter — does NOT skip string/template/comment contents. The
|
|
1004
|
+
* single-line structural absorb relies on this being safe-by-asymmetry: the
|
|
1005
|
+
* candidate boundary line is constrained by `STRUCTURAL_CLOSING_BOUNDARY_RE`
|
|
1006
|
+
* to be pure delimiters, so noise in deleted lines or non-boundary kept payload
|
|
1007
|
+
* tends to push `expected !== kept` and biases the heuristic toward NOT
|
|
1008
|
+
* absorbing (the safe direction). If we ever extend this to opening boundaries
|
|
1009
|
+
* or non-structural single lines, swap this for a real tokenizer.
|
|
1010
|
+
*/
|
|
1011
|
+
function computeDelimiterBalance(lines: string[]): DelimiterBalance {
|
|
1012
|
+
const balance: DelimiterBalance = { paren: 0, bracket: 0, brace: 0 };
|
|
1013
|
+
for (const line of lines) {
|
|
1014
|
+
for (const char of line) {
|
|
1015
|
+
switch (char) {
|
|
1016
|
+
case "(":
|
|
1017
|
+
balance.paren++;
|
|
1018
|
+
break;
|
|
1019
|
+
case ")":
|
|
1020
|
+
balance.paren--;
|
|
1021
|
+
break;
|
|
1022
|
+
case "[":
|
|
1023
|
+
balance.bracket++;
|
|
1024
|
+
break;
|
|
1025
|
+
case "]":
|
|
1026
|
+
balance.bracket--;
|
|
1027
|
+
break;
|
|
1028
|
+
case "{":
|
|
1029
|
+
balance.brace++;
|
|
1030
|
+
break;
|
|
1031
|
+
case "}":
|
|
1032
|
+
balance.brace--;
|
|
1033
|
+
break;
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
return balance;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
function delimiterBalancesEqual(a: DelimiterBalance, b: DelimiterBalance): boolean {
|
|
1041
|
+
return a.paren === b.paren && a.bracket === b.bracket && a.brace === b.brace;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
/**
|
|
1045
|
+
* Decides whether the structural-boundary candidate should be dropped: the
|
|
1046
|
+
* `keptPayload` (full payload with the boundary line removed) must restore the
|
|
1047
|
+
* caller's `expectedBalance`, while the `fullPayload` (boundary line still
|
|
1048
|
+
* present) must NOT. For replacements `expectedBalance` is the deleted
|
|
1049
|
+
* region's net delimiter balance; for pure inserts it is zero.
|
|
1050
|
+
*/
|
|
1051
|
+
function shouldDropSingleStructuralBoundary(
|
|
1052
|
+
fullPayload: string[],
|
|
1053
|
+
keptPayload: string[],
|
|
1054
|
+
expectedBalance: DelimiterBalance,
|
|
1055
|
+
): boolean {
|
|
1056
|
+
return (
|
|
1057
|
+
delimiterBalancesEqual(computeDelimiterBalance(keptPayload), expectedBalance) &&
|
|
1058
|
+
!delimiterBalancesEqual(computeDelimiterBalance(fullPayload), expectedBalance)
|
|
1059
|
+
);
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
function countMatchingSingleStructuralPrefixBoundary(
|
|
1063
|
+
fileLines: string[],
|
|
1064
|
+
startLine: number,
|
|
1065
|
+
replacement: string[],
|
|
1066
|
+
expectedBalance: DelimiterBalance,
|
|
1067
|
+
): number {
|
|
1068
|
+
if (replacement.length === 0 || startLine <= 1) return 0;
|
|
1069
|
+
const line = replacement[0];
|
|
1070
|
+
if (!isStructuralClosingBoundaryLine(line)) return 0;
|
|
1071
|
+
if (fileLines[startLine - 2] !== line) return 0;
|
|
1072
|
+
return shouldDropSingleStructuralBoundary(replacement, replacement.slice(1), expectedBalance) ? 1 : 0;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
function countMatchingSingleStructuralSuffixBoundary(
|
|
1076
|
+
fileLines: string[],
|
|
1077
|
+
endLine: number,
|
|
1078
|
+
replacement: string[],
|
|
1079
|
+
expectedBalance: DelimiterBalance,
|
|
1080
|
+
): number {
|
|
1081
|
+
if (replacement.length === 0 || endLine >= fileLines.length) return 0;
|
|
1082
|
+
const line = replacement[replacement.length - 1];
|
|
1083
|
+
if (!isStructuralClosingBoundaryLine(line)) return 0;
|
|
1084
|
+
if (fileLines[endLine] !== line) return 0;
|
|
1085
|
+
return shouldDropSingleStructuralBoundary(replacement, replacement.slice(0, -1), expectedBalance) ? 1 : 0;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
981
1088
|
function hasExternalTargets(lines: Iterable<number>, externalTargetLines: Set<number>): boolean {
|
|
982
1089
|
for (const line of lines) {
|
|
983
1090
|
if (externalTargetLines.has(line)) return true;
|
|
@@ -1003,10 +1110,181 @@ function deleteEditForAutoAbsorbedLine(
|
|
|
1003
1110
|
};
|
|
1004
1111
|
}
|
|
1005
1112
|
|
|
1113
|
+
interface HashlinePureInsertGroup {
|
|
1114
|
+
startIndex: number;
|
|
1115
|
+
endIndex: number;
|
|
1116
|
+
sourceLineNum: number;
|
|
1117
|
+
cursor: HashlineCursor;
|
|
1118
|
+
payload: string[];
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
function cursorMatches(a: HashlineCursor, b: HashlineCursor): boolean {
|
|
1122
|
+
if (a.kind !== b.kind) return false;
|
|
1123
|
+
if (a.kind === "bof" || a.kind === "eof") return true;
|
|
1124
|
+
const aAnchor = (a as { anchor: Anchor }).anchor;
|
|
1125
|
+
const bAnchor = (b as { anchor: Anchor }).anchor;
|
|
1126
|
+
return aAnchor.line === bAnchor.line && aAnchor.hash === bAnchor.hash;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
/**
|
|
1130
|
+
* Collects a run of consecutive `insert` edits that all share the same
|
|
1131
|
+
* `lineNum` and `cursor`, IFF that run is not immediately followed by a
|
|
1132
|
+
* `delete` at the same `lineNum` (which would make it a replacement group
|
|
1133
|
+
* instead). Returns the contiguous payload so we can check it for boundary
|
|
1134
|
+
* duplicates against the file.
|
|
1135
|
+
*/
|
|
1136
|
+
function findPureInsertGroup(edits: HashlineEdit[], startIndex: number): HashlinePureInsertGroup | undefined {
|
|
1137
|
+
const first = edits[startIndex];
|
|
1138
|
+
if (first?.kind !== "insert") return undefined;
|
|
1139
|
+
|
|
1140
|
+
const sourceLineNum = first.lineNum;
|
|
1141
|
+
const cursor = first.cursor;
|
|
1142
|
+
const payload: string[] = [];
|
|
1143
|
+
let index = startIndex;
|
|
1144
|
+
while (index < edits.length) {
|
|
1145
|
+
const edit = edits[index];
|
|
1146
|
+
if (edit.kind !== "insert" || edit.lineNum !== sourceLineNum) break;
|
|
1147
|
+
if (!cursorMatches(edit.cursor, cursor)) break;
|
|
1148
|
+
payload.push(edit.text);
|
|
1149
|
+
index++;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
// If the run is followed by a delete at the same source lineNum, this is a
|
|
1153
|
+
// replacement group (handled by absorbReplacement…). Decline.
|
|
1154
|
+
if (index < edits.length && edits[index].kind === "delete" && edits[index].lineNum === sourceLineNum) {
|
|
1155
|
+
return undefined;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
return { startIndex, endIndex: index - 1, sourceLineNum, cursor, payload };
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
/**
|
|
1162
|
+
* For a pure-insert group, locate the file region adjacent to the insertion
|
|
1163
|
+
* point. Returns 0-indexed bounds:
|
|
1164
|
+
* - `aboveEndIdx`: index of the last file line strictly above the insertion
|
|
1165
|
+
* point (-1 if none).
|
|
1166
|
+
* - `belowStartIdx`: index of the first file line strictly below the
|
|
1167
|
+
* insertion point (`fileLines.length` if none).
|
|
1168
|
+
*/
|
|
1169
|
+
function pureInsertNeighborhood(
|
|
1170
|
+
cursor: HashlineCursor,
|
|
1171
|
+
fileLines: string[],
|
|
1172
|
+
): { aboveEndIdx: number; belowStartIdx: number } {
|
|
1173
|
+
if (cursor.kind === "bof") return { aboveEndIdx: -1, belowStartIdx: 0 };
|
|
1174
|
+
if (cursor.kind === "eof") return { aboveEndIdx: fileLines.length - 1, belowStartIdx: fileLines.length };
|
|
1175
|
+
if (cursor.kind === "before_anchor") {
|
|
1176
|
+
return { aboveEndIdx: cursor.anchor.line - 2, belowStartIdx: cursor.anchor.line - 1 };
|
|
1177
|
+
}
|
|
1178
|
+
// after_anchor
|
|
1179
|
+
return { aboveEndIdx: cursor.anchor.line - 1, belowStartIdx: cursor.anchor.line };
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
interface PureInsertAbsorbResult {
|
|
1183
|
+
keptPayload: string[];
|
|
1184
|
+
absorbedLeading: number;
|
|
1185
|
+
absorbedTrailing: number;
|
|
1186
|
+
leadingFileRange?: { start: number; end: number }; // 1-indexed inclusive
|
|
1187
|
+
trailingFileRange?: { start: number; end: number }; // 1-indexed inclusive
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
/**
|
|
1191
|
+
* Mirror of replacement-absorb's prefix/suffix block check, but for pure
|
|
1192
|
+
* inserts: drop payload lines that exactly duplicate the file lines
|
|
1193
|
+
* immediately above (leading) or immediately below (trailing) the insertion
|
|
1194
|
+
* point. Generic context echo absorption requires a minimum run of 2, but a
|
|
1195
|
+
* single structural closing delimiter is absorbed because duplicated `}` /
|
|
1196
|
+
* `});`-style boundaries almost always mean the insert included adjacent
|
|
1197
|
+
* context.
|
|
1198
|
+
*/
|
|
1199
|
+
function tryAbsorbPureInsertGroup(
|
|
1200
|
+
group: HashlinePureInsertGroup,
|
|
1201
|
+
fileLines: string[],
|
|
1202
|
+
allowGenericBoundaryAbsorb: boolean,
|
|
1203
|
+
): PureInsertAbsorbResult {
|
|
1204
|
+
const empty: PureInsertAbsorbResult = { keptPayload: group.payload, absorbedLeading: 0, absorbedTrailing: 0 };
|
|
1205
|
+
if (group.payload.length === 0) return empty;
|
|
1206
|
+
|
|
1207
|
+
const { aboveEndIdx, belowStartIdx } = pureInsertNeighborhood(group.cursor, fileLines);
|
|
1208
|
+
|
|
1209
|
+
// Leading: payload[0..k-1] vs fileLines[aboveEndIdx-k+1 .. aboveEndIdx].
|
|
1210
|
+
let absorbedLeading = 0;
|
|
1211
|
+
if (allowGenericBoundaryAbsorb) {
|
|
1212
|
+
const maxLead = Math.min(group.payload.length, aboveEndIdx + 1);
|
|
1213
|
+
for (let count = maxLead; count >= 2; count--) {
|
|
1214
|
+
let ok = true;
|
|
1215
|
+
for (let offset = 0; offset < count; offset++) {
|
|
1216
|
+
if (group.payload[offset] !== fileLines[aboveEndIdx - count + 1 + offset]) {
|
|
1217
|
+
ok = false;
|
|
1218
|
+
break;
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
if (ok) {
|
|
1222
|
+
absorbedLeading = count;
|
|
1223
|
+
break;
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
if (
|
|
1228
|
+
absorbedLeading === 0 &&
|
|
1229
|
+
group.payload.length > 0 &&
|
|
1230
|
+
aboveEndIdx >= 0 &&
|
|
1231
|
+
isStructuralClosingBoundaryLine(group.payload[0]) &&
|
|
1232
|
+
group.payload[0] === fileLines[aboveEndIdx] &&
|
|
1233
|
+
shouldDropSingleStructuralBoundary(group.payload, group.payload.slice(1), ZERO_DELIMITER_BALANCE)
|
|
1234
|
+
) {
|
|
1235
|
+
absorbedLeading = 1;
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
// Trailing: payload[len-k..len-1] vs fileLines[belowStartIdx..belowStartIdx+k-1].
|
|
1239
|
+
// Don't double-count payload lines already absorbed as leading.
|
|
1240
|
+
let absorbedTrailing = 0;
|
|
1241
|
+
const remainingPayload = group.payload.slice(absorbedLeading);
|
|
1242
|
+
const remaining = remainingPayload.length;
|
|
1243
|
+
if (allowGenericBoundaryAbsorb) {
|
|
1244
|
+
const maxTrail = Math.min(remaining, fileLines.length - belowStartIdx);
|
|
1245
|
+
for (let count = maxTrail; count >= 2; count--) {
|
|
1246
|
+
let ok = true;
|
|
1247
|
+
for (let offset = 0; offset < count; offset++) {
|
|
1248
|
+
if (group.payload[group.payload.length - count + offset] !== fileLines[belowStartIdx + offset]) {
|
|
1249
|
+
ok = false;
|
|
1250
|
+
break;
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
if (ok) {
|
|
1254
|
+
absorbedTrailing = count;
|
|
1255
|
+
break;
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
if (
|
|
1260
|
+
absorbedTrailing === 0 &&
|
|
1261
|
+
remaining > 0 &&
|
|
1262
|
+
belowStartIdx < fileLines.length &&
|
|
1263
|
+
isStructuralClosingBoundaryLine(remainingPayload[remainingPayload.length - 1]) &&
|
|
1264
|
+
remainingPayload[remainingPayload.length - 1] === fileLines[belowStartIdx] &&
|
|
1265
|
+
shouldDropSingleStructuralBoundary(remainingPayload, remainingPayload.slice(0, -1), ZERO_DELIMITER_BALANCE)
|
|
1266
|
+
) {
|
|
1267
|
+
absorbedTrailing = 1;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
if (absorbedLeading === 0 && absorbedTrailing === 0) return empty;
|
|
1271
|
+
|
|
1272
|
+
return {
|
|
1273
|
+
keptPayload: group.payload.slice(absorbedLeading, group.payload.length - absorbedTrailing),
|
|
1274
|
+
absorbedLeading,
|
|
1275
|
+
absorbedTrailing,
|
|
1276
|
+
leadingFileRange:
|
|
1277
|
+
absorbedLeading > 0 ? { start: aboveEndIdx - absorbedLeading + 2, end: aboveEndIdx + 1 } : undefined,
|
|
1278
|
+
trailingFileRange:
|
|
1279
|
+
absorbedTrailing > 0 ? { start: belowStartIdx + 1, end: belowStartIdx + absorbedTrailing } : undefined,
|
|
1280
|
+
};
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1006
1283
|
function absorbReplacementBoundaryDuplicates(
|
|
1007
1284
|
edits: HashlineEdit[],
|
|
1008
1285
|
fileLines: string[],
|
|
1009
1286
|
warnings: string[],
|
|
1287
|
+
options: HashlineApplyOptions,
|
|
1010
1288
|
): HashlineEdit[] {
|
|
1011
1289
|
let nextSyntheticIndex = edits.length;
|
|
1012
1290
|
const absorbed: HashlineEdit[] = [];
|
|
@@ -1021,6 +1299,54 @@ function absorbReplacementBoundaryDuplicates(
|
|
|
1021
1299
|
for (let index = 0; index < edits.length; index++) {
|
|
1022
1300
|
const group = findReplacementGroup(edits, index);
|
|
1023
1301
|
if (!group) {
|
|
1302
|
+
const pureInsert = findPureInsertGroup(edits, index);
|
|
1303
|
+
if (pureInsert) {
|
|
1304
|
+
const result = tryAbsorbPureInsertGroup(
|
|
1305
|
+
pureInsert,
|
|
1306
|
+
fileLines,
|
|
1307
|
+
options.autoDropPureInsertDuplicates === true,
|
|
1308
|
+
);
|
|
1309
|
+
if (result.absorbedLeading > 0 || result.absorbedTrailing > 0) {
|
|
1310
|
+
if (result.leadingFileRange) {
|
|
1311
|
+
const { start, end } = result.leadingFileRange;
|
|
1312
|
+
const key = `pure-insert-leading:${start}..${end}`;
|
|
1313
|
+
if (!emittedAbsorbKeys.has(key)) {
|
|
1314
|
+
emittedAbsorbKeys.add(key);
|
|
1315
|
+
warnings.push(
|
|
1316
|
+
`Auto-dropped ${result.absorbedLeading} duplicate line(s) at the start of insert at line ${pureInsert.sourceLineNum} ` +
|
|
1317
|
+
`(file lines ${start}..${end} already match the payload's leading lines).`,
|
|
1318
|
+
);
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
if (result.trailingFileRange) {
|
|
1322
|
+
const { start, end } = result.trailingFileRange;
|
|
1323
|
+
const key = `pure-insert-trailing:${start}..${end}`;
|
|
1324
|
+
if (!emittedAbsorbKeys.has(key)) {
|
|
1325
|
+
emittedAbsorbKeys.add(key);
|
|
1326
|
+
warnings.push(
|
|
1327
|
+
`Auto-dropped ${result.absorbedTrailing} duplicate line(s) at the end of insert at line ${pureInsert.sourceLineNum} ` +
|
|
1328
|
+
`(file lines ${start}..${end} already match the payload's trailing lines).`,
|
|
1329
|
+
);
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
for (const text of result.keptPayload) {
|
|
1333
|
+
absorbed.push({
|
|
1334
|
+
kind: "insert",
|
|
1335
|
+
cursor: cloneCursor(pureInsert.cursor),
|
|
1336
|
+
text,
|
|
1337
|
+
lineNum: pureInsert.sourceLineNum,
|
|
1338
|
+
index: nextSyntheticIndex++,
|
|
1339
|
+
});
|
|
1340
|
+
}
|
|
1341
|
+
index = pureInsert.endIndex;
|
|
1342
|
+
continue;
|
|
1343
|
+
}
|
|
1344
|
+
for (let groupIndex = pureInsert.startIndex; groupIndex <= pureInsert.endIndex; groupIndex++) {
|
|
1345
|
+
absorbed.push(edits[groupIndex]);
|
|
1346
|
+
}
|
|
1347
|
+
index = pureInsert.endIndex;
|
|
1348
|
+
continue;
|
|
1349
|
+
}
|
|
1024
1350
|
absorbed.push(edits[index]);
|
|
1025
1351
|
continue;
|
|
1026
1352
|
}
|
|
@@ -1028,8 +1354,15 @@ function absorbReplacementBoundaryDuplicates(
|
|
|
1028
1354
|
const startLine = group.deletes[0].anchor.line;
|
|
1029
1355
|
const endLine = group.deletes[group.deletes.length - 1].anchor.line;
|
|
1030
1356
|
|
|
1031
|
-
const
|
|
1032
|
-
|
|
1357
|
+
const deletedBalance = computeDelimiterBalance(
|
|
1358
|
+
group.deletes.map(deleteEdit => fileLines[deleteEdit.anchor.line - 1] ?? ""),
|
|
1359
|
+
);
|
|
1360
|
+
const prefixCount =
|
|
1361
|
+
countMatchingPrefixBlock(fileLines, startLine, group.replacement) ||
|
|
1362
|
+
countMatchingSingleStructuralPrefixBoundary(fileLines, startLine, group.replacement, deletedBalance);
|
|
1363
|
+
const suffixCount =
|
|
1364
|
+
countMatchingSuffixBlock(fileLines, endLine, group.replacement) ||
|
|
1365
|
+
countMatchingSingleStructuralSuffixBoundary(fileLines, endLine, group.replacement, deletedBalance);
|
|
1033
1366
|
const prefixLines = contiguousRange(startLine - prefixCount, prefixCount);
|
|
1034
1367
|
const suffixLines = contiguousRange(endLine + 1, suffixCount);
|
|
1035
1368
|
const safePrefixCount = hasExternalTargets(prefixLines, allTargetLines) ? 0 : prefixCount;
|
|
@@ -1094,7 +1427,11 @@ function bucketAnchorEditsByLine(edits: IndexedEdit[]): Map<number, IndexedEdit[
|
|
|
1094
1427
|
return byLine;
|
|
1095
1428
|
}
|
|
1096
1429
|
|
|
1097
|
-
export function applyHashlineEdits(
|
|
1430
|
+
export function applyHashlineEdits(
|
|
1431
|
+
text: string,
|
|
1432
|
+
edits: HashlineEdit[],
|
|
1433
|
+
options: HashlineApplyOptions = {},
|
|
1434
|
+
): HashlineApplyResult {
|
|
1098
1435
|
if (edits.length === 0) return { lines: text, firstChangedLine: undefined };
|
|
1099
1436
|
|
|
1100
1437
|
const fileLines = text.split("\n");
|
|
@@ -1109,7 +1446,7 @@ export function applyHashlineEdits(text: string, edits: HashlineEdit[]): Hashlin
|
|
|
1109
1446
|
const mismatches = validateHashlineAnchors(edits, fileLines, warnings);
|
|
1110
1447
|
if (mismatches.length > 0) throw new HashlineMismatchError(mismatches, fileLines);
|
|
1111
1448
|
|
|
1112
|
-
const normalizedEdits = absorbReplacementBoundaryDuplicates(edits, fileLines, warnings);
|
|
1449
|
+
const normalizedEdits = absorbReplacementBoundaryDuplicates(edits, fileLines, warnings, options);
|
|
1113
1450
|
|
|
1114
1451
|
// Normalize after_anchor inserts to before_anchor of the next line, or EOF
|
|
1115
1452
|
// when the anchor is the final line. This keeps the bucketing logic below
|
|
@@ -1332,6 +1669,7 @@ async function readHashlineFileText(file: { text(): Promise<string> }, pathText:
|
|
|
1332
1669
|
export async function computeHashlineDiff(
|
|
1333
1670
|
input: { input: string; path?: string },
|
|
1334
1671
|
cwd: string,
|
|
1672
|
+
options: HashlineApplyOptions = {},
|
|
1335
1673
|
): Promise<{ diff: string; firstChangedLine: number | undefined } | { error: string }> {
|
|
1336
1674
|
try {
|
|
1337
1675
|
const sections = splitHashlineInputs(input.input, { cwd, path: input.path });
|
|
@@ -1344,7 +1682,7 @@ export async function computeHashlineDiff(
|
|
|
1344
1682
|
const rawContent = await readHashlineFileText(Bun.file(absolutePath), section.path);
|
|
1345
1683
|
const { text: content } = stripBom(rawContent);
|
|
1346
1684
|
const normalized = normalizeToLF(content);
|
|
1347
|
-
const result = applyHashlineEdits(normalized, parseHashline(section.diff));
|
|
1685
|
+
const result = applyHashlineEdits(normalized, parseHashline(section.diff), options);
|
|
1348
1686
|
if (normalized === result.lines) return { error: `No changes would be made to ${section.path}.` };
|
|
1349
1687
|
return generateDiffString(normalized, result.lines);
|
|
1350
1688
|
} catch (err) {
|
|
@@ -1382,6 +1720,12 @@ function formatNoChangeDiagnostic(pathText: string): string {
|
|
|
1382
1720
|
return `Edits to ${pathText} resulted in no changes being made.`;
|
|
1383
1721
|
}
|
|
1384
1722
|
|
|
1723
|
+
function getHashlineApplyOptions(session: ToolSession): HashlineApplyOptions {
|
|
1724
|
+
return {
|
|
1725
|
+
autoDropPureInsertDuplicates: session.settings.get("edit.hashlineAutoDropPureInsertDuplicates"),
|
|
1726
|
+
};
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1385
1729
|
function getTextContent(result: AgentToolResult<EditToolDetails>): string {
|
|
1386
1730
|
return result.content.map(part => (part.type === "text" ? part.text : "")).join("\n");
|
|
1387
1731
|
}
|
|
@@ -1408,7 +1752,7 @@ async function preflightHashlineSection(options: ExecuteHashlineSingleOptions &
|
|
|
1408
1752
|
|
|
1409
1753
|
const { text } = stripBom(source.rawContent);
|
|
1410
1754
|
const normalized = normalizeToLF(text);
|
|
1411
|
-
const result = applyHashlineEdits(normalized, edits);
|
|
1755
|
+
const result = applyHashlineEdits(normalized, edits, getHashlineApplyOptions(session));
|
|
1412
1756
|
if (normalized === result.lines) throw new Error(formatNoChangeDiagnostic(sectionPath));
|
|
1413
1757
|
}
|
|
1414
1758
|
|
|
@@ -1436,7 +1780,7 @@ async function executeHashlineSection(
|
|
|
1436
1780
|
const { bom, text } = stripBom(source.rawContent);
|
|
1437
1781
|
const originalEnding = detectLineEnding(text);
|
|
1438
1782
|
const originalNormalized = normalizeToLF(text);
|
|
1439
|
-
const result = applyHashlineEdits(originalNormalized, edits);
|
|
1783
|
+
const result = applyHashlineEdits(originalNormalized, edits, getHashlineApplyOptions(session));
|
|
1440
1784
|
|
|
1441
1785
|
if (originalNormalized === result.lines) {
|
|
1442
1786
|
return {
|
package/src/edit/streaming.ts
CHANGED
|
@@ -32,6 +32,7 @@ export interface StreamingDiffContext {
|
|
|
32
32
|
signal: AbortSignal;
|
|
33
33
|
fuzzyThreshold?: number;
|
|
34
34
|
allowFuzzy?: boolean;
|
|
35
|
+
hashlineAutoDropPureInsertDuplicates?: boolean;
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
export interface EditStreamingStrategy<Args = unknown> {
|
|
@@ -222,7 +223,9 @@ const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
|
|
|
222
223
|
async computeDiffPreview(args, ctx) {
|
|
223
224
|
if (typeof args.input !== "string" || args.input.length === 0) return null;
|
|
224
225
|
ctx.signal.throwIfAborted();
|
|
225
|
-
const result = await computeHashlineDiff({ input: args.input, path: args.path }, ctx.cwd
|
|
226
|
+
const result = await computeHashlineDiff({ input: args.input, path: args.path }, ctx.cwd, {
|
|
227
|
+
autoDropPureInsertDuplicates: ctx.hashlineAutoDropPureInsertDuplicates,
|
|
228
|
+
});
|
|
226
229
|
ctx.signal.throwIfAborted();
|
|
227
230
|
if ("error" in result && !args.path) return [{ path: "", error: result.error }];
|
|
228
231
|
return [toPerFilePreview(args.path ?? "", result)];
|
package/src/export/html/index.ts
CHANGED
|
@@ -124,7 +124,7 @@ export async function exportSessionToHtml(
|
|
|
124
124
|
header: sm.getHeader(),
|
|
125
125
|
entries: sm.getEntries(),
|
|
126
126
|
leafId: sm.getLeafId(),
|
|
127
|
-
systemPrompt: state?.systemPrompt,
|
|
127
|
+
systemPrompt: state?.systemPrompt.join("\n\n"),
|
|
128
128
|
tools: state?.tools?.map(t => ({ name: t.name, description: t.description })),
|
|
129
129
|
};
|
|
130
130
|
|
|
@@ -55,7 +55,7 @@ import type {
|
|
|
55
55
|
/** Combined result from all before_agent_start handlers */
|
|
56
56
|
interface BeforeAgentStartCombinedResult {
|
|
57
57
|
messages?: NonNullable<BeforeAgentStartEventResult["message"]>[];
|
|
58
|
-
systemPrompt?: string;
|
|
58
|
+
systemPrompt?: string[];
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
export type ExtensionErrorListener = (error: ExtensionError) => void;
|
|
@@ -168,7 +168,7 @@ export class ExtensionRunner {
|
|
|
168
168
|
#hasPendingMessagesFn: () => boolean = () => false;
|
|
169
169
|
#getContextUsageFn: () => ContextUsage | undefined = () => undefined;
|
|
170
170
|
#compactFn: (instructionsOrOptions?: string | CompactOptions) => Promise<void> = async () => {};
|
|
171
|
-
#getSystemPromptFn: () => string = () =>
|
|
171
|
+
#getSystemPromptFn: () => string[] = () => [];
|
|
172
172
|
#newSessionHandler: NewSessionHandler = async () => ({ cancelled: false });
|
|
173
173
|
#branchHandler: BranchHandler = async () => ({ cancelled: false });
|
|
174
174
|
#navigateTreeHandler: NavigateTreeHandler = async () => ({ cancelled: false });
|
|
@@ -795,7 +795,7 @@ export class ExtensionRunner {
|
|
|
795
795
|
async emitBeforeAgentStart(
|
|
796
796
|
prompt: string,
|
|
797
797
|
images: ImageContent[] | undefined,
|
|
798
|
-
systemPrompt: string,
|
|
798
|
+
systemPrompt: string[],
|
|
799
799
|
): Promise<BeforeAgentStartCombinedResult | undefined> {
|
|
800
800
|
const ctx = this.createContext();
|
|
801
801
|
const messages: NonNullable<BeforeAgentStartEventResult["message"]>[] = [];
|
|
@@ -240,7 +240,7 @@ export interface ExtensionContext {
|
|
|
240
240
|
/** Gracefully shutdown and exit. */
|
|
241
241
|
shutdown(): void;
|
|
242
242
|
/** Get the current effective system prompt. */
|
|
243
|
-
getSystemPrompt(): string;
|
|
243
|
+
getSystemPrompt(): string[];
|
|
244
244
|
/** @deprecated Use hasPendingMessages() instead */
|
|
245
245
|
hasQueuedMessages(): boolean;
|
|
246
246
|
}
|
|
@@ -492,7 +492,7 @@ export interface BeforeAgentStartEvent {
|
|
|
492
492
|
type: "before_agent_start";
|
|
493
493
|
prompt: string;
|
|
494
494
|
images?: ImageContent[];
|
|
495
|
-
systemPrompt: string;
|
|
495
|
+
systemPrompt: string[];
|
|
496
496
|
}
|
|
497
497
|
|
|
498
498
|
/** Fired when an agent loop starts */
|
|
@@ -876,7 +876,7 @@ export interface ToolResultEventResult {
|
|
|
876
876
|
export interface BeforeAgentStartEventResult {
|
|
877
877
|
message?: Pick<CustomMessage, "customType" | "content" | "display" | "details" | "attribution">;
|
|
878
878
|
/** Replace the system prompt for this turn. If multiple extensions return this, they are chained. */
|
|
879
|
-
systemPrompt?: string;
|
|
879
|
+
systemPrompt?: string[];
|
|
880
880
|
}
|
|
881
881
|
|
|
882
882
|
export interface SessionBeforeSwitchResult {
|
|
@@ -1318,7 +1318,7 @@ export interface ExtensionContextActions {
|
|
|
1318
1318
|
shutdown: () => void;
|
|
1319
1319
|
getContextUsage: () => ContextUsage | undefined;
|
|
1320
1320
|
compact: (instructionsOrOptions?: string | CompactOptions) => Promise<void>;
|
|
1321
|
-
getSystemPrompt: () => string;
|
|
1321
|
+
getSystemPrompt: () => string[];
|
|
1322
1322
|
}
|
|
1323
1323
|
|
|
1324
1324
|
/** Actions for ExtensionCommandContext (ctx.* in command handlers). */
|