@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.
- package/CHANGELOG.md +58 -0
- package/dist/cli.js +3402 -3443
- package/dist/types/advisor/index.d.ts +1 -0
- package/dist/types/advisor/transcript-recorder.d.ts +52 -0
- package/dist/types/collab/host.d.ts +2 -2
- package/dist/types/collab/protocol.d.ts +4 -5
- package/dist/types/commit/agentic/agent.d.ts +1 -1
- package/dist/types/config/model-resolver.d.ts +11 -2
- package/dist/types/config/settings-schema.d.ts +12 -6
- package/dist/types/edit/file-snapshot-store.d.ts +1 -1
- package/dist/types/extensibility/extensions/types.d.ts +7 -0
- package/dist/types/modes/components/agent-hub.d.ts +6 -1
- package/dist/types/modes/components/agent-transcript-viewer.d.ts +39 -0
- package/dist/types/modes/components/chat-transcript-builder.d.ts +42 -0
- package/dist/types/modes/controllers/command-controller.d.ts +3 -2
- package/dist/types/modes/interactive-mode.d.ts +2 -1
- package/dist/types/modes/types.d.ts +2 -1
- package/dist/types/registry/agent-registry.d.ts +10 -3
- package/dist/types/session/agent-session.d.ts +13 -0
- package/dist/types/session/compact-modes.d.ts +60 -0
- package/dist/types/session/streaming-output.d.ts +0 -2
- package/dist/types/slash-commands/builtin-registry.d.ts +1 -1
- package/dist/types/slash-commands/helpers/collab-qrcode.d.ts +13 -0
- package/dist/types/tools/__tests__/json-tree.test.d.ts +1 -0
- package/dist/types/tools/index.d.ts +9 -1
- package/dist/types/utils/image-loading.d.ts +12 -0
- package/dist/types/utils/qrcode.d.ts +48 -0
- package/package.json +12 -12
- package/src/advisor/index.ts +1 -0
- package/src/advisor/transcript-recorder.ts +136 -0
- package/src/cli/args.ts +7 -1
- package/src/cli/stats-cli.ts +2 -11
- package/src/collab/host.ts +29 -17
- package/src/collab/protocol.ts +48 -15
- package/src/commit/agentic/agent.ts +2 -1
- package/src/commit/agentic/tools/git-file-diff.ts +2 -2
- package/src/commit/changelog/index.ts +1 -1
- package/src/commit/map-reduce/map-phase.ts +1 -1
- package/src/commit/map-reduce/utils.ts +1 -1
- package/src/config/config-file.ts +1 -1
- package/src/config/keybindings.ts +2 -2
- package/src/config/model-registry.ts +16 -4
- package/src/config/model-resolver.ts +193 -35
- package/src/config/settings-schema.ts +14 -7
- package/src/config/settings.ts +3 -9
- package/src/edit/file-snapshot-store.ts +1 -1
- package/src/edit/renderer.ts +7 -7
- package/src/eval/js/tool-bridge.ts +3 -2
- package/src/eval/py/prelude.py +3 -2
- package/src/export/html/tool-views.generated.js +28 -28
- package/src/extensibility/extensions/types.ts +7 -0
- package/src/hindsight/mental-models.ts +1 -1
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/internal-urls/history-protocol.ts +8 -3
- package/src/irc/bus.ts +8 -0
- package/src/lsp/index.ts +2 -2
- package/src/main.ts +6 -3
- package/src/modes/acp/acp-agent.ts +63 -0
- package/src/modes/components/agent-hub.ts +97 -920
- package/src/modes/components/agent-transcript-viewer.ts +461 -0
- package/src/modes/components/chat-transcript-builder.ts +462 -0
- package/src/modes/components/diff.ts +12 -35
- package/src/modes/components/oauth-selector.ts +31 -2
- package/src/modes/controllers/command-controller.ts +12 -2
- package/src/modes/controllers/event-controller.ts +1 -1
- package/src/modes/controllers/input-controller.ts +8 -1
- package/src/modes/controllers/selector-controller.ts +4 -1
- package/src/modes/interactive-mode.ts +4 -2
- package/src/modes/types.ts +2 -1
- package/src/prompts/tools/inspect-image.md +1 -1
- package/src/prompts/tools/read.md +1 -1
- package/src/registry/agent-registry.ts +13 -4
- package/src/sdk.ts +27 -8
- package/src/session/agent-session.ts +185 -17
- package/src/session/compact-modes.ts +105 -0
- package/src/session/session-dump-format.ts +1 -1
- package/src/session/session-history-format.ts +1 -1
- package/src/session/streaming-output.ts +5 -5
- package/src/slash-commands/builtin-registry.ts +45 -15
- package/src/slash-commands/helpers/collab-qrcode.ts +28 -0
- package/src/task/executor.ts +1 -1
- package/src/task/output-manager.ts +5 -0
- package/src/thinking.ts +25 -5
- package/src/tools/__tests__/json-tree.test.ts +35 -0
- package/src/tools/approval.ts +1 -1
- package/src/tools/bash.ts +0 -1
- package/src/tools/browser.ts +0 -1
- package/src/tools/eval.ts +1 -1
- package/src/tools/gh.ts +1 -1
- package/src/tools/index.ts +10 -1
- package/src/tools/inspect-image.ts +72 -9
- package/src/tools/irc.ts +1 -1
- package/src/tools/json-tree.ts +22 -5
- package/src/tools/read.ts +5 -6
- package/src/utils/file-mentions.ts +5 -2
- package/src/utils/image-loading.ts +58 -0
- package/src/utils/qrcode.ts +535 -0
- package/src/web/scrapers/firefox-addons.ts +1 -1
- package/src/web/scrapers/github.ts +1 -1
- package/src/web/scrapers/go-pkg.ts +2 -2
- package/src/web/scrapers/metacpan.ts +2 -2
- package/src/web/scrapers/nvd.ts +2 -2
- package/src/web/scrapers/ollama.ts +1 -1
- package/src/web/scrapers/opencorporates.ts +1 -1
- package/src/web/scrapers/pub-dev.ts +1 -1
- package/src/web/scrapers/repology.ts +1 -1
- package/src/web/scrapers/sourcegraph.ts +1 -1
- package/src/web/scrapers/terraform.ts +6 -6
- package/src/web/scrapers/wikidata.ts +2 -2
- 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
|
|
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
|
|
593
|
-
if (
|
|
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 (
|
|
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
|
|
638
|
+
const knownStartVerb = verb === "start" || verb === "view";
|
|
639
|
+
const view = verb === "view";
|
|
624
640
|
if (ctx.collabHost) {
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
1292
|
+
const parsed = parseCompactArgs(command.args);
|
|
1267
1293
|
runtime.ctx.editor.setText("");
|
|
1268
|
-
|
|
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
|
+
}
|
package/src/task/executor.ts
CHANGED
|
@@ -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: "
|
|
35
|
+
label: "max",
|
|
36
36
|
description: "Maximum reasoning (~32k tokens)",
|
|
37
37
|
},
|
|
38
38
|
};
|
|
39
39
|
|
|
40
|
-
const
|
|
41
|
-
|
|
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
|
|
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
|
|
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
|
+
});
|
package/src/tools/approval.ts
CHANGED
|
@@ -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)}
|
|
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
|
|
package/src/tools/browser.ts
CHANGED
|
@@ -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
|
|
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(
|
|
2182
|
+
lines.push(`[…${files.length - FILE_PREVIEW_LIMIT} files elided…]`);
|
|
2183
2183
|
}
|
|
2184
2184
|
|
|
2185
2185
|
return lines;
|
package/src/tools/index.ts
CHANGED
|
@@ -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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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,
|
package/src/tools/json-tree.ts
CHANGED
|
@@ -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-
|
|
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
|
-
|
|
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
|
-
|
|
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 >
|
|
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()}
|
|
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 `[
|
|
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
|
|
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);
|