@gajae-code/coding-agent 0.11.2 → 0.11.4

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 (101) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/types/cli/launch-disposition.d.ts +23 -0
  3. package/dist/types/cli/worktree-cli.d.ts +8 -25
  4. package/dist/types/commands/gc.d.ts +4 -0
  5. package/dist/types/commands/worktree.d.ts +7 -32
  6. package/dist/types/config/settings-schema.d.ts +85 -10
  7. package/dist/types/gjc-runtime/gc-runtime.d.ts +9 -0
  8. package/dist/types/gjc-runtime/state-argv.d.ts +38 -0
  9. package/dist/types/main.d.ts +1 -0
  10. package/dist/types/modes/components/custom-editor.d.ts +8 -3
  11. package/dist/types/modes/components/tool-transcript-format.d.ts +19 -0
  12. package/dist/types/modes/controllers/input-controller.d.ts +8 -2
  13. package/dist/types/modes/prompt-action-autocomplete.d.ts +4 -0
  14. package/dist/types/sdk/broker/broker.d.ts +1 -0
  15. package/dist/types/sdk/broker/discovery.d.ts +10 -0
  16. package/dist/types/sdk/broker/ensure.d.ts +24 -1
  17. package/dist/types/sdk/broker/internal.d.ts +3 -0
  18. package/dist/types/sdk/broker/session-index.d.ts +12 -0
  19. package/dist/types/sdk/broker/state-version.d.ts +2 -1
  20. package/dist/types/sdk/bus/index.d.ts +2 -0
  21. package/dist/types/sdk/session.d.ts +2 -0
  22. package/dist/types/tools/ask.d.ts +39 -12
  23. package/dist/types/tools/browser/screenshot-format.d.ts +8 -0
  24. package/dist/types/tools/browser/tab-protocol.d.ts +11 -0
  25. package/dist/types/tools/browser/tab-supervisor.d.ts +1 -1
  26. package/dist/types/tools/browser/tab-worker-path-resolver.d.ts +2 -0
  27. package/dist/types/tools/browser/tab-worker-smoke.d.ts +16 -0
  28. package/dist/types/tools/path-utils.d.ts +9 -14
  29. package/dist/types/tools/read.d.ts +2 -0
  30. package/dist/types/tools/render-utils.d.ts +1 -8
  31. package/dist/types/tools/resource-gc.d.ts +14 -0
  32. package/dist/types/tools/subagent.d.ts +1 -0
  33. package/dist/types/utils/file-mentions.d.ts +3 -3
  34. package/dist/types/utils/image-loading.d.ts +23 -0
  35. package/dist/types/utils/image-resize.d.ts +2 -0
  36. package/dist/types/utils/pasted-image-loading.d.ts +39 -0
  37. package/dist/types/utils/pasted-image-path.d.ts +21 -19
  38. package/dist/types/web/scrapers/html-to-markdown.d.ts +5 -0
  39. package/dist/types/web/scrapers/types.d.ts +1 -5
  40. package/package.json +24 -8
  41. package/scripts/run-threat-model-check.ts +60 -0
  42. package/src/cli/launch-disposition.ts +42 -0
  43. package/src/cli/worktree-cli.ts +9 -287
  44. package/src/cli.ts +10 -9
  45. package/src/commands/gc.ts +11 -1
  46. package/src/commands/sdk.ts +3 -2
  47. package/src/commands/worktree.ts +10 -53
  48. package/src/config/model-profiles.ts +15 -15
  49. package/src/config/settings-schema.ts +55 -10
  50. package/src/eval/js/shared/runtime.ts +1 -1
  51. package/src/gjc-runtime/gc-render.ts +19 -1
  52. package/src/gjc-runtime/gc-runtime.ts +68 -3
  53. package/src/gjc-runtime/state-argv.ts +292 -0
  54. package/src/gjc-runtime/state-runtime.ts +51 -212
  55. package/src/main.ts +99 -22
  56. package/src/modes/components/custom-editor.ts +103 -32
  57. package/src/modes/components/custom-provider-wizard.ts +26 -1
  58. package/src/modes/components/session-observer-overlay.ts +10 -23
  59. package/src/modes/components/tool-transcript-format.ts +72 -0
  60. package/src/modes/components/transcript-viewer-overlay.ts +42 -2
  61. package/src/modes/controllers/input-controller.ts +98 -37
  62. package/src/modes/controllers/selector-controller.ts +1 -1
  63. package/src/modes/interactive-mode.ts +13 -2
  64. package/src/modes/prompt-action-autocomplete.ts +7 -0
  65. package/src/sdk/broker/broker.ts +139 -32
  66. package/src/sdk/broker/discovery.ts +125 -0
  67. package/src/sdk/broker/ensure.ts +103 -19
  68. package/src/sdk/broker/internal.ts +10 -0
  69. package/src/sdk/broker/session-index.ts +228 -15
  70. package/src/sdk/broker/state-version.ts +5 -2
  71. package/src/sdk/bus/index.ts +4 -1
  72. package/src/sdk/bus/telegram-daemon.ts +50 -10
  73. package/src/sdk/host/host.ts +13 -1
  74. package/src/sdk/session.ts +4 -1
  75. package/src/session/internal/managed-session-scope.ts +15 -1
  76. package/src/tools/ask.ts +216 -41
  77. package/src/tools/bash-allowed-prefixes.ts +79 -29
  78. package/src/tools/browser/launch.ts +3 -1
  79. package/src/tools/browser/readable.ts +1 -1
  80. package/src/tools/browser/screenshot-format.ts +34 -0
  81. package/src/tools/browser/tab-protocol.ts +3 -0
  82. package/src/tools/browser/tab-supervisor.ts +125 -122
  83. package/src/tools/browser/tab-worker-entry.ts +33 -14
  84. package/src/tools/browser/tab-worker-path-resolver.ts +67 -0
  85. package/src/tools/browser/tab-worker-smoke.ts +87 -0
  86. package/src/tools/browser/tab-worker.ts +7 -5
  87. package/src/tools/output-meta.ts +6 -2
  88. package/src/tools/path-utils.ts +42 -35
  89. package/src/tools/read.ts +189 -64
  90. package/src/tools/render-utils.ts +1 -27
  91. package/src/tools/resource-gc.ts +123 -34
  92. package/src/tools/subagent.ts +43 -2
  93. package/src/utils/file-mentions.ts +4 -4
  94. package/src/utils/image-loading.ts +85 -34
  95. package/src/utils/image-resize.ts +16 -9
  96. package/src/utils/pasted-image-loading.ts +514 -0
  97. package/src/utils/pasted-image-path.ts +223 -98
  98. package/src/web/scrapers/html-to-markdown.ts +68 -0
  99. package/src/web/scrapers/readthedocs.ts +1 -1
  100. package/src/web/scrapers/types.ts +1 -68
  101. package/src/web/search/providers/openai-compatible.ts +76 -23
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import * as url from "node:url";
5
- import { isEnoent } from "@gajae-code/utils";
5
+ import { isEnoent } from "@gajae-code/utils/fs-error";
6
6
  import { InternalUrlRouter } from "../internal-urls";
