@oh-my-pi/pi-coding-agent 16.1.18 → 16.1.19

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 (31) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/dist/cli.js +15389 -15103
  3. package/dist/types/edit/hashline/filesystem.d.ts +1 -18
  4. package/dist/types/extensibility/plugins/legacy-pi-bundled-keys.d.ts +10 -0
  5. package/dist/types/extensibility/plugins/legacy-pi-bundled-registry.d.ts +4 -1
  6. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +12 -0
  7. package/dist/types/internal-urls/skill-protocol.d.ts +2 -2
  8. package/dist/types/internal-urls/types.d.ts +3 -0
  9. package/dist/types/modes/components/custom-editor.d.ts +0 -2
  10. package/dist/types/modes/controllers/input-controller.d.ts +0 -1
  11. package/dist/types/tools/path-utils.d.ts +3 -0
  12. package/package.json +12 -12
  13. package/scripts/build-binary.ts +20 -0
  14. package/scripts/generate-legacy-pi-bundled-registry.ts +404 -0
  15. package/src/edit/hashline/filesystem.ts +14 -0
  16. package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +987 -0
  17. package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +3330 -18
  18. package/src/extensibility/plugins/legacy-pi-compat.ts +29 -14
  19. package/src/internal-urls/local-protocol.ts +116 -9
  20. package/src/internal-urls/skill-protocol.ts +3 -3
  21. package/src/internal-urls/types.ts +3 -0
  22. package/src/modes/components/custom-editor.test.ts +7 -5
  23. package/src/modes/components/custom-editor.ts +6 -16
  24. package/src/modes/controllers/input-controller.ts +0 -71
  25. package/src/session/messages.ts +70 -47
  26. package/src/tools/ast-edit.ts +1 -0
  27. package/src/tools/ast-grep.ts +1 -0
  28. package/src/tools/find.ts +1 -0
  29. package/src/tools/path-utils.ts +4 -0
  30. package/src/tools/read.ts +43 -17
  31. package/src/tools/search.ts +4 -0
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import * as url from "node:url";
4
4
  import { isCompiledBinary } from "@oh-my-pi/pi-utils";
5
+ import { BUNDLED_PI_REGISTRY_KEYS } from "./legacy-pi-bundled-keys";
5
6
 
6
7
  const IS_COMPILED_BINARY = isCompiledBinary();
7
8
 
@@ -344,20 +345,34 @@ export function __validateLegacyPiPackageRootOverrides(
344
345
  );
345
346
  }
346
347
 
347
- const LEGACY_PI_PACKAGE_ROOT_OVERRIDES = __validateLegacyPiPackageRootOverrides({
348
- [`${CANONICAL_PI_SCOPE}/pi-ai`]: LEGACY_PI_AI_SHIM_PATH,
349
- [`${CANONICAL_PI_SCOPE}/pi-coding-agent`]: LEGACY_PI_CODING_AGENT_SHIM_PATH,
350
- ...(IS_COMPILED_BINARY
351
- ? {
352
- [`${CANONICAL_PI_SCOPE}/pi-agent-core`]: bundledRegistryVirtualSpecifier(
353
- `${CANONICAL_PI_SCOPE}/pi-agent-core`,
354
- ),
355
- [`${CANONICAL_PI_SCOPE}/pi-natives`]: bundledRegistryVirtualSpecifier(`${CANONICAL_PI_SCOPE}/pi-natives`),
356
- [`${CANONICAL_PI_SCOPE}/pi-tui`]: bundledRegistryVirtualSpecifier(`${CANONICAL_PI_SCOPE}/pi-tui`),
357
- [`${CANONICAL_PI_SCOPE}/pi-utils`]: bundledRegistryVirtualSpecifier(`${CANONICAL_PI_SCOPE}/pi-utils`),
358
- }
359
- : {}),
360
- });
348
+ /**
349
+ * Compute the override map keyed by every canonical specifier the host serves
350
+ * directly: the pi-ai / pi-coding-agent roots (compat shims that re-attach
351
+ * legacy helpers) plus, in compiled-binary mode, every other canonical pi-*
352
+ * package root AND every non-wildcard subpath registered in the bundled
353
+ * registry (see `legacy-pi-bundled-keys.ts`). Subpath coverage is what stops
354
+ * `@(scope)/pi-ai/oauth` and friends from falling through to the extension's
355
+ * own — possibly absent — peer install when bunfs filesystem walks fail
356
+ * (issue #3442 follow-up to #3423). Exported as a test seam so the
357
+ * compiled-binary branch is verifiable from dev tests.
358
+ */
359
+ export function __buildLegacyPiPackageRootOverrides(isCompiled: boolean): Record<string, string> {
360
+ const candidates: Record<string, string> = {
361
+ [`${CANONICAL_PI_SCOPE}/pi-ai`]: LEGACY_PI_AI_SHIM_PATH,
362
+ [`${CANONICAL_PI_SCOPE}/pi-coding-agent`]: LEGACY_PI_CODING_AGENT_SHIM_PATH,
363
+ };
364
+ if (isCompiled) {
365
+ for (const key of BUNDLED_PI_REGISTRY_KEYS) {
366
+ // Shim-bearing roots above already mapped to their compat surface;
367
+ // the bundled typebox shim has a dedicated TYPEBOX_SHIM_PATH route.
368
+ if (key in candidates || key === TYPEBOX_BUNDLED_REGISTRY_KEY) continue;
369
+ candidates[key] = bundledRegistryVirtualSpecifier(key);
370
+ }
371
+ }
372
+ return __validateLegacyPiPackageRootOverrides(candidates);
373
+ }
374
+
375
+ const LEGACY_PI_PACKAGE_ROOT_OVERRIDES = __buildLegacyPiPackageRootOverrides(IS_COMPILED_BINARY);
361
376
 
