@oh-my-pi/pi-coding-agent 15.0.0 → 15.0.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 (165) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/examples/extensions/plan-mode.ts +0 -1
  3. package/package.json +10 -10
  4. package/scripts/build-binary.ts +5 -0
  5. package/src/autoresearch/helpers.ts +17 -0
  6. package/src/autoresearch/tools/log-experiment.ts +9 -17
  7. package/src/autoresearch/tools/run-experiment.ts +2 -17
  8. package/src/capability/skill.ts +7 -0
  9. package/src/cli/list-models.ts +1 -1
  10. package/src/cli/shell-cli.ts +3 -13
  11. package/src/cli/update-cli.ts +1 -1
  12. package/src/cli.ts +10 -29
  13. package/src/commands/commit.ts +10 -0
  14. package/src/commit/agentic/tools/propose-changelog.ts +8 -1
  15. package/src/commit/analysis/conventional.ts +8 -66
  16. package/src/commit/map-reduce/reduce-phase.ts +6 -65
  17. package/src/commit/pipeline.ts +2 -2
  18. package/src/commit/shared-llm.ts +89 -0
  19. package/src/config/config-file.ts +210 -0
  20. package/src/config/model-equivalence.ts +8 -11
  21. package/src/config/model-registry.ts +44 -3
  22. package/src/config/model-resolver.ts +1 -4
  23. package/src/config/settings-schema.ts +82 -1
  24. package/src/config/settings.ts +1 -1
  25. package/src/config.ts +3 -219
  26. package/src/discovery/claude-plugins.ts +19 -7
  27. package/src/edit/renderer.ts +7 -1
  28. package/src/eval/js/executor.ts +3 -0
  29. package/src/eval/js/shared/rewrite-imports.ts +2 -2
  30. package/src/eval/py/executor.ts +5 -0
  31. package/src/eval/py/runner.py +42 -11
  32. package/src/eval/py/runtime.ts +1 -0
  33. package/src/exa/factory.ts +2 -2
  34. package/src/exa/mcp-client.ts +74 -1
  35. package/src/exec/bash-executor.ts +5 -1
  36. package/src/export/html/template.generated.ts +1 -1
  37. package/src/export/html/template.js +0 -11
  38. package/src/extensibility/extensions/get-commands-handler.ts +77 -0
  39. package/src/extensibility/extensions/runner.ts +1 -1
  40. package/src/extensibility/extensions/types.ts +89 -223
  41. package/src/extensibility/hooks/types.ts +89 -314
  42. package/src/extensibility/plugins/legacy-pi-compat.ts +48 -31
  43. package/src/extensibility/shared-events.ts +343 -0
  44. package/src/extensibility/skills.ts +9 -0
  45. package/src/goals/index.ts +3 -0
  46. package/src/goals/runtime.ts +500 -0
  47. package/src/goals/state.ts +37 -0
  48. package/src/goals/tools/goal-tool.ts +237 -0
  49. package/src/hashline/anchors.ts +2 -2
  50. package/src/hashline/input.ts +2 -1
  51. package/src/hashline/parser.ts +27 -3
  52. package/src/hindsight/mental-models.ts +1 -1
  53. package/src/internal-urls/agent-protocol.ts +1 -20
  54. package/src/internal-urls/artifact-protocol.ts +1 -19
  55. package/src/internal-urls/docs-index.generated.ts +11 -12
  56. package/src/internal-urls/registry-helpers.ts +25 -0
  57. package/src/internal-urls/router.ts +8 -0
  58. package/src/internal-urls/types.ts +21 -0
  59. package/src/lsp/config.ts +15 -6
  60. package/src/lsp/defaults.json +6 -2
  61. package/src/main.ts +11 -2
  62. package/src/mcp/oauth-flow.ts +20 -0
  63. package/src/modes/acp/acp-agent.ts +327 -95
  64. package/src/modes/components/assistant-message.ts +14 -8
  65. package/src/modes/components/bash-execution.ts +24 -63
  66. package/src/modes/components/custom-message.ts +14 -40
  67. package/src/modes/components/eval-execution.ts +27 -57
  68. package/src/modes/components/execution-shared.ts +102 -0
  69. package/src/modes/components/hook-message.ts +17 -49
  70. package/src/modes/components/mcp-add-wizard.ts +26 -5
  71. package/src/modes/components/message-frame.ts +88 -0
  72. package/src/modes/components/model-selector.ts +1 -1
  73. package/src/modes/components/session-observer-overlay.ts +6 -2
  74. package/src/modes/components/session-selector.ts +1 -1
  75. package/src/modes/components/status-line/segments.ts +93 -8
  76. package/src/modes/components/status-line/types.ts +4 -0
  77. package/src/modes/components/status-line.ts +28 -10
  78. package/src/modes/components/tool-execution.ts +7 -8
  79. package/src/modes/controllers/command-controller-shared.ts +108 -0
  80. package/src/modes/controllers/command-controller.ts +13 -4
  81. package/src/modes/controllers/event-controller.ts +36 -7
  82. package/src/modes/controllers/extension-ui-controller.ts +3 -2
  83. package/src/modes/controllers/input-controller.ts +13 -0
  84. package/src/modes/controllers/mcp-command-controller.ts +56 -61
  85. package/src/modes/controllers/ssh-command-controller.ts +18 -57
  86. package/src/modes/interactive-mode.ts +624 -52
  87. package/src/modes/print-mode.ts +16 -86
  88. package/src/modes/rpc/host-uris.ts +235 -0
  89. package/src/modes/rpc/rpc-mode.ts +41 -88
  90. package/src/modes/rpc/rpc-types.ts +57 -0
  91. package/src/modes/runtime-init.ts +116 -0
  92. package/src/modes/theme/defaults/dark-poimandres.json +3 -0
  93. package/src/modes/theme/defaults/light-poimandres.json +3 -0
  94. package/src/modes/theme/theme.ts +24 -6
  95. package/src/modes/types.ts +14 -3
  96. package/src/modes/utils/context-usage.ts +13 -13
  97. package/src/modes/utils/ui-helpers.ts +10 -3
  98. package/src/plan-mode/approved-plan.ts +35 -1
  99. package/src/prompts/goals/goal-budget-limit.md +16 -0
  100. package/src/prompts/goals/goal-continuation.md +28 -0
  101. package/src/prompts/goals/goal-mode-active.md +23 -0
  102. package/src/prompts/system/plan-mode-active.md +5 -5
  103. package/src/prompts/system/plan-mode-tool-decision-reminder.md +1 -1
  104. package/src/prompts/tools/bash.md +6 -0
  105. package/src/prompts/tools/github.md +4 -4
  106. package/src/prompts/tools/goal.md +13 -0
  107. package/src/prompts/tools/hashline.md +101 -117
  108. package/src/prompts/tools/read.md +55 -36
  109. package/src/prompts/tools/resolve.md +6 -5
  110. package/src/sdk.ts +12 -5
  111. package/src/session/agent-session.ts +428 -106
  112. package/src/session/blob-store.ts +36 -3
  113. package/src/session/messages.ts +67 -2
  114. package/src/session/session-manager.ts +131 -12
  115. package/src/session/session-storage.ts +33 -15
  116. package/src/session/streaming-output.ts +309 -13
  117. package/src/slash-commands/builtin-registry.ts +18 -0
  118. package/src/ssh/ssh-executor.ts +5 -0
  119. package/src/system-prompt.ts +4 -2
  120. package/src/task/discovery.ts +5 -2
  121. package/src/task/executor.ts +19 -8
  122. package/src/task/index.ts +3 -0
  123. package/src/task/render.ts +21 -15
  124. package/src/task/types.ts +4 -0
  125. package/src/tools/ast-edit.ts +21 -120
  126. package/src/tools/ast-grep.ts +21 -119
  127. package/src/tools/bash-command-fixup.ts +47 -0
  128. package/src/tools/bash-interactive.ts +9 -1
  129. package/src/tools/bash.ts +66 -19
  130. package/src/tools/browser/attach.ts +3 -3
  131. package/src/tools/browser/launch.ts +81 -18
  132. package/src/tools/browser/registry.ts +1 -5
  133. package/src/tools/browser/render.ts +2 -2
  134. package/src/tools/browser/tab-supervisor.ts +51 -14
  135. package/src/tools/conflict-detect.ts +15 -4
  136. package/src/tools/eval.ts +12 -2
  137. package/src/tools/find.ts +20 -38
  138. package/src/tools/gh.ts +44 -10
  139. package/src/tools/index.ts +22 -11
  140. package/src/tools/inspect-image.ts +3 -10
  141. package/src/tools/job.ts +16 -7
  142. package/src/tools/output-meta.ts +202 -37
  143. package/src/tools/path-utils.ts +125 -2
  144. package/src/tools/read.ts +548 -237
  145. package/src/tools/render-utils.ts +92 -0
  146. package/src/tools/renderers.ts +2 -0
  147. package/src/tools/resolve.ts +72 -44
  148. package/src/tools/search.ts +120 -186
  149. package/src/tools/ssh.ts +3 -2
  150. package/src/tools/write.ts +64 -9
  151. package/src/utils/file-mentions.ts +1 -1
  152. package/src/utils/image-loading.ts +7 -3
  153. package/src/utils/image-resize.ts +32 -43
  154. package/src/vim/parser.ts +0 -17
  155. package/src/vim/render.ts +1 -1
  156. package/src/vim/types.ts +1 -1
  157. package/src/web/search/providers/anthropic.ts +5 -0
  158. package/src/web/search/providers/exa.ts +3 -0
  159. package/src/web/search/providers/gemini.ts +40 -95
  160. package/src/web/search/providers/jina.ts +5 -2
  161. package/src/web/search/providers/zai.ts +5 -2
  162. package/src/prompts/tools/exit-plan-mode.md +0 -6
  163. package/src/tools/exit-plan-mode.ts +0 -97
  164. package/src/utils/fuzzy.ts +0 -108
  165. package/src/utils/image-convert.ts +0 -27
