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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-vision-handoff",
3
- "version": "0.9.5",
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.1",
35
- "@earendil-works/pi-coding-agent": "0.84.1",
36
- "@earendil-works/pi-tui": "0.84.1",
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",
@@ -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
- IMAGE_PLACEHOLDER_PREFIX,
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
- /** Resolved when a description couldn't be obtained (graceful degradation).
44
- * Failures are NOT cached, so the next turn re-attempts. */
45
- export const UNAVAILABLE = `${IMAGE_PLACEHOLDER_PREFIX}description unavailable${IMAGE_PLACEHOLDER_SUFFIX}`;
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(getAgentDir(), LOG_SUBDIR);
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(getAgentDir(), CONFIG_SUBDIR, CONFIG_FILENAME);
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(getAgentDir(), CONFIG_SUBDIR);
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, UNAVAILABLE, type LoaderDeps } from "./src/dataloader.js";
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 { resizeImage } from "@earendil-works/pi-coding-agent";
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
- const loader = new DescriptionLoader(loaderDeps);
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,