7
7
  import { ToolError } from "./tool-errors";
8
8
 
@@ -10,13 +10,9 @@ const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
10
10
  const FILE_LINE_RANGE_RE = /^(?:L?\d+(?:[-+]L?\d+|-)?(?:,L?\d+(?:[-+]L?\d+|-)?)*|raw|conflicts)$/i;
11
11
  const FILE_LINE_RANGE_ONLY_RE = /^L?\d+(?:[-+]L?\d+|-)?(?:,L?\d+(?:[-+]L?\d+|-)?)*$/i;
12
12
  const FILE_RAW_ONLY_RE = /^raw$/i;
13
- // Permissive selector chunk for internal URLs — accepts well-formed selectors
14
- // plus common malformed shapes (e.g. `:-N`) so the read tool peels the entire
15
- // selector chain off before dispatching to a protocol handler.
16
- const INTERNAL_URL_SELECTOR_PART_RE =
17
- /^(?:raw|conflicts|L?\d+(?:[-+]L?\d+|-)?(?:,L?\d+(?:[-+]L?\d+|-)?)*|-\d+(?:[-+]\d+)?)$/i;
18
- // Schemes whose host grammar is identifier-shaped, so any trailing
19
- // `:<selector-chunk>` is unambiguously a read-tool selector.
13
+ // Schemes whose authority grammar is identifier-shaped and may therefore carry
14
+ // a read selector immediately after the authority. Colons in a path, query, or
15
+ // fragment remain part of the URL and are never considered selectors.
20
16
  const INTERNAL_SCHEMES_WITH_SELECTORS: Record<string, true> = {
21
17
  agent: true,
22
18
  artifact: true,
@@ -148,39 +144,50 @@ export function splitPathAndSel(rawPath: string): { path: string; sel?: string }
148
144
  /**
149
145
  * Variant of {@link splitPathAndSel} for internal URLs (`scheme://...`).
150
146
  *
151
- * The filesystem-path splitter is intentionally conservative: it refuses to
152
- * peel a trailing `:<chunk>` unless that chunk matches the strict selector
153
- * grammar. That rule is right for filesystem paths (a file named `a:1-50` is
154
- * legal) but wrong for internal URLs, where any trailing `:<chunk>` after the
155
- * scheme is unambiguously a read-tool selector — even if malformed (e.g.
156
- * `artifact://3:raw:-100`).
157
- *
158
- * This function iteratively peels selector-shaped chunks (well-formed plus
159
- * common malformed shapes like `:-N`) so the rest of the read tool can pass a
160
- * clean URL to the protocol handler and surface selector errors via parseSel
161
- * instead of as misleading "host invalid" errors from the handler.
162
- *
163
- * Falls back to the input unchanged when nothing matches.
147
+ * Artifact authorities are generated identifiers, so their complete tail is
148
+ * unambiguously a selector and malformed selectors can be rejected before
149
+ * resolving the artifact. Other resources may legitimately use colons in
150
+ * their identities, so they only lose strict, recognized selector suffixes.
151
+ * Skill authorities additionally use the active registry to distinguish a
152
+ * skill name from its selector without making path-utils depend on skills.
164
153
  */
165
- export function splitInternalUrlSel(rawPath: string): { path: string; sel?: string } {
154
+ export function splitInternalUrlSel(
155
+ rawPath: string,
156
+ options: { activeSkillNames?: readonly string[] } = {},
157
+ ): { path: string; sel?: string } {
166
158
  const schemeMatch = rawPath.match(INTERNAL_URL_SCHEME_RE);
167
159
  if (!schemeMatch) return { path: rawPath };
168
- if (!INTERNAL_SCHEMES_WITH_SELECTORS[schemeMatch[1].toLowerCase()]) return { path: rawPath };
160
+ const scheme = schemeMatch[1].toLowerCase();
161
+ if (!INTERNAL_SCHEMES_WITH_SELECTORS[scheme]) return { path: rawPath };
169
162
 
170
163
  const schemeEnd = schemeMatch[0].length;
171
- let path = rawPath;
172
- const chunks: string[] = [];
173
- while (true) {
174
- const colon = path.lastIndexOf(":");
175
- // Stop before crossing into the scheme separator `://`.
176
- if (colon < schemeEnd) break;
177
- const tail = path.slice(colon + 1);
178
- if (!INTERNAL_URL_SELECTOR_PART_RE.test(tail)) break;
179
- chunks.unshift(tail);
180
- path = path.slice(0, colon);
164
+ const authorityTerminator = rawPath.slice(schemeEnd).search(/[/?#]/);
165
+ if (authorityTerminator !== -1) return { path: rawPath };
166
+ const authority = rawPath.slice(schemeEnd);
167
+ const firstColon = authority.indexOf(":");
168
+ if (firstColon === -1) return { path: rawPath };
169
+
170
+ if (scheme === "artifact") {
171
+ return { path: rawPath.slice(0, schemeEnd + firstColon), sel: authority.slice(firstColon + 1) };
172
+ }
173
+
174
+ if (scheme === "skill") {
175
+ const activeSkillNames = options.activeSkillNames ?? [];
176
+ if (activeSkillNames.includes(authority)) return { path: rawPath };
177
+ const skillName = activeSkillNames
178
+ .filter(name => authority.startsWith(`${name}:`))
179
+ .sort((a, b) => b.length - a.length)[0];
180
+ if (skillName) {
181
+ return {
182
+ path: `${rawPath.slice(0, schemeEnd)}${skillName}`,
183
+ sel: authority.slice(skillName.length + 1),
184
+ };
185
+ }
181
186
  }
182
- if (chunks.length === 0) return { path: rawPath };
183
- return { path, sel: chunks.join(":") };
187
+
188
+ const strict = splitPathAndSel(authority);
189
+ if (strict.sel === undefined) return { path: rawPath };
190
+ return { path: `${rawPath.slice(0, schemeEnd)}${strict.path}`, sel: strict.sel };
184
191
  }
185
192
 
186
193
  function assertNotInternalUrl(expanded: string, original: string): void {
package/src/tools/read.ts CHANGED
@@ -11,6 +11,7 @@ import * as z from "zod/v4";
11
11
  import { getFileReadCache } from "../edit/file-read-cache";
12
12
  import { isNotebookPath, readEditableNotebookText } from "../edit/notebook";
13
13
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
14
+ import { getActiveSkills } from "../extensibility/skills";
14
15
  import { formatHashLine, formatHashLines, formatLineHash, HL_BODY_SEP } from "../hashline/hash";
15
16
  import { InternalUrlRouter } from "../internal-urls";
16
17
  import { parseInternalUrl } from "../internal-urls/parse";
@@ -95,6 +96,8 @@ const CONVERTIBLE_EXTENSIONS = new Set([".pdf", ".doc", ".docx", ".ppt", ".pptx"
95
96
 
96
97
  const MAX_SUMMARY_BYTES = 2 * 1024 * 1024;
97
98
  const MAX_SUMMARY_LINES = 20_000;
99
+ const RAW_COLLECTOR_MAX_BYTES = MAX_SUMMARY_BYTES;
100
+
98
101
  /**
99
102
  * Per-line column cap for file reads. Lines wider than the value of
100
103
  * `tools.outputMaxColumns` are ellipsis-truncated at display time; the file
@@ -193,6 +196,14 @@ function formatSummaryElisionFooter(readPath: string, elidedSpans: number, elide
193
196
  const linePart = elidedLines > 0 ? `${elidedLines} ${lineWord} across ` : "";
194
197
  return `[${linePart}${elidedSpans} elided ${spanWord}; read ${readPath}:raw or a line range like ${readPath}:1-9999 for verbatim content]`;
195
198
  }
199
+
200
+ function formatReceiptFooter(readPath: string, shownLines: number, totalLines: number, shownBytes: number): string {
201
+ return `[Showing first ${shownLines} of ${totalLines} lines (~${Math.ceil(shownBytes / 1024)} KiB); re-read ${readPath}:1-${totalLines} or ${readPath}:raw for the full file]`;
202
+ }
203
+
204
+ function formatSummaryCapFooter(readPath: string, maxBytes: number, totalLines: number): string {
205
+ return `[Summary truncated at ${Math.ceil(maxBytes / 1024)} KiB; re-read ${readPath}:raw or ${readPath}:1-${totalLines} for the full source]`;
206
+ }
196
207
  const READ_CHUNK_SIZE = 8 * 1024;
197
208
 
198
209
  /**
@@ -532,6 +543,9 @@ export interface ReadToolDetails {
532
543
  method?: string;
533
544
  notes?: string[];
534
545
  meta?: OutputMeta;
546
+ /** Whether this explicit content read may be spilled by output metadata. */
547
+ spillEligible?: boolean;
548
+
535
549
  /** Raw text + start line for user-visible TUI rendering, set when content is text-like.
536
550
  * Mirrors the same lines the model receives but without hashline/line-number prefixes,
537
551
  * so the TUI can render the file content with its own gutter without re-parsing the formatted text. */
@@ -816,10 +830,13 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
816
830
  options: {
817
831
  details?: ReadToolDetails;
818
832
  sourcePath?: string;
833
+ spillEligible?: boolean;
834
+ receiptPath?: string;
819
835
  sourceUrl?: string;
820
836
  sourceInternal?: string;
821
837
  entityLabel: string;
822
838
  ignoreResultLimits?: boolean;
839
+ resultByteCeiling?: number;
823
840
  raw?: boolean;
824
841
  immutable?: boolean;
825
842
  wrapUntrusted?: boolean;
@@ -871,9 +888,30 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
871
888
  }
872
889
 
873
890
  const endLine = endLineExpanded;
874
- const selectedContent = allLines.slice(startLine, endLine).join("\n");
891
+ let selectedContent = allLines.slice(startLine, endLine).join("\n");
892
+
893
+ if (options.receiptPath) {
894
+ const budgetLines = this.session.settings.get("read.receiptBudgetLines");
895
+ const budgetBytes = this.session.settings.get("read.receiptBudgetBytes") * 1024;
896
+ const receiptLines: string[] = [];
897
+ let bytes = 0;
898
+ for (const line of allLines) {
899
+ const lineBytes = Buffer.byteLength(line, "utf-8") + (receiptLines.length > 0 ? 1 : 0);
900
+ if (receiptLines.length >= budgetLines || bytes + lineBytes > budgetBytes) break;
901
+ receiptLines.push(line);
902
+ bytes += lineBytes;
903
+ }
904
+ selectedContent = receiptLines.join("\n");
905
+ }
875
906
  const userLimitedLines = limit !== undefined ? endLine - startLine : undefined;
876
- const truncation = ignoreResultLimits ? noTruncResult(selectedContent) : truncateHead(selectedContent);
907
+ const truncation = ignoreResultLimits
908
+ ? noTruncResult(selectedContent)
909
+ : truncateHead(
910
+ selectedContent,
911
+ options.resultByteCeiling === undefined
912
+ ? undefined
913
+ : { maxBytes: options.resultByteCeiling, maxLines: Number.MAX_SAFE_INTEGER },
914
+ );
877
915
 
878
916
  const shouldAddHashLines = displayMode.hashLines;
879
917
  const shouldAddLineNumbers = shouldAddHashLines ? false : displayMode.lineNumbers;
@@ -927,7 +965,11 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
927
965
  } else {
928
966
  outputText = formatText(truncation.content, startLineDisplay);
929
967
  }
968
+ if (options.receiptPath && (truncation.truncated || countTextLines(selectedContent) < totalLines)) {
969
+ outputText += `\n\n${formatReceiptFooter(options.receiptPath, countTextLines(selectedContent), totalLines, Buffer.byteLength(selectedContent, "utf-8"))}`;
970
+ }
930
971
 
972
+ details.spillEligible = options.spillEligible === true && selectedContent.length > 0;
931
973
  resultBuilder.text(options.wrapUntrusted ? wrapUntrustedContent(outputText) : outputText);
932
974
  if (truncationInfo) {
933
975
  resultBuilder.truncation(truncationInfo.result, truncationInfo.options);
@@ -987,6 +1029,8 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
987
1029
  }
988
1030
  const finalText =
989
1031
  notices.length > 0 ? (outputText ? `${outputText}\n${notices.join("\n")}` : notices.join("\n")) : outputText;
1032
+ details.spillEligible = parts.length > 0;
1033
+
990
1034
  resultBuilder.text(finalText);
991
1035
  return resultBuilder.done();
992
1036
  }
@@ -1006,6 +1050,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1006
1050
  signal: AbortSignal | undefined,
1007
1051
  ): Promise<{
1008
1052
  outputText: string;
1053
+ contentLineCount: number;
1009
1054
  columnTruncated: number;
1010
1055
  bridgeResult?: AgentToolResult<ReadToolDetails>;
1011
1056
  }> {
@@ -1027,7 +1072,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1027
1072
  const firstText = bridgeResult.content.find((c): c is TextContent => c.type === "text");
1028
1073
  if (firstText) firstText.text = `${notice}\n${firstText.text}`;
1029
1074
  }
1030
- return { outputText: "", columnTruncated: 0, bridgeResult };
1075
+ return { outputText: "", contentLineCount: 0, columnTruncated: 0, bridgeResult };
1031
1076
  } catch (error) {
1032
1077
  logger.warn("ACP fs readTextFile failed; falling back to disk", { path: absolutePath, error });
1033
1078
  }
@@ -1040,6 +1085,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1040
1085
  const blocks: string[] = [];
1041
1086
  const notices: string[] = [];
1042
1087
  let columnTruncated = 0;
1088
+ let contentLineCount = 0;
1043
1089
 
1044
1090
  for (const range of ranges) {
1045
1091
  const rangeStart = range.startLine - 1; // 0-indexed
@@ -1074,6 +1120,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1074
1120
  }
1075
1121
  }
1076
1122
 
1123
+ contentLineCount += collectedLines.length;
1077
1124
  if (collectedLines.length > 0) {
1078
1125
  getFileReadCache(this.session).recordContiguous(absolutePath, range.startLine, collectedLines);
1079
1126
  }
@@ -1086,7 +1133,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1086
1133
  if (notices.length > 0) {
1087
1134
  outputText = outputText ? `${outputText}\n${notices.join("\n")}` : notices.join("\n");
1088
1135
  }
1089
- return { outputText, columnTruncated };
1136
+ return { outputText, contentLineCount, columnTruncated };
1090
1137
  }
1091
1138
 
1092
1139
  async #readArchiveDirectory(
@@ -1369,6 +1416,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1369
1416
  displayText: string;
1370
1417
  elidedSpans: number;
1371
1418
  elidedLines: number;
1419
+ capped: boolean;
1372
1420
  } {
1373
1421
  const displayMode = resolveFileDisplayMode(this.session);
1374
1422
  const shouldAddHashLines = displayMode.hashLines;
@@ -1428,17 +1476,19 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1428
1476
 
1429
1477
  const modelParts: string[] = [];
1430
1478
  const displayParts: string[] = [];
1479
+ const capBytes = this.session.settings.get("read.summaryMaxBytes") * 1024;
1480
+ let usedBytes = 0;
1431
1481
  let elidedSpans = 0;
1432
1482
  let elidedLines = 0;
1483
+ let capDropped = false;
1433
1484
  for (const unit of units) {
1485
+ let model: string;
1486
+ let display: string;
1487
+ let implicitElidedLines = 0;
1434
1488
  if (unit.kind === "elided") {
1435
- modelParts.push("...");
1436
- displayParts.push("...");
1437
- elidedSpans++;
1438
- elidedLines += unit.endLine - unit.startLine + 1;
1439
- continue;
1440
- }
1441
- if (unit.kind === "merged") {
1489
+ model = display = "...";
1490
+ implicitElidedLines = unit.endLine - unit.startLine + 1;
1491
+ } else if (unit.kind === "merged") {
1442
1492
  const formatted = formatMergedBraceLine(
1443
1493
  unit.startLine,
1444
1494
  unit.endLine,
@@ -1447,18 +1497,39 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1447
1497
  shouldAddHashLines,
1448
1498
  shouldAddLineNumbers,
1449
1499
  );
1450
- modelParts.push(formatted.model);
1451
- displayParts.push(formatted.display);
1500
+ model = formatted.model;
1501
+ display = formatted.display;
1502
+ implicitElidedLines = Math.max(0, unit.endLine - unit.startLine - 1);
1503
+ } else {
1504
+ model = formatSingleLine(unit.line, unit.text, shouldAddHashLines, shouldAddLineNumbers);
1505
+ display = unit.text;
1506
+ }
1507
+ const separatorBytes = modelParts.length > 0 ? 1 : 0;
1508
+ if (usedBytes + separatorBytes + Buffer.byteLength(model, "utf-8") > capBytes) {
1509
+ capDropped = true;
1452
1510
  elidedSpans++;
1453
- // Merged brace pair encloses (start+1)..(end-1) as elided.
1454
- elidedLines += Math.max(0, unit.endLine - unit.startLine - 1);
1511
+ elidedLines += unit.kind === "line" ? 1 : unit.endLine - unit.startLine + 1;
1455
1512
  continue;
1456
1513
  }
1457
- modelParts.push(formatSingleLine(unit.line, unit.text, shouldAddHashLines, shouldAddLineNumbers));
1458
- displayParts.push(unit.text);
1514
+ usedBytes += separatorBytes + Buffer.byteLength(model, "utf-8");
1515
+ modelParts.push(model);
1516
+ displayParts.push(display);
1517
+ if (unit.kind === "elided" || unit.kind === "merged") {
1518
+ elidedSpans++;
1519
+ elidedLines += implicitElidedLines;
1520
+ }
1459
1521
  }
1460
-
1461
- return { text: modelParts.join("\n"), displayText: displayParts.join("\n"), elidedSpans, elidedLines };
1522
+ if (capDropped) {
1523
+ modelParts.push("…");
1524
+ displayParts.push("…");
1525
+ }
1526
+ return {
1527
+ text: modelParts.join("\n"),
1528
+ displayText: displayParts.join("\n"),
1529
+ elidedSpans,
1530
+ elidedLines,
1531
+ capped: capDropped,
1532
+ };
1462
1533
  }
1463
1534
 
1464
1535
  async execute(
@@ -1515,8 +1586,13 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1515
1586
  // off the URL and surfaced via parseSel rather than confusing handlers.
1516
1587
  const internalRouter = InternalUrlRouter.instance();
1517
1588
  if (internalRouter.canHandle(readPath)) {
1518
- const internalTarget = splitInternalUrlSel(readPath);
1589
+ const internalTarget = splitInternalUrlSel(readPath, {
1590
+ activeSkillNames: getActiveSkills().map(skill => skill.name),
1591
+ });
1519
1592
  const parsed = parseSel(internalTarget.sel);
1593
+ if (internalTarget.sel !== undefined && parsed.kind === "none") {
1594
+ throw new ToolError(`Invalid internal URL selector "${internalTarget.sel}".`);
1595
+ }
1520
1596
  return this.#handleInternalUrl(internalTarget.path, parsed, signal);
1521
1597
  }
1522
1598
 
@@ -1649,25 +1725,38 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1649
1725
  details: { resolvedPath: absolutePath },
1650
1726
  sourcePath: absolutePath,
1651
1727
  entityLabel: "notebook",
1728
+ spillEligible: parsed.kind !== "none",
1729
+ resultByteCeiling:
1730
+ parsed.kind !== "none"
1731
+ ? Math.max(
1732
+ RAW_COLLECTOR_MAX_BYTES,
1733
+ this.session.settings.get("tools.readArtifactSpillThreshold") * 1024,
1734
+ )
1735
+ : undefined,
1736
+ receiptPath: parsed.kind === "none" ? localReadPath : undefined,
1652
1737
  });