@@ -15,7 +15,7 @@ import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
15
15
  import { getDefault, type Settings } from "../config/settings";
16
16
  import { formatGroupedDiagnosticMessages } from "../lsp/utils";
17
17
  import type { Theme } from "../modes/theme/theme";
18
- import { type OutputSummary, type TruncationResult, truncateTail } from "../session/streaming-output";
18
+ import { type OutputSummary, type TruncationResult, truncateMiddle, truncateTail } from "../session/streaming-output";
19
19
  import { formatBytes, wrapBrackets } from "./render-utils";
20
20
  import { renderError } from "./tool-errors";
21
21
 
@@ -23,15 +23,22 @@ import { renderError } from "./tool-errors";
23
23
  * Truncation metadata for the output notice.
24
24
  */
25
25
  export interface TruncationMeta {
26
- direction: "head" | "tail";
27
- truncatedBy: "lines" | "bytes";
26
+ direction: "head" | "tail" | "middle";
27
+ truncatedBy: "lines" | "bytes" | "middle";
28
28
  totalLines: number;
29
29
  totalBytes: number;
30
30
  outputLines: number;
31
31
  outputBytes: number;
32
32
  maxBytes?: number;
33
- /** Line range shown (1-indexed, inclusive) */
33
+ /** Line range shown (1-indexed, inclusive). Omitted for middle elision. */
34
34
  shownRange?: { start: number; end: number };
35
+ /** Head/tail line ranges shown when direction === "middle". */
36
+ headRange?: { start: number; end: number };
37
+ tailRange?: { start: number; end: number };
38
+ /** Bytes elided from the middle. */
39
+ elidedBytes?: number;
40
+ /** Lines elided from the middle. */
41
+ elidedLines?: number;
35
42
  /** Artifact ID if full output was saved */
36
43
  artifactId?: string;
37
44
  /** Next offset for pagination (head truncation only) */
@@ -79,20 +86,20 @@ export interface OutputMeta {
79
86
  // =============================================================================
80
87
 
81
88
  export interface TruncationOptions {
82
- direction: "head" | "tail";
89
+ direction: "head" | "tail" | "middle";
83
90
  startLine?: number;
84
91
  totalFileLines?: number;
85
92
  artifactId?: string;
86
93
  }
87
94
 
88
95
  export interface TruncationSummaryOptions {
89
- direction: "head" | "tail";
96
+ direction: "head" | "tail" | "middle";
90
97
  startLine?: number;
91
98
  totalFileLines?: number;
92
99
  }
93
100
 
94
101
  export interface TruncationTextOptions {
95
- direction: "head" | "tail";
102
+ direction: "head" | "tail" | "middle";
96
103
  totalLines?: number;
97
104
  totalBytes?: number;
98
105
  maxBytes?: number;
@@ -120,7 +127,40 @@ export class OutputMetaBuilder {
120
127
  const { direction, startLine = 1, totalFileLines, artifactId } = options;
121
128
  const outputLines = result.outputLines ?? result.totalLines;
122
129
  const outputBytes = result.outputBytes ?? result.totalBytes;
123
- const truncatedBy: "lines" | "bytes" = result.truncatedBy === "lines" ? "lines" : "bytes";
130
+ const isMiddle = direction === "middle" || result.truncatedBy === "middle";
131
+ const truncatedBy: "lines" | "bytes" | "middle" = isMiddle
132
+ ? "middle"
133
+ : result.truncatedBy === "lines"
134
+ ? "lines"
135
+ : "bytes";
136
+
137
+ const effectiveTotalLines = totalFileLines ?? result.totalLines;
138
+
139
+ if (isMiddle) {
140
+ const elidedLines = result.elidedLines ?? Math.max(0, effectiveTotalLines - outputLines);
141
+ const elidedBytes = result.elidedBytes ?? Math.max(0, result.totalBytes - outputBytes);
142
+ // Reconstruct head/tail line ranges. The kept output spans the first
143
+ // `headLines` lines and the last `tailLines` lines of the source; lines
144
+ // in the middle (count == elidedLines) are dropped.
145
+ const keptLines = Math.max(0, outputLines - 1); // -1 for marker line
146
+ const headLines = Math.ceil(keptLines / 2);
147
+ const tailLines = keptLines - headLines;
148
+ this.#meta.truncation = {
149
+ direction: "middle",
150
+ truncatedBy: "middle",
151
+ totalLines: effectiveTotalLines,
152
+ totalBytes: result.totalBytes,
153
+ outputLines,
154
+ outputBytes,
155
+ headRange: headLines > 0 ? { start: 1, end: headLines } : undefined,
156
+ tailRange:
157
+ tailLines > 0 ? { start: effectiveTotalLines - tailLines + 1, end: effectiveTotalLines } : undefined,
158
+ elidedLines,
159
+ elidedBytes,
160
+ artifactId,
161
+ };
162
+ return this;
163
+ }
124
164
 
125
165
  let shownStart: number;
126
166
  let shownEnd: number;
@@ -136,7 +176,7 @@ export class OutputMetaBuilder {
136
176
  this.#meta.truncation = {
137
177
  direction,
138
178
  truncatedBy,
139
- totalLines: totalFileLines ?? result.totalLines,
179
+ totalLines: effectiveTotalLines,
140
180
  totalBytes: result.totalBytes,
141
181
  outputLines,
142
182
  outputBytes,
@@ -154,6 +194,29 @@ export class OutputMetaBuilder {
154
194
 
155
195
  const { direction, startLine = 1, totalFileLines } = options;
156
196
  const totalLines = totalFileLines ?? summary.totalLines;
197
+
198
+ // Middle elision: the sink retained head + tail with an elision marker.
199
+ if (summary.elidedBytes != null && summary.elidedBytes > 0) {
200
+ const elidedLines = summary.elidedLines ?? Math.max(0, totalLines - summary.outputLines);
201
+ const keptLines = Math.max(0, summary.outputLines - 1); // -1 for marker line
202
+ const headLines = Math.ceil(keptLines / 2);
203
+ const tailLines = keptLines - headLines;
204
+ this.#meta.truncation = {
205
+ direction: "middle",
206
+ truncatedBy: "middle",
207
+ totalLines,
208
+ totalBytes: summary.totalBytes,
209
+ outputLines: summary.outputLines,
210
+ outputBytes: summary.outputBytes,
211
+ headRange: headLines > 0 ? { start: 1, end: headLines } : undefined,
212
+ tailRange: tailLines > 0 ? { start: totalLines - tailLines + 1, end: totalLines } : undefined,
213
+ elidedBytes: summary.elidedBytes,
214
+ elidedLines,
215
+ artifactId: summary.artifactId,
216
+ };
217
+ return this;
218
+ }
219
+
157
220
  const truncatedBy: "lines" | "bytes" =
158
221
  summary.outputBytes < summary.totalBytes
159
222
  ? "bytes"
@@ -322,9 +385,28 @@ export function formatFullOutputReference(artifactId: string): string {
322
385
  }
323
386
 
324
387
  export function formatTruncationMetaNotice(truncation: TruncationMeta): string {
325
- const range = truncation.shownRange;
326
388
  let notice: string;
327
389
 
390
+ if (truncation.direction === "middle") {
391
+ const head = truncation.headRange;
392
+ const tail = truncation.tailRange;
393
+ const totalLines = truncation.totalLines;
394
+ const elidedBytes = truncation.elidedBytes ?? Math.max(0, truncation.totalBytes - truncation.outputBytes);
395
+ const elidedLines = truncation.elidedLines ?? Math.max(0, totalLines - truncation.outputLines);
396
+ const headPart = head ? `lines ${head.start}-${head.end}` : "";
397
+ const tailPart = tail ? `${tail.start}-${tail.end}` : "";
398
+ if (headPart && tailPart) {
399
+ notice = `Showing ${headPart} and ${tailPart} of ${totalLines}; ${elidedLines.toLocaleString()} middle line${elidedLines === 1 ? "" : "s"} (${formatBytes(elidedBytes)}) elided`;
400
+ } else {
401
+ notice = `Showing ${truncation.outputLines} of ${totalLines} lines; middle elided`;
402
+ }
403
+ if (truncation.artifactId != null) {
404
+ notice += `. ${formatFullOutputReference(truncation.artifactId)}`;
405
+ }
406
+ return notice;
407
+ }
408
+
409
+ const range = truncation.shownRange;
328
410
  if (range && range.end >= range.start) {
329
411
  notice = `Showing lines ${range.start}-${range.end} of ${truncation.totalLines}`;
330
412
  } else {
@@ -407,6 +489,32 @@ export function formatStyledTruncationWarning(meta: OutputMeta | undefined, them
407
489
  return theme.fg("warning", wrapBrackets(message, theme));
408
490
  }
409
491
 
492
+ /**
493
+ * Strip the trailing notice that {@link appendOutputNotice} bakes into the
494
+ * LLM-facing content body. Renderers should call this before printing
495
+ * `result.content` text in the TUI, because they emit a styled warning line of
496
+ * their own; without this, users see the same `[Showing lines …]` string twice
497
+ * (once verbatim from the body, once as the styled `⟨…⟩` warning).
498
+ *
499
+ * Safe to call eagerly: returns the input unchanged when no notice is present
500
+ * (e.g. during streaming, before {@link wrappedExecute} runs).
501
+ */
502
+ export function stripOutputNotice(text: string, meta: OutputMeta | undefined): string {
503
+ const notice = formatOutputNotice(meta);
504
+ if (!notice) return text;
505
+ // Trim trailing whitespace from `text` and from the notice itself so we
506
+ // match regardless of whether: (a) the caller already trimEnd()'d, (b)
507
+ // extra blank lines slipped in after the notice (diagnostics blocks add
508
+ // `\n\n` between sections, OutputSink may pad), or (c) neither. Returns
509
+ // the prefix before the notice so the caller can re-trim as needed.
510
+ const trimmedText = text.trimEnd();
511
+ const trimmedNotice = notice.trimEnd();
512
+ if (trimmedText.endsWith(trimmedNotice)) {
513
+ return trimmedText.slice(0, -trimmedNotice.length);
514
+ }
515
+ return text;
516
+ }
517
+
410
518
  // =============================================================================
411
519
  // Tool wrapper
412
520
  // =============================================================================
@@ -442,21 +550,44 @@ const kUnwrappedExecute = Symbol("OutputMeta.UnwrappedExecute");
442
550
 
443
551
  /** Resolved artifact spill config sourced from the session settings (or schema defaults). */
444
552
  function getSpillConfig(s: Settings | undefined) {
445
- const get = <P extends "tools.artifactSpillThreshold" | "tools.artifactTailBytes" | "tools.artifactTailLines">(
446
- path: P,
447
- ) => s?.get(path) ?? getDefault(path);
553
+ type Path =
554
+ | "tools.artifactSpillThreshold"
555
+ | "tools.artifactTailBytes"
556
+ | "tools.artifactTailLines"
557
+ | "tools.artifactHeadBytes";
558
+ const get = <P extends Path>(path: P) => s?.get(path) ?? getDefault(path);
448
559
  return {
449
560
  threshold: get("tools.artifactSpillThreshold") * 1024,
450
561
  tailBytes: get("tools.artifactTailBytes") * 1024,
451
562
  tailLines: get("tools.artifactTailLines"),
563
+ headBytes: get("tools.artifactHeadBytes") * 1024,
452
564
  };
453
565
  }
454
566
 
455
567
  /**
456
- * If the tool result text exceeds RESULT_ARTIFACT_THRESHOLD, save the full
457
- * output as a session artifact and replace the content with a tail-truncated
458
- * version plus an artifact reference. Skips when the tool already saved its
459
- * own artifact (e.g. bash/python via OutputSink).
568
+ * Resolve the OutputSink `headBytes` budget from session settings.
569
+ * Exposed so streaming executors (bash/python/ssh/eval) can opt into
570
+ * middle elision with the same per-user configuration.
571
+ */
572
+ export function resolveOutputSinkHeadBytes(s: Settings | undefined): number {
573
+ return getSpillConfig(s).headBytes;
574
+ }
575
+
576
+ /**
577
+ * Resolve the per-line column cap from session settings. Shared by streaming
578
+ * executors (bash/python/ssh/eval via OutputSink) and the `read` tool's
579
+ * line-buffer post-processing, so one setting controls both surfaces.
580
+ */
581
+ export function resolveOutputMaxColumns(s: Settings | undefined): number {
582
+ return s?.get("tools.outputMaxColumns") ?? getDefault("tools.outputMaxColumns");
583
+ }
584
+
585
+ /**
586
+ * If the tool result text exceeds the spill threshold, save the full output
587
+ * as a session artifact and replace the content with a head+tail (middle
588
+ * elision) view plus an artifact reference. When `tools.artifactHeadBytes`
589
+ * is 0, falls back to tail-only truncation. Skips when the tool already
590
+ * saved its own artifact (e.g. bash/python via OutputSink).
460
591
  */
461
592
  async function spillLargeResultToArtifact(
462
593
  result: AgentToolResult,
@@ -466,7 +597,7 @@ async function spillLargeResultToArtifact(
466
597
  const sessionManager = context?.sessionManager;
467
598
  if (!sessionManager) return result;
468
599
  if (toolName === "read") return result;
469
- const { threshold, tailBytes, tailLines } = getSpillConfig(context?.settings);
600
+ const { threshold, tailBytes, tailLines, headBytes } = getSpillConfig(context?.settings);
470
601
 
471
602
  // Skip if tool already saved an artifact
472
603
  const existingMeta: OutputMeta | undefined = result.details?.meta;
@@ -489,13 +620,21 @@ async function spillLargeResultToArtifact(
489
620
  const artifactId = await sessionManager.saveArtifact(fullText, toolName);
490
621
  if (!artifactId) return result;
491
622
 
492
- // Truncate to tail
493
- const truncated = truncateTail(fullText, {
494
- maxBytes: tailBytes,
495
- maxLines: tailLines,
496
- });
497
-
498
- // Replace text blocks with single tail-truncated block, keep images
623
+ // Truncate: middle elision when a head budget is configured, otherwise tail-only.
624
+ const useMiddle = headBytes > 0;
625
+ const truncated = useMiddle
626
+ ? truncateMiddle(fullText, {
627
+ maxBytes: headBytes + tailBytes,
628
+ maxLines: tailLines * 2,
629
+ maxHeadBytes: headBytes,
630
+ maxHeadLines: tailLines,
631
+ })
632
+ : truncateTail(fullText, {
633
+ maxBytes: tailBytes,
634
+ maxLines: tailLines,
635
+ });
636
+
637
+ // Replace text blocks with single truncated block, keep images
499
638
  const newContent: (TextContent | ImageContent)[] = [];
500
639
  for (const block of result.content) {
501
640
  if (block.type !== "text") {
@@ -507,18 +646,44 @@ async function spillLargeResultToArtifact(
507
646
  // Build truncation meta
508
647
  const outputLines = truncated.outputLines ?? truncated.totalLines;
509
648
  const outputBytes = truncated.outputBytes ?? truncated.totalBytes;
510
- const shownStart = truncated.totalLines - outputLines + 1;
511
- const truncationMeta: TruncationMeta = {
512
- direction: "tail",
513
- truncatedBy: truncated.truncatedBy ?? "bytes",
514
- totalLines: truncated.totalLines,
515
- totalBytes: truncated.totalBytes,
516
- outputLines,
517
- outputBytes,
518
- maxBytes: tailBytes,
519
- shownRange: { start: shownStart, end: truncated.totalLines },
520
- artifactId,
521
- };
649
+ let truncationMeta: TruncationMeta;
650
+ if (truncated.truncatedBy === "middle") {
651
+ const elidedLines = truncated.elidedLines ?? Math.max(0, truncated.totalLines - outputLines);
652
+ const elidedBytes = truncated.elidedBytes ?? Math.max(0, truncated.totalBytes - outputBytes);
653
+ const keptLines = Math.max(0, outputLines - 1); // -1 for marker line
654
+ const headLines = Math.ceil(keptLines / 2);
655
+ const tailLineCount = keptLines - headLines;
656
+ truncationMeta = {
657
+ direction: "middle",
658
+ truncatedBy: "middle",
659
+ totalLines: truncated.totalLines,
660
+ totalBytes: truncated.totalBytes,
661
+ outputLines,
662
+ outputBytes,
663
+ maxBytes: headBytes + tailBytes,
664
+ headRange: headLines > 0 ? { start: 1, end: headLines } : undefined,
665
+ tailRange:
666
+ tailLineCount > 0
667
+ ? { start: truncated.totalLines - tailLineCount + 1, end: truncated.totalLines }
668
+ : undefined,
669
+ elidedLines,
670
+ elidedBytes,
671
+ artifactId,
672
+ };
673
+ } else {
674
+ const shownStart = truncated.totalLines - outputLines + 1;
675
+ truncationMeta = {
676
+ direction: "tail",
677
+ truncatedBy: truncated.truncatedBy ?? "bytes",
678
+ totalLines: truncated.totalLines,
679
+ totalBytes: truncated.totalBytes,
680
+ outputLines,
681
+ outputBytes,
682
+ maxBytes: tailBytes,
683
+ shownRange: { start: shownStart, end: truncated.totalLines },
684
+ artifactId,
685
+ };
686
+ }
522
687
 
523
688
  const newMeta: OutputMeta = { ...(existingMeta ?? {}), truncation: truncationMeta };
524
689
  const newDetails = { ...(result.details ?? {}), meta: newMeta };
@@ -3,10 +3,12 @@ import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import * as url from "node:url";
5
5
  import { isEnoent } from "@oh-my-pi/pi-utils";
6
+ import { InternalUrlRouter } from "../internal-urls";
7
+ import { ToolError } from "./tool-errors";
6
8
 
7
9
  const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
8
- const FILE_LINE_RANGE_RE = /^(?:L?\d+(?:[-+]L?\d+)?|raw|conflicts)$/i;
9
- const FILE_LINE_RANGE_ONLY_RE = /^L?\d+(?:[-+]L?\d+)?$/i;
10
+ const FILE_LINE_RANGE_RE = /^(?:L?\d+(?:[-+]L?\d+)?(?:,L?\d+(?:[-+]L?\d+)?)*|raw|conflicts)$/i;
11
+ const FILE_LINE_RANGE_ONLY_RE = /^L?\d+(?:[-+]L?\d+)?(?:,L?\d+(?:[-+]L?\d+)?)*$/i;
10
12
  const FILE_RAW_ONLY_RE = /^raw$/i;
11
13
  const NARROW_NO_BREAK_SPACE = "\u202F";
12
14
  const TOP_LEVEL_INTERNAL_URL_PREFIXES = [
@@ -567,3 +569,124 @@ export function resolveReadPath(filePath: string, cwd: string): string {
567
569
 
568
570
  return resolved;
569
571
  }
572
+
573
+ // =============================================================================
574
+ // Tool-scope resolution (search/ast tools)
575
+ // =============================================================================
576
+
577
+ export interface ToolScopeOptions {
578
+ rawPaths: string[];
579
+ cwd: string;
580
+ /** Verb used in the "Cannot {action} internal URL without a backing file: …" message. */
581
+ internalUrlAction: string;
582
+ /** Collect absolute paths flagged immutable by their internal-URL handler. */
583
+ trackImmutableSources?: boolean;
584
+ /** Honor `exactFilePaths` from {@link resolveExplicitSearchPaths} (search-only). */
585
+ surfaceExactFilePaths?: boolean;
586
+ /** Extra hint appended to "Path not found" when stat fails and the user supplied multiple paths. */
587
+ multipathStatHint?: string;
588
+ }
589
+
590
+ export interface ToolScopeResolution {
591
+ searchPath: string;
592
+ scopePath: string;
593
+ globFilter: string | undefined;
594
+ isDirectory: boolean;
595
+ multiTargets?: ResolvedSearchTarget[];
596
+ exactFilePaths?: string[];
597
+ missingPaths: string[];
598
+ immutableSourcePaths: Set<string>;
599
+ }
600
+
601
+ /**
602
+ * Shared path-input pipeline for `search`, `ast_grep`, and `ast_edit`:
603
+ * 1. normalize + reject empty paths,
604
+ * 2. resolve internal URLs through {@link InternalUrlRouter} to backing files,
605
+ * 3. partition existing vs missing when multiple paths are supplied,
606
+ * 4. derive a single search base path / glob, or a multi-target list,
607
+ * 5. stat the resolved base path so callers can branch on directory vs file scope.
608
+ */
609
+ export async function resolveToolSearchScope(opts: ToolScopeOptions): Promise<ToolScopeResolution> {
610
+ const { rawPaths: inputs, cwd, internalUrlAction } = opts;
611
+ const rawPaths = inputs.map(normalizePathLikeInput);
612
+ if (rawPaths.some(rawPath => rawPath.length === 0)) {
613
+ throw new ToolError("`paths` must contain non-empty paths or globs");
614
+ }
615
+ const internalRouter = InternalUrlRouter.instance();
616
+ const resolvedPathInputs: string[] = [];
617
+ const immutableSourcePaths = new Set<string>();
618
+ for (const rawPath of rawPaths) {
619
+ if (!internalRouter.canHandle(rawPath)) {
620
+ resolvedPathInputs.push(rawPath);
621
+ continue;
622
+ }
623
+ if (hasGlobPathChars(rawPath)) {
624
+ throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`);
625
+ }
626
+ const resource = await internalRouter.resolve(rawPath);
627
+ if (!resource.sourcePath) {
628
+ throw new ToolError(`Cannot ${internalUrlAction} internal URL without a backing file: ${rawPath}`);
629
+ }
630
+ if (opts.trackImmutableSources && resource.immutable) {
631
+ immutableSourcePaths.add(path.resolve(resource.sourcePath));
632
+ }
633
+ resolvedPathInputs.push(resource.sourcePath);
634
+ }
635
+
636
+ let missingPaths: string[] = [];
637
+ let effectivePaths = resolvedPathInputs;
638
+ if (resolvedPathInputs.length > 1) {
639
+ const partition = await partitionExistingPaths(resolvedPathInputs, cwd, parseSearchPath);
640
+ if (partition.valid.length === 0) {
641
+ throw new ToolError(`Path not found: ${partition.missing.join(", ")}`);
642
+ }
643
+ effectivePaths = partition.valid;
644
+ missingPaths = partition.missing;
645
+ }
646
+
647
+ let searchPath: string;
648
+ let scopePath: string;
649
+ let globFilter: string | undefined;
650
+ let multiTargets: ResolvedSearchTarget[] | undefined;
651
+ let exactFilePaths: string[] | undefined;
652
+ if (effectivePaths.length === 1) {
653
+ const parsedPath = parseSearchPath(effectivePaths[0] ?? ".");
654
+ searchPath = resolveToCwd(parsedPath.basePath, cwd);
655
+ globFilter = parsedPath.glob;
656
+ scopePath = formatPathRelativeToCwd(searchPath, cwd);
657
+ } else {
658
+ const multiSearchPath = await resolveExplicitSearchPaths(effectivePaths, cwd);
659
+ if (!multiSearchPath) {
660
+ throw new ToolError("`paths` must contain at least one path or glob");
661
+ }
662
+ searchPath = multiSearchPath.basePath;
663
+ multiTargets = multiSearchPath.targets;
664
+ if (opts.surfaceExactFilePaths) {
665
+ exactFilePaths = multiSearchPath.exactFilePaths;
666
+ globFilter = exactFilePaths || multiTargets ? undefined : multiSearchPath.glob;
667
+ } else {
668
+ globFilter = multiTargets ? undefined : multiSearchPath.glob;
669
+ }
670
+ scopePath = multiSearchPath.scopePath;
671
+ }
672
+
673
+ let isDirectory: boolean;
674
+ try {
675
+ const stat = await Bun.file(searchPath).stat();
676
+ isDirectory = stat.isDirectory();
677
+ } catch {
678
+ const hint = opts.multipathStatHint && rawPaths.length > 1 ? opts.multipathStatHint : "";
679
+ throw new ToolError(`Path not found: ${scopePath}${hint}`);
680
+ }
681
+
682
+ return {
683
+ searchPath,
684
+ scopePath,
685
+ globFilter,
686
+ isDirectory,
687
+ multiTargets,
688
+ exactFilePaths,
689
+ missingPaths,
690
+ immutableSourcePaths,
691
+ };
692
+ }