@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.
Files changed (76) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/dist/types/cli/auth-gateway-cli.d.ts +8 -0
  3. package/dist/types/commands/auth-gateway.d.ts +3 -0
  4. package/dist/types/config/settings-schema.d.ts +60 -12
  5. package/dist/types/edit/file-snapshot-store.d.ts +9 -6
  6. package/dist/types/edit/hashline/diff.d.ts +4 -5
  7. package/dist/types/edit/streaming.d.ts +2 -1
  8. package/dist/types/eval/py/index.d.ts +1 -0
  9. package/dist/types/extensibility/custom-tools/types.d.ts +1 -1
  10. package/dist/types/extensibility/shared-events.d.ts +1 -1
  11. package/dist/types/internal-urls/index.d.ts +1 -0
  12. package/dist/types/internal-urls/vault-protocol.d.ts +93 -0
  13. package/dist/types/lib/xai-http.d.ts +40 -0
  14. package/dist/types/mcp/transports/http.d.ts +9 -0
  15. package/dist/types/modes/components/tool-execution.d.ts +2 -1
  16. package/dist/types/session/agent-session.d.ts +4 -1
  17. package/dist/types/tools/fetch.d.ts +16 -0
  18. package/dist/types/tools/image-gen.d.ts +6 -2
  19. package/dist/types/tools/index.d.ts +1 -0
  20. package/dist/types/tools/match-line-format.d.ts +2 -2
  21. package/dist/types/tools/plan-mode-guard.d.ts +5 -6
  22. package/dist/types/tools/render-utils.d.ts +3 -1
  23. package/dist/types/tools/tts.d.ts +18 -0
  24. package/dist/types/tools/write.d.ts +2 -0
  25. package/dist/types/utils/file-mentions.d.ts +2 -0
  26. package/package.json +8 -8
  27. package/src/cli/args.ts +2 -0
  28. package/src/cli/auth-broker-cli.ts +2 -1
  29. package/src/cli/auth-gateway-cli.ts +210 -9
  30. package/src/commands/auth-gateway.ts +7 -1
  31. package/src/config/model-registry.ts +41 -9
  32. package/src/config/settings-schema.ts +55 -13
  33. package/src/edit/file-snapshot-store.ts +9 -6
  34. package/src/edit/hashline/diff.ts +26 -13
  35. package/src/edit/hashline/execute.ts +13 -9
  36. package/src/edit/renderer.ts +9 -9
  37. package/src/edit/streaming.ts +4 -6
  38. package/src/eval/py/index.ts +1 -1
  39. package/src/extensibility/custom-tools/types.ts +1 -1
  40. package/src/extensibility/shared-events.ts +1 -1
  41. package/src/internal-urls/docs-index.generated.ts +7 -7
  42. package/src/internal-urls/index.ts +1 -0
  43. package/src/internal-urls/router.ts +2 -0
  44. package/src/internal-urls/vault-protocol.ts +936 -0
  45. package/src/lib/xai-http.ts +124 -0
  46. package/src/main.ts +1 -2
  47. package/src/mcp/transports/http.ts +29 -2
  48. package/src/modes/components/tool-execution.ts +6 -4
  49. package/src/modes/controllers/event-controller.ts +10 -3
  50. package/src/modes/controllers/selector-controller.ts +7 -2
  51. package/src/modes/interactive-mode.ts +11 -3
  52. package/src/modes/utils/ui-helpers.ts +2 -1
  53. package/src/prompts/system/system-prompt.md +3 -0
  54. package/src/prompts/tools/ast-edit.md +1 -1
  55. package/src/prompts/tools/ast-grep.md +1 -1
  56. package/src/prompts/tools/read.md +3 -3
  57. package/src/prompts/tools/search.md +1 -1
  58. package/src/sdk.ts +41 -10
  59. package/src/session/agent-session.ts +112 -14
  60. package/src/system-prompt.ts +2 -0
  61. package/src/tools/ast-edit.ts +10 -7
  62. package/src/tools/ast-grep.ts +12 -11
  63. package/src/tools/eval.ts +28 -3
  64. package/src/tools/fetch.ts +52 -24
  65. package/src/tools/image-gen.ts +205 -7
  66. package/src/tools/index.ts +1 -0
  67. package/src/tools/match-line-format.ts +2 -2
  68. package/src/tools/path-utils.ts +2 -0
  69. package/src/tools/plan-mode-guard.ts +20 -7
  70. package/src/tools/read.ts +70 -55
  71. package/src/tools/render-utils.ts +15 -0
  72. package/src/tools/search.ts +14 -14
  73. package/src/tools/tts.ts +133 -0
  74. package/src/tools/write.ts +61 -6
  75. package/src/utils/file-mentions.ts +11 -5
  76. package/src/web/search/providers/codex.ts +2 -1