1653
1738
  } else if (shouldConvertWithMarkit) {
1654
- // Convert document via markit.
1655
1739
  const result = await convertFileWithMarkit(absolutePath, signal);
1656
1740
  if (result.ok) {
1657
- // Apply truncation to converted content
1658
- const truncation = truncateHead(result.content);
1659
- const outputText = truncation.content;
1660
-
1661
- details = { truncation };
1662
- sourcePath = absolutePath;
1663
- truncationInfo = { result: truncation, options: { direction: "head", startLine: 1 } };
1664
-
1665
- content = [{ type: "text", text: outputText }];
1666
- } else if (result.error) {
1667
- content = [{ type: "text", text: `[Cannot read ${ext} file: ${result.error || "conversion failed"}]` }];
1668
- } else {
1669
- content = [{ type: "text", text: `[Cannot read ${ext} file: conversion failed]` }];
1741
+ const { offset, limit } = selToOffsetLimit(parsed);
1742
+ return this.#buildInMemoryTextResult(result.content, offset, limit, {
1743
+ details: { resolvedPath: absolutePath },
1744
+ sourcePath: absolutePath,
1745
+ entityLabel: "converted document",
1746
+ raw: isRawSelector(parsed),
1747
+ spillEligible: parsed.kind !== "none",
1748
+ resultByteCeiling:
1749
+ parsed.kind !== "none"
1750
+ ? Math.max(
1751
+ RAW_COLLECTOR_MAX_BYTES,
1752
+ this.session.settings.get("tools.readArtifactSpillThreshold") * 1024,
1753
+ )
1754
+ : undefined,
1755
+ receiptPath: parsed.kind === "none" ? localReadPath : undefined,
1756
+ });
1670
1757
  }