362
377
  let isLegacyPiSpecifierShimInstalled = false;
363
378
 
@@ -49,6 +49,121 @@ function getContentType(filePath: string): InternalResource["contentType"] {
49
49
  return "text/plain";
50
50
  }
51
51
 
52
+ const LOCAL_TEXT_SNIFF_BYTES = 8 * 1024;
53
+ const LOCAL_TEXT_RESOURCE_MAX_BYTES = 1024 * 1024;
54
+ const BINARY_FILE_EXTENSIONS = new Set([
55
+ ".7z",
56
+ ".avi",
57
+ ".bmp",
58
+ ".bz2",
59
+ ".db",
60
+ ".doc",
61
+ ".docx",
62
+ ".gif",
63
+ ".gz",
64
+ ".ico",
65
+ ".jpeg",
66
+ ".jpg",
67
+ ".m4v",
68
+ ".mkv",
69
+ ".mov",
70
+ ".mp4",
71
+ ".pdf",
72
+ ".png",
73
+ ".ppt",
74
+ ".pptx",
75
+ ".rar",
76
+ ".sqlite",
77
+ ".tgz",
78
+ ".webm",
79
+ ".webp",
80
+ ".wmv",
81
+ ".xls",
82
+ ".xlsx",
83
+ ".xz",
84
+ ".zip",
85
+ ]);
86
+
87
+ function formatLocalByteSize(bytes: number): string {
88
+ if (bytes < 1024) return `${bytes} B`;
89
+ const kib = bytes / 1024;
90
+ if (kib < 1024) return `${kib.toFixed(1)} KiB`;
91
+ const mib = kib / 1024;
92
+ if (mib < 1024) return `${mib.toFixed(1)} MiB`;
93
+ return `${(mib / 1024).toFixed(1)} GiB`;
94
+ }
95
+
96
+ function buildNonTextLocalResource(url: InternalUrl, filePath: string, size: number, reason: string): InternalResource {
97
+ const content = `[Cannot read binary local:// file '${url.href}' (${formatLocalByteSize(size)}): ${reason}. This resource is not text. Use a metadata/key-frame/video-specific workflow instead.]`;
98
+ return {
99
+ url: url.href,
100
+ content,
101
+ contentType: "text/plain",
102
+ size: Buffer.byteLength(content, "utf-8"),
103
+ sourcePath: filePath,
104
+ notes: [LOCAL_WRITE_NOTE],
105
+ };
106
+ }
107
+
108
+ function buildLargeLocalTextResource(url: InternalUrl, filePath: string, size: number): InternalResource {
109
+ const content = `[Cannot materialize local:// file '${url.href}' as an internal text resource (${formatLocalByteSize(size)} exceeds ${formatLocalByteSize(LOCAL_TEXT_RESOURCE_MAX_BYTES)}). Use the read tool's filesystem path handling or a line selector so content is streamed with file-size safeguards.]`;
110
+ return {
111
+ url: url.href,
112
+ content,
113
+ contentType: "text/plain",
114
+ size: Buffer.byteLength(content, "utf-8"),
115
+ sourcePath: filePath,
116
+ notes: [LOCAL_WRITE_NOTE],
117
+ };
118
+ }
119
+
120
+ async function readFilePrefix(filePath: string, maxBytes: number): Promise<Uint8Array> {
121
+ if (maxBytes <= 0) return new Uint8Array();
122
+ const handle = await fs.open(filePath, "r");
123
+ try {
124
+ const buffer = Buffer.allocUnsafe(maxBytes);
125
+ const { bytesRead } = await handle.read(buffer, 0, maxBytes, 0);
126
+ return buffer.subarray(0, bytesRead);
127
+ } finally {
128
+ await handle.close();
129
+ }
130
+ }
131
+
132
+ function isUtf8Text(bytes: Uint8Array): boolean {
133
+ if (bytes.indexOf(0) !== -1) return false;
134
+ try {
135
+ new TextDecoder("utf-8", { fatal: true }).decode(bytes);
136
+ return true;
137
+ } catch {
138
+ return false;
139
+ }
140
+ }
141
+
142
+ async function buildFileResource(
143
+ url: InternalUrl,
144
+ resolved: Extract<ResolvedLocalTarget, { kind: "file" }>,
145
+ ): Promise<InternalResource> {
146
+ if (BINARY_FILE_EXTENSIONS.has(path.extname(resolved.path).toLowerCase())) {
147
+ return buildNonTextLocalResource(url, resolved.path, resolved.size, "extension is a known binary/container type");
148
+ }
149
+ const sniffBytes = await readFilePrefix(resolved.path, Math.min(resolved.size, LOCAL_TEXT_SNIFF_BYTES));
150
+ if (!isUtf8Text(sniffBytes)) {
151
+ return buildNonTextLocalResource(url, resolved.path, resolved.size, "content is not valid UTF-8 text");
152
+ }
153
+ if (resolved.size > LOCAL_TEXT_RESOURCE_MAX_BYTES) {
154
+ return buildLargeLocalTextResource(url, resolved.path, resolved.size);
155
+ }
156
+ const content = await Bun.file(resolved.path).text();
157
+ return {
158
+ url: url.href,
159
+ content,
160
+ contentType: getContentType(resolved.path),
161
+ size: Buffer.byteLength(content, "utf-8"),
162
+ sourcePath: resolved.path,
163
+ notes: [LOCAL_WRITE_NOTE],
164
+ };
165
+ }
166
+
52
167
  async function listFilesRecursively(rootPath: string): Promise<string[]> {
53
168
  const pending = [""];
54
169
  const files: string[] = [];
@@ -336,15 +451,7 @@ export class LocalProtocolHandler implements ProtocolHandler {
336
451
  return buildDirectoryResource(url.href, resolved.path, [LOCAL_WRITE_NOTE]);
337
452
  }
338
453
 
339
- const content = await Bun.file(resolved.path).text();
340
- return {
341
- url: url.href,
342
- content,
343
- contentType: getContentType(resolved.path),
344
- size: Buffer.byteLength(content, "utf-8"),
345
- sourcePath: resolved.path,
346
- notes: [LOCAL_WRITE_NOTE],
347
- };
454
+ return buildFileResource(url, resolved);
348
455
  }
