@oh-my-pi/pi-coding-agent 14.7.0 → 14.7.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.
Files changed (61) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/package.json +12 -12
  3. package/src/cli/grep-cli.ts +1 -1
  4. package/src/config/model-equivalence.ts +1 -0
  5. package/src/config/model-registry.ts +108 -22
  6. package/src/config/settings-schema.ts +46 -1
  7. package/src/config/settings.ts +71 -1
  8. package/src/dap/client.ts +1 -0
  9. package/src/discovery/builtin.ts +34 -9
  10. package/src/discovery/helpers.ts +4 -3
  11. package/src/edit/index.ts +1 -0
  12. package/src/edit/modes/hashline.ts +212 -63
  13. package/src/eval/py/gateway-coordinator.ts +2 -3
  14. package/src/eval/py/runtime.ts +1 -0
  15. package/src/internal-urls/docs-index.generated.ts +2 -2
  16. package/src/lsp/index.ts +2 -0
  17. package/src/main.ts +10 -15
  18. package/src/mcp/discoverable-tool-metadata.ts +24 -202
  19. package/src/modes/components/extensions/extension-dashboard.ts +26 -2
  20. package/src/modes/components/extensions/state-manager.ts +41 -0
  21. package/src/modes/controllers/selector-controller.ts +3 -0
  22. package/src/modes/interactive-mode.ts +45 -13
  23. package/src/prompts/system/plan-mode-active.md +7 -3
  24. package/src/prompts/system/plan-mode-approved.md +5 -0
  25. package/src/prompts/tools/search-tool-bm25.md +14 -14
  26. package/src/prompts/tools/todo-write.md +1 -0
  27. package/src/sdk.ts +69 -8
  28. package/src/session/agent-session.ts +177 -1
  29. package/src/slash-commands/builtin-registry.ts +13 -2
  30. package/src/task/index.ts +2 -0
  31. package/src/task/isolation-backend.ts +22 -0
  32. package/src/tool-discovery/tool-index.ts +377 -0
  33. package/src/tools/ask.ts +2 -0
  34. package/src/tools/ast-edit.ts +2 -0
  35. package/src/tools/ast-grep.ts +2 -0
  36. package/src/tools/bash.ts +1 -0
  37. package/src/tools/browser.ts +2 -0
  38. package/src/tools/calculator.ts +2 -0
  39. package/src/tools/checkpoint.ts +4 -0
  40. package/src/tools/debug.ts +2 -0
  41. package/src/tools/eval.ts +2 -0
  42. package/src/tools/find.ts +2 -0
  43. package/src/tools/gh.ts +2 -0
  44. package/src/tools/hindsight-recall.ts +2 -0
  45. package/src/tools/hindsight-reflect.ts +2 -0
  46. package/src/tools/hindsight-retain.ts +2 -0
  47. package/src/tools/index.ts +74 -14
  48. package/src/tools/inspect-image.ts +2 -0
  49. package/src/tools/irc.ts +2 -1
  50. package/src/tools/job.ts +2 -1
  51. package/src/tools/notebook.ts +2 -0
  52. package/src/tools/read.ts +7 -1
  53. package/src/tools/recipe/index.ts +2 -0
  54. package/src/tools/render-mermaid.ts +2 -0
  55. package/src/tools/search-tool-bm25.ts +128 -42
  56. package/src/tools/search.ts +2 -0
  57. package/src/tools/ssh.ts +2 -0
  58. package/src/tools/todo-write.ts +2 -1
  59. package/src/tools/write.ts +2 -0
  60. package/src/web/search/index.ts +2 -0
  61. package/src/web/search/providers/searxng.ts +8 -0
@@ -981,6 +981,110 @@ function countMatchingSuffixBlock(fileLines: string[], endLine: number, replacem
981
981
  return 0;
982
982
  }
