@gajae-code/coding-agent 0.8.2 → 0.9.0

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 (179) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/types/append-only-mode.d.ts +31 -0
  3. package/dist/types/capability/rule.d.ts +8 -0
  4. package/dist/types/cli/fixture-report.d.ts +20 -0
  5. package/dist/types/cli/fixture-report.test.d.ts +1 -0
  6. package/dist/types/cli/nofile-limit.d.ts +11 -0
  7. package/dist/types/cli/update-cli.d.ts +22 -0
  8. package/dist/types/config/settings-schema.d.ts +105 -1
  9. package/dist/types/discovery/index.d.ts +1 -0
  10. package/dist/types/export/html/template.generated.d.ts +1 -1
  11. package/dist/types/export/ttsr.d.ts +14 -3
  12. package/dist/types/gjc-runtime/deep-interview-state.d.ts +5 -2
  13. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +7 -0
  14. package/dist/types/gjc-runtime/state-writer.d.ts +3 -0
  15. package/dist/types/gjc-runtime/team-runtime.d.ts +26 -0
  16. package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +42 -0
  17. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +119 -13
  18. package/dist/types/goals/runtime.d.ts +1 -0
  19. package/dist/types/hooks/native-skill-hook.d.ts +12 -0
  20. package/dist/types/modes/components/assistant-message.d.ts +5 -1
  21. package/dist/types/modes/components/status-line.d.ts +9 -0
  22. package/dist/types/modes/controllers/command-controller.d.ts +1 -0
  23. package/dist/types/modes/controllers/event-controller.d.ts +5 -0
  24. package/dist/types/modes/interactive-mode.d.ts +2 -0
  25. package/dist/types/modes/rpc/rpc-types.d.ts +14 -0
  26. package/dist/types/modes/shared/agent-wire/event-contract.d.ts +19 -0
  27. package/dist/types/modes/shared/agent-wire/event-envelope.d.ts +23 -1
  28. package/dist/types/modes/types.d.ts +1 -0
  29. package/dist/types/modes/utils/ui-helpers.d.ts +1 -0
  30. package/dist/types/notifications/config.d.ts +6 -0
  31. package/dist/types/notifications/telegram-daemon.d.ts +8 -0
  32. package/dist/types/notifications/threaded-render.d.ts +6 -0
  33. package/dist/types/sdk.d.ts +3 -0
  34. package/dist/types/session/agent-session.d.ts +43 -1
  35. package/dist/types/session/file-mention-pruning.d.ts +8 -0
  36. package/dist/types/session/messages.d.ts +4 -0
  37. package/dist/types/session/session-manager.d.ts +17 -1
  38. package/dist/types/skill-state/active-state.d.ts +18 -1
  39. package/dist/types/system-prompt.d.ts +18 -2
  40. package/dist/types/task/executor.d.ts +1 -0
  41. package/dist/types/task/fork-context-budget.d.ts +7 -0
  42. package/dist/types/task/token-log.d.ts +30 -0
  43. package/dist/types/task/token-log.test.d.ts +1 -0
  44. package/dist/types/tools/bisect.d.ts +81 -0
  45. package/dist/types/tools/index.d.ts +1 -0
  46. package/dist/types/tools/search.d.ts +2 -2
  47. package/dist/types/utils/file-mentions.d.ts +14 -0
  48. package/dist/types/utils/git.d.ts +32 -0
  49. package/dist/types/web/scrapers/youtube.d.ts +8 -0
  50. package/package.json +7 -7
  51. package/src/append-only-mode.ts +58 -0
  52. package/src/autoresearch/prompt.md +1 -1
  53. package/src/autoresearch/tools/log-experiment.ts +1 -1
  54. package/src/capability/rule.ts +9 -0
  55. package/src/cli/config-cli.ts +27 -3
  56. package/src/cli/fixture-report.test.ts +230 -0
  57. package/src/cli/fixture-report.ts +171 -0
  58. package/src/cli/nofile-limit.ts +48 -0
  59. package/src/cli/update-cli.ts +85 -9
  60. package/src/cli.ts +26 -0
  61. package/src/commands/contribution-prep.ts +1 -1
  62. package/src/config/settings-schema.ts +68 -1
  63. package/src/defaults/gjc/skills/deep-interview/SKILL.md +12 -13
  64. package/src/defaults/gjc/skills/ralplan/SKILL.md +30 -93
  65. package/src/defaults/gjc/skills/team/SKILL.md +16 -13
  66. package/src/defaults/gjc/skills/ultragoal/SKILL.md +59 -4
  67. package/src/defaults/gjc/skills/ultragoal/pipeline-validation-contracts.md +30 -0
  68. package/src/defaults/gjc-defaults.ts +9 -0
  69. package/src/discovery/helpers.ts +10 -0
  70. package/src/discovery/index.ts +1 -0
  71. package/src/export/html/template.generated.ts +1 -1
  72. package/src/export/html/template.js +60 -2
  73. package/src/export/ttsr.ts +96 -18
  74. package/src/extensibility/extensions/runner.ts +181 -219
  75. package/src/extensibility/gjc-plugins/runtime-adapters.ts +156 -44
  76. package/src/gjc-runtime/deep-interview-state.ts +31 -2
  77. package/src/gjc-runtime/session-state-sidecar.ts +208 -13
  78. package/src/gjc-runtime/state-writer.ts +15 -0
  79. package/src/gjc-runtime/team-runtime.ts +164 -15
  80. package/src/gjc-runtime/ultragoal-guard.ts +55 -66
  81. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +315 -0
  82. package/src/gjc-runtime/ultragoal-runtime.ts +1593 -143
  83. package/src/gjc-runtime/workflow-manifest.generated.json +113 -2
  84. package/src/gjc-runtime/workflow-manifest.ts +74 -1
  85. package/src/goals/runtime.ts +10 -2
  86. package/src/hindsight/backend.ts +1 -3
  87. package/src/hooks/native-skill-hook.ts +95 -4
  88. package/src/internal-urls/docs-index.generated.ts +14 -5
  89. package/src/main.ts +43 -2
  90. package/src/memories/index.ts +12 -1
  91. package/src/modes/components/assistant-message.ts +241 -82
  92. package/src/modes/components/custom-editor.ts +1 -0
  93. package/src/modes/components/status-line.ts +167 -54
  94. package/src/modes/components/tool-execution.ts +44 -19
  95. package/src/modes/controllers/command-controller.ts +39 -2
  96. package/src/modes/controllers/event-controller.ts +66 -47
  97. package/src/modes/controllers/input-controller.ts +1 -1
  98. package/src/modes/interactive-mode.ts +9 -1
  99. package/src/modes/rpc/rpc-mode.ts +29 -3
  100. package/src/modes/rpc/rpc-types.ts +9 -0
  101. package/src/modes/shared/agent-wire/command-validation.ts +4 -0
  102. package/src/modes/shared/agent-wire/event-contract.ts +22 -0
  103. package/src/modes/shared/agent-wire/event-envelope.ts +82 -0
  104. package/src/modes/shared/agent-wire/scopes.ts +1 -0
  105. package/src/modes/types.ts +1 -0
  106. package/src/modes/utils/ui-helpers.ts +17 -4
  107. package/src/notifications/config.ts +16 -4
  108. package/src/notifications/index.ts +78 -5
  109. package/src/notifications/telegram-daemon-cli.ts +2 -2
  110. package/src/notifications/telegram-daemon-control.ts +2 -2
  111. package/src/notifications/telegram-daemon.ts +85 -8
  112. package/src/notifications/threaded-render.ts +17 -1
  113. package/src/prompts/agents/architect.md +19 -10
  114. package/src/prompts/agents/critic.md +16 -18
  115. package/src/prompts/agents/executor.md +14 -11
  116. package/src/prompts/agents/planner.md +18 -13
  117. package/src/prompts/ci-green-request.md +3 -8
  118. package/src/prompts/goals/goal-continuation.md +0 -5
  119. package/src/prompts/goals/goal-mode-active.md +0 -5
  120. package/src/prompts/system/agent-creation-architect.md +2 -2
  121. package/src/prompts/system/plan-mode-active.md +1 -1
  122. package/src/prompts/system/plan-mode-subagent.md +3 -3
  123. package/src/prompts/system/project-prompt.md +0 -13
  124. package/src/prompts/system/subagent-system-prompt.md +3 -2
  125. package/src/prompts/system/system-prompt.md +26 -19
  126. package/src/prompts/system/volatile-project-context.md +10 -0
  127. package/src/prompts/system/web-search.md +4 -7
  128. package/src/prompts/tools/apply-patch.md +6 -0
  129. package/src/prompts/tools/ast-edit.md +1 -0
  130. package/src/prompts/tools/bash.md +1 -1
  131. package/src/prompts/tools/bisect.md +22 -0
  132. package/src/prompts/tools/browser.md +1 -1
  133. package/src/prompts/tools/cron.md +0 -2
  134. package/src/prompts/tools/eval.md +2 -2
  135. package/src/prompts/tools/find.md +1 -1
  136. package/src/prompts/tools/github.md +6 -6
  137. package/src/prompts/tools/goal.md +1 -3
  138. package/src/prompts/tools/image-gen.md +12 -5
  139. package/src/prompts/tools/irc.md +5 -4
  140. package/src/prompts/tools/job.md +2 -2
  141. package/src/prompts/tools/lsp.md +1 -1
  142. package/src/prompts/tools/monitor.md +2 -2
  143. package/src/prompts/tools/patch.md +2 -2
  144. package/src/prompts/tools/replace.md +2 -21
  145. package/src/prompts/tools/rewind.md +1 -1
  146. package/src/prompts/tools/search-tool-bm25.md +1 -11
  147. package/src/prompts/tools/search.md +1 -1
  148. package/src/prompts/tools/skill.md +3 -2
  149. package/src/prompts/tools/ssh.md +2 -1
  150. package/src/prompts/tools/subagent.md +2 -2
  151. package/src/prompts/tools/task.md +4 -5
  152. package/src/prompts/tools/todo-write.md +1 -1
  153. package/src/prompts/tools/vim.md +1 -0
  154. package/src/prompts/tools/web-search.md +2 -0
  155. package/src/prompts/tools/write.md +8 -2
  156. package/src/sdk.ts +32 -22
  157. package/src/session/agent-session.ts +732 -107
  158. package/src/session/file-mention-pruning.ts +64 -0
  159. package/src/session/messages.ts +4 -0
  160. package/src/session/session-manager.ts +59 -2
  161. package/src/skill-state/active-state.ts +141 -5
  162. package/src/skill-state/deep-interview-mutation-guard.ts +67 -3
  163. package/src/slash-commands/builtin-registry.ts +65 -0
  164. package/src/system-prompt.ts +57 -16
  165. package/src/task/executor.ts +125 -20
  166. package/src/task/fork-context-advisory.ts +2 -7
  167. package/src/task/fork-context-budget.ts +9 -0
  168. package/src/task/index.ts +4 -3
  169. package/src/task/output-manager.ts +4 -2
  170. package/src/task/token-log.test.ts +188 -0
  171. package/src/task/token-log.ts +179 -0
  172. package/src/tools/bisect.ts +367 -0
  173. package/src/tools/index.ts +3 -0
  174. package/src/tools/output-meta.ts +116 -4
  175. package/src/tools/search.ts +8 -2
  176. package/src/tools/skill.ts +15 -0
  177. package/src/utils/file-mentions.ts +39 -7
  178. package/src/utils/git.ts +73 -0
  179. package/src/web/scrapers/youtube.ts +24 -12
