@oh-my-pi/pi-coding-agent 15.5.6 → 15.5.8
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 +72 -0
- package/dist/types/cli/auth-gateway-cli.d.ts +8 -0
- package/dist/types/commands/auth-gateway.d.ts +3 -0
- package/dist/types/config/settings-schema.d.ts +60 -12
- package/dist/types/edit/file-snapshot-store.d.ts +9 -6
- package/dist/types/edit/hashline/diff.d.ts +4 -5
- package/dist/types/edit/streaming.d.ts +2 -1
- package/dist/types/eval/py/index.d.ts +1 -0
- package/dist/types/extensibility/custom-tools/types.d.ts +1 -1
- package/dist/types/extensibility/shared-events.d.ts +1 -1
- package/dist/types/internal-urls/index.d.ts +1 -0
- package/dist/types/internal-urls/vault-protocol.d.ts +93 -0
- package/dist/types/lib/xai-http.d.ts +40 -0
- package/dist/types/mcp/transports/http.d.ts +9 -0
- package/dist/types/modes/components/tool-execution.d.ts +2 -1
- package/dist/types/session/agent-session.d.ts +4 -1
- package/dist/types/tools/fetch.d.ts +16 -0
- package/dist/types/tools/image-gen.d.ts +6 -2
- package/dist/types/tools/index.d.ts +1 -0
- package/dist/types/tools/match-line-format.d.ts +2 -2
- package/dist/types/tools/plan-mode-guard.d.ts +5 -6
- package/dist/types/tools/render-utils.d.ts +3 -1
- package/dist/types/tools/tts.d.ts +18 -0
- package/dist/types/tools/write.d.ts +2 -0
- package/dist/types/utils/file-mentions.d.ts +2 -0
- package/package.json +8 -8
- package/src/cli/args.ts +2 -0
- package/src/cli/auth-broker-cli.ts +2 -1
- package/src/cli/auth-gateway-cli.ts +210 -9
- package/src/commands/auth-gateway.ts +7 -1
- package/src/config/model-registry.ts +41 -9
- package/src/config/settings-schema.ts +55 -13
- package/src/edit/file-snapshot-store.ts +9 -6
- package/src/edit/hashline/diff.ts +26 -13
- package/src/edit/hashline/execute.ts +13 -9
- package/src/edit/renderer.ts +9 -9
- package/src/edit/streaming.ts +4 -6
- package/src/eval/py/index.ts +1 -1
- package/src/extensibility/custom-tools/types.ts +1 -1
- package/src/extensibility/shared-events.ts +1 -1
- package/src/internal-urls/docs-index.generated.ts +7 -7
- package/src/internal-urls/index.ts +1 -0
- package/src/internal-urls/router.ts +2 -0
- package/src/internal-urls/vault-protocol.ts +936 -0
- package/src/lib/xai-http.ts +124 -0
- package/src/main.ts +1 -2
- package/src/mcp/transports/http.ts +29 -2
- package/src/modes/components/tool-execution.ts +6 -4
- package/src/modes/controllers/event-controller.ts +10 -3
- package/src/modes/controllers/selector-controller.ts +7 -2
- package/src/modes/interactive-mode.ts +11 -3
- package/src/modes/utils/ui-helpers.ts +2 -1
- package/src/prompts/system/system-prompt.md +3 -0
- package/src/prompts/tools/ast-edit.md +1 -1
- package/src/prompts/tools/ast-grep.md +1 -1
- package/src/prompts/tools/read.md +3 -3
- package/src/prompts/tools/search.md +1 -1
- package/src/sdk.ts +41 -10
- package/src/session/agent-session.ts +112 -14
- package/src/system-prompt.ts +2 -0
- package/src/tools/ast-edit.ts +10 -7
- package/src/tools/ast-grep.ts +12 -11
- package/src/tools/eval.ts +28 -3
- package/src/tools/fetch.ts +52 -24
- package/src/tools/image-gen.ts +205 -7
- package/src/tools/index.ts +1 -0
- package/src/tools/match-line-format.ts +2 -2
- package/src/tools/path-utils.ts +2 -0
- package/src/tools/plan-mode-guard.ts +20 -7
- package/src/tools/read.ts +70 -55
- package/src/tools/render-utils.ts +15 -0
- package/src/tools/search.ts +14 -14
- package/src/tools/tts.ts +133 -0
- package/src/tools/write.ts +61 -6
- package/src/utils/file-mentions.ts +11 -5
- package/src/web/search/providers/codex.ts +2 -1
package/src/tools/image-gen.ts
CHANGED
|
@@ -22,12 +22,14 @@ import * as z from "zod/v4";
|
|
|
22
22
|
import packageJson from "../../package.json" with { type: "json" };
|
|
23
23
|
import { isAuthenticated, type ModelRegistry } from "../config/model-registry";
|
|
24
24
|
import type { CustomTool } from "../extensibility/custom-tools/types";
|
|
25
|
+
import { ohMyPiXAIUserAgent, resolveXAIHttpCredentials } from "../lib/xai-http";
|
|
25
26
|
import imageGenDescription from "../prompts/tools/image-gen.md" with { type: "text" };
|
|
26
27
|
import { resolveReadPath } from "./path-utils";
|
|
27
28
|
|
|
28
29
|
const DEFAULT_MODEL = "gemini-3-pro-image-preview";
|
|
29
30
|
const DEFAULT_OPENROUTER_MODEL = "google/gemini-3-pro-image-preview";
|
|
30
31
|
const DEFAULT_ANTIGRAVITY_MODEL = "gemini-3-pro-image";
|
|
32
|
+
const DEFAULT_XAI_IMAGE_MODEL = "grok-imagine-image";
|
|
31
33
|
const IMAGE_TIMEOUT = 3 * 60 * 1000; // 3 minutes
|
|
32
34
|
const MAX_IMAGE_SIZE = 35 * 1024 * 1024;
|
|
33
35
|
const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1";
|
|
@@ -38,7 +40,9 @@ const ANTIGRAVITY_ENDPOINT = "https://daily-cloudcode-pa.sandbox.googleapis.com"
|
|
|
38
40
|
const IMAGE_SYSTEM_INSTRUCTION =
|
|
39
41
|
"You are an AI image generator. Generate images based on user descriptions. Focus on creating high-quality, visually appealing images that match the user's request.";
|
|
40
42
|
|
|
41
|
-
type ImageProvider = "antigravity" | "gemini" | "openai" | "openai-codex" | "openrouter";
|
|
43
|
+
export type ImageProvider = "antigravity" | "gemini" | "openai" | "openai-codex" | "openrouter" | "xai";
|
|
44
|
+
export type ImageProviderPreference = Exclude<ImageProvider, "openai-codex"> | "auto";
|
|
45
|
+
|
|
42
46
|
interface ImageApiKey {
|
|
43
47
|
provider: ImageProvider;
|
|
44
48
|
apiKey: string;
|
|
@@ -46,8 +50,13 @@ interface ImageApiKey {
|
|
|
46
50
|
model?: Model;
|
|
47
51
|
}
|
|
48
52
|
|
|
53
|
+
const COMMON_IMAGE_ASPECT_RATIOS = ["1:1", "3:4", "4:3", "9:16", "16:9"] as const;
|
|
54
|
+
const XAI_IMAGE_ASPECT_RATIOS = [...COMMON_IMAGE_ASPECT_RATIOS, "3:2", "2:3"] as const;
|
|
55
|
+
const COMMON_IMAGE_ASPECT_RATIO_SET = new Set<string>(COMMON_IMAGE_ASPECT_RATIOS);
|
|
56
|
+
const IMAGE_PROVIDER_PREFERENCES = new Set<string>(["auto", "antigravity", "gemini", "openai", "openrouter", "xai"]);
|
|
57
|
+
|
|
49
58
|
const responseModalitySchema = z.enum(["IMAGE", "TEXT"] as const);
|
|
50
|
-
const aspectRatioSchema = z.enum(
|
|
59
|
+
const aspectRatioSchema = z.enum(XAI_IMAGE_ASPECT_RATIOS).describe("aspect ratio");
|
|
51
60
|
const imageSizeSchema = z.enum(["1024x1024", "1536x1024", "1024x1536"] as const).describe("image size");
|
|
52
61
|
|
|
53
62
|
const inputImageSchema = z
|
|
@@ -274,6 +283,36 @@ interface AntigravityRequest {
|
|
|
274
283
|
requestId?: string;
|
|
275
284
|
}
|
|
276
285
|
|
|
286
|
+
interface XAIImageReference {
|
|
287
|
+
// OpenAI-compat discriminator. Every code example at
|
|
288
|
+
// docs.x.ai/developers/rest-api-reference/inference/images sends this
|
|
289
|
+
// alongside `url`; the schema text doesn't strictly require it, but
|
|
290
|
+
// matching the documented wire format avoids relying on schema-vs-example.
|
|
291
|
+
readonly type: "image_url";
|
|
292
|
+
readonly url: string;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
interface XAIImageRequestBase {
|
|
296
|
+
readonly model: string;
|
|
297
|
+
readonly prompt: string;
|
|
298
|
+
readonly aspect_ratio: string;
|
|
299
|
+
readonly resolution: "1k" | "2k";
|
|
300
|
+
readonly n: number;
|
|
301
|
+
readonly response_format: "b64_json" | "url";
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// xAI image request body. Three shapes:
|
|
305
|
+
// 1. text-only generation → POST /v1/images/generations
|
|
306
|
+
// 2. single-source edit (image field) → POST /v1/images/edits
|
|
307
|
+
// 3. multi-reference edit (images field) → POST /v1/images/edits
|
|
308
|
+
// `image` and `images` are mutually exclusive per docs.x.ai; the discriminated
|
|
309
|
+
// union enforces that statically. The runtime cap (XAI_MAX_EDIT_IMAGES) bounds
|
|
310
|
+
// the array length, which TypeScript cannot encode without lossy tuple unions.
|
|
311
|
+
type XAIImageRequestBody =
|
|
312
|
+
| (XAIImageRequestBase & { readonly image?: never; readonly images?: never })
|
|
313
|
+
| (XAIImageRequestBase & { readonly image: XAIImageReference; readonly images?: never })
|
|
314
|
+
| (XAIImageRequestBase & { readonly images: readonly XAIImageReference[]; readonly image?: never });
|
|
315
|
+
|
|
277
316
|
interface AntigravityResponseChunk {
|
|
278
317
|
response?: {
|
|
279
318
|
candidates?: Array<{
|
|
@@ -391,12 +430,24 @@ function extractOpenRouterImageUrls(message: OpenRouterMessage | undefined): str
|
|
|
391
430
|
}
|
|
392
431
|
|
|
393
432
|
/** Preferred provider set via settings (default: auto) */
|
|
394
|
-
let preferredImageProvider:
|
|
433
|
+
let preferredImageProvider: ImageProviderPreference = "auto";
|
|
434
|
+
|
|
435
|
+
export function isImageProviderPreference(value: unknown): value is ImageProviderPreference {
|
|
436
|
+
return typeof value === "string" && IMAGE_PROVIDER_PREFERENCES.has(value);
|
|
437
|
+
}
|
|
395
438
|
|
|
396
439
|
/** Set the preferred image provider from settings */
|
|
397
|
-
export function setPreferredImageProvider(provider:
|
|
440
|
+
export function setPreferredImageProvider(provider: ImageProviderPreference): void {
|
|
398
441
|
preferredImageProvider = provider;
|
|
399
442
|
}
|
|
443
|
+
function assertImageAspectRatioSupported(provider: ImageProvider, aspectRatio: ImageGenParams["aspect_ratio"]): void {
|
|
444
|
+
if (!aspectRatio || provider === "xai" || COMMON_IMAGE_ASPECT_RATIO_SET.has(aspectRatio)) {
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
throw new Error(
|
|
448
|
+
`Aspect ratio ${aspectRatio} is only supported by xAI image generation. Set providers.image to xai or use one of ${COMMON_IMAGE_ASPECT_RATIOS.join(", ")}.`,
|
|
449
|
+
);
|
|
450
|
+
}
|
|
400
451
|
|
|
401
452
|
interface ParsedAntigravityCredentials {
|
|
402
453
|
accessToken: string;
|
|
@@ -429,6 +480,17 @@ async function findAntigravityCredentials(modelRegistry: ModelRegistry): Promise
|
|
|
429
480
|
};
|
|
430
481
|
}
|
|
431
482
|
|
|
483
|
+
async function findXAIImageCredentials(modelRegistry?: ModelRegistry): Promise<ImageApiKey | null> {
|
|
484
|
+
if (modelRegistry) {
|
|
485
|
+
const creds = await resolveXAIHttpCredentials(modelRegistry);
|
|
486
|
+
if (creds) return { provider: "xai", apiKey: creds.apiKey };
|
|
487
|
+
return null;
|
|
488
|
+
}
|
|
489
|
+
const apiKey = $env.XAI_API_KEY;
|
|
490
|
+
if (apiKey) return { provider: "xai", apiKey };
|
|
491
|
+
return null;
|
|
492
|
+
}
|
|
493
|
+
|
|
432
494
|
async function findOpenAIHostedImageCredentials(
|
|
433
495
|
modelRegistry: ModelRegistry | undefined,
|
|
434
496
|
activeModel: Model | undefined,
|
|
@@ -468,9 +530,13 @@ async function findImageApiKey(
|
|
|
468
530
|
const openRouterKey = getEnvApiKey("openrouter");
|
|
469
531
|
if (openRouterKey) return { provider: "openrouter", apiKey: openRouterKey };
|
|
470
532
|
// Fall through to auto-detect if preferred provider key not found.
|
|
533
|
+
} else if (preferredImageProvider === "xai") {
|
|
534
|
+
const xai = await findXAIImageCredentials(modelRegistry);
|
|
535
|
+
if (xai) return xai;
|
|
536
|
+
// Fall through to auto-detect if preferred provider key not found.
|
|
471
537
|
}
|
|
472
538
|
|
|
473
|
-
// Auto-detect: GPT hosted image generation, then Antigravity, OpenRouter, Gemini.
|
|
539
|
+
// Auto-detect: GPT hosted image generation, then Antigravity, xAI, OpenRouter, Gemini.
|
|
474
540
|
const openAI = await findOpenAIHostedImageCredentials(modelRegistry, activeModel, sessionId);
|
|
475
541
|
if (openAI) return openAI;
|
|
476
542
|
|
|
@@ -479,6 +545,9 @@ async function findImageApiKey(
|
|
|
479
545
|
if (antigravity) return antigravity;
|
|
480
546
|
}
|
|
481
547
|
|
|
548
|
+
const xai = await findXAIImageCredentials(modelRegistry);
|
|
549
|
+
if (xai) return xai;
|
|
550
|
+
|
|
482
551
|
const openRouterKey = getEnvApiKey("openrouter");
|
|
483
552
|
if (openRouterKey) return { provider: "openrouter", apiKey: openRouterKey };
|
|
484
553
|
|
|
@@ -857,6 +926,31 @@ function buildAntigravityRequest(
|
|
|
857
926
|
};
|
|
858
927
|
}
|
|
859
928
|
|
|
929
|
+
// xAI image-edit cap per docs.x.ai (POST /v1/images/edits supports up to 3
|
|
930
|
+
// source images for multi-reference editing).
|
|
931
|
+
const XAI_MAX_EDIT_IMAGES = 3;
|
|
932
|
+
|
|
933
|
+
// Map the OpenAI-style pixel-size enum (image_size) to xAI's discrete tier.
|
|
934
|
+
// "1024x1024" → "1k"; anything wider (1536x... or ...x1536) → "2k". Absent
|
|
935
|
+
// image_size defaults to "1k", matching hermes-agent's DEFAULT_RESOLUTION
|
|
936
|
+
// (plugins/image_gen/xai/__init__.py:71).
|
|
937
|
+
function resolveXAIResolution(imageSize: string | undefined): "1k" | "2k" {
|
|
938
|
+
if (!imageSize || imageSize === "1024x1024") return "1k";
|
|
939
|
+
return "2k";
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// Build the discriminated edit body. Caller must ensure images.length is in
|
|
943
|
+
// [1, XAI_MAX_EDIT_IMAGES]; the bound check fires earlier in execute().
|
|
944
|
+
function buildXAIEditPayload(base: XAIImageRequestBase, images: readonly InlineImageData[]): XAIImageRequestBody {
|
|
945
|
+
const refs: readonly XAIImageReference[] = images.map(img => ({
|
|
946
|
+
type: "image_url",
|
|
947
|
+
url: toDataUrl(img),
|
|
948
|
+
}));
|
|
949
|
+
const [first, ...rest] = refs;
|
|
950
|
+
if (first === undefined) return base; // unreachable: caller checked images.length > 0
|
|
951
|
+
return rest.length === 0 ? { ...base, image: first } : { ...base, images: refs };
|
|
952
|
+
}
|
|
953
|
+
|
|
860
954
|
interface AntigravitySseResult {
|
|
861
955
|
images: InlineImageData[];
|
|
862
956
|
text: string[];
|
|
@@ -910,7 +1004,7 @@ export const imageGenTool: CustomTool<typeof imageGenSchema, ImageGenToolDetails
|
|
|
910
1004
|
const apiKey = await findImageApiKey(ctx.modelRegistry, ctx.model, sessionId);
|
|
911
1005
|
if (!apiKey) {
|
|
912
1006
|
throw new Error(
|
|
913
|
-
"No image API credentials found. Use a GPT Responses/Codex model with OpenAI credentials, login with google-antigravity, or set OPENROUTER_API_KEY, GEMINI_API_KEY, or GOOGLE_API_KEY.",
|
|
1007
|
+
"No image API credentials found. Use a GPT Responses/Codex model with OpenAI credentials, login with google-antigravity or xAI Grok OAuth, or set XAI_API_KEY, OPENROUTER_API_KEY, GEMINI_API_KEY, or GOOGLE_API_KEY.",
|
|
914
1008
|
);
|
|
915
1009
|
}
|
|
916
1010
|
|
|
@@ -922,8 +1016,11 @@ export const imageGenTool: CustomTool<typeof imageGenSchema, ImageGenToolDetails
|
|
|
922
1016
|
? DEFAULT_ANTIGRAVITY_MODEL
|
|
923
1017
|
: provider === "openrouter"
|
|
924
1018
|
? DEFAULT_OPENROUTER_MODEL
|
|
925
|
-
:
|
|
1019
|
+
: provider === "xai"
|
|
1020
|
+
? DEFAULT_XAI_IMAGE_MODEL
|
|
1021
|
+
: DEFAULT_MODEL;
|
|
926
1022
|
const resolvedModel = provider === "openrouter" ? resolveOpenRouterModel(model) : model;
|
|
1023
|
+
assertImageAspectRatioSupported(provider, params.aspect_ratio);
|
|
927
1024
|
const cwd = ctx.sessionManager.getCwd();
|
|
928
1025
|
|
|
929
1026
|
const resolvedImages: InlineImageData[] = [];
|
|
@@ -1059,6 +1156,107 @@ export const imageGenTool: CustomTool<typeof imageGenSchema, ImageGenToolDetails
|
|
|
1059
1156
|
};
|
|
1060
1157
|
}
|
|
1061
1158
|
|
|
1159
|
+
if (provider === "xai") {
|
|
1160
|
+
if (!ctx.modelRegistry) {
|
|
1161
|
+
throw new Error("Missing modelRegistry for xAI image generation");
|
|
1162
|
+
}
|
|
1163
|
+
const xaiCreds = await resolveXAIHttpCredentials(ctx.modelRegistry, resolvedModel);
|
|
1164
|
+
if (!xaiCreds) {
|
|
1165
|
+
throw new Error(
|
|
1166
|
+
"No xAI credentials. Run /login → xAI Grok OAuth (SuperGrok Subscription) or set XAI_API_KEY.",
|
|
1167
|
+
);
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
const prompt = assemblePrompt(params);
|
|
1171
|
+
const aspectRatio = params.aspect_ratio ?? "1:1";
|
|
1172
|
+
const xaiResolution = resolveXAIResolution(params.image_size);
|
|
1173
|
+
|
|
1174
|
+
const isEdit = resolvedImages.length > 0;
|
|
1175
|
+
if (isEdit && resolvedImages.length > XAI_MAX_EDIT_IMAGES) {
|
|
1176
|
+
throw new Error(
|
|
1177
|
+
`xAI image edits accept up to ${XAI_MAX_EDIT_IMAGES} reference images; got ${resolvedImages.length}.`,
|
|
1178
|
+
);
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
const xaiBaseBody: XAIImageRequestBase = {
|
|
1182
|
+
model: resolvedModel,
|
|
1183
|
+
prompt,
|
|
1184
|
+
aspect_ratio: aspectRatio,
|
|
1185
|
+
resolution: xaiResolution,
|
|
1186
|
+
n: 1,
|
|
1187
|
+
response_format: "b64_json",
|
|
1188
|
+
};
|
|
1189
|
+
const xaiBody: XAIImageRequestBody = isEdit
|
|
1190
|
+
? buildXAIEditPayload(xaiBaseBody, resolvedImages)
|
|
1191
|
+
: xaiBaseBody;
|
|
1192
|
+
const xaiEndpoint = isEdit ? "/images/edits" : "/images/generations";
|
|
1193
|
+
|
|
1194
|
+
const xaiResponse = await fetch(`${xaiCreds.baseURL}${xaiEndpoint}`, {
|
|
1195
|
+
method: "POST",
|
|
1196
|
+
headers: {
|
|
1197
|
+
Authorization: `Bearer ${xaiCreds.apiKey}`,
|
|
1198
|
+
"Content-Type": "application/json",
|
|
1199
|
+
"User-Agent": ohMyPiXAIUserAgent(),
|
|
1200
|
+
},
|
|
1201
|
+
body: JSON.stringify(xaiBody),
|
|
1202
|
+
signal: requestSignal,
|
|
1203
|
+
});
|
|
1204
|
+
|
|
1205
|
+
const xaiRawText = await xaiResponse.text();
|
|
1206
|
+
if (!xaiResponse.ok) {
|
|
1207
|
+
let message = xaiRawText;
|
|
1208
|
+
try {
|
|
1209
|
+
const parsedErr = JSON.parse(xaiRawText) as { error?: { message?: string } };
|
|
1210
|
+
message = parsedErr.error?.message ?? message;
|
|
1211
|
+
} catch {
|
|
1212
|
+
// Keep raw text.
|
|
1213
|
+
}
|
|
1214
|
+
throw new Error(`xAI image request failed (${xaiResponse.status}): ${message}`);
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
const xaiData = JSON.parse(xaiRawText) as {
|
|
1218
|
+
data?: Array<{ b64_json?: string; url?: string }>;
|
|
1219
|
+
};
|
|
1220
|
+
const xaiInlineImages: InlineImageData[] = [];
|
|
1221
|
+
for (const entry of xaiData.data ?? []) {
|
|
1222
|
+
if (entry.b64_json) {
|
|
1223
|
+
const bytes = Buffer.from(entry.b64_json, "base64");
|
|
1224
|
+
const mimeType = parseImageMetadata(bytes)?.mimeType ?? "image/png";
|
|
1225
|
+
xaiInlineImages.push({ data: entry.b64_json, mimeType });
|
|
1226
|
+
} else if (entry.url) {
|
|
1227
|
+
xaiInlineImages.push(await loadImageFromUrl(entry.url, requestSignal));
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
if (xaiInlineImages.length === 0) {
|
|
1232
|
+
return {
|
|
1233
|
+
content: [{ type: "text", text: "No image data returned." }],
|
|
1234
|
+
details: {
|
|
1235
|
+
provider,
|
|
1236
|
+
model: resolvedModel,
|
|
1237
|
+
imageCount: 0,
|
|
1238
|
+
imagePaths: [],
|
|
1239
|
+
images: [],
|
|
1240
|
+
},
|
|
1241
|
+
};
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
const xaiImagePaths = await saveImagesToTemp(xaiInlineImages);
|
|
1245
|
+
|
|
1246
|
+
return {
|
|
1247
|
+
content: [
|
|
1248
|
+
{ type: "text", text: buildResponseSummary(provider, resolvedModel, xaiImagePaths, undefined) },
|
|
1249
|
+
],
|
|
1250
|
+
details: {
|
|
1251
|
+
provider,
|
|
1252
|
+
model: resolvedModel,
|
|
1253
|
+
imageCount: xaiInlineImages.length,
|
|
1254
|
+
imagePaths: xaiImagePaths,
|
|
1255
|
+
images: xaiInlineImages,
|
|
1256
|
+
},
|
|
1257
|
+
};
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1062
1260
|
if (provider === "openrouter") {
|
|
1063
1261
|
const prompt = assemblePrompt(params);
|
|
1064
1262
|
const contentParts: OpenRouterContentPart[] = [{ type: "text", text: prompt }];
|
package/src/tools/index.ts
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Matched lines are prefixed with `*`; context lines are prefixed with a single
|
|
5
5
|
* space so line numbers align in column. In hashline mode the line uses the
|
|
6
|
-
* editable `LINE:content` shape under a
|
|
7
|
-
* the legacy `LINE|content` display-only shape. Line numbers are never padded.
|
|
6
|
+
* editable `LINE:content` shape under a snapshot-tag header; in plain mode it
|
|
7
|
+
* keeps the legacy `LINE|content` display-only shape. Line numbers are never padded.
|
|
8
8
|
*/
|
|
9
9
|
export function formatMatchLine(
|
|
10
10
|
lineNumber: number,
|
package/src/tools/path-utils.ts
CHANGED
|
@@ -28,6 +28,7 @@ const INTERNAL_SCHEMES_WITH_SELECTORS: Record<string, true> = {
|
|
|
28
28
|
pr: true,
|
|
29
29
|
rule: true,
|
|
30
30
|
skill: true,
|
|
31
|
+
vault: true,
|
|
31
32
|
};
|
|
32
33
|
// Schemes whose resource URIs are server-defined and may legitimately end
|
|
33
34
|
// with selector-shaped tails (e.g. `:raw`, `:conflicts`, `:1-50`, `/:raw`).
|
|
@@ -45,6 +46,7 @@ const TOP_LEVEL_INTERNAL_URL_PREFIXES = [
|
|
|
45
46
|
"rule://",
|
|
46
47
|
"local://",
|
|
47
48
|
"mcp://",
|
|
49
|
+
"vault://",
|
|
48
50
|
] as const;
|
|
49
51
|
|
|
50
52
|
function normalizeUnicodeSpaces(str: string): string {
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import * as path from "node:path";
|
|
2
|
-
import { resolveLocalUrlToPath } from "../internal-urls";
|
|
2
|
+
import { resolveLocalUrlToPath, resolveVaultUrlToPath } from "../internal-urls";
|
|
3
3
|
import type { ToolSession } from ".";
|
|
4
4
|
import { normalizeLocalScheme, resolveToCwd } from "./path-utils";
|
|
5
5
|
import { ToolError } from "./tool-errors";
|
|
6
6
|
|
|
7
|
+
const VAULT_SCHEME_PREFIX = "vault:";
|
|
7
8
|
const LOCAL_SCHEME_PREFIX = "local:";
|
|
9
|
+
const PLAN_ALIAS_FILE = "PLAN.md";
|
|
10
|
+
const LOCAL_PLAN_ALIAS = "local://PLAN.md";
|
|
8
11
|
|
|
9
12
|
function resolveRawPath(session: ToolSession, targetPath: string): string {
|
|
10
13
|
const normalized = normalizeLocalScheme(targetPath);
|
|
@@ -15,18 +18,27 @@ function resolveRawPath(session: ToolSession, targetPath: string): string {
|
|
|
15
18
|
});
|
|
16
19
|
}
|
|
17
20
|
|
|
21
|
+
if (normalized.startsWith(VAULT_SCHEME_PREFIX)) {
|
|
22
|
+
return resolveVaultUrlToPath(normalized);
|
|
23
|
+
}
|
|
24
|
+
|
|
18
25
|
return resolveToCwd(normalized, session.cwd);
|
|
19
26
|
}
|
|
20
27
|
|
|
28
|
+
function isPlanAliasTarget(session: ToolSession, targetPath: string, resolved: string): boolean {
|
|
29
|
+
const normalized = normalizeLocalScheme(targetPath);
|
|
30
|
+
if (normalized === LOCAL_PLAN_ALIAS) return true;
|
|
31
|
+
return resolved === resolveToCwd(PLAN_ALIAS_FILE, session.cwd);
|
|
32
|
+
}
|
|
33
|
+
|
|
21
34
|
/**
|
|
22
35
|
* Resolve a write/edit target to its absolute filesystem path.
|
|
23
36
|
*
|
|
24
|
-
* In plan mode, transparently redirects
|
|
25
|
-
* plan file's basename
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* file the plan-mode guard would otherwise reject.
|
|
37
|
+
* In plan mode, transparently redirects `PLAN.md` aliases and targets whose
|
|
38
|
+
* basename matches the plan file's basename to the canonical plan file
|
|
39
|
+
* location at `state.planFilePath`. This lets `write` and `edit` accept the
|
|
40
|
+
* habitual plan filename after approval even when the active artifact has a
|
|
41
|
+
* titled path such as `local://APPROVED.md`.
|
|
30
42
|
*
|
|
31
43
|
* Outside plan mode (or when the basename does not match) this is a no-op.
|
|
32
44
|
*/
|
|
@@ -38,6 +50,7 @@ export function resolvePlanPath(session: ToolSession, targetPath: string): strin
|
|
|
38
50
|
|
|
39
51
|
const planResolved = resolveRawPath(session, state.planFilePath);
|
|
40
52
|
if (resolved === planResolved) return resolved;
|
|
53
|
+
if (isPlanAliasTarget(session, targetPath, resolved)) return planResolved;
|
|
41
54
|
if (path.basename(resolved) !== path.basename(planResolved)) return resolved;
|
|
42
55
|
|
|
43
56
|
return planResolved;
|
package/src/tools/read.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
2
|
import * as fs from "node:fs/promises";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
-
import {
|
|
4
|
+
import { formatHashlineHeader, formatNumberedLine, formatNumberedLines } from "@oh-my-pi/hashline";
|
|
5
5
|
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
|
|
6
6
|
import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
|
|
7
7
|
import { glob, type SummaryResult, summarizeCode } from "@oh-my-pi/pi-natives";
|
|
@@ -118,39 +118,50 @@ function prependLineNumbers(text: string, startNum: number): string {
|
|
|
118
118
|
|
|
119
119
|
interface HashlineHeaderContext {
|
|
120
120
|
header: string;
|
|
121
|
-
|
|
122
|
-
fullText
|
|
121
|
+
tag: string;
|
|
122
|
+
fullText?: string;
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
function
|
|
125
|
+
function recordFullHashlineContext(
|
|
126
|
+
session: ToolSession,
|
|
127
|
+
absolutePath: string | undefined,
|
|
128
|
+
displayPath: string,
|
|
129
|
+
fullText: string,
|
|
130
|
+
): HashlineHeaderContext | undefined {
|
|
131
|
+
if (!absolutePath || !path.isAbsolute(absolutePath)) return undefined;
|
|
126
132
|
const normalized = normalizeToLF(fullText);
|
|
127
|
-
const
|
|
133
|
+
const tag = getFileSnapshotStore(session).recordContiguous(absolutePath, 1, normalized.split("\n"), {
|
|
134
|
+
fullText: normalized,
|
|
135
|
+
});
|
|
128
136
|
return {
|
|
129
|
-
header: formatHashlineHeader(displayPath,
|
|
130
|
-
|
|
137
|
+
header: formatHashlineHeader(displayPath, tag),
|
|
138
|
+
tag,
|
|
131
139
|
fullText: normalized,
|
|
132
140
|
};
|
|
133
141
|
}
|
|
134
142
|
|
|
135
|
-
async function readHashlineHeaderContext(
|
|
143
|
+
async function readHashlineHeaderContext(
|
|
144
|
+
session: ToolSession,
|
|
145
|
+
absolutePath: string,
|
|
146
|
+
cwd: string,
|
|
147
|
+
): Promise<HashlineHeaderContext> {
|
|
136
148
|
const fullText = await Bun.file(absolutePath).text();
|
|
137
|
-
|
|
149
|
+
const context = recordFullHashlineContext(
|
|
150
|
+
session,
|
|
151
|
+
absolutePath,
|
|
152
|
+
formatPathRelativeToCwd(absolutePath, cwd),
|
|
153
|
+
fullText,
|
|
154
|
+
);
|
|
155
|
+
if (!context) throw new ToolError(`Cannot record hashline snapshot for non-absolute path: ${absolutePath}`);
|
|
156
|
+
return context;
|
|
138
157
|
}
|
|
139
158
|
|
|
140
|
-
function
|
|
141
|
-
return
|
|
159
|
+
function hashlineHeaderContext(displayPath: string, tag: string): HashlineHeaderContext {
|
|
160
|
+
return { header: formatHashlineHeader(displayPath, tag), tag };
|
|
142
161
|
}
|
|
143
162
|
|
|
144
|
-
function
|
|
145
|
-
|
|
146
|
-
absolutePath: string | undefined,
|
|
147
|
-
context: HashlineHeaderContext | undefined,
|
|
148
|
-
): void {
|
|
149
|
-
if (!context || !absolutePath || !path.isAbsolute(absolutePath)) return;
|
|
150
|
-
getFileSnapshotStore(session).recordContiguous(absolutePath, 1, context.fullText.split("\n"), {
|
|
151
|
-
fullText: context.fullText,
|
|
152
|
-
fileHash: context.fileHash,
|
|
153
|
-
});
|
|
163
|
+
function prependHashlineHeader(text: string, context: HashlineHeaderContext | undefined): string {
|
|
164
|
+
return context ? `${context.header}\n${text}` : text;
|
|
154
165
|
}
|
|
155
166
|
|
|
156
167
|
function formatTextWithMode(
|
|
@@ -841,9 +852,13 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
841
852
|
const shouldAddLineNumbers = shouldAddHashLines ? false : displayMode.lineNumbers;
|
|
842
853
|
const hashContext =
|
|
843
854
|
shouldAddHashLines && options.sourcePath
|
|
844
|
-
?
|
|
855
|
+
? recordFullHashlineContext(
|
|
856
|
+
this.session,
|
|
857
|
+
options.sourcePath,
|
|
858
|
+
formatPathRelativeToCwd(options.sourcePath, this.session.cwd),
|
|
859
|
+
text,
|
|
860
|
+
)
|
|
845
861
|
: undefined;
|
|
846
|
-
recordHashlineSnapshot(this.session, options.sourcePath, hashContext);
|
|
847
862
|
let emittedHashlineHeader = false;
|
|
848
863
|
const formatText = (content: string, startNum: number): string => {
|
|
849
864
|
details.displayContent = { text: content, startLine: startNum };
|
|
@@ -934,9 +949,13 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
934
949
|
const shouldAddLineNumbers = shouldAddHashLines ? false : displayMode.lineNumbers;
|
|
935
950
|
const hashContext =
|
|
936
951
|
shouldAddHashLines && options.sourcePath
|
|
937
|
-
?
|
|
952
|
+
? recordFullHashlineContext(
|
|
953
|
+
this.session,
|
|
954
|
+
options.sourcePath,
|
|
955
|
+
formatPathRelativeToCwd(options.sourcePath, this.session.cwd),
|
|
956
|
+
text,
|
|
957
|
+
)
|
|
938
958
|
: undefined;
|
|
939
|
-
recordHashlineSnapshot(this.session, options.sourcePath, hashContext);
|
|
940
959
|
let emittedHashlineHeader = false;
|
|
941
960
|
|
|
942
961
|
const resultBuilder = toolResult(details);
|
|
@@ -1014,11 +1033,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1014
1033
|
|
|
1015
1034
|
const shouldAddHashLines = !rawSelector && displayMode.hashLines;
|
|
1016
1035
|
const shouldAddLineNumbers = rawSelector ? false : shouldAddHashLines ? false : displayMode.lineNumbers;
|
|
1017
|
-
const
|
|
1018
|
-
? await readHashlineHeaderContext(absolutePath, this.session.cwd)
|
|
1019
|
-
: undefined;
|
|
1020
|
-
recordHashlineSnapshot(this.session, absolutePath, hashContext);
|
|
1021
|
-
let emittedHashlineHeader = false;
|
|
1036
|
+
const sparseSnapshotEntries: Array<readonly [number, string]> = [];
|
|
1022
1037
|
const maxColumns = resolveOutputMaxColumns(this.session.settings);
|
|
1023
1038
|
|
|
1024
1039
|
const blocks: string[] = [];
|
|
@@ -1058,22 +1073,19 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1058
1073
|
}
|
|
1059
1074
|
}
|
|
1060
1075
|
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
absolutePath,
|
|
1064
|
-
range.startLine,
|
|
1065
|
-
collectedLines,
|
|
1066
|
-
hashContext ? { fullText: hashContext.fullText, fileHash: hashContext.fileHash } : {},
|
|
1067
|
-
);
|
|
1076
|
+
for (let index = 0; index < collectedLines.length; index++) {
|
|
1077
|
+
sparseSnapshotEntries.push([range.startLine + index, collectedLines[index]]);
|
|
1068
1078
|
}
|
|
1069
1079
|
|
|
1070
1080
|
const blockText = collectedLines.join("\n");
|
|
1071
|
-
|
|
1072
|
-
blocks.push(hashContext && !emittedHashlineHeader ? prependHashlineHeader(formatted, hashContext) : formatted);
|
|
1073
|
-
if (hashContext) emittedHashlineHeader = true;
|
|
1081
|
+
blocks.push(formatTextWithMode(blockText, range.startLine, shouldAddHashLines, shouldAddLineNumbers));
|
|
1074
1082
|
}
|
|
1075
1083
|
|
|
1076
1084
|
let outputText = blocks.join("\n\n…\n\n");
|
|
1085
|
+
if (shouldAddHashLines && sparseSnapshotEntries.length > 0 && outputText) {
|
|
1086
|
+
const tag = getFileSnapshotStore(this.session).recordSparse(absolutePath, sparseSnapshotEntries);
|
|
1087
|
+
outputText = `${formatHashlineHeader(formatPathRelativeToCwd(absolutePath, this.session.cwd), tag)}\n${outputText}`;
|
|
1088
|
+
}
|
|
1077
1089
|
if (notices.length > 0) {
|
|
1078
1090
|
outputText = outputText ? `${outputText}\n${notices.join("\n")}` : notices.join("\n");
|
|
1079
1091
|
}
|
|
@@ -1726,9 +1738,8 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1726
1738
|
renderedSummary.elidedLines,
|
|
1727
1739
|
);
|
|
1728
1740
|
const summaryHashContext = displayMode.hashLines
|
|
1729
|
-
? await readHashlineHeaderContext(absolutePath, this.session.cwd)
|
|
1741
|
+
? await readHashlineHeaderContext(this.session, absolutePath, this.session.cwd)
|
|
1730
1742
|
: undefined;
|
|
1731
|
-
recordHashlineSnapshot(this.session, absolutePath, summaryHashContext);
|
|
1732
1743
|
const bodyText = footer ? `${renderedSummary.text}\n\n${footer}` : renderedSummary.text;
|
|
1733
1744
|
const modelText = prependHashlineHeader(bodyText, summaryHashContext);
|
|
1734
1745
|
details = {
|
|
@@ -1875,17 +1886,19 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1875
1886
|
|
|
1876
1887
|
const shouldAddHashLines = !rawSelector && displayMode.hashLines;
|
|
1877
1888
|
const shouldAddLineNumbers = rawSelector ? false : shouldAddHashLines ? false : displayMode.lineNumbers;
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
+
let hashContext: HashlineHeaderContext | undefined;
|
|
1890
|
+
if (shouldAddHashLines && collectedLines.length > 0 && !firstLineExceedsLimit) {
|
|
1891
|
+
const store = getFileSnapshotStore(this.session);
|
|
1892
|
+
const tag =
|
|
1893
|
+
offset === undefined && limit === undefined && !wasTruncated && columnTruncated === 0
|
|
1894
|
+
? (() => {
|
|
1895
|
+
const normalized = normalizeToLF(selectedContent);
|
|
1896
|
+
return store.recordContiguous(absolutePath, 1, normalized.split("\n"), {
|
|
1897
|
+
fullText: normalized,
|
|
1898
|
+
});
|
|
1899
|
+
})()
|
|
1900
|
+
: store.recordContiguous(absolutePath, startLineDisplay, collectedLines);
|
|
1901
|
+
hashContext = hashlineHeaderContext(formatPathRelativeToCwd(absolutePath, this.session.cwd), tag);
|
|
1889
1902
|
}
|
|
1890
1903
|
|
|
1891
1904
|
let capturedDisplayContent: { text: string; startLine: number } | undefined;
|
|
@@ -2031,9 +2044,11 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
2031
2044
|
|
|
2032
2045
|
const rawText = region.lines.join("\n");
|
|
2033
2046
|
const hashContext = shouldAddHashLines
|
|
2034
|
-
?
|
|
2047
|
+
? hashlineHeaderContext(
|
|
2048
|
+
formatPathRelativeToCwd(entry.absolutePath, this.session.cwd),
|
|
2049
|
+
getFileSnapshotStore(this.session).recordContiguous(entry.absolutePath, region.startLine, region.lines),
|
|
2050
|
+
)
|
|
2035
2051
|
: undefined;
|
|
2036
|
-
recordHashlineSnapshot(this.session, entry.absolutePath, hashContext);
|
|
2037
2052
|
const formattedBody = formatTextWithMode(rawText, region.startLine, shouldAddHashLines, shouldAddLineNumbers);
|
|
2038
2053
|
const formattedText = prependHashlineHeader(formattedBody, hashContext);
|
|
2039
2054
|
|
|
@@ -469,7 +469,22 @@ export function truncateDiffByHunk(
|
|
|
469
469
|
diffText: string,
|
|
470
470
|
maxHunks: number,
|
|
471
471
|
maxLines: number,
|
|
472
|
+
options?: { fromTail?: boolean },
|
|
472
473
|
): { text: string; hiddenHunks: number; hiddenLines: number } {
|
|
474
|
+
if (options?.fromTail) {
|
|
475
|
+
// Streaming previews want to track the tail of the diff as new hunks
|
|
476
|
+
// arrive. Reversing the line buffer reuses the head-mode logic without
|
|
477
|
+
// duplicating the segment-budget bookkeeping: hunk runs survive
|
|
478
|
+
// reversal (a continuous `+`/`-` block stays contiguous) and so do the
|
|
479
|
+
// per-line `+`/`-` markers, so getDiffStats yields identical counts.
|
|
480
|
+
const reversed = (diffText ?? "").split("\n").reverse().join("\n");
|
|
481
|
+
const result = truncateDiffByHunk(reversed, maxHunks, maxLines);
|
|
482
|
+
return {
|
|
483
|
+
text: result.text.split("\n").reverse().join("\n"),
|
|
484
|
+
hiddenHunks: result.hiddenHunks,
|
|
485
|
+
hiddenLines: result.hiddenLines,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
473
488
|
const lines = diffText ? diffText.split("\n") : [];
|
|
474
489
|
const totalStats = getDiffStats(diffText);
|
|
475
490
|
|