349
456
 
350
457
  async complete(_query?: string, context?: ResolveContext): Promise<UrlCompletion[]> {
@@ -13,7 +13,7 @@ import * as path from "node:path";
13
13
  import { isEnoent } from "@oh-my-pi/pi-utils";
14
14
  import { getActiveSkills } from "../extensibility/skills";
15
15
  import { buildDirectoryResource } from "./filesystem-resource";
16
- import type { InternalResource, InternalUrl, ProtocolHandler, UrlCompletion } from "./types";
16
+ import type { InternalResource, InternalUrl, ProtocolHandler, ResolveContext, UrlCompletion } from "./types";
17
17
 
18
18
  function getContentType(filePath: string): InternalResource["contentType"] {
19
19
  const ext = path.extname(filePath).toLowerCase();
@@ -42,8 +42,8 @@ export class SkillProtocolHandler implements ProtocolHandler {
42
42
  readonly scheme = "skill";
43
43
  readonly immutable = true;
44
44
 
45
- async resolve(url: InternalUrl): Promise<InternalResource> {
46
- const skills = getActiveSkills();
45
+ async resolve(url: InternalUrl, context?: ResolveContext): Promise<InternalResource> {
46
+ const skills = context?.skills ?? getActiveSkills();
47
47
 
48
48
  const skillName = url.rawHost || url.hostname;
49
49
  if (!skillName) {
@@ -5,6 +5,7 @@
5
5
  * providing access to agent outputs and server resources without exposing filesystem paths.
6
6
  */
7
7
 
8
+ import type { Skill } from "../extensibility/skills";
8
9
  import type { LocalProtocolOptions } from "./local-protocol";
9
10
 
10
11
  /**
@@ -90,6 +91,8 @@ export interface ResolveContext {
90
91
  * [#1608](https://github.com/can1357/oh-my-pi/issues/1608).
91
92
  */
92
93
  localProtocolOptions?: LocalProtocolOptions;
94
+ /** Calling session's loaded skills. Prefer this over process-global skill state. */
95
+ skills?: readonly Skill[];
93
96
  }
94
97
 
95
98
  /**
@@ -93,15 +93,17 @@ describe("CustomEditor bracketed path paste", () => {
93
93
  expect(extractBracketedImagePastePaths(bracketedPaste("/tmp/report.csv"))).toBeUndefined();
94
94
  });
95
95
 
96
- it("routes non-image path pastes through the file-path hook", async () => {
96
+ it("inserts non-image path pastes as literal text instead of attaching them", () => {
97
97
  const { editor } = makeEditor();
98
- const pasted = Promise.withResolvers<string>();
99
- editor.onPasteFilePath = path => pasted.resolve(path);
98
+ let imagePathCalls = 0;
99
+ editor.onPasteImagePath = () => {
100
+ imagePathCalls++;
101
+ };
100
102
 
101
103
  editor.handleInput(bracketedPaste("/tmp/report.csv"));
102
104
 
103
- expect(await pasted.promise).toBe("/tmp/report.csv");
104
- expect(editor.getText()).toBe("");
105
+ expect(editor.getText()).toBe("/tmp/report.csv");
106
+ expect(imagePathCalls).toBe(0);
105
107
  });
106
108
  });
107
109
 
@@ -318,8 +318,6 @@ export class CustomEditor extends Editor {
318
318
  onPasteImage?: () => Promise<boolean>;
319
319
  /** Called when a bracketed paste contains one or more image-file paths. */
320
320
  onPasteImagePath?: (path: string) => void | Promise<void>;
321
- /** Called when a bracketed paste contains one or more non-image file paths. */
322
- onPasteFilePath?: (path: string) => void | Promise<void>;
323
321
  /** Called when the configured raw text-paste shortcut is pressed. */
324
322
  onPasteTextRaw?: () => void;
325
323
  /** Called when the configured dequeue shortcut is pressed. */
@@ -505,20 +503,12 @@ export class CustomEditor extends Editor {
505
503
  return;
506
504
  }
507
505
 
508
- const pastedPaths = extractBracketedPastePaths(data);
509
- if (pastedPaths) {
510
- const canHandlePaths = pastedPaths.every(path =>
511
- isImagePath(path) ? this.onPasteImagePath !== undefined : this.onPasteFilePath !== undefined,
512
- );
513
- if (canHandlePaths) {
514
- void (async () => {
515
- for (const path of pastedPaths) {
516
- if (isImagePath(path)) await this.onPasteImagePath?.(path);
517
- else await this.onPasteFilePath?.(path);
518
- }
519
- })();
520
- return;
521
- }
506
+ const pastedImagePaths = extractBracketedImagePastePaths(data);
507
+ if (pastedImagePaths && this.onPasteImagePath) {
508
+ void (async () => {
509
+ for (const path of pastedImagePaths) await this.onPasteImagePath?.(path);
510
+ })();
511
+ return;
522
512
  }
523
513
 
524
514
  const parsedKey = parseKey(data);
@@ -1,6 +1,5 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
- import { fileURLToPath } from "node:url";
4
3
  import type { ImageContent } from "@oh-my-pi/pi-ai";
5
4
  import { type AutocompleteProvider, matchesKey, type SlashCommand } from "@oh-my-pi/pi-tui";
6
5
  import { $env, isEnoent, logger, sanitizeText } from "@oh-my-pi/pi-utils";
@@ -20,7 +19,6 @@ import { isTinyTitleLocalModelKey } from "../../tiny/models";
20
19
  import { isLowSignalTitleInput } from "../../tiny/text";
21
20
  import { tinyTitleClient } from "../../tiny/title-client";
22
21
  import type { TinyTitleProgressEvent } from "../../tiny/title-protocol";
23
- import { resolveReadPath } from "../../tools/path-utils";
24
22
  import { shortenPath, TRUNCATE_LENGTHS, truncateToWidth } from "../../tools/render-utils";
25
23
  import { copyToClipboard, readImageFromClipboard, readTextFromClipboard } from "../../utils/clipboard";
26
24
  import { EnhancedPasteController } from "../../utils/enhanced-paste";
@@ -104,20 +102,6 @@ function wrapPasteInAttachmentBlock(content: string): string {
104
102
  return `<attachment>\n${content}\n</attachment>`;
105
103
  }
106
104
 
107
- const FILE_URI_REGEX = /^file:\/\//i;
108
-
109
- function pastedFileAttachmentExtension(sourcePath: string): string {
110
- const ext = path.extname(sourcePath);
111
- const bareExt = ext.slice(1);
112
- if (!bareExt || bareExt.length > 32 || !/^[a-z0-9][a-z0-9._-]*$/i.test(bareExt)) return "";
113
- return ext;
114
- }
115
-
116
- function resolvePastedFilePath(filePath: string, cwd: string): string {
117
- if (FILE_URI_REGEX.test(filePath)) return fileURLToPath(filePath);
118
- return resolveReadPath(filePath, cwd);
119
- }
120
-
121
105
  const TINY_TITLE_PROGRESS_DONE_TTL_MS = 3_000;
122
106
  // A cached model fires its file-load events in a short burst and then goes silent
123
107
  // while onnxruntime builds the session; a genuine download keeps streaming progress
@@ -392,7 +376,6 @@ export class InputController {
392
376
  );
393
377
  this.ctx.editor.onPasteImage = () => this.handleImagePaste();
394
378
  this.ctx.editor.onPasteImagePath = path => this.handleImagePathPaste(path);
395
- this.ctx.editor.onPasteFilePath = path => this.handleFilePathPaste(path);
396
379
  this.ctx.editor.setActionKeys(
397
380
  "app.clipboard.pasteTextRaw",
398
381
  this.ctx.keybindings.getKeys("app.clipboard.pasteTextRaw"),
@@ -1261,37 +1244,6 @@ export class InputController {
1261
1244
  }
1262
1245
  }
1263
1246
 
1264
- async handleFilePathPaste(filePath: string): Promise<void> {
1265
- try {
1266
- const resolvedPath = resolvePastedFilePath(filePath, this.ctx.sessionManager.getCwd());
1267
- const stat = await Bun.file(resolvedPath).stat();
1268
- if (!stat.isFile()) {
1269
- this.ctx.editor.pasteText(filePath);
1270
- this.ctx.ui.requestRender();
1271
- this.ctx.showStatus("Pasted path is not a file");
1272
- return;
1273
- }
1274
-
1275
- const reference = await this.#attachExistingFileAsLocal(resolvedPath);
1276
- this.ctx.editor.insertText(`${reference} `);
1277
- this.ctx.ui.requestRender();
1278
- this.ctx.showStatus(`Attached file as ${reference}`);
1279
- } catch (error) {
1280
- if (isEnoent(error)) {
1281
- this.ctx.editor.pasteText(filePath);
1282
- this.ctx.ui.requestRender();
1283
- this.ctx.showStatus("Pasted file path was not found");
1284
- return;
1285
- }
1286
- logger.warn("failed to attach pasted file path", {
1287
- error: error instanceof Error ? error.message : String(error),
1288
- });
1289
- this.ctx.editor.pasteText(filePath);
1290
- this.ctx.ui.requestRender();
1291
- this.ctx.showError("Failed to attach pasted file path — pasted path inline instead");
1292
- }
1293
- }
1294
-
1295
1247
  async handleImagePathPaste(path: string): Promise<void> {
1296
1248
  try {
1297
1249
  const image = await loadImageInput({
@@ -1462,29 +1414,6 @@ export class InputController {
1462
1414
  this.ctx.ui.requestRender();
1463
1415
  }
1464
1416
 
1465
- async #attachExistingFileAsLocal(sourcePath: string): Promise<string> {
1466
- const localRoot = resolveLocalRoot({
1467
- getArtifactsDir: () => this.ctx.sessionManager.getArtifactsDir(),
1468
- getSessionId: () => this.ctx.sessionManager.getSessionId(),
1469
- });
1470
- await fs.mkdir(localRoot, { recursive: true });
1471
- const ext = pastedFileAttachmentExtension(sourcePath);
1472
- let name: string;
1473
- let filePath: string;
1474
- do {
1475
- this.#attachmentCounter++;
1476
- name = `attachment-${this.#attachmentCounter}${ext}`;
1477
- filePath = path.join(localRoot, name);
1478
- } while (await Bun.file(filePath).exists());
1479
-
1480
- try {
1481
- await fs.link(sourcePath, filePath);
1482
- } catch {
1483
- await fs.copyFile(sourcePath, filePath);
1484
- }
1485
- return `local://${name}`;
1486
- }
1487
-
1488
1417
  /**
1489
1418
  * Save a large paste to the session's `local://` store and insert a clean `local://attachment-N`
1490
1419
  * reference into the editor so the agent can `read` it on demand — instead of inlining the text or
@@ -482,65 +482,88 @@ export function sanitizeRehydratedOpenAIResponsesAssistantMessage(message: Assis
482
482
  * - Custom extensions and tools
483
483
  */
484
484
  export function convertToLlm(messages: AgentMessage[]): Message[] {
485
- return messages
486
- .map((m): Message | undefined => {
487
- switch (m.role) {
488
- case "bashExecution":
489
- if (m.excludeFromContext) {
490
- return undefined;
491
- }
492
- return {
485
+ return messages.flatMap((m): Message[] => {
486
+ switch (m.role) {
487
+ case "bashExecution":
488
+ if (m.excludeFromContext) {
489
+ return [];
490
+ }
491
+ return [
492
+ {
493
493
  role: "user",
494
494
  content: [{ type: "text", text: bashExecutionToText(m) }],
495
495
  attribution: "user",
496
496
  timestamp: m.timestamp,
497
- };
498
- case "pythonExecution":
499
- if (m.excludeFromContext) {
500
- return undefined;
501
- }
502
- return {
497
+ },
498
+ ];
499
+ case "pythonExecution":
500
+ if (m.excludeFromContext) {
501
+ return [];
502
+ }
503
+ return [
504
+ {
503
505
  role: "user",
504
506
  content: [{ type: "text", text: pythonExecutionToText(m) }],
505
507
  attribution: "user",
506
508
  timestamp: m.timestamp,
507
- };
508
- case "fileMention": {
509
- const fileContents = m.files
510
- .map(file => {
511
- const inner = file.content ? `\n${file.content}\n` : "\n";
512
- return `<file path="${file.path}">${inner}</file>`;
513
- })
514
- .join("\n");
515
- const content: (TextContent | ImageContent)[] = [{ type: "text" as const, text: fileContents }];
516
- for (const file of m.files) {
517
- if (file.image) {
518
- content.push(file.image);
519
- }
520
- }
521
- return {
509
+ },
510
+ ];
511
+ case "fileMention": {
512
+ // One `fileMention` can mix `@notes.md` (text) and `@screenshot.png` (image)
513
+ // in the same turn (`generateFileMentionMessages` packs every `@…` into a
514
+ // single message). Splitting by image presence keeps text-only mentions on
515
+ // the higher-priority `developer` slot while routing image attachments
516
+ // through `user`, the only Responses content slot that legitimately accepts
517
+ // `input_image` (Codex chatgpt.com /codex/responses rejects everything else
518
+ // with `Invalid value: 'input_image'`, #3443).
519
+ const wrap = (file: FileMentionMessage["files"][number]): string => {
520
+ const inner = file.content ? `\n${file.content}\n` : "\n";
521
+ return `<file path="${file.path}">${inner}</file>`;
522
+ };
523
+ const textFiles = m.files.filter(file => !file.image);
524
+ const imageFiles = m.files.filter(file => file.image);
525
+ const out: Message[] = [];
526
+ if (textFiles.length > 0) {
527
+ out.push({
522
528
  role: "developer",
529
+ content: [{ type: "text" as const, text: textFiles.map(wrap).join("\n") }],
530
+ attribution: "user",
531
+ timestamp: m.timestamp,
532
+ });
533
+ }
534
+ if (imageFiles.length > 0) {
535
+ const content: (TextContent | ImageContent)[] = [
536
+ { type: "text" as const, text: imageFiles.map(wrap).join("\n") },
537
+ ];
538
+ for (const file of imageFiles) {
539
+ if (file.image) content.push(file.image);
540
+ }
541
+ out.push({
542
+ role: "user",
523
543
  content,
524
544
  attribution: "user",
525
545
  timestamp: m.timestamp,
526
- };
546
+ });
527
547
  }
528
- case "custom":
529
- case "hookMessage":
530
- case "branchSummary":
531
- case "compactionSummary":
532
- case "user":
533
- case "developer":
534
- case "assistant":
535
- case "toolResult":
536
- // Core roles share one transformer with agent-core —
537
- // duplicating them here is how snapcompact frames once
538
- // silently fell off the provider request.
539
- return convertMessageToLlm(m);
540
- default:
541
- m satisfies never;
542
- return undefined;
548
+ return out;
543
549
  }
544
- })
545
- .filter(m => m !== undefined);
550
+ case "custom":
551
+ case "hookMessage":
552
+ case "branchSummary":
553
+ case "compactionSummary":
554
+ case "user":
555
+ case "developer":
556
+ case "assistant":
557
+ case "toolResult": {
558
+ // Core roles share one transformer with agent-core —
559
+ // duplicating them here is how snapcompact frames once
560
+ // silently fell off the provider request.
561
+ const converted = convertMessageToLlm(m);
562
+ return converted ? [converted] : [];
563
+ }
564
+ default:
565
+ m satisfies never;
566
+ return [];
567
+ }
568
+ });
546
569
  }
@@ -283,6 +283,7 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
283
283
  settings: this.session.settings,
284
284
  signal,
285
285
  localProtocolOptions: this.session.localProtocolOptions,
286
+ skills: this.session.skills,
286
287
  });
287
288
  const { searchPath: resolvedSearchPath, scopePath, isDirectory, multiTargets, globFilter } = scope;
288
289
 
@@ -185,6 +185,7 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
185
185
  settings: this.session.settings,
186
186
  signal,
187
187
  localProtocolOptions: this.session.localProtocolOptions,
188
+ skills: this.session.skills,
188
189
  });
189
190
  const { searchPath: resolvedSearchPath, scopePath, isDirectory, multiTargets, globFilter } = scope;
190
191
 
package/src/tools/find.ts CHANGED
@@ -166,6 +166,7 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
166
166
  settings: this.session.settings,
167
167
  signal,
168
168
  localProtocolOptions: this.session.localProtocolOptions,
169
+ skills: this.session.skills,
169
170
  });
170
171
  if (!resource.sourcePath) {
171
172
  throw new ToolError(`Cannot find internal URL without a backing file: ${rawPattern}`);
@@ -3,6 +3,7 @@ import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import * as url from "node:url";
5
5
  import { isEnoent } from "@oh-my-pi/pi-utils";
6
+ import type { Skill } from "../extensibility/skills";
6
7
  import { InternalUrlRouter, type LocalProtocolOptions } from "../internal-urls";
7
8
  import { ToolError } from "./tool-errors";
8
9
 
@@ -986,6 +987,8 @@ export interface ToolScopeOptions {
986
987
  signal?: AbortSignal;
987
988
  /** Calling session's `local://` root mapping — pins resolutions to the calling session. */
988
989
  localProtocolOptions?: LocalProtocolOptions;
990
+ /** Calling session's loaded skills — lets skill:// resolve without process-global state. */
991
+ skills?: readonly Skill[];
989
992
  }
990
993
 
991
994
  export interface ToolScopeResolution {
@@ -1042,6 +1045,7 @@ export async function resolveToolSearchScope(opts: ToolScopeOptions): Promise<To
1042
1045
  settings: opts.settings,
1043
1046
  signal: opts.signal,
1044
1047
  localProtocolOptions: opts.localProtocolOptions,
1048
+ skills: opts.skills,
1045
1049
  });
1046
1050
  if (!resource.sourcePath) {
1047
1051
  throw new ToolError(`Cannot ${internalUrlAction} internal URL without a backing file: ${rawPath}`);