Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 21 additions & 18 deletions extensions/paste.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,11 @@
* session_start + every mutation).
*/
import { existsSync, statSync } from "node:fs";
import { isAbsolute, resolve as resolvePath } from "node:path";
import { resolve as resolvePath } from "node:path";
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import type { ImageContent } from "@earendil-works/pi-ai";
import { isMultimodal } from "../lib/capability.ts";
import { loadImage } from "../lib/image.ts";
import { expandTilde, loadImage } from "../lib/image.ts";
import { renderMarkers, buildHintLine, buildDescriptionsBlock } from "../lib/marker.ts";
import { getSharedConfig, getSharedCache } from "../lib/state.ts";
import { delegateToVisionModel, type DelegateParams } from "../lib/delegate.ts";
Expand Down Expand Up @@ -66,12 +66,12 @@ export function findImagePathTokens(text: string): string[] {

/** Resolve a token against cwd and return the absolute path if it's a real
* image file, else undefined. Unescapes \ (escaped spaces from terminal
* drag-and-drop) before resolving. */
function resolveImageFile(token: string, cwd: string): string | undefined {
// Unescape \ → space (terminal drag-paste escaping)
const unescaped = token.replace(/\\ /g, " ");
const expanded = unescaped.startsWith("~/") ? resolvePath(cwd, unescaped) : unescaped;
const abs = isAbsolute(expanded) ? expanded : resolvePath(cwd, expanded);
* drag-and-drop) and expands a leading ~/ before resolving. Exported so the
* resolution rules are unit-testable without a pi runtime. */
export function resolveImageFile(token: string, cwd: string): string | undefined {
// Unescape \ → space (terminal drag-paste escaping). `resolvePath` returns an
// already-absolute path unchanged, so no isAbsolute branch is needed.
const abs = resolvePath(cwd, expandTilde(token.replace(/\\ /g, " ")));
if (!existsSync(abs)) return undefined;
try {
if (!statSync(abs).isFile()) return undefined;
Expand Down Expand Up @@ -250,9 +250,8 @@ async function updateComposePreview(ctx: ExtensionContext): Promise<void> {
// Resolve + load each token (compress: false — show original quality)
const previewImages: ReturnType<typeof makePreviewImage>[] = [];
for (const token of tokens) {
const unescaped = token.replace(/\\ /g, " ");
const abs = isAbsolute(unescaped) ? unescaped : resolvePath(ctx.cwd, unescaped);
if (!existsSync(abs)) continue;
const abs = resolveImageFile(token, ctx.cwd);
if (!abs) continue;
const result = await loadImage(abs, {
compress: false,
maxDimension: 1568,
Expand Down Expand Up @@ -361,9 +360,14 @@ export default function pasteExtension(_pi: ExtensionAPI): void {
return { action: "transform" as const, text };
}

// Everything the model is shown from here on names `abs`, never the raw
// token: `~/x.png` or `./x.png` is not actionable without $HOME or the
// cwd, and the model would have to shell out to resolve it first.
const hintPaths = loaded.map((l) => l.abs);

if (mode === "hint") {
// Markers + hint line nudging the model to call describe_image.
text = `${text}\n${buildHintLine(loaded.map((l, i) => ({ token: l.token, index: resolved.get(l.token)?.index ?? i })))}`;
text = `${text}\n${buildHintLine(hintPaths)}`;
return { action: "transform" as const, text };
}

Expand All @@ -374,21 +378,20 @@ export default function pasteExtension(_pi: ExtensionAPI): void {
// failure (all-fail → hint; per-image fail → that image gets no description).
const cache = getSharedCache();
const visionModel = config.provider && config.model ? `${config.provider}/${config.model}` : "(unconfigured)";
const hintImages = loaded.map((l, i) => ({ token: l.token, index: resolved.get(l.token)?.index ?? i }));

// ── Local-only short-circuit (SPEC-5 §3.2) ────────────────────────────
// If local-only mode is on, every delegation would be refused (cache miss)
// or cache-only. Skip the batch entirely — don't burn autoDelegateTimeoutMs
// waiting for refused calls to abort. Fall straight to hint so the model
// can still call describe_image for cache hits (which local-only allows).
if (config.localOnly) {
text = `${text}\n${buildHintLine(hintImages)}`;
text = `${text}\n${buildHintLine(hintPaths)}`;
return { action: "transform" as const, text };
}

if (!cache || !config.provider || !config.model) {
// Can't delegate (no cache or unconfigured) → fall back to hint.
text = `${text}\n${buildHintLine(hintImages)}`;
text = `${text}\n${buildHintLine(hintPaths)}`;
return { action: "transform" as const, text };
}

Expand All @@ -405,20 +408,20 @@ export default function pasteExtension(_pi: ExtensionAPI): void {
clearTimeout(timer);
}

const descriptions: Array<{ token: string; index: number; text: string; cached: boolean }> = [];
const descriptions: Array<{ path: string; index: number; text: string; cached: boolean }> = [];
let ok = 0;
for (let i = 0; i < loaded.length; i++) {
const r = results[i];
if (r) {
descriptions.push({ token: loaded[i]!.token, index: resolved.get(loaded[i]!.token)?.index ?? i, text: r.text, cached: r.cached });
descriptions.push({ path: loaded[i]!.abs, index: resolved.get(loaded[i]!.token)?.index ?? i, text: r.text, cached: r.cached });
ok++;
}
// undefined → that image gets no description (timeout/failure mid-batch)
}

if (ok === 0) {
// All failed/timed out → hint fallback (with paths, §3.4).
text = `${text}\n${buildHintLine(hintImages)}`;
text = `${text}\n${buildHintLine(hintPaths)}`;
return { action: "transform" as const, text };
}

Expand Down
15 changes: 14 additions & 1 deletion lib/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
*/
import { readFile, stat } from "node:fs/promises";
import { existsSync, statSync } from "node:fs";
import { homedir } from "node:os";
import { isAbsolute, resolve as resolvePath } from "node:path";
import { createHash } from "node:crypto";
import { resizeImage } from "@earendil-works/pi-coding-agent";
Expand Down Expand Up @@ -109,6 +110,18 @@ function parseDataUrl(input: string): ImageLoadResult {
return { ok: true, image: { data: payload, mimeType: mime }, sourceHash: hashBytes(bytes) };
}

/**
* Expand a leading `~/` (or a bare `~`) to the user's home directory.
*
* `~user/…` is deliberately NOT expanded: resolving another user's home needs
* a passwd lookup, and silently mapping it onto the current user's home would
* be wrong. Such a token falls through unchanged.
*/
export function expandTilde(input: string): string {
if (input !== "~" && !input.startsWith("~/")) return input;
return resolvePath(homedir(), input.slice(2));
}

/** Is `input` plausibly a file path we should try to read (vs raw base64)? */
function looksLikeFilePath(input: string): boolean {
if (input.startsWith("data:")) return false;
Expand Down Expand Up @@ -148,7 +161,7 @@ export async function loadImage(input: string, options: LoadOptions): Promise<Im
}

if (looksLikeFilePath(input)) {
const abs = resolvePath(options.cwd, input.replace(/^~/, ""));
const abs = resolvePath(options.cwd, expandTilde(input));
if (!existsSync(abs)) {
// A path-looking string that doesn't exist might still be raw base64
// (rare). Fall through to base64 decode rather than hard-failing.
Expand Down
20 changes: 13 additions & 7 deletions lib/marker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,18 +120,20 @@ export function renderMarkers(
* For N≥2 images, names the `image_paths` batch affordance so the model
* learns the batch tool exists. Paths are listed on indented lines so they
* are trivially extractable (regex `^ (.+)$`).
*
* `paths` must be RESOLVED absolute paths, in marker order — a raw user
* token (`~/x.png`, `./x.png`) is not actionable without $HOME or the cwd
* and defeats §3.4's purpose.
*/
export function buildHintLine(
images: Array<{ token: string; index: number }>,
): string {
const n = images.length;
export function buildHintLine(paths: string[]): string {
const n = paths.length;
if (n === 0) {
return "0 images referenced.";
}
const noun = n === 1 ? "image" : "images";
const verb = n === 1 ? "analyze it" : "analyze them";
const clause = n >= 2 ? " (single, or pass all paths to image_paths for batch analysis)" : "";
const pathLines = images.map((img) => ` ${img.token}`).join("\n");
const pathLines = paths.map((p) => ` ${p}`).join("\n");
return `${n} ${noun} referenced. The active model cannot process images natively — use the describe_image tool to ${verb}${clause}.
Image paths:
${pathLines}`;
Expand Down Expand Up @@ -191,17 +193,21 @@ export function buildBatchToolResult(
* Build the descriptions block appended in text-only + "auto" mode.
* Each image's delegation result is appended as a labeled line. A footer
* notes the vision model + how to switch to hint mode (cost awareness).
*
* `path` is the resolved absolute path, for the same reason as
* `buildHintLine`: it is the model's handle for a follow-up
* `describe_image` call on the same image.
*/
export function buildDescriptionsBlock(
descriptions: Array<{ token: string; index: number; text: string; cached: boolean }>,
descriptions: Array<{ path: string; index: number; text: string; cached: boolean }>,
visionModel: string,
): string {
if (descriptions.length === 0) return "";

const lines = descriptions.map((d) => {
const label = styleMarker(d.index + 1, "code");
const cachedTag = d.cached ? " (cached)" : "";
return `[${label} ${d.token}]: ${d.text}${cachedTag}`;
return `[${label} ${d.path}]: ${d.text}${cachedTag}`;
});

const footer = `[${descriptions.length} image(s) auto-described via ${visionModel}. Set textOnlyPasteMode to "hint" to delegate on-demand instead.]`;
Expand Down
46 changes: 43 additions & 3 deletions tests/image.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { homedir, tmpdir } from "node:os";
import { join } from "node:path";
import { detectMimeType, hashBytes, loadImage, MAX_IMAGE_BYTES } from "../lib/image.ts";
import { detectMimeType, expandTilde, hashBytes, loadImage, MAX_IMAGE_BYTES } from "../lib/image.ts";

// 1×1 transparent PNG — decodes to bytes starting with the PNG signature
// (89 50 4E 47 0D 0A 1A 0A).
Expand Down Expand Up @@ -210,4 +210,44 @@ test("loadImage: data URL + raw base64 both return sourceHash", async () => {
test("hashBytes: empty + known vector", () => {
assert.equal(hashBytes(Buffer.alloc(0)), "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
assert.equal(hashBytes(PNG_BYTES).length, 64);
});
});
// ── Tilde expansion (fix: ~/ was stripped to "/", never hitting $HOME) ──────

test("expandTilde: ~/ expands against the home directory", () => {
assert.equal(expandTilde("~/Desktop/x.png"), join(homedir(), "Desktop", "x.png"));
});

test("expandTilde: bare ~ expands to the home directory", () => {
assert.equal(expandTilde("~"), homedir());
});

test("expandTilde: ~user is NOT expanded (another user's home)", () => {
assert.equal(expandTilde("~alice/x.png"), "~alice/x.png");
});

test("expandTilde: non-tilde input passes through unchanged", () => {
assert.equal(expandTilde("/tmp/x.png"), "/tmp/x.png");
assert.equal(expandTilde("./x.png"), "./x.png");
assert.equal(expandTilde("../a/x.png"), "../a/x.png");
});

test("expandTilde: a tilde mid-path is not a home reference", () => {
assert.equal(expandTilde("/tmp/~/x.png"), "/tmp/~/x.png");
});

test("loadImage: ~/ path resolves against $HOME, not cwd", async () => {
const home = tmpDir();
const origHome = process.env.HOME;
try {
process.env.HOME = home;
writeFileSync(join(home, "tilde.png"), PNG_BYTES);
// cwd deliberately points elsewhere: a correct expansion must ignore it.
const result = await loadImage("~/tilde.png", { ...LOAD_OPTS, cwd: join(home, "unrelated") });
assert.equal(result.ok, true, "~/tilde.png must resolve to $HOME/tilde.png");
if (result.ok) assert.equal(result.image.mimeType, "image/png");
} finally {
if (origHome === undefined) delete process.env.HOME;
else process.env.HOME = origHome;
rmSync(home, { recursive: true, force: true });
}
});
78 changes: 78 additions & 0 deletions tests/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -877,6 +877,45 @@ test("T32: text-only + auto → delegate per image + descriptions appended, no a
}
});

// ── T32b: the descriptions block names the RESOLVED path too ───────────
// Same reason as the hint line (T52b): the path in `[[Image-#1] <path>]:` is
// the model's handle for a follow-up describe_image call on that image, so a
// raw `~/…` token there is just as unusable.
test("T32b: text-only + auto + ~/ token → descriptions block lists the resolved absolute path", async () => {
const pi = createMockPi();
visionFactory(pi as unknown as ExtensionAPI);
pasteFactory(pi as unknown as ExtensionAPI);
const home = mkdtempSync(join(tmpdir(), "vision-eval-home-"));
const cwd = mkdtempSync(join(tmpdir(), "vision-eval-cwd-"));
const origHome = process.env.HOME;
const file = join(home, "shot.png");
writeFileSync(file, make1x1Png(255, 0, 255));
const fm = mockFetch({ choices: [{ message: { content: "A magenta square." } }] });
try {
process.env.HOME = home;
await pi.emit("session_start", { type: "session_start", reason: "startup" }, makeCtx({ model: TEXT_ONLY, cwd }));
await runVisionCommand(pi, "provider ollama", TEXT_ONLY);
await runVisionCommand(pi, "model minimax-m3:cloud", TEXT_ONLY);
await runVisionCommand(pi, "paste-mode auto", TEXT_ONLY);
const inputResult = await pi.emit(
"input",
{ type: "input", text: "analyze ~/shot.png", source: "interactive", images: [] },
makeCtx({ model: TEXT_ONLY, cwd, registry: makeRegistry({ model: VISION_MODEL }) }),
);
assert.equal(inputResult?.action, "transform");
assert.match(inputResult.text, /magenta square/, "description appended");
// ★ GATE: the labeled line names the resolved path, not the typed token.
assert.ok(inputResult.text.includes(`${file}]:`), "★ descriptions block lists the resolved absolute path");
assert.ok(!inputResult.text.includes("~/shot.png"), "★ raw tilde token absent from the transformed text");
} finally {
fm.restore();
if (origHome === undefined) delete process.env.HOME;
else process.env.HOME = origHome;
rmSync(home, { recursive: true, force: true });
rmSync(cwd, { recursive: true, force: true });
}
});

// ── T33: text-only + off → markers only, no hint, no delegation ──
test("T33: text-only + off → markers only, no hint, no delegation", async () => {
const pi = createMockPi();
Expand Down Expand Up @@ -1543,6 +1582,45 @@ test("T52: text-only + hint + 2 paths → markers + hint lists paths + batch aff
}
});

// ── T52b: the hint lists RESOLVED paths, never the raw token ───────────
// A `~/x.png` token is not actionable for the model without knowing $HOME —
// observed in a real session, the model shelled out to `echo ~/x.png` just
// to resolve the tilde before it could call describe_image. Guards the
// paste.ts side of that fix (buildHintLine itself only sees paths now).
test("T52b: text-only + hint + ~/ token → hint lists the resolved absolute path", async () => {
const pi = createMockPi();
visionFactory(pi as unknown as ExtensionAPI);
pasteFactory(pi as unknown as ExtensionAPI);
const home = mkdtempSync(join(tmpdir(), "vision-eval-home-"));
const cwd = mkdtempSync(join(tmpdir(), "vision-eval-cwd-"));
const origHome = process.env.HOME;
const file = join(home, "shot.png");
writeFileSync(file, make1x1Png(0, 0, 255));
try {
process.env.HOME = home;
writeFileSync(join(TMP_AGENT, "vision.json"), JSON.stringify({
provider: "ollama", model: "minimax-m3:cloud", enabled: true,
textOnlyPasteMode: "hint",
}));
await pi.emit("session_start", { type: "session_start", reason: "startup" }, makeCtx({ model: TEXT_ONLY, cwd }));
const inputResult = await pi.emit(
"input",
{ type: "input", text: "analyze ~/shot.png", source: "interactive", images: [] },
makeCtx({ model: TEXT_ONLY, cwd }),
);
assert.equal(inputResult?.action, "transform");
// ★ GATE: the resolved path is listed; the raw tilde token never reaches
// the model — neither in the body (markered) nor in the hint.
assert.ok(inputResult.text.includes(` ${file}`), "★ hint lists the resolved absolute path");
assert.ok(!inputResult.text.includes("~/shot.png"), "★ raw tilde token absent from the transformed text");
} finally {
if (origHome === undefined) delete process.env.HOME;
else process.env.HOME = origHome;
rmSync(home, { recursive: true, force: true });
rmSync(cwd, { recursive: true, force: true });
}
});

// ── T51: clipboard paste path regression guard (★ SPEC-4 §3.3) ─────────
// Pi routes ctrl+v clipboard images to /tmp/pi-clipboard-<uuid>.png then
// inserts the path at the cursor (interactive-mode.js:2055). Our pipeline
Expand Down
Loading