summaryrefslogtreecommitdiffhomepage
path: root/packages/vision-handoff/src/extension.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/vision-handoff/src/extension.ts')
-rw-r--r--packages/vision-handoff/src/extension.ts198
1 files changed, 198 insertions, 0 deletions
diff --git a/packages/vision-handoff/src/extension.ts b/packages/vision-handoff/src/extension.ts
new file mode 100644
index 0000000..08fddca
--- /dev/null
+++ b/packages/vision-handoff/src/extension.ts
@@ -0,0 +1,198 @@
+/**
+ * vision-handoff extension — registers the universal vision handoff service +
+ * the `consult_vision` tool.
+ *
+ * The service performs provider-agnostic vision handoff: when a non-vision model
+ * (e.g. glm-5.2) receives an image, it replaces the image with a numbered
+ * placeholder and registers it for tool access. The `consult_vision` tool opens
+ * a NEW conversation tab with a vision-capable model (e.g. Kimi), attaches the
+ * image + the model's specific question, and returns the conversation ID + the
+ * vision model's answer. Follow-ups go through the dispatch CLI.
+ *
+ * Images are saved to a tmp directory (`/tmp/dispatch/images/<convId>/`) so the
+ * conversation store (SQLite) only holds a compact URL reference — not
+ * megabytes of base64. Tmp files are purged on reboot (ephemeral dir), after
+ * compaction (the transcription replaces the image), and on conversation close.
+ *
+ * Effects (filesystem, orchestrator) live here in the shell, injected into the
+ * service. The pure decisions live in `pure.ts`. No `console.*`; logging via
+ * `host.logger`.
+ */
+
+import { mkdir, readFile, rm, unlink, writeFile } from "node:fs/promises";
+import { extname, isAbsolute, join, resolve as pathResolve } from "node:path";
+import { conversationStoreHandle } from "@dispatch/conversation-store";
+import type { CredentialStore } from "@dispatch/credential-store";
+import { credentialStoreHandle } from "@dispatch/credential-store";
+import type { Extension, HostAPI, Manifest } from "@dispatch/kernel";
+import {
+ createVisionHandoffService,
+ orchestratorLocalHandle,
+ visionHandoffHandle,
+} from "./service.js";
+import { createConsultVisionTool } from "./tool.js";
+
+export const manifest: Manifest = {
+ id: "vision-handoff",
+ name: "Vision Handoff",
+ version: "0.0.0",
+ apiVersion: "^0.1.0",
+ trust: "bundled",
+ activation: "eager",
+ capabilities: { network: true },
+ contributes: { services: ["vision-handoff/service"], tools: ["consult_vision"] },
+};
+
+const IMAGE_DIR = process.env.DISPATCH_IMAGE_DIR ?? "/tmp/dispatch/images";
+
+/** MIME types for recognized image extensions. */
+const MIME_BY_EXT: Readonly<Record<string, string>> = {
+ ".png": "image/png",
+ ".jpg": "image/jpeg",
+ ".jpeg": "image/jpeg",
+ ".webp": "image/webp",
+ ".gif": "image/gif",
+ ".bmp": "image/bmp",
+};
+
+/** Reverse: MIME → extension. */
+const EXT_BY_MIME: Readonly<Record<string, string>> = {
+ "image/png": ".png",
+ "image/jpeg": ".jpg",
+ "image/webp": ".webp",
+ "image/gif": ".gif",
+ "image/bmp": ".bmp",
+};
+
+/**
+ * Read an image file from disk as a base64 data URL. Resolves relative paths
+ * against the cwd (the conversation's working directory). Throws on missing
+ * file / read error (the caller surfaces it). The shell edge — real `node:fs`.
+ */
+async function readFileAsDataUrl(path: string, cwd?: string): Promise<string> {
+ const abs = cwd !== undefined && !isAbsolute(path) ? pathResolve(cwd, path) : pathResolve(path);
+ const buf = await readFile(abs);
+ const ext = extname(abs).toLowerCase();
+ const mime = MIME_BY_EXT[ext] ?? "image/png";
+ return `data:${mime};base64,${buf.toString("base64")}`;
+}
+
+/**
+ * Save a data URL image to a tmp file and return a compact HTTP path.
+ * The compact URL (`/images/<conversationId>/<uuid>.<ext>`) is what gets
+ * persisted in the conversation store — a tiny string, not megabytes of base64.
+ */
+async function saveImageToTmp(
+ conversationId: string,
+ dataUrl: string,
+ mimeType?: string,
+): Promise<string> {
+ const mime = mimeType ?? "image/png";
+ const ext = EXT_BY_MIME[mime] ?? ".png";
+ const imageId = `${crypto.randomUUID()}${ext}`;
+ const dir = join(IMAGE_DIR, conversationId);
+ await mkdir(dir, { recursive: true });
+ const filePath = join(dir, imageId);
+ const base64 = dataUrl.split(",")[1] ?? "";
+ await writeFile(filePath, Buffer.from(base64, "base64"));
+ return `/images/${conversationId}/${imageId}`;
+}
+
+/**
+ * Resolve a compact URL (`/images/<convId>/<imageId>`) back to a data URL by
+ * reading the tmp file. Data URLs and HTTP URLs pass through unchanged.
+ */
+async function resolveImageUrl(url: string): Promise<string> {
+ if (url.startsWith("data:") || url.startsWith("http")) return url;
+ if (!url.startsWith("/images/")) return url;
+ const parts = url.split("/"); // ["", "images", convId, imageId]
+ const convId = parts[2];
+ const imageId = parts[3];
+ if (convId === undefined || imageId === undefined) return url;
+ const filePath = join(IMAGE_DIR, convId, imageId);
+ const buf = await readFile(filePath);
+ const ext = extname(imageId).toLowerCase();
+ const mime = MIME_BY_EXT[ext] ?? "image/png";
+ return `data:${mime};base64,${buf.toString("base64")}`;
+}
+
+/** Delete a single tmp image file (after compaction — best-effort). */
+async function deleteTmpImage(compactUrl: string): Promise<void> {
+ if (!compactUrl.startsWith("/images/")) return;
+ const parts = compactUrl.split("/");
+ const convId = parts[2];
+ const imageId = parts[3];
+ if (convId === undefined || imageId === undefined) return;
+ const filePath = join(IMAGE_DIR, convId, imageId);
+ try {
+ await unlink(filePath);
+ } catch {
+ // Best-effort — file may already be deleted.
+ }
+}
+
+/** Delete all tmp images for a conversation (on close — best-effort). */
+async function deleteConversationImages(conversationId: string): Promise<void> {
+ const dir = join(IMAGE_DIR, conversationId);
+ try {
+ await rm(dir, { recursive: true, force: true });
+ } catch {
+ // Best-effort.
+ }
+}
+
+export async function activate(host: HostAPI): Promise<void> {
+ const credentialStore = host.getService(credentialStoreHandle) as CredentialStore | undefined;
+ if (credentialStore === undefined) {
+ host.logger.warn(
+ "vision-handoff: credential-store service not available. The consult_vision tool and image handoff are disabled.",
+ );
+ return;
+ }
+
+ const resolveModel = (modelName: string) => {
+ const resolved = credentialStore.resolve(modelName);
+ if (resolved === undefined) return undefined;
+ const provider = host.getProviders().get(resolved.providerId);
+ if (provider === undefined) return undefined;
+ return { provider, model: resolved.model };
+ };
+
+ const service = createVisionHandoffService({
+ credentialStore,
+ resolveModel,
+ readFileAsDataUrl,
+ saveImageToTmp,
+ resolveImageUrl,
+ deleteTmpImage,
+ deleteConversationImages,
+ resolveOrchestrator: () => {
+ const loaded = host.getExtensions().some((m) => m.id === "session-orchestrator");
+ if (!loaded) return undefined;
+ try {
+ return host.getService(orchestratorLocalHandle);
+ } catch {
+ return undefined;
+ }
+ },
+ getImageTranscriptions: async (conversationId: string) => {
+ const store = host.getService(conversationStoreHandle);
+ return store.getImageTranscriptions(conversationId);
+ },
+ setImageTranscription: async (conversationId: string, url: string, text: string) => {
+ const store = host.getService(conversationStoreHandle);
+ await store.setImageTranscription(conversationId, url, text);
+ },
+ setConversationTitle: async (conversationId: string, title: string) => {
+ const store = host.getService(conversationStoreHandle);
+ await store.setConversationTitle(conversationId, title);
+ },
+ logger: host.logger.child({ extensionId: "vision-handoff" }),
+ });
+
+ host.provideService(visionHandoffHandle, service);
+ host.defineTool(createConsultVisionTool(service));
+ host.logger.info("vision-handoff: registered (consult_vision tool + handoff service)");
+}
+
+export const extension: Extension = { manifest, activate };