@oh-my-pi/pi-coding-agent 16.0.9 → 16.0.11

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 (110) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/dist/cli.js +3402 -3443
  3. package/dist/types/advisor/index.d.ts +1 -0
  4. package/dist/types/advisor/transcript-recorder.d.ts +52 -0
  5. package/dist/types/collab/host.d.ts +2 -2
  6. package/dist/types/collab/protocol.d.ts +4 -5
  7. package/dist/types/commit/agentic/agent.d.ts +1 -1
  8. package/dist/types/config/model-resolver.d.ts +11 -2
  9. package/dist/types/config/settings-schema.d.ts +12 -6
  10. package/dist/types/edit/file-snapshot-store.d.ts +1 -1
  11. package/dist/types/extensibility/extensions/types.d.ts +7 -0
  12. package/dist/types/modes/components/agent-hub.d.ts +6 -1
  13. package/dist/types/modes/components/agent-transcript-viewer.d.ts +39 -0
  14. package/dist/types/modes/components/chat-transcript-builder.d.ts +42 -0
  15. package/dist/types/modes/controllers/command-controller.d.ts +3 -2
  16. package/dist/types/modes/interactive-mode.d.ts +2 -1
  17. package/dist/types/modes/types.d.ts +2 -1
  18. package/dist/types/registry/agent-registry.d.ts +10 -3
  19. package/dist/types/session/agent-session.d.ts +13 -0
  20. package/dist/types/session/compact-modes.d.ts +60 -0
  21. package/dist/types/session/streaming-output.d.ts +0 -2
  22. package/dist/types/slash-commands/builtin-registry.d.ts +1 -1
  23. package/dist/types/slash-commands/helpers/collab-qrcode.d.ts +13 -0
  24. package/dist/types/tools/__tests__/json-tree.test.d.ts +1 -0
  25. package/dist/types/tools/index.d.ts +9 -1
  26. package/dist/types/utils/image-loading.d.ts +12 -0
  27. package/dist/types/utils/qrcode.d.ts +48 -0
  28. package/package.json +12 -12
  29. package/src/advisor/index.ts +1 -0
  30. package/src/advisor/transcript-recorder.ts +136 -0
  31. package/src/cli/args.ts +7 -1
  32. package/src/cli/stats-cli.ts +2 -11
  33. package/src/collab/host.ts +29 -17
  34. package/src/collab/protocol.ts +48 -15
  35. package/src/commit/agentic/agent.ts +2 -1
  36. package/src/commit/agentic/tools/git-file-diff.ts +2 -2
  37. package/src/commit/changelog/index.ts +1 -1
  38. package/src/commit/map-reduce/map-phase.ts +1 -1
  39. package/src/commit/map-reduce/utils.ts +1 -1
  40. package/src/config/config-file.ts +1 -1
  41. package/src/config/keybindings.ts +2 -2
  42. package/src/config/model-registry.ts +16 -4
  43. package/src/config/model-resolver.ts +193 -35
  44. package/src/config/settings-schema.ts +14 -7
  45. package/src/config/settings.ts +3 -9
  46. package/src/edit/file-snapshot-store.ts +1 -1
  47. package/src/edit/renderer.ts +7 -7
  48. package/src/eval/js/tool-bridge.ts +3 -2
  49. package/src/eval/py/prelude.py +3 -2
  50. package/src/export/html/tool-views.generated.js +28 -28
  51. package/src/extensibility/extensions/types.ts +7 -0
  52. package/src/hindsight/mental-models.ts +1 -1
  53. package/src/internal-urls/docs-index.generated.txt +1 -1
  54. package/src/internal-urls/history-protocol.ts +8 -3
  55. package/src/irc/bus.ts +8 -0
  56. package/src/lsp/index.ts +2 -2
  57. package/src/main.ts +6 -3
  58. package/src/modes/acp/acp-agent.ts +63 -0
  59. package/src/modes/components/agent-hub.ts +97 -920
  60. package/src/modes/components/agent-transcript-viewer.ts +461 -0
  61. package/src/modes/components/chat-transcript-builder.ts +462 -0
  62. package/src/modes/components/diff.ts +12 -35
  63. package/src/modes/components/oauth-selector.ts +31 -2
  64. package/src/modes/controllers/command-controller.ts +12 -2
  65. package/src/modes/controllers/event-controller.ts +1 -1
  66. package/src/modes/controllers/input-controller.ts +8 -1
  67. package/src/modes/controllers/selector-controller.ts +4 -1
  68. package/src/modes/interactive-mode.ts +4 -2
  69. package/src/modes/types.ts +2 -1
  70. package/src/prompts/tools/inspect-image.md +1 -1
  71. package/src/prompts/tools/read.md +1 -1
  72. package/src/registry/agent-registry.ts +13 -4
  73. package/src/sdk.ts +27 -8
  74. package/src/session/agent-session.ts +185 -17
  75. package/src/session/compact-modes.ts +105 -0
  76. package/src/session/session-dump-format.ts +1 -1
  77. package/src/session/session-history-format.ts +1 -1
  78. package/src/session/streaming-output.ts +5 -5
  79. package/src/slash-commands/builtin-registry.ts +45 -15
  80. package/src/slash-commands/helpers/collab-qrcode.ts +28 -0
  81. package/src/task/executor.ts +1 -1
  82. package/src/task/output-manager.ts +5 -0
  83. package/src/thinking.ts +25 -5
  84. package/src/tools/__tests__/json-tree.test.ts +35 -0
  85. package/src/tools/approval.ts +1 -1
  86. package/src/tools/bash.ts +0 -1
  87. package/src/tools/browser.ts +0 -1
  88. package/src/tools/eval.ts +1 -1
  89. package/src/tools/gh.ts +1 -1
  90. package/src/tools/index.ts +10 -1
  91. package/src/tools/inspect-image.ts +72 -9
  92. package/src/tools/irc.ts +1 -1
  93. package/src/tools/json-tree.ts +22 -5
  94. package/src/tools/read.ts +5 -6
  95. package/src/utils/file-mentions.ts +5 -2
  96. package/src/utils/image-loading.ts +58 -0
  97. package/src/utils/qrcode.ts +535 -0
  98. package/src/web/scrapers/firefox-addons.ts +1 -1
  99. package/src/web/scrapers/github.ts +1 -1
  100. package/src/web/scrapers/go-pkg.ts +2 -2
  101. package/src/web/scrapers/metacpan.ts +2 -2
  102. package/src/web/scrapers/nvd.ts +2 -2
  103. package/src/web/scrapers/ollama.ts +1 -1
  104. package/src/web/scrapers/opencorporates.ts +1 -1
  105. package/src/web/scrapers/pub-dev.ts +1 -1
  106. package/src/web/scrapers/repology.ts +1 -1
  107. package/src/web/scrapers/sourcegraph.ts +1 -1
  108. package/src/web/scrapers/terraform.ts +6 -6
  109. package/src/web/scrapers/wikidata.ts +2 -2
  110. package/src/workspace-tree.ts +1 -1
