pi-vision-handoff 0.9.5 → 0.9.7
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/package.json +4 -4
- package/src/agent-dir.ts +16 -0
- package/src/dataloader.ts +4 -5
- package/src/error-log.ts +2 -2
- package/src/index.ts +8 -3
- package/vision-handoff.ts +28 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-vision-handoff",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.7",
|
|
4
4
|
"description": "Give text-only pi models vision — describe images with a vision model you pick via an interactive picker, then hand off the text description to non-vision models",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "Tom X Nguyen",
|
|
@@ -31,9 +31,9 @@
|
|
|
31
31
|
"README.md"
|
|
32
32
|
],
|
|
33
33
|
"devDependencies": {
|
|
34
|
-
"@earendil-works/pi-ai": "0.84.
|
|
35
|
-
"@earendil-works/pi-coding-agent": "0.84.
|
|
36
|
-
"@earendil-works/pi-tui": "0.84.
|
|
34
|
+
"@earendil-works/pi-ai": "0.84.2",
|
|
35
|
+
"@earendil-works/pi-coding-agent": "0.84.2",
|
|
36
|
+
"@earendil-works/pi-tui": "0.84.2",
|
|
37
37
|
"@types/node": "25.9.1",
|
|
38
38
|
"@vitest/coverage-v8": "4.1.7",
|
|
39
39
|
"knip": "6.14.1",
|
package/src/agent-dir.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// pi's agent-dir resolution in-process, so no module on the extension load
|
|
2
|
+
// path imports the pi-coding-agent runtime (its graph costs ~1s per extension
|
|
3
|
+
// in pi's loader).
|
|
4
|
+
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
|
|
8
|
+
const expandTilde = (value: string): string => {
|
|
9
|
+
if (value === "~") return homedir();
|
|
10
|
+
return value.startsWith("~/") ? join(homedir(), value.slice(2)) : value;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const resolveAgentDir = (): string => {
|
|
14
|
+
const override = process.env.PI_CODING_AGENT_DIR;
|
|
15
|
+
return override ? expandTilde(override) : join(homedir(), ".pi", "agent");
|
|
16
|
+
};
|
package/src/dataloader.ts
CHANGED
|
@@ -31,8 +31,7 @@
|
|
|
31
31
|
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
32
32
|
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
33
33
|
import {
|
|
34
|
-
|
|
35
|
-
IMAGE_PLACEHOLDER_SUFFIX,
|
|
34
|
+
UNAVAILABLE,
|
|
36
35
|
wrapDescription,
|
|
37
36
|
type ExtractedImage,
|
|
38
37
|
type VisionHandoffConfig,
|
|
@@ -40,9 +39,9 @@ import {
|
|
|
40
39
|
import { imageHash } from "./image.js";
|
|
41
40
|
import { runBatch, describeSingle, type DescriberDeps } from "./describer.js";
|
|
42
41
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
export
|
|
42
|
+
// Kept for back-compat with tests and older consumers; the canonical export
|
|
43
|
+
// lives in ./index.js where it costs no describer-chain import.
|
|
44
|
+
export { UNAVAILABLE };
|
|
46
45
|
|
|
47
46
|
interface DescriptionBatch {
|
|
48
47
|
keys: string[];
|
package/src/error-log.ts
CHANGED
|
@@ -22,9 +22,9 @@
|
|
|
22
22
|
* errors.log.1) bounds growth to ~2× the cap.
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
-
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
26
25
|
import { appendFileSync, existsSync, mkdirSync, renameSync, statSync } from "node:fs";
|
|
27
26
|
import { join } from "node:path";
|
|
27
|
+
import { resolveAgentDir } from "./agent-dir.js";
|
|
28
28
|
|
|
29
29
|
/** Subdirectory under the pi agent dir holding this extension's logs. */
|
|
30
30
|
const LOG_SUBDIR = "logs/pi-vision-handoff";
|
|
@@ -76,7 +76,7 @@ export interface VisionErrorLogEntry {
|
|
|
76
76
|
/** Directory holding this extension's logs:
|
|
77
77
|
* ~/.pi/agent/logs/pi-vision-handoff (overridable via $PI_CODING_AGENT_DIR). */
|
|
78
78
|
export function getErrorLogDir(): string {
|
|
79
|
-
return join(
|
|
79
|
+
return join(resolveAgentDir(), LOG_SUBDIR);
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
/** Path to the active error log file. */
|
package/src/index.ts
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { ImageContent, TextContent, ThinkingLevel } from "@earendil-works/pi-ai";
|
|
9
|
-
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
10
9
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
11
10
|
import { join } from "node:path";
|
|
11
|
+
import { resolveAgentDir } from "./agent-dir.js";
|
|
12
12
|
|
|
13
13
|
export * from "./usage.js";
|
|
14
14
|
|
|
@@ -20,7 +20,7 @@ export const CONFIG_FILENAME = "pi-vision-handoff.json";
|
|
|
20
20
|
|
|
21
21
|
/** Full config path: ~/.pi/agent/extensions/pi-vision-handoff.json */
|
|
22
22
|
export function getConfigPath(): string {
|
|
23
|
-
return join(
|
|
23
|
+
return join(resolveAgentDir(), CONFIG_SUBDIR, CONFIG_FILENAME);
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
/** Description shown in the / commands list. */
|
|
@@ -38,6 +38,11 @@ export const DEFAULT_USER_PROMPT_PREFIX = "The user's request about this image:
|
|
|
38
38
|
export const IMAGE_PLACEHOLDER_PREFIX = "[Image: ";
|
|
39
39
|
export const IMAGE_PLACEHOLDER_SUFFIX = "]";
|
|
40
40
|
|
|
41
|
+
/** Resolved when a description couldn't be obtained (graceful degradation).
|
|
42
|
+
* Failures are NOT cached, so the next turn re-attempts. Defined here so
|
|
43
|
+
* consumers reach it without loading the describer chain. */
|
|
44
|
+
export const UNAVAILABLE = `${IMAGE_PLACEHOLDER_PREFIX}description unavailable${IMAGE_PLACEHOLDER_SUFFIX}`;
|
|
45
|
+
|
|
41
46
|
/** Marker appended to a description whose `completeSimple()` call ended with
|
|
42
47
|
* `stopReason: "length"` — i.e. the vision model hit a token limit (either the
|
|
43
48
|
* configured `maxTokens` or the provider's hard output cap) before finishing.
|
|
@@ -299,7 +304,7 @@ export function readConfig(): VisionHandoffConfig {
|
|
|
299
304
|
/** Write config to disk. Creates the directory if needed. Returns the path written. */
|
|
300
305
|
export function writeConfig(config: VisionHandoffConfig): string {
|
|
301
306
|
const path = getConfigPath();
|
|
302
|
-
const dir = join(
|
|
307
|
+
const dir = join(resolveAgentDir(), CONFIG_SUBDIR);
|
|
303
308
|
if (!existsSync(dir)) {
|
|
304
309
|
mkdirSync(dir, { recursive: true });
|
|
305
310
|
}
|
package/vision-handoff.ts
CHANGED
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
stripNonVisionImageNote,
|
|
45
45
|
writeConfig,
|
|
46
46
|
HANDOFF_COMMAND_DESCRIPTION,
|
|
47
|
+
UNAVAILABLE,
|
|
47
48
|
type ExtractedImage,
|
|
48
49
|
type VisionHandoffConfig,
|
|
49
50
|
} from "./src/index.js";
|
|
@@ -52,12 +53,10 @@ import {
|
|
|
52
53
|
USAGE_EVENT_CHANNEL,
|
|
53
54
|
type VisionHandoffUsageRecord,
|
|
54
55
|
} from "./src/usage.js";
|
|
55
|
-
import { DescriptionLoader,
|
|
56
|
+
import type { DescriptionLoader, LoaderDeps } from "./src/dataloader.js";
|
|
56
57
|
import { imageHash, findPastedImagePaths, readImageBuffer, readImageBufferBounded, resolvePrewarmImage, isOmittedImageNote } from "./src/image.js";
|
|
57
58
|
import { appendVisionError } from "./src/error-log.js";
|
|
58
|
-
import {
|
|
59
|
-
import { VisionModelSelectorComponent, type VisionModelSelectorResult } from "./src/vision-model-selector.js";
|
|
60
|
-
import { PrewarmEditor } from "./src/prewarm-editor.js";
|
|
59
|
+
import type { VisionModelSelectorResult } from "./src/vision-model-selector.js";
|
|
61
60
|
import { Text } from "@earendil-works/pi-tui";
|
|
62
61
|
|
|
63
62
|
let config: VisionHandoffConfig = readConfig();
|
|
@@ -224,7 +223,19 @@ const loaderDeps: LoaderDeps = {
|
|
|
224
223
|
lastDescriberError = msg;
|
|
225
224
|
},
|
|
226
225
|
};
|
|
227
|
-
|
|
226
|
+
// The describer chain (dataloader -> describer -> pi-ai/compat) and pi-core's
|
|
227
|
+
// resizeImage / pi-tui's Text each drag a host module graph into this
|
|
228
|
+
// extension's loader when imported statically. Load them behind the first
|
|
229
|
+
// image turn / editor install / async render so startup stays import-free.
|
|
230
|
+
let loaderPromise: Promise<DescriptionLoader> | undefined;
|
|
231
|
+
const getLoader = (): Promise<DescriptionLoader> => {
|
|
232
|
+
loaderPromise ??= import("./src/dataloader.js").then(
|
|
233
|
+
({ DescriptionLoader }) => new DescriptionLoader(loaderDeps),
|
|
234
|
+
);
|
|
235
|
+
return loaderPromise;
|
|
236
|
+
};
|
|
237
|
+
const loadResizeImage = () =>
|
|
238
|
+
import("@earendil-works/pi-coding-agent").then(({ resizeImage }) => resizeImage);
|
|
228
239
|
|
|
229
240
|
function isConfigured(cfg: VisionHandoffConfig): boolean {
|
|
230
241
|
return cfg.enabled && !!cfg.visionModel;
|
|
@@ -257,9 +268,11 @@ function shouldPrewarmPaste(): boolean {
|
|
|
257
268
|
* the description is generated without question context (the documented
|
|
258
269
|
* tradeoff of the opt-in). If the user submits before this dispatch fires,
|
|
259
270
|
* before_agent_start overwrites the prompt — a benign bonus, not a bug. */
|
|
260
|
-
function prewarmClipboardPath(path: string, modelRegistry: ModelRegistry): void {
|
|
271
|
+
async function prewarmClipboardPath(path: string, modelRegistry: ModelRegistry): Promise<void> {
|
|
261
272
|
const read = readImageBuffer(path);
|
|
262
273
|
if (!read) return;
|
|
274
|
+
const resizeImage = await loadResizeImage();
|
|
275
|
+
const loader = await getLoader();
|
|
263
276
|
resolvePrewarmImage(read.buf, read.mimeType, resizeImage)
|
|
264
277
|
.then((img) => {
|
|
265
278
|
if (!img) return;
|
|
@@ -283,7 +296,7 @@ function prewarmClipboardPath(path: string, modelRegistry: ModelRegistry): void
|
|
|
283
296
|
* (pi wires paste-image to the outermost editor only). When a custom editor
|
|
284
297
|
* is present, paste-time prewarm is unavailable; submit-time prewarm
|
|
285
298
|
* (before_agent_start) still covers clipboard paths. */
|
|
286
|
-
function installPrewarmEditor(ctx: ExtensionContext): void {
|
|
299
|
+
async function installPrewarmEditor(ctx: ExtensionContext): Promise<void> {
|
|
287
300
|
if (ctx.mode !== "tui") {
|
|
288
301
|
editorInstalled = false;
|
|
289
302
|
return;
|
|
@@ -292,6 +305,7 @@ function installPrewarmEditor(ctx: ExtensionContext): void {
|
|
|
292
305
|
editorInstalled = false;
|
|
293
306
|
return;
|
|
294
307
|
}
|
|
308
|
+
const { PrewarmEditor } = await import("./src/prewarm-editor.js");
|
|
295
309
|
editorInstalled = true;
|
|
296
310
|
ctx.ui.setEditorComponent((_tui, theme, keybindings) =>
|
|
297
311
|
new PrewarmEditor(_tui, theme, keybindings, {
|
|
@@ -398,9 +412,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
398
412
|
visionModelCache = null;
|
|
399
413
|
visionModelUnresolvedRef = null;
|
|
400
414
|
warnedHashes.clear();
|
|
401
|
-
loader.reset();
|
|
415
|
+
void getLoader().then((loader) => loader.reset());
|
|
402
416
|
currentModel = ctx.model;
|
|
403
|
-
installPrewarmEditor(ctx);
|
|
417
|
+
await installPrewarmEditor(ctx);
|
|
404
418
|
});
|
|
405
419
|
|
|
406
420
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
@@ -418,6 +432,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
418
432
|
// signal into it. Without this reset, a previous turn's ESC would leave
|
|
419
433
|
// the controller aborted and every dispatch would short-circuit to
|
|
420
434
|
// UNAVAILABLE.
|
|
435
|
+
const loader = await getLoader();
|
|
421
436
|
loader.resetTurnAbort();
|
|
422
437
|
|
|
423
438
|
// Capture this turn's user prompt so every image in the turn — attached or
|
|
@@ -470,6 +485,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
470
485
|
// a supported image, or fails to resize is skipped (the agent's `read`
|
|
471
486
|
// will still describe it via `tool_result` if it emits an image block).
|
|
472
487
|
const clipboardPaths = findPastedImagePaths(event.prompt || "");
|
|
488
|
+
const resizeImage = await loadResizeImage();
|
|
473
489
|
const preparedClipboardImages = clipboardPaths.map(
|
|
474
490
|
async (path): Promise<PreparedClipboardImage | null> => {
|
|
475
491
|
try {
|
|
@@ -584,6 +600,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
584
600
|
}
|
|
585
601
|
|
|
586
602
|
if (imgs.length === 0) return;
|
|
603
|
+
const loader = await getLoader();
|
|
587
604
|
loader.bindTurnContext(ctx);
|
|
588
605
|
if (!isAnyVisionModelResolvable(ctx.modelRegistry, config)) {
|
|
589
606
|
notifyUnresolvedVisionModel(ctx, config.visionModel!);
|
|
@@ -676,6 +693,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
676
693
|
}
|
|
677
694
|
if (!anyImage) return;
|
|
678
695
|
if (!isHandoffTarget(ctx.model, config)) return;
|
|
696
|
+
const loader = await getLoader();
|
|
679
697
|
loader.bindTurnContext(ctx);
|
|
680
698
|
if (!isAnyVisionModelResolvable(ctx.modelRegistry, config)) {
|
|
681
699
|
notifyUnresolvedVisionModel(ctx, config.visionModel!);
|
|
@@ -1046,6 +1064,7 @@ async function showSelector(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
1046
1064
|
return;
|
|
1047
1065
|
}
|
|
1048
1066
|
|
|
1067
|
+
const { VisionModelSelectorComponent } = await import("./src/vision-model-selector.js");
|
|
1049
1068
|
const result = await ctx.ui.custom<VisionModelSelectorResult>((tui, theme, _kb, done) => {
|
|
1050
1069
|
const selector = new VisionModelSelectorComponent(
|
|
1051
1070
|
theme,
|