From 81bca850d176d47dc92e4404eff3210d3e08341e Mon Sep 17 00:00:00 2001 From: RECTOR Date: Fri, 24 Jul 2026 13:43:21 +0700 Subject: [PATCH] feat: add exports surface + createVisionDelegator for non-extension consumers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors armory-todo #12/#13. Adds exports map (types→./src/index.d.ts, default→./src/index.ts) + src/index.ts re-exporting capability/delegate/config pure functions + src/index.d.ts. Adds createVisionDelegator({modelRegistry, cwd, agentDir}) — takes a ModelRegistry (or the {find, getApiKeyAndHeaders} slice) so consumers with a ModelRuntime (e.g. armory-fleet) delegate without constructing an ExtensionContext. ModelRegistry is a pi-coding-agent export; consumers construct new ModelRegistry(modelRuntime) themselves. Additive, no behavior change to the vision extension. --- lib/delegate.ts | 40 +++++++++++++++++++++++++++++++++++++--- package.json | 7 +++++++ src/index.d.ts | 14 ++++++++++++++ src/index.ts | 16 ++++++++++++++++ test/delegator.test.mts | 34 ++++++++++++++++++++++++++++++++++ 5 files changed, 108 insertions(+), 3 deletions(-) create mode 100644 src/index.d.ts create mode 100644 src/index.ts create mode 100644 test/delegator.test.mts diff --git a/lib/delegate.ts b/lib/delegate.ts index ebc5c9f..8d6d85a 100644 --- a/lib/delegate.ts +++ b/lib/delegate.ts @@ -19,12 +19,12 @@ */ import type { Model, Api } from "@earendil-works/pi-ai"; import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; -import { isConfiguredForDelegation, type ReasoningLevel, type VisionConfig } from "./config.ts"; +import { getAgentDir } from "@earendil-works/pi-coding-agent"; +import { isConfiguredForDelegation, loadConfig, type ReasoningLevel, type VisionConfig } from "./config.ts"; import { loadImage, type LoadedImage } from "./image.ts"; import { cacheKey, type VisionCache } from "./cache.ts"; import { AbortError, classifyError, withRetry } from "./resilience.ts"; import { appendAuditEntry, resolveAuditPath, truncateImagePathForLog, type AuditEntry } from "./audit.ts"; -import { getAgentDir } from "@earendil-works/pi-coding-agent"; export interface DelegateParams { image_path: string; @@ -455,4 +455,38 @@ async function runFallback( details: { primaryError: primaryErrorTag(primaryErr), fallbackModel: fallbackId }, }; } -} \ No newline at end of file +} +// ── ModelRegistry-backed delegator (SPEC-2: for non-extension consumers like armory-fleet). +// Takes a ModelRegistry (the { find, getApiKeyAndHeaders } slice delegateToVisionModel reads) +// so consumers with a ModelRuntime construct `new ModelRegistry(modelRuntime)` themselves +// (ModelRegistry is exported by @earendil-works/pi-coding-agent) and pass it here. +// This keeps vision free of ModelRuntime coupling and is unit-testable with a trivial fake. + +export interface ModelRegistryLike { + find(provider: string, modelId: string): Model | undefined; + getApiKeyAndHeaders(model: Model): Promise; +} + +export interface VisionDelegatorDeps { + /** A ModelRegistry (or the minimal { find, getApiKeyAndHeaders } slice). */ + modelRegistry: ModelRegistryLike; + /** The cwd for image path resolution. */ + cwd: string; + /** The pi agent dir (where vision.json lives). */ + agentDir: string; +} + +export interface VisionDelegator { + delegate(params: DelegateParams, signal?: AbortSignal | undefined): Promise; + config: VisionConfig; +} + +export function createVisionDelegator(deps: VisionDelegatorDeps): VisionDelegator { + const config = loadConfig(deps.agentDir); + // delegateToVisionModel reads only ctx.modelRegistry + ctx.cwd; construct a minimal ctx. + const ctx = { modelRegistry: deps.modelRegistry, cwd: deps.cwd } as unknown as ExtensionContext; + return { + config, + delegate: (params, signal) => delegateToVisionModel(ctx, config, params, signal), + }; +} diff --git a/package.json b/package.json index f35441a..ed9086f 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,12 @@ "url": "https://github.com/getpipher/vision/issues" }, "type": "module", + "exports": { + ".": { + "types": "./src/index.d.ts", + "default": "./src/index.ts" + } + }, "engines": { "node": ">=20" }, @@ -33,6 +39,7 @@ "files": [ "extensions", "lib", + "src", "README.md", "LICENSE" ], diff --git a/src/index.d.ts b/src/index.d.ts new file mode 100644 index 0000000..1ccc787 --- /dev/null +++ b/src/index.d.ts @@ -0,0 +1,14 @@ +// src/index.d.ts — typed declaration mirroring src/index.ts (dual-condition: types→.d.ts, default→.ts). +export { isMultimodal, TOOL_NAME } from "../lib/capability.ts"; +export { loadConfig, configFilePath, type VisionConfig } from "../lib/config.ts"; +export { + delegateToVisionModel, + createVisionDelegator, + type DelegateParams, + type DelegateResult, + type DelegateSuccess, + type DelegateFailure, + type VisionDelegator, + type VisionDelegatorDeps, + type ModelRegistryLike, +} from "../lib/delegate.ts"; \ No newline at end of file diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..782d2cd --- /dev/null +++ b/src/index.ts @@ -0,0 +1,16 @@ +// src/index.ts — public stable surface for @getpipher/vision. +// Re-exports the capability/delegate/config pure functions so non-extension +// consumers (e.g. @getpipher/armory-fleet) can delegate without an ExtensionContext. +export { isMultimodal, TOOL_NAME } from "../lib/capability.ts"; +export { loadConfig, configFilePath, type VisionConfig } from "../lib/config.ts"; +export { + delegateToVisionModel, + createVisionDelegator, + type DelegateParams, + type DelegateResult, + type DelegateSuccess, + type DelegateFailure, + type VisionDelegator, + type VisionDelegatorDeps, + type ModelRegistryLike, +} from "../lib/delegate.ts"; \ No newline at end of file diff --git a/test/delegator.test.mts b/test/delegator.test.mts new file mode 100644 index 0000000..7210e8a --- /dev/null +++ b/test/delegator.test.mts @@ -0,0 +1,34 @@ +// delegator.test.mts — verifies createVisionDelegator + the public exports surface. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { isMultimodal, loadConfig, createVisionDelegator } from "../src/index.ts"; + +// A fake ModelRegistry slice (the { find, getApiKeyAndHeaders } delegateToVisionModel reads). +const fakeRegistry = { + find: () => undefined, + getApiKeyAndHeaders: async () => ({ ok: false, error: "no auth" }), +}; + +test("isMultimodal + loadConfig + createVisionDelegator are exported", () => { + assert.equal(typeof isMultimodal, "function"); + assert.equal(typeof loadConfig, "function"); + assert.equal(typeof createVisionDelegator, "function"); +}); + +test("createVisionDelegator returns a delegator with config + delegate fn", () => { + const d = createVisionDelegator({ modelRegistry: fakeRegistry as any, cwd: "/tmp", agentDir: "/tmp" }); + assert.equal(typeof d.delegate, "function"); + assert.equal(typeof d.config, "object"); + assert.equal(typeof d.config.defaultReasoningEffort, "string"); +}); + +test("delegator.delegate on an unconfigured vision.json returns an actionable error (no crash)", async () => { + const d = createVisionDelegator({ modelRegistry: fakeRegistry as any, cwd: "/tmp", agentDir: "/tmp/no-vision-config-here" }); + // default config has enabled:true but no provider/model → not_configured (isConfiguredForDelegation false) + const result = await d.delegate({ image_path: "/nonexistent.png", prompt: "describe", compress: false, reasoning: "off" }); + assert.equal(result.ok, false); + if (!result.ok) { + // not_configured is the expected path (no provider/model in default config); model_not_found/disabled also acceptable + assert.ok(["not_configured", "model_not_found", "disabled"].includes(result.error.code)); + } +}); \ No newline at end of file