@@ -3,7 +3,7 @@ import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth";
5
5
  import { setNextRequestDebugPath } from "@oh-my-pi/pi-ai/utils/request-debug";
6
- import type { AutocompleteItem } from "@oh-my-pi/pi-tui";
6
+ import { type AutocompleteItem, Spacer } from "@oh-my-pi/pi-tui";
7
7
  import { APP_NAME, setProjectDir } from "@oh-my-pi/pi-utils";
8
8
  import { COLLAB_GUEST_ALLOWED_COMMANDS, CollabGuestLink } from "../collab/guest";
9
9
  import { CollabHost } from "../collab/host";
@@ -27,9 +27,11 @@ import { resolveMemoryBackend } from "../memory-backend";
27
27
  import { theme } from "../modes/theme/theme";
28
28
  import type { InteractiveModeContext } from "../modes/types";
29
29
  import type { AgentSession, FreshSessionResult } from "../session/agent-session";
30
+ import { COMPACT_MODES, parseCompactArgs } from "../session/compact-modes";
30
31
  import { formatShakeSummary, type ShakeMode } from "../session/shake-types";
31
32
  import { urlHyperlinkAlways } from "../tui";
32
33
  import { getChangelogPath, parseChangelog } from "../utils/changelog";
34
+ import { CollabQrCodeComponent } from "./helpers/collab-qrcode";
33
35
  import { buildContextReportText } from "./helpers/context-report";
34
36
  import { formatDuration } from "./helpers/format";
35
37
  import { createMarketplaceManager } from "./helpers/marketplace-manager";
@@ -99,6 +101,19 @@ function collabLinkHint(host: CollabHost, heading: string, view = false): string
99
101
  ].join("\n");
100
102
  }
101
103
 
