@oh-my-pi/pi-coding-agent 14.9.1 → 14.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/package.json +7 -7
  3. package/scripts/format-prompts.ts +3 -3
  4. package/src/config/prompt-templates.ts +0 -5
  5. package/src/config/settings-schema.ts +38 -0
  6. package/src/eval/eval.lark +10 -31
  7. package/src/eval/index.ts +1 -0
  8. package/src/eval/parse.ts +156 -255
  9. package/src/eval/sniff.ts +28 -0
  10. package/src/export/html/template.css +38 -0
  11. package/src/export/html/template.generated.ts +1 -1
  12. package/src/export/html/template.js +209 -15
  13. package/src/extensibility/extensions/runner.ts +173 -177
  14. package/src/hashline/apply.ts +8 -24
  15. package/src/hashline/constants.ts +20 -0
  16. package/src/hashline/execute.ts +0 -1
  17. package/src/hashline/grammar.lark +16 -27
  18. package/src/hashline/hash.ts +4 -34
  19. package/src/hashline/input.ts +16 -2
  20. package/src/hashline/parser.ts +12 -40
  21. package/src/hashline/types.ts +1 -2
  22. package/src/internal-urls/agent-protocol.ts +1 -0
  23. package/src/internal-urls/artifact-protocol.ts +1 -0
  24. package/src/internal-urls/docs-index.generated.ts +2 -1
  25. package/src/internal-urls/jobs-protocol.ts +1 -0
  26. package/src/internal-urls/local-protocol.ts +1 -0
  27. package/src/internal-urls/mcp-protocol.ts +1 -0
  28. package/src/internal-urls/memory-protocol.ts +1 -0
  29. package/src/internal-urls/pi-protocol.ts +1 -0
  30. package/src/internal-urls/router.ts +2 -1
  31. package/src/internal-urls/rule-protocol.ts +1 -0
  32. package/src/internal-urls/skill-protocol.ts +1 -0
  33. package/src/internal-urls/types.ts +18 -2
  34. package/src/mcp/transports/http.ts +49 -47
  35. package/src/prompts/system/custom-system-prompt.md +0 -2
  36. package/src/prompts/system/now-prompt.md +7 -0
  37. package/src/prompts/system/project-prompt.md +2 -0
  38. package/src/prompts/system/subagent-system-prompt.md +18 -9
  39. package/src/prompts/system/subagent-user-prompt.md +1 -10
  40. package/src/prompts/system/system-prompt.md +154 -233
  41. package/src/prompts/tools/bash.md +0 -24
  42. package/src/prompts/tools/eval.md +26 -13
  43. package/src/prompts/tools/hashline.md +1 -4
  44. package/src/sdk.ts +12 -22
  45. package/src/session/agent-session.ts +49 -17
  46. package/src/system-prompt.ts +38 -104
  47. package/src/task/executor.ts +15 -9
  48. package/src/task/index.ts +38 -33
  49. package/src/task/render.ts +4 -2
  50. package/src/tools/bash.ts +15 -41
  51. package/src/tools/eval.ts +13 -36
  52. package/src/tools/index.ts +0 -3
  53. package/src/tools/path-utils.ts +21 -1
  54. package/src/tools/read.ts +71 -49
  55. package/src/tools/search.ts +13 -1
  56. package/src/utils/file-display-mode.ts +11 -5
  57. package/src/workspace-tree.ts +210 -410
  58. package/src/task/template.ts +0 -47
  59. package/src/tools/bash-normalize.ts +0 -107
package/src/tools/read.ts CHANGED
@@ -73,20 +73,6 @@ const PROSE_SUMMARY_EXTENSIONS = new Set([".md", ".txt"]);
73
73
  // Remote mount path prefix (sshfs mounts) - skip fuzzy matching to avoid hangs
74
74
  const REMOTE_MOUNT_PREFIX = getRemoteDir() + path.sep;
75
75
 
76
- const READ_DIRECTORY_EXCLUDED_DIRS = new Set([
77
- "node_modules",
78
- ".git",
79
- ".next",
80
- "dist",
81
- "build",
82
- "target",
83
- ".venv",
84
- ".cache",
85
- ".turbo",
86
- ".parcel-cache",
87
- "coverage",
88
- ]);
89
-
90
76
  function isRemoteMountPath(absolutePath: string): boolean {
91
77
  return absolutePath.startsWith(REMOTE_MOUNT_PREFIX);
92
78
  }
@@ -476,34 +462,67 @@ type ReadParams = ReadToolInput;
476
462
  type ParsedSelector =
477
463
  | { kind: "none" }
478
464
  | { kind: "raw" }
479
- | { kind: "lines"; startLine: number; endLine: number | undefined };
465
+ | { kind: "lines"; startLine: number; endLine: number | undefined; raw?: boolean };
480
466
 