983
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
+
984
1088
  function hasExternalTargets(lines: Iterable<number>, externalTargetLines: Set<number>): boolean {
985
1089
  for (const line of lines) {
986
1090
  if (externalTargetLines.has(line)) return true;
@@ -1087,49 +1191,80 @@ interface PureInsertAbsorbResult {
1087
1191
  * Mirror of replacement-absorb's prefix/suffix block check, but for pure
1088
1192
  * inserts: drop payload lines that exactly duplicate the file lines
1089
1193
  * immediately above (leading) or immediately below (trailing) the insertion
1090
- * point. Requires a minimum run of 2 to avoid spurious single-line matches,
1091
- * matching the existing replacement-absorb threshold.
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.
1092
1198
  */
1093
- function tryAbsorbPureInsertGroup(group: HashlinePureInsertGroup, fileLines: string[]): PureInsertAbsorbResult {
1199
+ function tryAbsorbPureInsertGroup(
1200
+ group: HashlinePureInsertGroup,
1201
+ fileLines: string[],
1202
+ allowGenericBoundaryAbsorb: boolean,
1203
+ ): PureInsertAbsorbResult {
1094
1204
  const empty: PureInsertAbsorbResult = { keptPayload: group.payload, absorbedLeading: 0, absorbedTrailing: 0 };
1095
- if (group.payload.length < 2) return empty;
1205
+ if (group.payload.length === 0) return empty;
1096
1206
 
1097
1207
  const { aboveEndIdx, belowStartIdx } = pureInsertNeighborhood(group.cursor, fileLines);
1098
1208
 
1099
1209
  // Leading: payload[0..k-1] vs fileLines[aboveEndIdx-k+1 .. aboveEndIdx].
1100
1210
  let absorbedLeading = 0;
1101
- const maxLead = Math.min(group.payload.length, aboveEndIdx + 1);
1102
- for (let count = maxLead; count >= 2; count--) {
1103
- let ok = true;
1104
- for (let offset = 0; offset < count; offset++) {
1105
- if (group.payload[offset] !== fileLines[aboveEndIdx - count + 1 + offset]) {
1106
- ok = false;
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;
1107
1223
  break;
1108
1224
  }
1109
1225
  }
1110
- if (ok) {
1111
- absorbedLeading = count;
1112
- break;
1113
- }
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;
1114
1236
  }
1115
1237
 
1116
1238
  // Trailing: payload[len-k..len-1] vs fileLines[belowStartIdx..belowStartIdx+k-1].
1117
1239
  // Don't double-count payload lines already absorbed as leading.
1118
1240
  let absorbedTrailing = 0;
1119
- const remaining = group.payload.length - absorbedLeading;
1120
- const maxTrail = Math.min(remaining, fileLines.length - belowStartIdx);
1121
- for (let count = maxTrail; count >= 2; count--) {
1122
- let ok = true;
1123
- for (let offset = 0; offset < count; offset++) {
1124
- if (group.payload[group.payload.length - count + offset] !== fileLines[belowStartIdx + offset]) {
1125
- ok = false;
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;
1126
1255
  break;
1127
1256
  }
1128
1257
  }
1129
- if (ok) {
1130
- absorbedTrailing = count;
1131
- break;
1132
- }
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;
1133
1268
  }
1134
1269
 
1135
1270
  if (absorbedLeading === 0 && absorbedTrailing === 0) return empty;
@@ -1164,46 +1299,53 @@ function absorbReplacementBoundaryDuplicates(
1164
1299
  for (let index = 0; index < edits.length; index++) {
1165
1300
  const group = findReplacementGroup(edits, index);
1166
1301
  if (!group) {
1167
- if (options.autoDropPureInsertDuplicates) {
1168
- const pureInsert = findPureInsertGroup(edits, index);
1169
- if (pureInsert) {
1170
- const result = tryAbsorbPureInsertGroup(pureInsert, fileLines);
1171
- if (result.absorbedLeading > 0 || result.absorbedTrailing > 0) {
1172
- if (result.leadingFileRange) {
1173
- const { start, end } = result.leadingFileRange;
1174
- const key = `pure-insert-leading:${start}..${end}`;
1175
- if (!emittedAbsorbKeys.has(key)) {
1176
- emittedAbsorbKeys.add(key);
1177
- warnings.push(
1178
- `Auto-dropped ${result.absorbedLeading} duplicate line(s) at the start of insert at line ${pureInsert.sourceLineNum} ` +
1179
- `(file lines ${start}..${end} already match the payload's leading lines).`,
1180
- );
1181
- }
1182
- }
1183
- if (result.trailingFileRange) {
1184
- const { start, end } = result.trailingFileRange;
1185
- const key = `pure-insert-trailing:${start}..${end}`;
1186
- if (!emittedAbsorbKeys.has(key)) {
1187
- emittedAbsorbKeys.add(key);
1188
- warnings.push(
1189
- `Auto-dropped ${result.absorbedTrailing} duplicate line(s) at the end of insert at line ${pureInsert.sourceLineNum} ` +
1190
- `(file lines ${start}..${end} already match the payload's trailing lines).`,
1191
- );
1192
- }
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
+ );
1193
1319
  }
1194
- for (const text of result.keptPayload) {
1195
- absorbed.push({
1196
- kind: "insert",
1197
- cursor: cloneCursor(pureInsert.cursor),
1198
- text,
1199
- lineNum: pureInsert.sourceLineNum,
1200
- index: nextSyntheticIndex++,
1201
- });
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
+ );
1202
1330
  }
1203
- index = pureInsert.endIndex;
1204
- continue;
1205
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]);
1206
1346
  }
1347
+ index = pureInsert.endIndex;
1348
+ continue;
1207
1349
  }
1208
1350
  absorbed.push(edits[index]);
1209
1351
  continue;
@@ -1212,8 +1354,15 @@ function absorbReplacementBoundaryDuplicates(
1212
1354
  const startLine = group.deletes[0].anchor.line;
1213
1355
  const endLine = group.deletes[group.deletes.length - 1].anchor.line;
1214
1356
 
1215
- const prefixCount = countMatchingPrefixBlock(fileLines, startLine, group.replacement);
1216
- const suffixCount = countMatchingSuffixBlock(fileLines, endLine, group.replacement);
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);
1217
1366
  const prefixLines = contiguousRange(startLine - prefixCount, prefixCount);
1218
1367
  const suffixLines = contiguousRange(endLine + 1, suffixCount);
1219
1368
  const safePrefixCount = hasExternalTargets(prefixLines, allTargetLines) ? 0 : prefixCount;
@@ -2,13 +2,12 @@ import * as fs from "node:fs";
2
2
  import { createServer } from "node:net";
3
3
  import * as path from "node:path";
4
4
  import { Process } from "@oh-my-pi/pi-natives";
5
- import { getAgentDir, isEnoent, logger, procmgr } from "@oh-my-pi/pi-utils";
5
+ import { getPythonGatewayDir, isEnoent, logger, procmgr } from "@oh-my-pi/pi-utils";
6
6
  import type { Subprocess } from "bun";
7
7
  import { Settings } from "../../config/settings";
8
8
  import { getOrCreateSnapshot } from "../../utils/shell-snapshot";
9
9
  import { filterEnv, resolvePythonRuntime } from "./runtime";
10
10
 
11
- const GATEWAY_DIR_NAME = "python-gateway";
12
11
  const GATEWAY_INFO_FILE = "gateway.json";
13
12
  const GATEWAY_LOCK_FILE = "gateway.lock";
14
13
  const GATEWAY_STARTUP_TIMEOUT_MS = 30000;
@@ -66,7 +65,7 @@ async function allocatePort(): Promise<number> {
66
65
  }
67
66
 
68
67
  function getGatewayDir(): string {
69
- return path.join(getAgentDir(), GATEWAY_DIR_NAME);
68
+ return getPythonGatewayDir();
70
69
  }
71
70
 
72
71
  function getGatewayInfoPath(): string {
@@ -34,6 +34,7 @@ const DEFAULT_ENV_ALLOWLIST = new Set([
34
34
  "CONDA_DEFAULT_ENV",
35
35
  "VIRTUAL_ENV",
36
36
  "PYTHONPATH",
37
+ "LD_LIBRARY_PATH",
37
38
  ]);
38
39
 
39
40
  const WINDOWS_ENV_ALLOWLIST = new Set([