@@ -554,13 +554,17 @@ function getSpillConfig(s: Settings | undefined) {
554
554
  | "tools.artifactSpillThreshold"
555
555
  | "tools.artifactTailBytes"
556
556
  | "tools.artifactTailLines"
557
- | "tools.artifactHeadBytes";
557
+ | "tools.artifactHeadBytes"
558
+ | "tools.maxInlineResultBytes"
559
+ | "tools.readArtifactSpillThreshold";
558
560
  const get = <P extends Path>(path: P) => s?.get(path) ?? getDefault(path);
559
561
  return {
560
562
  threshold: get("tools.artifactSpillThreshold") * 1024,
563
+ readThreshold: get("tools.readArtifactSpillThreshold") * 1024,
561
564
  tailBytes: get("tools.artifactTailBytes") * 1024,
562
565
  tailLines: get("tools.artifactTailLines"),
563
566
  headBytes: get("tools.artifactHeadBytes") * 1024,
567
+ maxInlineBytes: get("tools.maxInlineResultBytes") * 1024,
564
568
  };
565
569
  }
566
570
 
@@ -596,8 +600,12 @@ async function spillLargeResultToArtifact(
596
600
  ): Promise<AgentToolResult> {
597
601
  const sessionManager = context?.sessionManager;
598
602
  if (!sessionManager) return result;
599
- if (toolName === "read") return result;
600
- const { threshold, tailBytes, tailLines, headBytes } = getSpillConfig(context?.settings);
603
+ const { threshold, readThreshold, tailBytes, tailLines, headBytes } = getSpillConfig(context?.settings);
604
+ // `read` manages its own per-range truncation, but the combined multi-range
605
+ // output has no cap — enforce a read-specific (higher) combined threshold
606
+ // instead of exempting read entirely. 0 disables read spill (backstop only).
607
+ const effectiveThreshold = toolName === "read" ? readThreshold : threshold;
608
+ if (effectiveThreshold <= 0) return result;
601
609
 
602
610
  // Skip if tool already saved an artifact
603
611
  const existingMeta: OutputMeta | undefined = result.details?.meta;
@@ -614,7 +622,7 @@ async function spillLargeResultToArtifact(
614
622
 
615
623
  const fullText = textParts.length === 1 ? textParts[0] : textParts.join("\n");
616
624
  const totalBytes = Buffer.byteLength(fullText, "utf-8");
617
- if (totalBytes <= threshold) return result;
625
+ if (totalBytes <= effectiveThreshold) return result;
618
626
 
619
627
  // Save full output as artifact
620
628
  const artifactId = await sessionManager.saveArtifact(fullText, toolName);
@@ -691,6 +699,106 @@ async function spillLargeResultToArtifact(
691
699
  return { ...result, content: newContent, details: newDetails };
692
700
  }
693
701
 
702
+ /**
703
+ * Absolute inline-size backstop enforced after {@link spillLargeResultToArtifact}.
704
+ *
705
+ * The threshold-based spill above has escape hatches: it early-returns for the
706
+ * `read` tool and for results that already carry an `artifactId` (a tool may set
707
+ * partial truncation meta yet still emit oversized inline text). This backstop
708
+ * closes those gaps: when `tools.maxInlineResultBytes` is configured (> 0), any
709
+ * final result whose inline text exceeds the cap is force-saved to an artifact
710
+ * (reusing an existing artifactId to avoid double-artifacting) and truncated to a
711
+ * head+tail view that fits the cap. Disabled by default (opt-in pending
712
+ * measurement); a 0 cap returns the result untouched.
713
+ */
714
+ async function enforceInlineResultBackstop(
715
+ result: AgentToolResult,
716
+ toolName: string,
717
+ context: AgentToolContext | undefined,
718
+ ): Promise<AgentToolResult> {
719
+ const { maxInlineBytes, tailLines, headBytes } = getSpillConfig(context?.settings);
720
+ if (maxInlineBytes <= 0) return result;
721
+
722
+ const textParts: string[] = [];
723
+ for (const block of result.content) {
724
+ if (block.type === "text" && block.text) {
725
+ textParts.push(block.text);
726
+ }
727
+ }
728
+ if (textParts.length === 0) return result;
729
+
730
+ const fullText = textParts.length === 1 ? textParts[0] : textParts.join("\n");
731
+ const totalBytes = Buffer.byteLength(fullText, "utf-8");
732
+ if (totalBytes <= maxInlineBytes) return result;
733
+
734
+ // Reuse an existing artifact (avoid double-artifacting); otherwise save the
735
+ // full output so the truncated view keeps a reference to the complete text.
736
+ const existingMeta: OutputMeta | undefined = result.details?.meta;
737
+ let artifactId = existingMeta?.truncation?.artifactId;
738
+ if (!artifactId) {
739
+ artifactId = (await context?.sessionManager?.saveArtifact(fullText, toolName)) ?? undefined;
740
+ }
741
+
742
+ // Budget head+tail below the cap, reserving room for the elision marker so the
743
+ // composed `<head>\n<marker>\n<tail>` view never exceeds the configured cap.
744
+ const MARKER_RESERVE = 256;
745
+ const budget = maxInlineBytes - MARKER_RESERVE;
746
+ const useMiddle = headBytes > 0 && budget > 0;
747
+ let truncated = useMiddle
748
+ ? truncateMiddle(fullText, {
749
+ maxBytes: budget,
750
+ maxLines: tailLines * 2,
751
+ maxHeadBytes: Math.min(headBytes, Math.floor(budget / 2)),
752
+ maxHeadLines: tailLines,
753
+ })
754
+ : truncateTail(fullText, { maxBytes: maxInlineBytes, maxLines: tailLines });
755
+
756
+ // Defensive clamp: guarantee the contract even for pathological marker sizes.
757
+ if (Buffer.byteLength(truncated.content, "utf-8") > maxInlineBytes) {
758
+ truncated = truncateTail(fullText, { maxBytes: maxInlineBytes, maxLines: tailLines });
759
+ }
760
+
761
+ const newContent: (TextContent | ImageContent)[] = [];
762
+ for (const block of result.content) {
763
+ if (block.type !== "text") {
764
+ newContent.push(block);
765
+ }
766
+ }
767
+ newContent.push({ type: "text", text: truncated.content });
768
+
769
+ const outputLines = truncated.outputLines ?? truncated.totalLines;
770
+ const outputBytes = truncated.outputBytes ?? truncated.totalBytes;
771
+ const truncationMeta: TruncationMeta =
772
+ truncated.truncatedBy === "middle"
773
+ ? {
774
+ direction: "middle",
775
+ truncatedBy: "middle",
776
+ totalLines: truncated.totalLines,
777
+ totalBytes: truncated.totalBytes,
778
+ outputLines,
779
+ outputBytes,
780
+ maxBytes: maxInlineBytes,
781
+ elidedLines: truncated.elidedLines ?? Math.max(0, truncated.totalLines - outputLines),
782
+ elidedBytes: truncated.elidedBytes ?? Math.max(0, truncated.totalBytes - outputBytes),
783
+ artifactId,
784
+ }
785
+ : {
786
+ direction: "tail",
787
+ truncatedBy: truncated.truncatedBy ?? "bytes",
788
+ totalLines: truncated.totalLines,
789
+ totalBytes: truncated.totalBytes,
790
+ outputLines,
791
+ outputBytes,
792
+ maxBytes: maxInlineBytes,
793
+ shownRange: { start: truncated.totalLines - outputLines + 1, end: truncated.totalLines },
794
+ artifactId,
795
+ };
796
+
797
+ const newMeta: OutputMeta = { ...(existingMeta ?? {}), truncation: truncationMeta };
798
+ const newDetails = { ...(result.details ?? {}), meta: newMeta };
799
+ return { ...result, content: newContent, details: newDetails };
800
+ }
801
+
694
802
  // =============================================================================
695
803
  // Tool wrapper
696
804
  // =============================================================================
@@ -711,6 +819,10 @@ async function wrappedExecute(
711
819
  // Spill large results to artifact, truncate to tail
712
820
  result = await spillLargeResultToArtifact(result, this.name, context);
713
821
 
822
+ // Absolute inline-size backstop: catches oversized text the threshold spill
823
+ // skipped (read exemption, tools with pre-existing partial artifact meta).
824
+ result = await enforceInlineResultBackstop(result, this.name, context);
825
+
714
826
  // Append notices from meta
715
827
  const meta: OutputMeta | undefined = result.details?.meta;
716
828
  if (meta) {
@@ -44,7 +44,10 @@ const searchSchema = z
44
44
  paths: z
45
45
  .array(z.string().describe("file, directory, glob, or internal URL to search"))
46
46
  .min(1)
47
- .describe("files, directories, globs, or internal URLs to search"),
47
+ .optional()
48
+ .describe(
49
+ "files, directories, globs, or internal URLs to search (defaults to the working directory when omitted)",
50
+ ),
48
51
  i: z.boolean().optional().describe("case-insensitive search"),
49
52
  gitignore: z.boolean().optional().describe("respect gitignore"),
50
53
  skip: z
@@ -236,7 +239,10 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
236
239
  _onUpdate?: AgentToolUpdateCallback<SearchToolDetails>,
237
240
  _toolContext?: AgentToolContext,
238
241
  ): Promise<AgentToolResult<SearchToolDetails>> {
239
- const { pattern, paths, i, gitignore, skip } = params;
242
+ const { pattern, i, gitignore, skip } = params;
243
+ // Recurring real-world failure mode: models omit `paths` for repo-wide
244
+ // searches. Default to the working directory instead of failing the call.
245
+ const paths = params.paths ?? ["."];
240
246
 
241
247
  return untilAborted(signal, async () => {
242
248
  const normalizedPattern = pattern.trim();
@@ -37,6 +37,8 @@ function normalizeSkillName(name: string | undefined): string {
37
37
  return (name ?? "").trim();
38
38
  }
39
39
 
40
+ const SKILL_NAME_GLOB_PATTERN = /[*?[\]{}]/;
41
+
40
42
  type SkillToolInput = z.infer<typeof skillSchema>;
41
43
 
42
44
  export interface SkillToolDetails {
@@ -87,6 +89,19 @@ export class SkillTool implements AgentTool<typeof skillSchema, SkillToolDetails
87
89
  if (!requestedName) {
88
90
  throw new ToolError("skill tool: `name` is required");
89
91
  }
92
+ // Fail fast on glob/wildcard names (e.g. `*`). A model that sees the
93
+ // `--skills '*'` launch filter may echo the glob into the skill tool.
94
+ // Without this guard `*` slips through to the generic unknown-skill
95
+ // path only because no skill is literally named `*`, so a future
96
+ // odd skill name could get dispatched — and the actionable signal
97
+ // that names are concrete (not globs) is lost. Reject before any
98
+ // dispatch or handoff state mutation so the call ends immediately
99
+ // instead of spawning skill work that burns the whole turn budget.
100
+ if (SKILL_NAME_GLOB_PATTERN.test(requestedName)) {
101
+ throw new ToolError(
102
+ `skill tool: "${requestedName}" is not a valid skill name. The name must be a single concrete skill (e.g. "ralplan"), not a glob or wildcard pattern. Pass one exact skill name as shown in /skill:<name>.`,
103
+ );
104
+ }
90
105
  const activeState = this.#session.getActiveSkillState?.();
91
106
  const activeSkill = normalizeSkillName(activeState?.skill);
92
107
  if (activeSkill && requestedName === activeSkill) {
@@ -159,24 +159,32 @@ async function resolveMentionPath(
159
159
  return best?.candidate.path ?? null;
160
160
  }
161
161
 
162
- function buildTextOutput(textContent: string): { output: string; lineCount: number } {
162
+ /**
163
+ * Default inline cap for auto-read file mentions, deliberately below the
164
+ * read-tool cap (`DEFAULT_MAX_BYTES`, 50KB) so an incidental `@path` mention
165
+ * injects a smaller snippet than an explicit `read`. Overridable per call.
166
+ */
167
+ export const DEFAULT_FILE_MENTION_INLINE_BYTES = 20 * 1024;
168
+
169
+ function buildTextOutput(textContent: string, maxInlineBytes?: number): { output: string; lineCount: number } {
170
+ const cap = maxInlineBytes && maxInlineBytes > 0 ? maxInlineBytes : DEFAULT_FILE_MENTION_INLINE_BYTES;
163
171
  const allLines = textContent.split("\n");
164
172
  const totalFileLines = allLines.length;
165
- const truncation = truncateHead(textContent);
173
+ const truncation = truncateHead(textContent, { maxBytes: cap });
166
174
 
167
175
  if (truncation.firstLineExceedsLimit) {
168
176
  const firstLine = allLines[0] ?? "";
169
177
  const firstLineBytes = Buffer.byteLength(firstLine, "utf-8");
170
- const snippet = truncateHeadBytes(firstLine, DEFAULT_MAX_BYTES);
178
+ const snippet = truncateHeadBytes(firstLine, cap);
171
179
  let outputText = snippet.text;
172
180
 
173
181
  if (outputText.length > 0) {
174
182
  outputText += `\n\n[Line 1 is ${formatBytes(firstLineBytes)}, exceeds ${formatBytes(
175
- DEFAULT_MAX_BYTES,
183
+ cap,
176
184
  )} limit. Showing first ${formatBytes(snippet.bytes)} of the line.]`;
177
185
  } else {
178
186
  outputText = `[Line 1 is ${formatBytes(firstLineBytes)}, exceeds ${formatBytes(
179
- DEFAULT_MAX_BYTES,
187
+ cap,
180
188
  )} limit. Unable to display a valid UTF-8 snippet.]`;
181
189
  }
182
190
 
@@ -277,7 +285,18 @@ export function extractFileMentions(text: string): string[] {
277
285
  export async function generateFileMentionMessages(
278
286
  filePaths: string[],
279
287
  cwd: string,
280
- options?: { autoResizeImages?: boolean; useHashLines?: boolean },
288
+ options?: {
289
+ autoResizeImages?: boolean;
290
+ useHashLines?: boolean;
291
+ /** Inline byte cap for auto-read text. Default: DEFAULT_FILE_MENTION_INLINE_BYTES. */
292
+ maxInlineBytes?: number;
293
+ /**
294
+ * Resolved paths already shown (read or mentioned) recently in the
295
+ * conversation. A mention of one of these emits a compact duplicate note
296
+ * instead of re-injecting the full body.
297
+ */
298
+ recentlyShownPaths?: ReadonlySet<string>;
299
+ },
281
300
  ): Promise<AgentMessage[]> {
282
301
  if (filePaths.length === 0) return [];
283
302
 
@@ -290,12 +309,25 @@ export async function generateFileMentionMessages(
290
309
  return mentionCandidatesPromise;
291
310
  };
292
311
 
312
+ const shownInBatch = new Set<string>();
293
313
  for (const filePath of filePaths) {
294
314
  const resolvedPath = await resolveMentionPath(filePath, cwd, getMentionCandidates);
295
315
  if (!resolvedPath) {
296
316
  continue;
297
317
  }
298
318
  const absolutePath = resolveReadPath(resolvedPath, cwd);
319
+ // Duplicate suppression, keyed by absolute path: same path shown earlier this
320
+ // batch or recently in the conversation → compact note, no re-read (the path
321
+ // stays referenced so the model can re-fetch via the read tool).
322
+ if (shownInBatch.has(absolutePath) || options?.recentlyShownPaths?.has(absolutePath)) {
323
+ files.push({
324
+ path: resolvedPath,
325
+ content: `(already shown in this conversation; not re-read — use the read tool to fetch \`${resolvedPath}\` again)`,
326
+ duplicate: true,
327
+ });
328
+ continue;
329
+ }
330
+ shownInBatch.add(absolutePath);
299
331
  try {
300
332
  const stat = await Bun.file(absolutePath).stat();
301
333
  if (stat.isDirectory()) {
@@ -354,7 +386,7 @@ export async function generateFileMentionMessages(
354
386
  }
355
387
 
356
388
  const content = await Bun.file(absolutePath).text();
357
- let { output, lineCount } = buildTextOutput(content);
389
+ let { output, lineCount } = buildTextOutput(content, options?.maxInlineBytes);
358
390
  if (options?.useHashLines) {
359
391
  output = formatHashLines(output);
360
392
  }
package/src/utils/git.ts CHANGED
@@ -1317,6 +1317,79 @@ export async function clean(
1317
1317
  await runEffect(cwd, args, { signal: options.signal });
1318
1318
  }
1319
1319
 
1320
+ // ════════════════════════════════════════════════════════════════════════════
1321
+ // API: bisect
1322
+ // ════════════════════════════════════════════════════════════════════════════
1323
+
1324
+ export interface BisectCommitInfo {
1325
+ sha: string;
1326
+ author: string;
1327
+ date: string;
1328
+ subject: string;
1329
+ stat: string;
1330
+ }
1331
+
1332
+ /**
1333
+ * Thin wrappers over `git bisect`. The marking commands (`good`/`bad`/`skip`)
1334
+ * return the raw {@link GitCommandResult} without throwing so callers can both
1335
+ * detect a genuine failure (non-zero exit) and scan the output for the
1336
+ * "<sha> is the first bad commit" conclusion. `reset` is best-effort and never
1337
+ * throws so it is safe to call from a `finally` block to restore the worktree.
1338
+ */
1339
+ export const bisect = {
1340
+ /** Begin a bisect session (does not seed good/bad endpoints). */
1341
+ async start(cwd: string, signal?: AbortSignal): Promise<void> {
1342
+ await runEffect(cwd, ["bisect", "start"], { signal });
1343
+ },
1344
+
1345
+ /** Mark a revision (or current HEAD when omitted) good. Returns raw output. */
1346
+ async good(cwd: string, ref?: string, signal?: AbortSignal): Promise<GitCommandResult> {
1347
+ return runCommand(cwd, ref ? ["bisect", "good", ref] : ["bisect", "good"], { signal });
1348
+ },
1349
+
1350
+ /** Mark a revision (or current HEAD when omitted) bad. Returns raw output. */
1351
+ async bad(cwd: string, ref?: string, signal?: AbortSignal): Promise<GitCommandResult> {
1352
+ return runCommand(cwd, ref ? ["bisect", "bad", ref] : ["bisect", "bad"], { signal });
1353
+ },
1354
+
1355
+ /** Skip the current (untestable) revision. Returns raw output. */
1356
+ async skip(cwd: string, signal?: AbortSignal): Promise<GitCommandResult> {
1357
+ return runCommand(cwd, ["bisect", "skip"], { signal });
1358
+ },
1359
+
1360
+ /** Read the replayable bisect log for the current session. */
1361
+ async log(cwd: string, signal?: AbortSignal): Promise<string> {
1362
+ return runText(cwd, ["bisect", "log"], { signal });
1363
+ },
1364
+
1365
+ /** End the bisect session, restoring the original HEAD. Never throws. */
1366
+ async reset(cwd: string, signal?: AbortSignal): Promise<void> {
1367
+ await runCommand(cwd, ["bisect", "reset"], { signal });
1368
+ },
1369
+
1370
+ /** True when `ancestor` is a first-parent-or-merge ancestor of `descendant`. */
1371
+ async isAncestor(cwd: string, ancestor: string, descendant: string, signal?: AbortSignal): Promise<boolean> {
1372
+ const result = await runCommand(cwd, ["merge-base", "--is-ancestor", ancestor, descendant], {
1373
+ readOnly: true,
1374
+ signal,
1375
+ });
1376
+ return result.exitCode === 0;
1377
+ },
1378
+
1379
+ /** Resolve author/date/subject and a diffstat for a commit (for the receipt). */
1380
+ async describe(cwd: string, sha: string, signal?: AbortSignal): Promise<BisectCommitInfo> {
1381
+ // Newline-delimited so the format string carries no NUL byte (argv cannot
1382
+ // contain NUL); %s is already a single line, so the four fields never wrap.
1383
+ const meta = await runText(cwd, ["show", "-s", "--format=%H%n%an <%ae>%n%aI%n%s", sha], {
1384
+ readOnly: true,
1385
+ signal,
1386
+ });
1387
+ const [resolvedSha = sha, author = "", date = "", subject = ""] = meta.split("\n");
1388
+ const stat = await runText(cwd, ["show", "--stat", "--format=", sha], { readOnly: true, signal });
1389
+ return { sha: resolvedSha, author, date, subject, stat: stat.trim() };
1390
+ },
1391
+ };
1392
+
1320
1393
  // ════════════════════════════════════════════════════════════════════════════
1321
1394
  // API: ls
1322
1395
  // ════════════════════════════════════════════════════════════════════════════
@@ -10,44 +10,56 @@ import { extractWithParallel, findParallelApiKey, getParallelExtractContent } fr
10
10
  import type { RenderResult, SpecialHandler } from "./types";
11
11
  import { buildResult, formatMediaDuration, formatNumber } from "./types";
12
12
 
13
- interface YouTubeUrl {
13
+ export interface YouTubeUrl {
14
14
  videoId: string;
15
15
  playlistId?: string;
16
16
  }
17
17
 
18
+ const YOUTUBE_VIDEO_ID_PATTERN = /^[a-zA-Z0-9_-]{11}$/;
19
+
20
+ function isYouTubeHost(hostname: string): boolean {
21
+ return hostname === "youtube.com" || hostname === "m.youtube.com" || hostname === "music.youtube.com";
22
+ }
23
+
24
+ function isYouTubeEmbedHost(hostname: string): boolean {
25
+ return isYouTubeHost(hostname) || hostname === "youtube-nocookie.com";
26
+ }
27
+
18
28
  /**
19
29
  * Parse YouTube URL into components
20
30
  */
21
- function parseYouTubeUrl(url: string): YouTubeUrl | null {
31
+ export function parseYouTubeUrl(url: string): YouTubeUrl | null {
22
32
  try {
23
33
  const parsed = new URL(url);
24
34
  const hostname = parsed.hostname.replace(/^www\./, "");
25
35
 
26
- // youtube.com/watch?v=VIDEO_ID
27
- if ((hostname === "youtube.com" || hostname === "m.youtube.com") && parsed.pathname === "/watch") {
36
+ // youtube.com/watch?v=VIDEO_ID and music.youtube.com/watch?v=VIDEO_ID
37
+ if (isYouTubeHost(hostname) && parsed.pathname === "/watch") {
28
38
  const videoId = parsed.searchParams.get("v");
29
39
  const playlistId = parsed.searchParams.get("list") || undefined;
30
- if (videoId) return { videoId, playlistId };
40
+ if (videoId && YOUTUBE_VIDEO_ID_PATTERN.test(videoId)) return { videoId, playlistId };
31
41
  }
32
42
 
33
- // youtube.com/v/VIDEO_ID or youtube.com/embed/VIDEO_ID
34
- if (hostname === "youtube.com" || hostname === "m.youtube.com") {
35
- const match = parsed.pathname.match(/^\/(v|embed)\/([a-zA-Z0-9_-]{11})/);
43
+ // youtube.com/v/VIDEO_ID, youtube.com/embed/VIDEO_ID, youtube.com/live/VIDEO_ID,
44
+ // and youtube-nocookie.com/embed/VIDEO_ID. Require the id segment to end
45
+ // before matching so malformed overlong ids are not silently truncated.
46
+ if (isYouTubeEmbedHost(hostname)) {
47
+ const match = parsed.pathname.match(/^\/(v|embed|live)\/([a-zA-Z0-9_-]{11})(?:$|\/)/);
36
48
  if (match) return { videoId: match[2] };
37
49
  }
38
50
 
39
51
  // youtu.be/VIDEO_ID
40
52
  if (hostname === "youtu.be") {
41
53
  const videoId = parsed.pathname.slice(1).split("/")[0];
42
- if (videoId && /^[a-zA-Z0-9_-]{11}$/.test(videoId)) {
54
+ if (videoId && YOUTUBE_VIDEO_ID_PATTERN.test(videoId)) {
43
55
  return { videoId };
44
56
  }
45
57
  }
46
58
 
47
- // youtube.com/shorts/VIDEO_ID
48
- if (hostname === "youtube.com" && parsed.pathname.startsWith("/shorts/")) {
59
+ // youtube.com/shorts/VIDEO_ID and m.youtube.com/shorts/VIDEO_ID
60
+ if (isYouTubeHost(hostname) && parsed.pathname.startsWith("/shorts/")) {
49
61
  const videoId = parsed.pathname.replace("/shorts/", "").split("/")[0];
50
- if (videoId && /^[a-zA-Z0-9_-]{11}$/.test(videoId)) {
62
+ if (videoId && YOUTUBE_VIDEO_ID_PATTERN.test(videoId)) {
51
63
  return { videoId };
52
64
  }
53
65
  }