481
467
  const LINE_RANGE_RE = /^L?(\d+)(?:([-+])L?(\d+))?$/i;
482
468
 
483
- function parseSel(sel: string | undefined): ParsedSelector {
484
- if (!sel || sel.length === 0) return { kind: "none" };
485
- if (sel.toLowerCase() === "raw") return { kind: "raw" };
469
+ /** Returns true when the selector requested verbatim/raw output (alone or combined with a range). */
470
+ function isRawSelector(parsed: ParsedSelector): boolean {
471
+ return parsed.kind === "raw" || (parsed.kind === "lines" && parsed.raw === true);
472
+ }
473
+
474
+ function parseLineRangeChunk(sel: string): { startLine: number; endLine: number | undefined } | null {
486
475
  const lineMatch = LINE_RANGE_RE.exec(sel);
487
- if (lineMatch) {
488
- const rawStart = Number.parseInt(lineMatch[1]!, 10);
489
- if (rawStart < 1) {
490
- throw new ToolError("Line selector 0 is invalid; lines are 1-indexed. Use :1.");
476
+ if (!lineMatch) return null;
477
+ const rawStart = Number.parseInt(lineMatch[1]!, 10);
478
+ if (rawStart < 1) {
479
+ throw new ToolError("Line selector 0 is invalid; lines are 1-indexed. Use :1.");
480
+ }
481
+ const sep = lineMatch[2];
482
+ const rhs = lineMatch[3] ? Number.parseInt(lineMatch[3], 10) : undefined;
483
+ let rawEnd: number | undefined;
484
+ if (sep === "+") {
485
+ if (rhs === undefined || rhs < 1) {
486
+ throw new ToolError(`Invalid range ${rawStart}+${rhs ?? 0}: count must be >= 1.`);
491
487
  }
492
- const sep = lineMatch[2];
493
- const rhs = lineMatch[3] ? Number.parseInt(lineMatch[3], 10) : undefined;
494
- let rawEnd: number | undefined;
495
- if (sep === "+") {
496
- if (rhs === undefined || rhs < 1) {
497
- throw new ToolError(`Invalid range ${rawStart}+${rhs ?? 0}: count must be >= 1.`);
498
- }
499
- rawEnd = rawStart + rhs - 1;
500
- } else if (sep === "-") {
501
- if (rhs === undefined || rhs < rawStart) {
502
- throw new ToolError(`Invalid range ${rawStart}-${rhs ?? 0}: end must be >= start.`);
488
+ rawEnd = rawStart + rhs - 1;
489
+ } else if (sep === "-") {
490
+ if (rhs === undefined || rhs < rawStart) {
491
+ throw new ToolError(`Invalid range ${rawStart}-${rhs ?? 0}: end must be >= start.`);
492
+ }
493
+ rawEnd = rhs;
494
+ }
495
+ return { startLine: rawStart, endLine: rawEnd };
496
+ }
497
+
498
+ function parseSel(sel: string | undefined): ParsedSelector {
499
+ if (!sel || sel.length === 0) return { kind: "none" };
500
+
501
+ // Compound selector: `1-50:raw` or `raw:1-50`. Split into chunks and accept
502
+ // any combination of one line range and the literal `raw`.
503
+ if (sel.includes(":")) {
504
+ const chunks = sel.split(":");
505
+ if (chunks.length === 2) {
506
+ const [a, b] = chunks as [string, string];
507
+ const aIsRaw = a.toLowerCase() === "raw";
508
+ const bIsRaw = b.toLowerCase() === "raw";
509
+ const rangeChunk = aIsRaw ? b : bIsRaw ? a : null;
510
+ const rawChunk = aIsRaw ? a : bIsRaw ? b : null;
511
+ if (rangeChunk !== null && rawChunk !== null) {
512
+ const range = parseLineRangeChunk(rangeChunk);
513
+ if (range) {
514
+ return { kind: "lines", startLine: range.startLine, endLine: range.endLine, raw: true };
515
+ }
503
516
  }
504
- rawEnd = rhs;
505
517
  }
506
- return { kind: "lines", startLine: rawStart, endLine: rawEnd };
518
+ // Unrecognized compound fall through (sqlite/archive/url consume their own colon syntax).
519
+ return { kind: "none" };
520
+ }
521
+
522
+ if (sel.toLowerCase() === "raw") return { kind: "raw" };
523
+ const range = parseLineRangeChunk(sel);
524
+ if (range) {
525
+ return { kind: "lines", startLine: range.startLine, endLine: range.endLine };
507
526
  }
508
527
  // Unrecognized selectors fall through; sqlite/archive/url readers consume their own colon syntax.
509
528
  return { kind: "none" };
@@ -667,9 +686,10 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
667
686
  entityLabel: string;
668
687
  ignoreResultLimits?: boolean;
669
688
  raw?: boolean;
689
+ immutable?: boolean;
670
690
  },
671
691
  ): AgentToolResult<ReadToolDetails> {
672
- const displayMode = resolveFileDisplayMode(this.session, { raw: options.raw });
692
+ const displayMode = resolveFileDisplayMode(this.session, { raw: options.raw, immutable: options.immutable });
673
693
  const details = options.details ?? {};
674
694
  const allLines = text.split("\n");
675
695
  const totalLines = allLines.length;
@@ -1153,6 +1173,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1153
1173
  details: { ...cached.details },
1154
1174
  sourceUrl: cached.details.finalUrl,
1155
1175
  entityLabel: "URL output",
1176
+ immutable: true,
1156
1177
  });
1157
1178
  }
1158
1179
  return executeReadUrl(this.session, { path: parsedUrlTarget.path, raw: parsedUrlTarget.raw }, signal);
@@ -1164,7 +1185,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1164
1185
  if (internalRouter?.canHandle(internalTarget.path)) {
1165
1186
  const parsed = parseSel(internalTarget.sel);
1166
1187
  const { offset, limit } = selToOffsetLimit(parsed);
1167
- return this.#handleInternalUrl(internalTarget.path, offset, limit);
1188
+ return this.#handleInternalUrl(internalTarget.path, offset, limit, { raw: isRawSelector(parsed) });
1168
1189
  }
1169
1190
 
1170
1191
  const archivePath = await this.#resolveArchiveReadPath(readPath, signal);
@@ -1178,7 +1199,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1178
1199
  limit,
1179
1200
  { ...archivePath, archiveSubPath: archiveSubPath.path },
1180
1201
  signal,
1181
- { raw: archiveParsed.kind === "raw" },
1202
+ { raw: isRawSelector(archiveParsed) },
1182
1203
  );
1183
1204
  }