1758
+ content = [{ type: "text", text: `[Cannot read ${ext} file: ${result.error || "conversion failed"}]` }];
1759
+ details = { spillEligible: false };
1671
1760
  } else {
1672
1761
  if (
1673
1762
  parsed.kind === "none" &&
@@ -1682,8 +1771,22 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1682
1771
  renderedSummary.elidedSpans,
1683
1772
  renderedSummary.elidedLines,
1684
1773
  );
1685
- const modelText = footer ? `${renderedSummary.text}\n\n${footer}` : renderedSummary.text;
1774
+ const summaryTotalLines = Math.max(...summary.segments.map(segment => segment.endLine));
1775
+ const modelText = [
1776
+ renderedSummary.text,
1777
+ footer,
1778
+ renderedSummary.capped
1779
+ ? formatSummaryCapFooter(
1780
+ localReadPath,
1781
+ this.session.settings.get("read.summaryMaxBytes") * 1024,
1782
+ summaryTotalLines,
1783
+ )
1784
+ : "",
1785
+ ]
1786
+ .filter(Boolean)
1787
+ .join("\n\n");
1686
1788
  details = {
1789
+ spillEligible: false,
1687
1790
  displayContent: { text: renderedSummary.displayText, startLine: 1 },
1688
1791
  summary: {
1689
1792
  lines: countTextLines(renderedSummary.text),
@@ -1710,7 +1813,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1710
1813
  if (multiResult.bridgeResult) return multiResult.bridgeResult;
1711
1814
  content = [{ type: "text", text: multiResult.outputText }];
1712
1815
  sourcePath = absolutePath;
1713
- details = {};
1816
+ details = { spillEligible: multiResult.contentLineCount > 0 };
1714
1817
  if (multiResult.columnTruncated > 0) {
1715
1818
  columnTruncated = multiResult.columnTruncated;
1716
1819
  }
@@ -1750,13 +1853,25 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1750
1853
  const startLine = requestedStart - leadingContext;
1751
1854
  const startLineDisplay = startLine + 1;
1752
1855
 
1856
+ const isBareReceipt = parsed.kind === "none" && !content;
1857
+ const rawSelector = isRawSelector(parsed);
1753
1858
  const DEFAULT_LIMIT = this.#defaultLimit;
1754
- const effectiveLimit = limit ?? DEFAULT_LIMIT;
1755
- const maxLinesToCollect = Math.min(effectiveLimit + leadingContext + trailingContext, DEFAULT_MAX_LINES);
1756
- const selectedLineLimit = effectiveLimit + leadingContext + trailingContext;
1757
- // Scale byte budget with line limit so the configured line count actually fits.
1758
- // Assume ~512 bytes/line average; never go below the shared default.
1759
- const maxBytesForRead = Math.max(DEFAULT_MAX_BYTES, maxLinesToCollect * 512);
1859
+ const effectiveLimit = rawSelector ? Number.MAX_SAFE_INTEGER : (limit ?? DEFAULT_LIMIT);
1860
+ const receiptBudgetBytes = this.session.settings.get("read.receiptBudgetBytes") * 1024;
1861
+ const maxLinesToCollect = rawSelector
1862
+ ? Number.MAX_SAFE_INTEGER
1863
+ : isBareReceipt
1864
+ ? this.session.settings.get("read.receiptBudgetLines")
1865
+ : Math.min(effectiveLimit + leadingContext + trailingContext, DEFAULT_MAX_LINES);
1866
+ const selectedLineLimit = rawSelector ? null : effectiveLimit + leadingContext + trailingContext;
1867
+ const maxBytesForRead = rawSelector
1868
+ ? Math.max(
1869
+ RAW_COLLECTOR_MAX_BYTES,
1870
+ this.session.settings.get("tools.readArtifactSpillThreshold") * 1024,
1871
+ )
1872
+ : isBareReceipt
1873
+ ? receiptBudgetBytes
1874
+ : Math.max(DEFAULT_MAX_BYTES, maxLinesToCollect * 512);
1760
1875
 
1761
1876
  const streamResult = await streamLinesFromFile(
1762
1877
  absolutePath,
@@ -1793,7 +1908,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1793
1908
  // verbatim bytes for paste-back-into-tool workflows. Total byte/line
1794
1909
  // counts in `truncation` keep reflecting the source, not the trimmed
1795
1910
  // view — column truncation surfaces separately via `.limits()`.
1796
- const rawSelector = isRawSelector(parsed);
1911
+
1797
1912
  const shouldAddHashLines = !rawSelector && displayMode.hashLines;
1798
1913
  const maxColumns = resolveOutputMaxColumns(this.session.settings);
1799
1914
  if (!rawSelector && !shouldAddHashLines && maxColumns > 0) {
@@ -1855,7 +1970,8 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1855
1970
  firstLineBytes,
1856
1971
  )}, exceeds ${formatBytes(maxBytesForRead)} limit. Unable to display a valid UTF-8 snippet.]`;
1857
1972
  }
1858
- details = { truncation };
1973
+ details = { truncation, spillEligible: !isBareReceipt && collectedLines.length > 0 };
1974
+
1859
1975
  sourcePath = absolutePath;
1860
1976
  truncationInfo = {
1861
1977
  result: truncation,
@@ -1863,7 +1979,8 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1863
1979
  };
1864
1980
  } else if (truncation.truncated) {
1865
1981
  outputText = formatText(truncation.content, startLineDisplay);
1866
- details = { truncation };
1982
+ details = { truncation, spillEligible: !isBareReceipt && collectedLines.length > 0 };
1983
+
1867
1984
  sourcePath = absolutePath;
1868
1985
  truncationInfo = {
1869
1986
  result: truncation,
@@ -1875,14 +1992,22 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1875
1992
 
1876
1993
  outputText = formatText(truncation.content, startLineDisplay);
1877
1994
  outputText += `\n\n[${remaining} more lines in file. Use :${nextOffset} to continue]`;
1878
- details = {};
1995
+ details = { spillEligible: !isBareReceipt && collectedLines.length > 0 };
1996
+
1879
1997
  sourcePath = absolutePath;
1880
1998
  } else {
1881
1999
  // No truncation, no user limit exceeded
1882
2000
  outputText = formatText(truncation.content, startLineDisplay);
1883
- details = {};
2001
+ details = { spillEligible: !isBareReceipt && collectedLines.length > 0 };
2002
+
1884
2003
  sourcePath = absolutePath;
1885
2004
  }
2005
+ if (isBareReceipt && (truncation.truncated || collectedLines.length < totalFileLines)) {
2006
+ outputText += `\n\n${formatReceiptFooter(localReadPath, collectedLines.length, totalFileLines, collectedBytes)}`;
2007
+ }
2008
+ if (rawSelector && truncation.truncated) {
2009
+ outputText += `\n\n[Raw read capped at ${formatBytes(maxBytesForRead)} of ${formatBytes(fileSize)}; re-read a line range to narrow the view]`;
2010
+ }
1886
2011
 
1887
2012
  if (capturedDisplayContent) {
1888
2013
  details.displayContent = capturedDisplayContent;
@@ -2109,23 +2234,23 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2109
2234
  }
2110
2235
  throwIfAborted(signal);
2111
2236
 
2112
- const output = tree.totalLines <= 1 ? "(empty directory)" : tree.rendered;
2113
- const truncation = truncateHead(output, { maxLines: Number.MAX_SAFE_INTEGER });
2114
- const details: ReadToolDetails = {
2115
- isDirectory: true,
2116
- resolvedPath: tree.rootPath,
2117
- };
2118
-
2119
- const resultBuilder = toolResult(details).text(truncation.content).sourcePath(tree.rootPath);
2120
- if (tree.truncated) {
2121
- resultBuilder.limits({ resultLimit: 1 });
2122
- }
2123
- if (truncation.truncated) {
2124
- resultBuilder.truncation(truncation, { direction: "head" });
2125
- details.truncation = truncation;
2126
- }
2127
-
2128
- return resultBuilder.done();
2237
+ const receiptLines = this.session.settings.get("read.receiptBudgetLines");
2238
+ const receiptBytes = this.session.settings.get("read.receiptBudgetBytes") * 1024;
2239
+ const sourceLines = tree.totalLines <= 1 ? ["(empty directory)"] : tree.rendered.split("\n");
2240
+ const bodyLines: string[] = [];
2241
+ let bodyBytes = 0;
2242
+ for (const line of sourceLines) {
2243
+ const bytes = Buffer.byteLength(line, "utf-8") + (bodyLines.length > 0 ? 1 : 0);
2244
+ if (bodyLines.length >= receiptLines || bodyBytes + bytes > receiptBytes) break;
2245
+ bodyLines.push(line);
2246
+ bodyBytes += bytes;
2247
+ }
2248
+ let output = bodyLines.join("\n");
2249
+ if (bodyLines.length < sourceLines.length || tree.truncated) {
2250
+ output += `\n\n[Listing truncated at ${bodyLines.length} lines / ${Math.ceil(bodyBytes / 1024)} KiB; read a deeper subpath (e.g. ${absolutePath}/<subdir>) to narrow the view]`;
2251
+ }
2252
+ const details: ReadToolDetails = { isDirectory: true, spillEligible: false, resolvedPath: tree.rootPath };
2253
+ return toolResult(details).text(output).sourcePath(tree.rootPath).done();
2129
2254
  }
2130
2255
  }
2131
2256
 
@@ -15,10 +15,10 @@ import { pluralize } from "@gajae-code/utils";
15
15
  import { settings } from "../config/settings";
16
16
  import type { Theme } from "../modes/theme/theme";
17
17
  import { Hasher } from "../tui/utils";
18
- import { formatDimensionNote, type ResizedImage } from "../utils/image-resize";
19
18
 
20
19
  export { Ellipsis } from "@gajae-code/natives";
21
20
  export { replaceTabs, truncateToWidth, wrapTextWithAnsi } from "@gajae-code/tui";
21
+ export { formatScreenshot } from "./browser/screenshot-format";
22
22
 
23
23
  // =============================================================================
24
24
  // Standardized Display Constants
@@ -591,32 +591,6 @@ export function formatToolWorkingDirectory(workdir: string | undefined, projectD
591
591
  return replaceTabs(displayWorkdir);
592
592
  }
593
593
 
594
- export function formatScreenshot(opts: {
595
- saveFullRes: boolean;
596
- savedMimeType: string;
597
- savedByteLength: number;
598
- dest: string;
599
- resized: ResizedImage;
600
- }): string[] {
601
- const lines = ["Screenshot captured"];
602
- if (opts.saveFullRes) {
603
- lines.push(
604
- `Saved: ${opts.savedMimeType} (${(opts.savedByteLength / 1024).toFixed(2)} KB) to ${shortenPath(opts.dest)}`,
605
- );
606
- lines.push(
607
- `Model: ${opts.resized.mimeType} (${(opts.resized.buffer.length / 1024).toFixed(2)} KB, ${opts.resized.width}x${opts.resized.height})`,
608
- );
609
- } else {
610
- lines.push(`Format: ${opts.resized.mimeType} (${(opts.resized.buffer.length / 1024).toFixed(2)} KB)`);
611
- lines.push(`Dimensions: ${opts.resized.width}x${opts.resized.height}`);
612
- }
613
- const dimensionNote = formatDimensionNote(opts.resized);
614
- if (dimensionNote) {
615
- lines.push(dimensionNote);
616
- }
617
- return lines;
618
- }
619
-
620
594
  export function wrapBrackets(text: string, theme: Theme): string {
621
595
  return `${theme.format.bracketLeft}${text}${theme.format.bracketRight}`;
622
596
  }