104
+ function showCollabQrCode(ctx: InteractiveModeContext, webLink: string): void {
105
+ try {
106
+ ctx.present([new Spacer(1), new CollabQrCodeComponent(webLink)]);
107
+ } catch (err) {
108
+ ctx.showError(`Failed to render collab QR code: ${errorMessage(err)}`);
109
+ }
110
+ }
111
+
112
+ function showCollabLink(ctx: InteractiveModeContext, host: CollabHost, heading: string, view = false): void {
113
+ ctx.showStatus(collabLinkHint(host, heading, view), { dim: false });
114
+ showCollabQrCode(ctx, view ? host.webViewLink : host.webLink);
115
+ }
116
+
102
117
  function formatFreshSessionResult(result: FreshSessionResult): string {
103
118
  const stateLabel = result.closedProviderSessions === 1 ? "provider state" : "provider states";
104
119
  return `Fresh provider session started (${result.closedProviderSessions} ${stateLabel} pruned).`;
@@ -589,8 +604,8 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
589
604
  const ctx = runtime.ctx;
590
605
  ctx.editor.setText("");
591
606
  const args = command.args.trim();
592
- const [first = ""] = args.split(/\s+/, 1);
593
- if (first === "stop") {
607
+ const { verb, rest } = parseSubcommand(args);
608
+ if (verb === "stop") {
594
609
  if (!ctx.collabHost) {
595
610
  ctx.showStatus("Not hosting a collab session");
596
611
  return;
@@ -599,7 +614,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
599
614
  ctx.showStatus("Collab stopped");
600
615
  return;
601
616
  }
602
- if (first === "status") {
617
+ if (verb === "status") {
603
618
  if (ctx.collabHost) {
604
619
  const names = ctx.collabHost.participants.map(p =>
605
620
  p.role === "host" ? `${p.name} (host)` : p.readOnly ? `${p.name} (view-only)` : p.name,
@@ -620,15 +635,18 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
620
635
  ctx.showError("Already in a collab session as a guest (/leave first)");
621
636
  return;
622
637
  }
623
- const view = first === "view";
638
+ const knownStartVerb = verb === "start" || verb === "view";
639
+ const view = verb === "view";
624
640
  if (ctx.collabHost) {
625
- ctx.showStatus(
626
- collabLinkHint(ctx.collabHost, view ? "Read-only collab link" : "Collab session active", view),
627
- { dim: false },
641
+ showCollabLink(
642
+ ctx,
643
+ ctx.collabHost,
644
+ view ? "Read-only collab session active" : "Collab session active",
645
+ view,
628
646
  );
629
647
  return;
630
648
  }
631
- const explicitUrl = first === "start" || view ? args.slice(first.length).trim() : args;
649
+ const explicitUrl = knownStartVerb ? rest : args;
632
650
  const relayInput = explicitUrl || ctx.settings.get("collab.relayUrl") || "";
633
651
  if (!relayInput) {
634
652
  ctx.showError(
@@ -638,15 +656,16 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
638
656
  }
639
657
  // Scheme-less relay args default to wss (ws:// must be spelled out for localhost).
640
658
  const relayUrl = relayInput.includes("://") ? relayInput : `wss://${relayInput}`;
659
+ const webUrl = ctx.settings.get("collab.webUrl") || "";
641
660
  const host = new CollabHost(ctx);
642
661
  try {
643
- await host.start(relayUrl);
662
+ await host.start(relayUrl, webUrl);
644
663
  } catch (err) {
645
664
  ctx.showError(`Failed to start collab session: ${errorMessage(err)}`);
646
665
  return;
647
666
  }
648
667
  ctx.collabHost = host;
649
- ctx.showStatus(collabLinkHint(host, "Collab session started!", view), { dim: false });
668
+ showCollabLink(ctx, host, "Collab session started!", view);
650
669
  },
651
670
  },
652
671
  {
@@ -1239,13 +1258,20 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
1239
1258
  name: "compact",
1240
1259
  description: "Manually compact the session context",
1241
1260
  acpDescription: "Compact the conversation",
1242
- inlineHint: "[focus instructions]",
1261
+ subcommands: COMPACT_MODES.map(mode => ({
1262
+ name: mode.name,
1263
+ description: mode.description,
1264
+ usage: mode.rejectsFocus ? undefined : "[focus]",
1265
+ })),
1266
+ acpInputHint: `[${COMPACT_MODES.map(mode => mode.name).join("|")}] [focus]`,
1243
1267
  allowArgs: true,
1244
1268
  handle: async (command, runtime) => {
1269
+ const parsed = parseCompactArgs(command.args);
1270
+ if ("error" in parsed) return usage(parsed.error, runtime);
1245
1271
  const before = runtime.session.getContextUsage?.();
1246
1272
  const beforeTokens = before?.tokens;
1247
1273
  try {
1248
- await runtime.session.compact(command.args || undefined);
1274
+ await runtime.session.compact(parsed.instructions, parsed.mode ? { mode: parsed.mode } : undefined);
1249
1275
  } catch (err) {
1250
1276
  // Compaction precondition failures (no model, already compacted, too
1251
1277
  // small) and provider errors propagate as plain Errors; surface them
@@ -1263,9 +1289,13 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
1263
1289
  return commandConsumed();
1264
1290
  },
1265
1291
  handleTui: async (command, runtime) => {
1266
- const customInstructions = command.args || undefined;
1292
+ const parsed = parseCompactArgs(command.args);
1267
1293
  runtime.ctx.editor.setText("");
1268
- await runtime.ctx.handleCompactCommand(customInstructions);
1294
+ if ("error" in parsed) {
1295
+ runtime.ctx.showWarning(parsed.error);
1296
+ return;
1297
+ }
1298
+ await runtime.ctx.handleCompactCommand(parsed.instructions, parsed.mode);
1269
1299
  },
1270
1300
  },
1271
1301
  {
@@ -0,0 +1,28 @@
1
+ import { type Component, visibleWidth } from "@oh-my-pi/pi-tui";
2
+ import { fgOrPlain } from "../../modes/theme/theme";
3
+ import { QrCode, renderQrHalfBlocks } from "../../utils/qrcode";
4
+
5
+ /**
6
+ * One-shot transcript block that prints a collab browser-join URL as a
7
+ * scannable QR code. The symbol is encoded once at construction (byte mode,
8
+ * EC level M) and rendered as ANSI half-blocks; on terminals too narrow for
9
+ * the symbol it degrades to a one-line hint pointing at the printed URL.
10
+ */
11
+ export class CollabQrCodeComponent implements Component {
12
+ readonly #lines: readonly string[];
13
+ readonly #minWidth: number;
14
+
15
+ constructor(readonly url: string) {
16
+ const rows = renderQrHalfBlocks(QrCode.encodeText(url, "M"));
17
+ this.#lines = rows.map(row => ` ${row}`);
18
+ this.#minWidth = rows.reduce((max, row) => Math.max(max, visibleWidth(row)), 0) + 1;
19
+ }
20
+
21
+ render(width: number): readonly string[] {
22
+ if (width < this.#minWidth) {
23
+ const warning = `QR code hidden: terminal width ${width}; need ${this.#minWidth}. Use the browser URL above.`;
24
+ return [` ${fgOrPlain("warning", warning)}`];
25
+ }
26
+ return this.#lines;
27
+ }
28
+ }
@@ -196,7 +196,7 @@ function installSubagentRetryFallbackChain(args: {
196
196
  function renderIrcPeerRoster(selfId: string): string {
197
197
  const peers = AgentRegistry.global()
198
198
  .list()
199
- .filter(ref => ref.id !== selfId && ref.status !== "aborted");
199
+ .filter(ref => ref.id !== selfId && ref.status !== "aborted" && ref.kind !== "advisor");
200
200
  if (peers.length === 0) return "- (no other agents)";
201
201
  const lines = peers.map(
202
202
  peer =>
@@ -11,6 +11,7 @@
11
11
  * collisions across repeated or nested task invocations.
12
12
  */
13
13
  import * as fs from "node:fs/promises";
14
+ import { ADVISOR_TRANSCRIPT_STEM } from "../advisor/transcript-recorder";
14
15
 
15
16
  /**
16
17
  * Manages agent output ID allocation to ensure uniqueness.
@@ -29,6 +30,10 @@ export class AgentOutputManager {
29
30
  constructor(getArtifactsDir: () => string | null, options?: { parentPrefix?: string }) {
30
31
  this.#getArtifactsDir = getArtifactsDir;
31
32
  this.#parentPrefix = options?.parentPrefix;
33
+ // Reserve the advisor transcript stem: a subagent allocated this id would
34
+ // write `<id>.jsonl`, clobbering the advisor's `__advisor.jsonl` in the same
35
+ // artifacts dir. Reserving bumps such a request to `__advisor-2`.
36
+ this.#taken.add(ADVISOR_TRANSCRIPT_STEM);
32
37
  }
33
38
 
34
39
  /**
package/src/thinking.ts CHANGED
@@ -32,26 +32,45 @@ const THINKING_LEVEL_METADATA: Record<ThinkingLevel, ThinkingLevelMetadata> = {
32
32
  [ThinkingLevel.High]: { value: ThinkingLevel.High, label: "high", description: "Deep reasoning (~16k tokens)" },
33
33
  [ThinkingLevel.XHigh]: {
34
34
  value: ThinkingLevel.XHigh,
35
- label: "xhigh",
35
+ label: "max",
36
36
  description: "Maximum reasoning (~32k tokens)",
37
37
  },
38
38
  };
39
39
 
40
- const THINKING_LEVELS = new Set<string>([ThinkingLevel.Inherit, ThinkingLevel.Off, ...THINKING_EFFORTS]);
41
- const EFFORT_LEVELS = new Set<string>(THINKING_EFFORTS);
40
+ const EFFORT_BY_SELECTOR: Readonly<Record<string, Effort>> = {
41
+ [Effort.Minimal]: Effort.Minimal,
42
+ [Effort.Low]: Effort.Low,
43
+ [Effort.Medium]: Effort.Medium,
44
+ [Effort.High]: Effort.High,
45
+ [Effort.XHigh]: Effort.XHigh,
46
+ max: Effort.XHigh,
47
+ };
48
+ const THINKING_LEVEL_BY_SELECTOR: Readonly<Record<string, ThinkingLevel>> = {
49
+ [ThinkingLevel.Inherit]: ThinkingLevel.Inherit,
50
+ [ThinkingLevel.Off]: ThinkingLevel.Off,
51
+ [ThinkingLevel.Minimal]: ThinkingLevel.Minimal,
52
+ [ThinkingLevel.Low]: ThinkingLevel.Low,
53
+ [ThinkingLevel.Medium]: ThinkingLevel.Medium,
54
+ [ThinkingLevel.High]: ThinkingLevel.High,
55
+ [ThinkingLevel.XHigh]: ThinkingLevel.XHigh,
56
+ };
57
+
58
+ function getOwnSelector<T>(selectors: Readonly<Record<string, T>>, value: string | null | undefined): T | undefined {
59
+ return value === undefined || value === null || !Object.hasOwn(selectors, value) ? undefined : selectors[value];
60
+ }
42
61
 
43
62
  /**
44
63
  * Parses a provider-facing effort value.
45
64
  */
46
65
  export function parseEffort(value: string | null | undefined): Effort | undefined {
47
- return value !== undefined && value !== null && EFFORT_LEVELS.has(value) ? (value as Effort) : undefined;
66
+ return getOwnSelector(EFFORT_BY_SELECTOR, value);
48
67
  }
49
68
 
50
69
  /**
51
70
  * Parses an agent-local thinking selector.
52
71
  */
53
72
  export function parseThinkingLevel(value: string | null | undefined): ThinkingLevel | undefined {
54
- return value !== undefined && value !== null && THINKING_LEVELS.has(value) ? (value as ThinkingLevel) : undefined;
73
+ return getOwnSelector(THINKING_LEVEL_BY_SELECTOR, value);
55
74
  }
56
75
 
57
76
  /**
@@ -125,6 +144,7 @@ const AUTO_THINKING_METADATA: ConfiguredThinkingLevelMetadata = {
125
144
  */
126
145
  export function parseConfiguredThinkingLevel(value: string | null | undefined): ConfiguredThinkingLevel | undefined {
127
146
  if (value === AUTO_THINKING) return AUTO_THINKING;
147
+ if (value === "max") return ThinkingLevel.XHigh;
128
148
  return parseThinkingLevel(value);
129
149
  }
130
150
 
@@ -0,0 +1,35 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
3
+ import { formatArgsInline } from "../json-tree";
4
+
5
+ describe("formatArgsInline", () => {
6
+ test("a trailing scalar grows into the available width instead of a fixed cap", () => {
7
+ // Regression: the value used to be hard-capped at 24 columns, so a long
8
+ // note was truncated even when the card had plenty of room (issue: advise
9
+ // preview cut to `note="Your “stric…"`).
10
+ const note = "x".repeat(200);
11
+ const narrow = formatArgsInline({ severity: "concern", note }, 40);
12
+ const wide = formatArgsInline({ severity: "concern", note }, 120);
13
+ expect(Bun.stringWidth(wide)).toBeGreaterThan(Bun.stringWidth(narrow) + 40);
14
+ // Both stay within their budget.
15
+ expect(Bun.stringWidth(narrow)).toBeLessThanOrEqual(40);
16
+ expect(Bun.stringWidth(wide)).toBeLessThanOrEqual(120);
17
+ });
18
+
19
+ test("every key stays visible even when a leading value is long", () => {
20
+ const out = formatArgsInline({ path: "x".repeat(200), pattern: "needle", limit: 5 }, 80);
21
+ expect(out).toContain("path=");
22
+ expect(out).toContain("pattern=");
23
+ expect(out).toContain("limit=");
24
+ expect(Bun.stringWidth(out)).toBeLessThanOrEqual(80);
25
+ });
26
+
27
+ test("short values render fully without truncation markers", () => {
28
+ expect(formatArgsInline({ a: "x", b: 5, c: true }, 80)).toBe('a="x", b=5, c=true');
29
+ });
30
+
31
+ test("hidden meta keys are skipped", () => {
32
+ const out = formatArgsInline({ [INTENT_FIELD]: "noise", __partialJson: "{}", path: "src/foo.ts" }, 80);
33
+ expect(out).toBe('path="src/foo.ts"');
34
+ });
35
+ });
@@ -159,7 +159,7 @@ export function requiresApproval(
159
159
  export function truncateForPrompt(value: string, maxChars = DEFAULT_PROMPT_TRUNCATE_CHARS): string {
160
160
  if (value.length <= maxChars) return value;
161
161
  const omitted = value.length - maxChars;
162
- return `${value.slice(0, maxChars)}… (${omitted} chars truncated)`;
162
+ return `${value.slice(0, maxChars)}[…${omitted}ch elided…]`;
163
163
  }
164
164
 
165
165
  /**
package/src/tools/bash.ts CHANGED
@@ -485,7 +485,6 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
485
485
  // head-retention spill, minimizer miss) may emit more than
486
486
  // ~DEFAULT_MAX_BYTES inline. No-op for already-bounded output.
487
487
  const cappedOutputText = await enforceInlineByteCap(outputText, {
488
- label: "bash output",
489
488
  saveArtifact: full => saveBashOriginalArtifact(this.session, full),
490
489
  });
491
490
 
@@ -332,7 +332,6 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
332
332
  // text inline; the full text stays recoverable via the artifact footer
333
333
  // when allocation succeeds.
334
334
  const cappedText = await enforceInlineByteCap(textOnly, {
335
- label: "browser output",
336
335
  saveArtifact: full => saveBrowserOutputArtifact(this.session, full),
337
336
  });
338
337
  details.result = cappedText;
package/src/tools/eval.ts CHANGED
@@ -64,7 +64,7 @@ function formatDisplayJsonForText(value: unknown): string {
64
64
  text = String(value);
65
65
  }
66
66
  if (text.length > MAX_DISPLAY_TEXT_BYTES) {
67
- text = `${text.slice(0, MAX_DISPLAY_TEXT_BYTES)}\n… (${text.length - MAX_DISPLAY_TEXT_BYTES} chars truncated)`;
67
+ text = `${text.slice(0, MAX_DISPLAY_TEXT_BYTES)}\n[…${text.length - MAX_DISPLAY_TEXT_BYTES}ch elided…]`;
68
68
  }
69
69
  return text;
70
70
  }
package/src/tools/gh.ts CHANGED
@@ -2179,7 +2179,7 @@ function formatPrFiles(files: GhPrFile[] | undefined): string[] {
2179
2179
  }
2180
2180
 
2181
2181
  if (files.length > FILE_PREVIEW_LIMIT) {
2182
- lines.push(`- ... ${files.length - FILE_PREVIEW_LIMIT} more files`);
2182
+ lines.push(`[…${files.length - FILE_PREVIEW_LIMIT} files elided…]`);
2183
2183
  }
2184
2184
 
2185
2185
  return lines;
@@ -1,6 +1,6 @@
1
1
  import type { InMemorySnapshotStore } from "@oh-my-pi/hashline";
2
2
  import type { AgentTelemetryConfig, AgentTool } from "@oh-my-pi/pi-agent-core";
3
- import type { FetchImpl, Model, ToolChoice } from "@oh-my-pi/pi-ai";
3
+ import type { FetchImpl, ImageContent, Model, ToolChoice } from "@oh-my-pi/pi-ai";
4
4
  import { logger } from "@oh-my-pi/pi-utils";
5
5
  import type { AsyncJobManager } from "../async/job-manager";
6
6
  import type { Rule } from "../capability/rule";
@@ -113,6 +113,13 @@ export type ContextFileEntry = {
113
113
  depth?: number;
114
114
  };
115
115
 
116
+ /** Image attachment handle exposed to tools for user-facing labels such as `Image #1`. */
117
+ export type ImageAttachmentEntry = {
118
+ label: string;
119
+ uri: string;
120
+ image: ImageContent;
121
+ };
122
+
116
123
  export type {
117
124
  DiscoverableTool,
118
125
  DiscoverableToolSearchIndex,
@@ -353,6 +360,8 @@ export interface ToolSession {
353
360
  /** Get the active OpenTelemetry config so subagent dispatch can forward
354
361
  * the parent's tracer/hooks with the subagent's own identity stamped. */
355
362
  getTelemetry?: () => AgentTelemetryConfig | undefined;
363
+ /** Return image attachments visible to tools for resolving labels such as `Image #1`. */
364
+ getImageAttachments?: () => ImageAttachmentEntry[];
356
365
  }
357
366
 
358
367
  export type ToolFactory = (session: ToolSession) => Tool | null | Promise<Tool | null>;
@@ -1,6 +1,6 @@
1
1
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
2
2
  import { instrumentedCompleteSimple, resolveTelemetry } from "@oh-my-pi/pi-agent-core";
3
- import { type Api, completeSimple, type Model, type ToolExample } from "@oh-my-pi/pi-ai";
3
+ import { type Api, completeSimple, type ImageContent, type Model, type ToolExample } from "@oh-my-pi/pi-ai";
4
4
  import { prompt } from "@oh-my-pi/pi-utils";
5
5
  import { type } from "arktype";
6
6
  import { extractTextContent } from "../commit/utils";
@@ -11,6 +11,7 @@ import inspectImageSystemPromptTemplate from "../prompts/tools/inspect-image-sys
11
11
  import {
12
12
  ImageInputTooLargeError,
13
13
  type LoadedImageInput,
14
+ loadImageAttachmentInput,
14
15
  loadImageInput,
15
16
  MAX_IMAGE_INPUT_BYTES,
16
17
  webpExclusionForModel,
@@ -19,13 +20,62 @@ import type { ToolSession } from "./index";
19
20
  import { ToolError } from "./tool-errors";
20
21
 
21
22
  const inspectImageSchema = type({
22
- path: type("string").describe("image path"),
23
+ path: type("string").describe("image file path, Image #N label, or attachment://N URI"),
23
24
  question: type("string").describe("question about image"),
24
25
  "+": "reject",
25
26
  });
26
27
 
27
28
  export type InspectImageParams = typeof inspectImageSchema.infer;
28
29
 
30
+ interface ImageAttachmentReference {
31
+ index: number;
32
+ }
33
+
34
+ const IMAGE_ATTACHMENT_REFERENCE_REGEX =
35
+ /^\s*(?:\[?Image #([1-9]\d*)(?:,[^\]\n]*)?\]?|(?:attachment|image):\/\/([1-9]\d*))\s*$/i;
36
+
37
+ function parseImageAttachmentReference(path: string): ImageAttachmentReference | null {
38
+ const match = IMAGE_ATTACHMENT_REFERENCE_REGEX.exec(path);
39
+ if (!match) return null;
40
+ const rawIndex = match[1] ?? match[2];
41
+ if (!rawIndex) return null;
42
+ return { index: Number(rawIndex) };
43
+ }
44
+
45
+ function formatAvailableImageAttachments(attachments: readonly { label: string; uri: string }[]): string {
46
+ if (attachments.length === 0) return "none";
47
+ return attachments.map(attachment => `${attachment.label} -> ${attachment.uri}`).join(", ");
48
+ }
49
+
50
+ async function loadAttachmentReferenceInput(options: {
51
+ path: string;
52
+ reference: ImageAttachmentReference;
53
+ attachments: readonly { label: string; uri: string; image: ImageContent }[];
54
+ autoResize: boolean;
55
+ excludeWebP: boolean | undefined;
56
+ }): Promise<LoadedImageInput | null> {
57
+ const attachment = options.attachments[options.reference.index - 1];
58
+ if (!attachment) {
59
+ const available = formatAvailableImageAttachments(options.attachments);
60
+ if (options.attachments.length === 0) {
61
+ throw new ToolError(
62
+ `No image attachments are available in this turn. path="${options.path}" must be a readable file path or attachment URI.`,
63
+ );
64
+ }
65
+ throw new ToolError(
66
+ `Could not resolve image attachment '${options.path}'. Available image attachments: ${available}. Pass an attachment URI or a readable filesystem path.`,
67
+ );
68
+ }
69
+ return loadImageAttachmentInput({
70
+ image: attachment.image,
71
+ label: attachment.label,
72
+ uri: attachment.uri,
73
+ autoResize: options.autoResize,
74
+ maxBytes: MAX_IMAGE_INPUT_BYTES,
75
+ excludeWebP: options.excludeWebP,
76
+ });
77
+ }
78
+
29
79
  export interface InspectImageToolDetails {
30
80
  model: string;
31
81
  imagePath: string;
@@ -129,14 +179,27 @@ export class InspectImageTool implements AgentTool<typeof inspectImageSchema, In
129
179
  }
130
180
 
131
181
  let imageInput: LoadedImageInput | null;
182
+ const autoResize = this.session.settings.get("images.autoResize");
183
+ const excludeWebP = webpExclusionForModel(model);
184
+ const attachmentReference = parseImageAttachmentReference(params.path);
132
185
  try {
133
- imageInput = await loadImageInput({
134
- path: params.path,
135
- cwd: this.session.cwd,
136
- autoResize: this.session.settings.get("images.autoResize"),
137
- maxBytes: MAX_IMAGE_INPUT_BYTES,
138
- excludeWebP: webpExclusionForModel(model),
139
- });
186
+ if (attachmentReference) {
187
+ imageInput = await loadAttachmentReferenceInput({
188
+ path: params.path,
189
+ reference: attachmentReference,
190
+ attachments: this.session.getImageAttachments?.() ?? [],
191
+ autoResize,
192
+ excludeWebP,
193
+ });
194
+ } else {
195
+ imageInput = await loadImageInput({
196
+ path: params.path,
197
+ cwd: this.session.cwd,
198
+ autoResize,
199
+ maxBytes: MAX_IMAGE_INPUT_BYTES,
200
+ excludeWebP,
201
+ });
202
+ }
140
203
  } catch (error) {
141
204
  if (error instanceof ImageInputTooLargeError) {
142
205
  throw new ToolError(error.message);
package/src/tools/irc.ts CHANGED
@@ -182,7 +182,7 @@ export class IrcTool implements AgentTool<typeof ircSchema, IrcDetails> {
182
182
  const bus = IrcBus.global();
183
183
  const peers = registry
184
184
  .list()
185
- .filter(ref => ref.id !== senderId && ref.status !== "aborted")
185
+ .filter(ref => ref.id !== senderId && ref.status !== "aborted" && ref.kind !== "advisor")
186
186
  .map(ref => ({
187
187
  id: ref.id,
188
188
  displayName: ref.displayName,
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * JSON tree rendering utilities shared across tool renderers.
3
3
  */
4
- import { INTENT_FIELD } from "@oh-my-pi/pi-agent-core";
4
+ import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
5
5
  import type { Theme } from "../modes/theme/theme";
6
6
  import { truncateToWidth } from "./render-utils";
7
7
 
@@ -19,6 +19,8 @@ const ARGS_INLINE_PAIR_SEP = ", ";
19
19
  const ARGS_INLINE_PAIR_SEP_WIDTH = Bun.stringWidth(ARGS_INLINE_PAIR_SEP);
20
20
  const ARGS_INLINE_MORE = "…";
21
21
  const ARGS_INLINE_MORE_WIDTH = Bun.stringWidth(ARGS_INLINE_MORE);
22
+ /** Minimal value footprint (quotes + a couple chars) reserved for each not-yet-rendered key. */
23
+ const ARGS_INLINE_TAIL_VALUE_RESERVE = 4;
22
24
 
23
25
  function isRecord(value: unknown): value is Record<string, unknown> {
24
26
  return !!value && typeof value === "object" && !Array.isArray(value);
@@ -49,10 +51,15 @@ export function formatScalar(value: unknown, maxLen: number): string {
49
51
  * Format args inline for collapsed view.
50
52
  */
51
53
  export function formatArgsInline(args: Record<string, unknown>, maxWidth: number): string {
52
- let result = "";
53
- let width = 0;
54
+ const keys: string[] = [];
54
55
  for (const key in args) {
55
56
  if (key in HIDDEN_ARG_KEYS) continue;
57
+ keys.push(key);
58
+ }
59
+ let result = "";
60
+ let width = 0;
61
+ for (let i = 0; i < keys.length; i++) {
62
+ const key = keys[i];
56
63
  const value = args[key];
57
64
  const sep = width > 0 ? ARGS_INLINE_PAIR_SEP : "";
58
65
  const sepW = width > 0 ? ARGS_INLINE_PAIR_SEP_WIDTH : 0;
@@ -61,11 +68,21 @@ export function formatArgsInline(args: Record<string, unknown>, maxWidth: number
61
68
  if (cap <= 0) {
62
69
  return `${result}${ARGS_INLINE_MORE}`;
63
70
  }
64
- const valueMaxLen = Math.min(maxWidth - current, 24);
71
+ // Reserve each still-pending key's minimal footprint (sep + name + `=` +
72
+ // a short value) so a long value can't starve the keys that follow it.
73
+ let tailReserve = 0;
74
+ for (let j = i + 1; j < keys.length; j++) {
75
+ tailReserve += ARGS_INLINE_PAIR_SEP_WIDTH + Bun.stringWidth(keys[j]) + 1 + ARGS_INLINE_TAIL_VALUE_RESERVE;
76
+ }
77
+ // Budget the whole `key=value` piece against the width left after the
78
+ // tail reserve, then back out the value's share. The last key reserves
79
+ // nothing and fills the line.
80
+ const pieceBudget = Math.min(cap, maxWidth - current - tailReserve);
81
+ const valueMaxLen = Math.max(1, pieceBudget - Bun.stringWidth(key) - 3);
65
82
  const valueStr = formatScalar(value, valueMaxLen);
66
83
  const piece = `${key}=${valueStr}`;
67
84
  const pieceW = Bun.stringWidth(piece);
68
- if (pieceW > cap) {
85
+ if (pieceW > pieceBudget) {
69
86
  return `${result}${sep}${truncateToWidth(piece, cap)}`;
70
87
  }
71
88
  result += sep + piece;
package/src/tools/read.ts CHANGED
@@ -275,7 +275,7 @@ function formatMergedBraceLine(
275
275
  shouldAddHashLines: boolean,
276
276
  shouldAddLineNumbers: boolean,
277
277
  ): { model: string; display: string } {
278
- const merged = `${headText.trimEnd()} .. ${tailText.trim()}`;
278
+ const merged = `${headText.trimEnd()} ${tailText.trim()}`;
279
279
  if (shouldAddHashLines) {
280
280
  return { model: `${startLine}-${endLine}:${merged}`, display: merged };
281
281
  }
@@ -315,7 +315,7 @@ const FOOTER_RANGE_SAMPLES = 2;
315
315
 
316
316
  /**
317
317
  * Footer appended to summarized reads telling the model how to recover the
318
- * elided body. Without this hint, agents either ignore the `...`/`{ .. }`
318
+ * elided body. Without this hint, agents either ignore the `…`/`{ }`
319
319
  * markers or burn a turn guessing the right selector (see issue #1046). The
320
320
  * footer demonstrates the multi-range selector syntax with concrete sample
321
321
  * ranges drawn from the actual elision so the model re-reads only what it
@@ -327,7 +327,6 @@ function formatSummaryElisionFooter(
327
327
  elidedLines: number,
328
328
  ): string {
329
329
  if (elidedRanges.length === 0) return "";
330
- const lineWord = elidedLines === 1 ? "line" : "lines";
331
330
  const sampleCount = Math.min(elidedRanges.length, FOOTER_RANGE_SAMPLES);
332
331
  const selector = elidedRanges
333
332
  .slice(0, sampleCount)
@@ -335,7 +334,7 @@ function formatSummaryElisionFooter(
335
334
  .join(",");
336
335
  const example = `${readPath}:${selector}`;
337
336
  const tail = elidedRanges.length > sampleCount ? `, e.g. ${example}` : ` with ${example}`;
338
- return `[${elidedLines} ${lineWord} elided; re-read needed ranges${tail}]`;
337
+ return `[…${elidedLines}ln elided; re-read needed ranges${tail}]`;
339
338
  }
340
339
  const READ_CHUNK_SIZE = 8 * 1024;
341
340
 
@@ -1904,8 +1903,8 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1904
1903
  let elidedLines = 0;
1905
1904
  for (const unit of units) {
1906
1905
  if (unit.kind === "elided") {
1907
- modelParts.push("...");
1908
- displayParts.push("...");
1906
+ modelParts.push("");
1907
+ displayParts.push("");
1909
1908
  elidedRanges.push({ start: unit.startLine, end: unit.endLine });
1910
1909
  elidedLines += unit.endLine - unit.startLine + 1;
1911
1910
  continue;
@@ -24,7 +24,7 @@ import { resolveReadPath } from "../tools/path-utils";
24
24
  import { formatDimensionNote, resizeImage } from "./image-resize";
25
25
 
26
26
  /** Regex to match @filepath patterns in text */
27
- const FILE_MENTION_REGEX = /@([^\s@]+)/g;
27
+ const FILE_MENTION_REGEX = /@(?:"([^"]+)"|'([^']+)'|([^\s@]+))/g;
28
28
  const LEADING_PUNCTUATION_REGEX = /^[`"'([{<]+/;
29
29
  const TRAILING_PUNCTUATION_REGEX = /[)\]}>.,;:!?"'`]+$/;
30
30
  const MENTION_BOUNDARY_REGEX = /[\s([{<"'`]/;
@@ -168,7 +168,10 @@ export function extractFileMentions(text: string): string[] {
168
168
  const index = match.index ?? 0;
169
169
  if (!isMentionBoundary(text, index)) continue;
170
170
 
171
- const cleaned = sanitizeMentionPath(match[1]);
171
+ const rawPath = match[1] ?? match[2] ?? match[3];
172
+ if (!rawPath) continue;
173
+
174
+ const cleaned = match[1] !== undefined || match[2] !== undefined ? rawPath.trim() : sanitizeMentionPath(rawPath);
172
175
  if (!cleaned) continue;
173
176
 
174
177
  mentions.push(cleaned);