1184
1205
 
@@ -1307,7 +1328,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1307
1328
  throw error;
1308
1329
  }
1309
1330
  }
1310
- } else if (isNotebookPath(absolutePath) && parsed.kind !== "raw") {
1331
+ } else if (isNotebookPath(absolutePath) && !isRawSelector(parsed)) {
1311
1332
  const { offset, limit } = selToOffsetLimit(parsed);
1312
1333
  return this.#buildInMemoryTextResult(
1313
1334
  await readEditableNotebookText(absolutePath, localReadPath),
@@ -1435,7 +1456,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1435
1456
  getFileReadCache(this.session).recordContiguous(absolutePath, startLineDisplay, collectedLines);
1436
1457
  }
1437
1458
 
1438
- const isRawMode = parsed.kind === "raw";
1459
+ const isRawMode = isRawSelector(parsed);
1439
1460
  const shouldAddHashLines = !isRawMode && displayMode.hashLines;
1440
1461
  const shouldAddLineNumbers = isRawMode ? false : shouldAddHashLines ? false : displayMode.lineNumbers;
1441
1462
  let capturedDisplayContent: { text: string; startLine: number } | undefined;
@@ -1524,7 +1545,12 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1524
1545
  * Handle internal URLs (agent://, artifact://, memory://, skill://, rule://, local://, mcp://).
1525
1546
  * Supports pagination via offset/limit but rejects them when query extraction is used.
1526
1547
  */
1527
- async #handleInternalUrl(url: string, offset?: number, limit?: number): Promise<AgentToolResult<ReadToolDetails>> {
1548
+ async #handleInternalUrl(
1549
+ url: string,
1550
+ offset?: number,
1551
+ limit?: number,
1552
+ options?: { raw?: boolean },
1553
+ ): Promise<AgentToolResult<ReadToolDetails>> {
1528
1554
  const internalRouter = this.session.internalRouter!;
1529
1555
 
1530
1556
  // Check if URL has query extraction (agent:// only).
@@ -1564,6 +1590,8 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1564
1590
  sourceInternal: url,
1565
1591
  entityLabel: "resource",
1566
1592
  ignoreResultLimits: scheme === "skill",
1593
+ immutable: resource.immutable,
1594
+ raw: options?.raw,
1567
1595
  });
1568
1596
  }
1569
1597
 