@@ -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(["1:1", "3:4", "4:3", "9:16", "16:9"] as const).describe("aspect ratio");
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: ImageProvider | "auto" = "auto";
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: ImageProvider | "auto"): void {
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
- : DEFAULT_MODEL;
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 }];
@@ -92,6 +92,7 @@ export * from "./search";
92
92
  export * from "./search-tool-bm25";
93
93
  export * from "./ssh";
94
94
  export * from "./todo-write";
95
+ export * from "./tts";
95
96
  export * from "./write";
96
97
  export * from "./yield";
97
98
 
@@ -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 file-hash header; in plain mode it keeps
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,
@@ -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 targets whose basename matches the
25
- * plan file's basename (e.g. a bare `PLAN.md` or `./PLAN.md`) to the canonical
26
- * plan file location at `state.planFilePath`. This lets `write` and `edit`
27
- * accept the unqualified plan filename and have the change land at the
28
- * session-scoped `local://PLAN.md` artifact instead of a stray cwd-relative
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 { computeFileHash, formatHashlineHeader, formatNumberedLine, formatNumberedLines } from "@oh-my-pi/hashline";
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
- fileHash: string;
122
- fullText: string;
121
+ tag: string;
122
+ fullText?: string;
123
123
  }
124
124
 
125
- function buildHashlineHeaderContext(displayPath: string, fullText: string): HashlineHeaderContext {
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 fileHash = computeFileHash(normalized);
133
+ const tag = getFileSnapshotStore(session).recordContiguous(absolutePath, 1, normalized.split("\n"), {
134
+ fullText: normalized,
135
+ });
128
136
  return {
129
- header: formatHashlineHeader(displayPath, fileHash),
130
- fileHash,
137
+ header: formatHashlineHeader(displayPath, tag),
138
+ tag,
131
139
  fullText: normalized,
132
140
  };
133
141
  }
134
142
 
135
- async function readHashlineHeaderContext(absolutePath: string, cwd: string): Promise<HashlineHeaderContext> {
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
- return buildHashlineHeaderContext(formatPathRelativeToCwd(absolutePath, cwd), fullText);
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 prependHashlineHeader(text: string, context: HashlineHeaderContext | undefined): string {
141
- return context ? `${context.header}\n${text}` : text;
159
+ function hashlineHeaderContext(displayPath: string, tag: string): HashlineHeaderContext {
160
+ return { header: formatHashlineHeader(displayPath, tag), tag };
142
161
  }
143
162
 
144
- function recordHashlineSnapshot(
145
- session: ToolSession,
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
- ? buildHashlineHeaderContext(formatPathRelativeToCwd(options.sourcePath, this.session.cwd), text)
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
- ? buildHashlineHeaderContext(formatPathRelativeToCwd(options.sourcePath, this.session.cwd), text)
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 hashContext = shouldAddHashLines
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
- if (collectedLines.length > 0) {
1062
- getFileSnapshotStore(this.session).recordContiguous(
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
- const formatted = formatTextWithMode(blockText, range.startLine, shouldAddHashLines, shouldAddLineNumbers);
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
- const hashContext = shouldAddHashLines
1879
- ? await readHashlineHeaderContext(absolutePath, this.session.cwd)
1880
- : undefined;
1881
-
1882
- if (collectedLines.length > 0 && !firstLineExceedsLimit) {
1883
- getFileSnapshotStore(this.session).recordContiguous(
1884
- absolutePath,
1885
- startLineDisplay,
1886
- collectedLines,
1887
- hashContext ? { fullText: hashContext.fullText, fileHash: hashContext.fileHash } : {},
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
- ? await readHashlineHeaderContext(entry.absolutePath, this.session.cwd)
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