@@ -1581,15 +1609,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1581
1609
  try {
1582
1610
  tree = await buildDirectoryTree(absolutePath, {
1583
1611
  maxDepth: READ_DIRECTORY_MAX_DEPTH,
1584
- directoryEntryLimit: READ_DIRECTORY_CHILD_LIMIT,
1585
- rootEntryLimit: null,
1612
+ perDirLimit: READ_DIRECTORY_CHILD_LIMIT,
1613
+ rootLimit: null,
1586
1614
  lineCap: limit ?? null,
1587
- lineCapProtectedDepth: 1,
1588
- hidden: true,
1589
- gitignore: false,
1590
- cache: true,
1591
- excludedDirectoryNames: READ_DIRECTORY_EXCLUDED_DIRS,
1592
- rootLabel: ".",
1593
1615
  });
1594
1616
  } catch (error) {
1595
1617
  const message = error instanceof Error ? error.message : String(error);
@@ -121,7 +121,6 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
121
121
  const patternHasNewline = normalizedPattern.includes("\n") || normalizedPattern.includes("\\n");
122
122
  const effectiveMultiline = patternHasNewline;
123
123
 
124
- const useHashLines = resolveFileDisplayMode(this.session).hashLines;
125
124
  const formatScopePath = (targetPath: string): string => formatPathRelativeToCwd(targetPath, this.session.cwd);
126
125
  let searchPath: string;
127
126
  let scopePath: string;
@@ -134,6 +133,10 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
134
133
  }
135
134
  const internalRouter = this.session.internalRouter;
136
135
  const resolvedPathInputs: string[] = [];
136
+ // Absolute filesystem paths whose source is immutable (e.g. artifact://,
137
+ // pi://, skill://). Hashline anchors are suppressed for these on a
138
+ // per-file basis, leaving editable mixed-in files untouched.
139
+ const immutableSourcePaths = new Set<string>();
137
140
  for (const rawPath of rawPaths) {
138
141
  if (!internalRouter?.canHandle(rawPath)) {
139
142
  resolvedPathInputs.push(rawPath);
@@ -146,8 +149,13 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
146
149
  if (!resource.sourcePath) {
147
150
  throw new ToolError(`Cannot search internal URL without a backing file: ${rawPath}`);
148
151
  }
152
+ if (resource.immutable) {
153
+ immutableSourcePaths.add(path.resolve(resource.sourcePath));
154
+ }
149
155
  resolvedPathInputs.push(resource.sourcePath);
150
156
  }
157
+ const baseDisplayMode = resolveFileDisplayMode(this.session);
158
+ const immutableDisplayMode = resolveFileDisplayMode(this.session, { immutable: true });
151
159
  // Tolerate missing entries in a multi-path call: skip ones whose base
152
160
  // directory is gone, and only error if every entry is missing. Single
153
161
  // missing path keeps the original ENOENT semantics.
@@ -336,6 +344,10 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
336
344
  const modelOut: string[] = [];
337
345
  const displayOut: string[] = [];
338
346
  const fileMatches = matchesByFile.get(relativePath) ?? [];
347
+ const absoluteFilePath = path.resolve(this.session.cwd, relativePath);
348
+ const useHashLines = immutableSourcePaths.has(absoluteFilePath)
349
+ ? immutableDisplayMode.hashLines
350
+ : baseDisplayMode.hashLines;
339
351
  const lineNumberWidth = fileMatches.reduce((width, match) => {
340
352
  let nextWidth = Math.max(width, String(match.lineNumber).length);
341
353
  for (const ctx of match.contextBefore ?? []) {
@@ -21,17 +21,23 @@ export interface FileDisplayModeSession {
21
21
  /**
22
22
  * Computes effective line display mode from session settings/env.
23
23
  * Hashline mode takes precedence and implies line-addressed output everywhere.
24
- * Hashlines are suppressed when the edit tool is not available (e.g. explore agents)
25
- * and when the caller signals a `raw` read raw output should be returned as-is
26
- * without injecting hashline anchors or line numbers.
24
+ * Hashlines are suppressed when the edit tool is not available (e.g. explore agents),
25
+ * when the caller signals a `raw` read, and when the source is `immutable`
26
+ * (e.g. internal URLs like artifact://, agent://, memory:// — there is no edit
27
+ * path that could consume the anchors). Raw output is returned as-is.
27
28
  */
28
- export function resolveFileDisplayMode(session: FileDisplayModeSession, options?: { raw?: boolean }): FileDisplayMode {
29
+ export function resolveFileDisplayMode(
30
+ session: FileDisplayModeSession,
31
+ options?: { raw?: boolean; immutable?: boolean },
32
+ ): FileDisplayMode {
29
33
  const { settings } = session;
30
34
  const hasEditTool = session.hasEditTool ?? true;
31
35
  const editMode = resolveEditMode(session);
32
36
  const usesHashLineAnchors = editMode === "hashline";
33
37
  const raw = options?.raw === true;
34
- const hashLines = !raw && hasEditTool && usesHashLineAnchors && settings.get("readHashLines") !== false;
38
+ const immutable = options?.immutable === true;
39
+ const hashLines =
40
+ !raw && !immutable && hasEditTool && usesHashLineAnchors && settings.get("readHashLines") !== false;
35
41
  return {
36
42
  hashLines,
37
43
  lineNumbers: !raw && (hashLines || settings.get("readLineNumbers") === true),