privateer-agent 0.12.45 → 0.12.47
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/bin/privateer-subagent.mjs +1 -0
- package/extensions/privateer-vision.ts +39 -0
- package/package.json +1 -1
- package/patches/@earendil-works+pi-coding-agent+0.86.0.patch +140 -1
- package/src/cli/chat.ts +1 -1
- package/src/config/moat.ts +5 -0
- package/src/config/moatManifest.json +1 -0
- package/src/engine/errors.ts +4 -1
- package/src/providers/visionDelegate.ts +199 -0
|
@@ -55,6 +55,7 @@ export function moatExtensionPaths(repoRoot = REPO, env = process.env) {
|
|
|
55
55
|
join(repoRoot, "extensions", "privateer-privacy.ts"),
|
|
56
56
|
join(repoRoot, "extensions", "privateer-account.ts"),
|
|
57
57
|
join(repoRoot, "extensions", "privateer-media.ts"),
|
|
58
|
+
join(repoRoot, "extensions", "privateer-vision.ts"),
|
|
58
59
|
];
|
|
59
60
|
}
|
|
60
61
|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Vision delegation: when the current model can't see images, a vision model on the
|
|
2
|
+
// same provider (same key) describes them, and the description is what gets sent.
|
|
3
|
+
// All the policy — which delegate, why never another provider, what gets rewritten —
|
|
4
|
+
// lives in src/providers/visionDelegate.ts; this file is only the Pi wiring.
|
|
5
|
+
//
|
|
6
|
+
// It hooks `context`, which runs before EVERY LLM call on a per-request copy of the
|
|
7
|
+
// messages, rather than `input`/`tool_result`. That catches images from every source
|
|
8
|
+
// (@file mentions, pasted images, `read`, media and computer tools, MCP results, and
|
|
9
|
+
// history from before a /model switch) while leaving the session's own copy intact.
|
|
10
|
+
// Descriptions are cached by image hash, so each image costs one delegate call per
|
|
11
|
+
// process, not one per turn.
|
|
12
|
+
|
|
13
|
+
import { describeImagesInMessages, pickVisionDelegate } from "../src/providers/visionDelegate.ts";
|
|
14
|
+
|
|
15
|
+
export default function privateerVision(pi: any): void {
|
|
16
|
+
const announced = new Set<string>();
|
|
17
|
+
|
|
18
|
+
pi.on("context", async (event: any, ctx: any) => {
|
|
19
|
+
const model = ctx?.model;
|
|
20
|
+
const registry = ctx?.modelRegistry;
|
|
21
|
+
if (!model || !registry) return;
|
|
22
|
+
const delegate = pickVisionDelegate(model, registry);
|
|
23
|
+
if (!delegate) return;
|
|
24
|
+
|
|
25
|
+
const currentSpec = `${model.provider}/${model.id}`;
|
|
26
|
+
const delegateSpec = `${delegate.provider}/${delegate.id}`;
|
|
27
|
+
const messages = await describeImagesInMessages(event.messages, delegate, registry, {
|
|
28
|
+
currentSpec,
|
|
29
|
+
signal: ctx.signal,
|
|
30
|
+
onDescribe: () => {
|
|
31
|
+
const key = `${currentSpec}→${delegateSpec}`;
|
|
32
|
+
if (announced.has(key) || !ctx.hasUI) return;
|
|
33
|
+
announced.add(key);
|
|
34
|
+
ctx.ui.notify(`${currentSpec} can't see images — ${delegateSpec} will describe them for it.`, "info");
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
return messages ? { messages } : undefined;
|
|
38
|
+
});
|
|
39
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "privateer-agent",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.47",
|
|
4
4
|
"description": "Privacy-first terminal coding agent — bring your own model across 20 providers (Anthropic, OpenAI, OpenRouter, Google, local Ollama…). Safe-by-default permissions, MCP, sub-agents, workflows, and verifiable TEE inference. Built on the Pi toolkit.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -1025,6 +1025,19 @@ index d101003..540b972 100644
|
|
|
1025
1025
|
return "project";
|
|
1026
1026
|
}
|
|
1027
1027
|
return "path";
|
|
1028
|
+
diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/core/slash-commands.js b/node_modules/@earendil-works/pi-coding-agent/dist/core/slash-commands.js
|
|
1029
|
+
index 170a066..b535f72 100644
|
|
1030
|
+
--- a/node_modules/@earendil-works/pi-coding-agent/dist/core/slash-commands.js
|
|
1031
|
+
+++ b/node_modules/@earendil-works/pi-coding-agent/dist/core/slash-commands.js
|
|
1032
|
+
@@ -8,7 +8,7 @@ export const BUILTIN_SLASH_COMMANDS = [
|
|
1033
|
+
{ name: "export", description: "Export session (HTML default, or specify path: .html/.jsonl)" },
|
|
1034
|
+
{ name: "import", description: "Import and resume a session from a JSONL file" },
|
|
1035
|
+
{ name: "share", description: "Share session as a secret GitHub gist" },
|
|
1036
|
+
- { name: "bug", description: "Report a bug to the Pi developers", argumentHint: "<description>" },
|
|
1037
|
+
+ { name: "bug", description: "Export a bug report to email to Privateer support", argumentHint: "<description>" },
|
|
1038
|
+
{ name: "copy", description: "Copy last agent message to clipboard" },
|
|
1039
|
+
{ name: "name", description: "Set session display name" },
|
|
1040
|
+
{ name: "session", description: "Show session info and stats" },
|
|
1028
1041
|
diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/core/tools/output-accumulator.js b/node_modules/@earendil-works/pi-coding-agent/dist/core/tools/output-accumulator.js
|
|
1029
1042
|
index 7241668..7b79305 100644
|
|
1030
1043
|
--- a/node_modules/@earendil-works/pi-coding-agent/dist/core/tools/output-accumulator.js
|
|
@@ -1292,6 +1305,123 @@ index c30d6b8..ad1654a 100644
|
|
|
1292
1305
|
];
|
|
1293
1306
|
return warnings;
|
|
1294
1307
|
}
|
|
1308
|
+
diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/bug-report.d.ts b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/bug-report.d.ts
|
|
1309
|
+
index 7eae594..b21fae3 100644
|
|
1310
|
+
--- a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/bug-report.d.ts
|
|
1311
|
+
+++ b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/bug-report.d.ts
|
|
1312
|
+
@@ -8,7 +8,7 @@ interface BugReportContext {
|
|
1313
|
+
showStatus: (message: string) => void;
|
|
1314
|
+
showError: (message: string) => void;
|
|
1315
|
+
}
|
|
1316
|
+
-/** Run the `/bug` flow: consent, optional summary, then upload or export. */
|
|
1317
|
+
+/** Run the `/bug` flow: consent, optional summary, then export a zip to email to support. */
|
|
1318
|
+
export declare function reportBug(context: BugReportContext, initialHint?: string): Promise<void>;
|
|
1319
|
+
export {};
|
|
1320
|
+
//# sourceMappingURL=bug-report.d.ts.map
|
|
1321
|
+
|
|
1322
|
+
diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/bug-report.js b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/bug-report.js
|
|
1323
|
+
index 59ae447..68cf842 100644
|
|
1324
|
+
--- a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/bug-report.js
|
|
1325
|
+
+++ b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/bug-report.js
|
|
1326
|
+
@@ -1,18 +1,21 @@
|
|
1327
|
+
import * as path from "node:path";
|
|
1328
|
+
-import { getAuthCredential } from "../../cli/auth-command.js";
|
|
1329
|
+
import { BUG_REPORT_CUSTOM_ENTRY_TYPE, bugReportArchiveFileName, collectBugReportDiagnostics, collectBugReportMetadata, writeBugReportArchive, } from "../../core/bug-report.js";
|
|
1330
|
+
-import { uploadBugReport } from "../../core/bug-report-upload.js";
|
|
1331
|
+
import { clearCrashLog, readCrashLog } from "../../core/crash-log.js";
|
|
1332
|
+
-import { getRadiusGatewayUrl, RADIUS_PROVIDER_ID } from "../../core/radius.js";
|
|
1333
|
+
import { serializeSessionBranch } from "../../core/session-export.js";
|
|
1334
|
+
import { BorderedLoader } from "./components/bordered-loader.js";
|
|
1335
|
+
import { ExtensionInputComponent } from "./components/extension-input.js";
|
|
1336
|
+
import { ExtensionSelectorComponent } from "./components/extension-selector.js";
|
|
1337
|
+
import { createShareTrailingEntries } from "./session-share.js";
|
|
1338
|
+
import { theme } from "./theme/theme.js";
|
|
1339
|
+
-const DISCLAIMER = "This report goes to the Pi developers (Earendil) and is not shared publicly. It includes your pi version, operating system, the current model and provider configuration (without API keys), loaded extensions, settings, and provider error diagnostics from this session.";
|
|
1340
|
+
+// Privateer patch: stock Pi's `/bug` uploads this bundle straight to Earendil's Radius
|
|
1341
|
+
+// Gateway (session transcript, settings, environment) with no Privateer-controlled
|
|
1342
|
+
+// endpoint in between — not acceptable for a privacy-first tool. We keep the same
|
|
1343
|
+
+// consent/redaction flow but the only delivery is a local zip the user emails to us
|
|
1344
|
+
+// themselves, so nothing leaves the machine without them explicitly attaching it.
|
|
1345
|
+
+const SUPPORT_EMAIL = "support@privateer.pro";
|
|
1346
|
+
+const DISCLAIMER = `This report is written to a zip file on your machine only — nothing is uploaded automatically. It includes your Privateer version, operating system, the current model and provider configuration (without API keys), loaded extensions, settings, and provider error diagnostics from this session. Email the file to ${SUPPORT_EMAIL} to send it to us.`;
|
|
1347
|
+
const TRANSCRIPT_NOTE = "The transcript contains your messages, model output, tool calls and their results, including file contents and command output read during this session.";
|
|
1348
|
+
-/** Run the `/bug` flow: consent, optional summary, then upload or export. */
|
|
1349
|
+
+/** Run the `/bug` flow: consent, optional summary, then export a zip for the user to email us. */
|
|
1350
|
+
export async function reportBug(context, initialHint) {
|
|
1351
|
+
const options = await promptForOptions(context, initialHint);
|
|
1352
|
+
if (!options) {
|
|
1353
|
+
@@ -47,16 +50,6 @@ export async function reportBug(context, initialHint) {
|
|
1354
|
+
context.showError(`Failed to build bug report: ${errorMessage(error)}`);
|
|
1355
|
+
return;
|
|
1356
|
+
}
|
|
1357
|
+
- if (options.delivery === "upload") {
|
|
1358
|
+
- const failure = await upload(context, bundle);
|
|
1359
|
+
- if (failure === undefined)
|
|
1360
|
+
- return;
|
|
1361
|
+
- const fallback = await choose(context, "Upload failed", ["Export as Zip", "Cancel"], `${failure}\n\nExport the report as a zip archive instead?`);
|
|
1362
|
+
- if (fallback !== "Export as Zip") {
|
|
1363
|
+
- context.showStatus("Bug report cancelled");
|
|
1364
|
+
- return;
|
|
1365
|
+
- }
|
|
1366
|
+
- }
|
|
1367
|
+
await exportZip(context, bundle);
|
|
1368
|
+
}
|
|
1369
|
+
async function promptForOptions(context, initialHint) {
|
|
1370
|
+
@@ -76,14 +69,13 @@ async function promptForOptions(context, initialHint) {
|
|
1371
|
+
includeSummary = summary !== "No";
|
|
1372
|
+
}
|
|
1373
|
+
const description = hint.trim();
|
|
1374
|
+
- const delivery = await choose(context, "Bug report", ["Upload Report", "Export as Zip", "Cancel"], `Description: ${description || "none"}\nTranscript: ${includeSession ? "included" : "not included"}\nSummary: ${includeSummary ? `written by ${context.session.model?.name ?? "the current model"}` : "none"}\n\nUpload sends the report to ${new URL(getRadiusGatewayUrl()).host}. Export writes a zip archive to the current directory instead.`);
|
|
1375
|
+
- if (!delivery || delivery === "Cancel")
|
|
1376
|
+
+ const confirmed = await choose(context, "Bug report", ["Export Report", "Cancel"], `Description: ${description || "none"}\nTranscript: ${includeSession ? "included" : "not included"}\nSummary: ${includeSummary ? `written by ${context.session.model?.name ?? "the current model"}` : "none"}\n\nWrites a zip archive to the current directory. Email it to ${SUPPORT_EMAIL}.`);
|
|
1377
|
+
+ if (!confirmed || confirmed === "Cancel")
|
|
1378
|
+
return undefined;
|
|
1379
|
+
return {
|
|
1380
|
+
hint: description || undefined,
|
|
1381
|
+
includeSession,
|
|
1382
|
+
includeSummary,
|
|
1383
|
+
- delivery: delivery === "Upload Report" ? "upload" : "zip",
|
|
1384
|
+
};
|
|
1385
|
+
}
|
|
1386
|
+
function buildBundle(session, options, summary) {
|
|
1387
|
+
@@ -111,28 +103,6 @@ function buildBundle(session, options, summary) {
|
|
1388
|
+
: undefined,
|
|
1389
|
+
};
|
|
1390
|
+
}
|
|
1391
|
+
-async function upload(context, bundle) {
|
|
1392
|
+
- const loader = showLoader(context, "Uploading bug report...");
|
|
1393
|
+
- try {
|
|
1394
|
+
- const provider = context.session.modelRuntime.getProvider(RADIUS_PROVIDER_ID);
|
|
1395
|
+
- const token = provider
|
|
1396
|
+
- ? getAuthCredential(await context.session.modelRuntime.getAuth(RADIUS_PROVIDER_ID, { minOAuthValidityMs: 5 * 60_000 }))
|
|
1397
|
+
- : undefined;
|
|
1398
|
+
- const result = await uploadBugReport(bundle, { token, signal: loader.signal });
|
|
1399
|
+
- restoreEditor(context, loader);
|
|
1400
|
+
- recordInSession(context.session, bundle, { delivery: "upload" });
|
|
1401
|
+
- context.showStatus(`Bug report uploaded. Report ID: ${result.id}`);
|
|
1402
|
+
- return undefined;
|
|
1403
|
+
- }
|
|
1404
|
+
- catch (error) {
|
|
1405
|
+
- restoreEditor(context, loader);
|
|
1406
|
+
- if (loader.signal.aborted) {
|
|
1407
|
+
- context.showStatus("Bug report cancelled");
|
|
1408
|
+
- return undefined;
|
|
1409
|
+
- }
|
|
1410
|
+
- return errorMessage(error);
|
|
1411
|
+
- }
|
|
1412
|
+
-}
|
|
1413
|
+
async function exportZip(context, bundle) {
|
|
1414
|
+
const archivePath = path.join(process.cwd(), bugReportArchiveFileName(bundle.metadata.id));
|
|
1415
|
+
try {
|
|
1416
|
+
@@ -143,7 +113,7 @@ async function exportZip(context, bundle) {
|
|
1417
|
+
return;
|
|
1418
|
+
}
|
|
1419
|
+
recordInSession(context.session, bundle, { delivery: "zip", path: archivePath });
|
|
1420
|
+
- context.showStatus(`Bug report exported to: ${archivePath}\nReport ID: ${bundle.metadata.id}`);
|
|
1421
|
+
+ context.showStatus(`Bug report exported to: ${archivePath}\nReport ID: ${bundle.metadata.id}\n\nEmail this file to ${SUPPORT_EMAIL} to reach support.`);
|
|
1422
|
+
}
|
|
1423
|
+
function recordInSession(session, bundle, delivery) {
|
|
1424
|
+
session.sessionManager.appendCustomEntry(BUG_REPORT_CUSTOM_ENTRY_TYPE, {
|
|
1295
1425
|
diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/config-selector.js b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/config-selector.js
|
|
1296
1426
|
index 6f7ed6a..b0ed0ae 100644
|
|
1297
1427
|
--- a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/config-selector.js
|
|
@@ -1694,7 +1824,7 @@ index 6d0356c..ca0aa98 100644
|
|
|
1694
1824
|
if (content) {
|
|
1695
1825
|
text += `\n\n${content}`;
|
|
1696
1826
|
diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js
|
|
1697
|
-
index d82182c..
|
|
1827
|
+
index d82182c..2764952 100644
|
|
1698
1828
|
--- a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js
|
|
1699
1829
|
+++ b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js
|
|
1700
1830
|
@@ -10,7 +10,7 @@ import * as TuiLayouts from "@earendil-works/pi-tui";
|
|
@@ -1835,6 +1965,15 @@ index d82182c..41426bc 100644
|
|
|
1835
1965
|
// Check tmux keyboard setup asynchronously
|
|
1836
1966
|
this.checkTmuxKeyboardSetup().then((warning) => {
|
|
1837
1967
|
if (warning) {
|
|
1968
|
+
@@ -1568,7 +1635,7 @@ export class InteractiveMode {
|
|
1969
|
+
if (this.bugReportHintShown)
|
|
1970
|
+
return;
|
|
1971
|
+
this.bugReportHintShown = true;
|
|
1972
|
+
- this.chatContainer.addChild(new Text(theme.fg("muted", `If this looks like a ${APP_NAME} bug, /bug sends a report to the developers.`), this.outputPad, 0));
|
|
1973
|
+
+ this.chatContainer.addChild(new Text(theme.fg("muted", `If this looks like a ${APP_NAME} bug, /bug exports a report you can email to support@privateer.pro.`), this.outputPad, 0));
|
|
1974
|
+
this.ui.requestRender();
|
|
1975
|
+
}
|
|
1976
|
+
renderCurrentSessionState() {
|
|
1838
1977
|
@@ -2419,7 +2486,17 @@ export class InteractiveMode {
|
|
1839
1978
|
if (text === "/model" || text.startsWith("/model ")) {
|
|
1840
1979
|
const searchTerm = text.startsWith("/model ") ? text.slice(7).trim() : undefined;
|
package/src/cli/chat.ts
CHANGED
|
@@ -449,7 +449,7 @@ async function main() {
|
|
|
449
449
|
// Shown once per distinct non-vision spec (visionWarningAcknowledged), not on every
|
|
450
450
|
// launch — a user who keeps a deliberate non-vision pick already knows the tradeoff.
|
|
451
451
|
if (!acceptsImages(spec) && !visionWarningAcknowledged(spec)) {
|
|
452
|
-
console.log(`${YELLOW}⚠ ${provider}/${modelId} can't see images —
|
|
452
|
+
console.log(`${YELLOW}⚠ ${provider}/${modelId} can't see images — a vision model on the same key will describe them for it (none available → they're dropped). Run /model to switch.${RESET}`);
|
|
453
453
|
acknowledgeVisionWarning(spec);
|
|
454
454
|
}
|
|
455
455
|
if (noQuarterActive()) applyNoQuarter(true); // launched with --no-quarter: say so up front
|
package/src/config/moat.ts
CHANGED
|
@@ -284,6 +284,11 @@ export async function buildMoat(opts: MoatOptions): Promise<ExtensionFactory[]>
|
|
|
284
284
|
}
|
|
285
285
|
factories.push(makeAccountProvider()); // must follow pi-privacy — see header
|
|
286
286
|
|
|
287
|
+
// Every kind: a text-only model gets image descriptions from a vision model on the
|
|
288
|
+
// same key instead of silently losing the image. See providers/visionDelegate.ts.
|
|
289
|
+
const { default: privateerVision } = await import("../../extensions/privateer-vision.ts");
|
|
290
|
+
factories.push(privateerVision);
|
|
291
|
+
|
|
287
292
|
if (caps.context) {
|
|
288
293
|
const { default: privateerContext } = await import("../../extensions/privateer-context.ts");
|
|
289
294
|
factories.push(privateerContext);
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
{ "entry": "extensions/privateer-tools.ts", "name": "privateer-tools", "note": "Privateer tool pack" },
|
|
17
17
|
{ "entry": "extensions/privateer-privacy.ts", "name": "privateer-privacy", "note": "pi-privacy + account tier resolver" },
|
|
18
18
|
{ "entry": "extensions/privateer-connect.ts", "name": "privateer-connect", "note": "/connect — MCP connector manager" },
|
|
19
|
+
{ "entry": "extensions/privateer-vision.ts", "name": "privateer-vision", "note": "a vision model on the same key describes images for a text-only model" },
|
|
19
20
|
{ "entry": "extensions/privateer-media.ts", "name": "privateer-media", "note": "image/video/speech/music + ffmpeg compose" },
|
|
20
21
|
{ "entry": "extensions/privateer-computer.ts", "name": "privateer-computer", "note": "screen control — screenshots, mouse and keyboard (off unless armed with --allow-computer-control)" },
|
|
21
22
|
{ "entry": "extensions/privateer-desktop.ts", "name": "privateer-desktop", "note": "/desktop — open the Privateer desktop app" },
|
package/src/engine/errors.ts
CHANGED
|
@@ -359,8 +359,11 @@ export function describeError(err: unknown): DescribedError {
|
|
|
359
359
|
});
|
|
360
360
|
}
|
|
361
361
|
if (status === 429) {
|
|
362
|
+
// Never name the upstream provider here — OpenRouter is an implementation
|
|
363
|
+
// detail behind the Privateer account channel, and a plain "rate limited"
|
|
364
|
+
// is all a user needs to know regardless of who's actually throttling us.
|
|
362
365
|
return out({
|
|
363
|
-
message: `Rate limited
|
|
366
|
+
message: `Rate limited (429).`,
|
|
364
367
|
hint: "Wait a moment and try again.",
|
|
365
368
|
retryable: true,
|
|
366
369
|
});
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// Seeing on behalf of a model that can't.
|
|
2
|
+
//
|
|
3
|
+
// providers/vision.ts makes a text-only model HONEST about images — it declares
|
|
4
|
+
// `input: ["text"]`, and Pi then drops every image block on the way to the provider.
|
|
5
|
+
// Honest, but blind: a user on glm-5-2 who points the agent at a screenshot still
|
|
6
|
+
// gets an answer about a picture the model never saw, just with a note saying so.
|
|
7
|
+
//
|
|
8
|
+
// This module closes that gap. Before each LLM call (Pi's `context` event) every
|
|
9
|
+
// image in the outgoing messages is handed to a vision-capable model and replaced
|
|
10
|
+
// with that model's description — so the text-only model gets words where it would
|
|
11
|
+
// have got nothing. The session itself keeps the original images: only the
|
|
12
|
+
// per-request copy is rewritten, so switching to a vision model later still sends
|
|
13
|
+
// the real pixels.
|
|
14
|
+
//
|
|
15
|
+
// WHICH delegate — "the key type provided". The delegate must be reachable with the
|
|
16
|
+
// credential the user is ALREADY using, i.e. the same provider as the current model:
|
|
17
|
+
//
|
|
18
|
+
// privateer/* (account) → privateer/tinfoil/gemma4-31b, the confidential default
|
|
19
|
+
// tinfoil/* (TEE key) → tinfoil/gemma4-31b
|
|
20
|
+
// openrouter/* … → the best vision model that provider serves
|
|
21
|
+
//
|
|
22
|
+
// Never a different provider. Crossing providers would ship the user's screenshot
|
|
23
|
+
// to a company they did not pick for this session — on a privacy-first agent that
|
|
24
|
+
// is not a fallback, it's a leak. If the current provider has no vision model with
|
|
25
|
+
// working auth, nothing is rewritten and Pi's existing "image omitted" behaviour
|
|
26
|
+
// stands. PRIVATEER_VISION_MODEL=<provider/id> overrides the choice (and is the one
|
|
27
|
+
// deliberate way to cross providers).
|
|
28
|
+
|
|
29
|
+
import { createHash } from "node:crypto";
|
|
30
|
+
import { ACCOUNT_DEFAULT_MODEL_ID, TINFOIL_MODEL_ID } from "./defaultModel.ts";
|
|
31
|
+
|
|
32
|
+
export interface VisionModel {
|
|
33
|
+
provider: string;
|
|
34
|
+
id: string;
|
|
35
|
+
input: string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The slice of Pi's ModelRegistry this module needs (kept structural). */
|
|
39
|
+
export interface VisionRegistry {
|
|
40
|
+
getAvailable(): VisionModel[];
|
|
41
|
+
find(provider: string, id: string): VisionModel | undefined;
|
|
42
|
+
hasConfiguredAuth(model: VisionModel): boolean;
|
|
43
|
+
complete(model: any, context: any, options?: any): Promise<{ content: any[]; stopReason?: string; errorMessage?: string }>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const specOf = (m: { provider: string; id: string }) => `${m.provider}/${m.id}`;
|
|
47
|
+
const seesImages = (m: { input?: string[] }) => Array.isArray(m.input) && m.input.includes("image");
|
|
48
|
+
|
|
49
|
+
// Per-provider first choices, tried before "any vision model on this provider". Only
|
|
50
|
+
// providers where the pick matters are listed: on the account channel it keeps the
|
|
51
|
+
// image inside the attested Tinfoil enclave rather than whichever vision model sorts
|
|
52
|
+
// first; elsewhere the registry's own list is good enough.
|
|
53
|
+
const PREFERRED: Record<string, string[]> = {
|
|
54
|
+
privateer: [ACCOUNT_DEFAULT_MODEL_ID],
|
|
55
|
+
tinfoil: [TINFOIL_MODEL_ID.replace(/^tinfoil\//, "")],
|
|
56
|
+
openrouter: ["google/gemini-2.5-flash", "openai/gpt-4o-mini", "anthropic/claude-haiku-4.5"],
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The model that should look at images for `current`, or undefined when there is no
|
|
61
|
+
* reachable vision model on the same key. Returns undefined for a model that can
|
|
62
|
+
* already see — there is nothing to delegate.
|
|
63
|
+
*/
|
|
64
|
+
export function pickVisionDelegate(
|
|
65
|
+
current: VisionModel | undefined,
|
|
66
|
+
registry: VisionRegistry,
|
|
67
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
68
|
+
): VisionModel | undefined {
|
|
69
|
+
if (!current || seesImages(current)) return undefined;
|
|
70
|
+
const usable = (m: VisionModel | undefined): m is VisionModel =>
|
|
71
|
+
!!m && seesImages(m) && registry.hasConfiguredAuth(m);
|
|
72
|
+
|
|
73
|
+
const override = env.PRIVATEER_VISION_MODEL?.trim();
|
|
74
|
+
if (override) {
|
|
75
|
+
const slash = override.indexOf("/");
|
|
76
|
+
const m = slash > 0 ? registry.find(override.slice(0, slash), override.slice(slash + 1)) : undefined;
|
|
77
|
+
if (usable(m)) return m;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
for (const id of PREFERRED[current.provider] ?? []) {
|
|
81
|
+
const m = registry.find(current.provider, id);
|
|
82
|
+
if (usable(m)) return m;
|
|
83
|
+
}
|
|
84
|
+
return registry.getAvailable().find((m) => m.provider === current.provider && usable(m));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Pi's read tool appends this to an image it knows the model can't see. Once we've
|
|
88
|
+
// described the image the note is false, and leaving it in invites the model to tell
|
|
89
|
+
// the user it couldn't look.
|
|
90
|
+
const OMITTED_NOTE = /\n?\[Current model does not support images\. The image will be omitted from this request\.\]/g;
|
|
91
|
+
|
|
92
|
+
const SYSTEM_PROMPT = [
|
|
93
|
+
"You are the eyes for another AI model that cannot see images.",
|
|
94
|
+
"Describe the image so that model can act on it without seeing it.",
|
|
95
|
+
"Transcribe ALL legible text verbatim (code, error messages, labels, numbers), preserving line breaks.",
|
|
96
|
+
"Then describe layout, UI elements and their state, colors where meaningful, charts/diagrams and what they show, and anything unusual.",
|
|
97
|
+
"Be precise and factual. Do not speculate beyond what is visible. No preamble.",
|
|
98
|
+
].join(" ");
|
|
99
|
+
|
|
100
|
+
type Block = { type: string; text?: string; data?: string; mimeType?: string };
|
|
101
|
+
|
|
102
|
+
const cache = new Map<string, Promise<string>>();
|
|
103
|
+
|
|
104
|
+
/** Test hook: forget every cached description. */
|
|
105
|
+
export function clearVisionCache(): void {
|
|
106
|
+
cache.clear();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function describe(
|
|
110
|
+
registry: VisionRegistry,
|
|
111
|
+
delegate: VisionModel,
|
|
112
|
+
image: Block,
|
|
113
|
+
hint: string,
|
|
114
|
+
signal?: AbortSignal,
|
|
115
|
+
): Promise<string> {
|
|
116
|
+
const key = `${specOf(delegate)}:${createHash("sha256").update(image.data ?? "").digest("hex")}`;
|
|
117
|
+
const hit = cache.get(key);
|
|
118
|
+
if (hit) return hit;
|
|
119
|
+
const job = (async () => {
|
|
120
|
+
const res = await registry.complete(
|
|
121
|
+
delegate,
|
|
122
|
+
{
|
|
123
|
+
systemPrompt: SYSTEM_PROMPT,
|
|
124
|
+
messages: [
|
|
125
|
+
{
|
|
126
|
+
role: "user",
|
|
127
|
+
content: [
|
|
128
|
+
{ type: "text", text: hint ? `Context it appeared in:\n${hint.slice(0, 2000)}\n\nDescribe this image.` : "Describe this image." },
|
|
129
|
+
{ type: "image", data: image.data, mimeType: image.mimeType },
|
|
130
|
+
],
|
|
131
|
+
timestamp: Date.now(),
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
},
|
|
135
|
+
{ signal, cacheRetention: "none" },
|
|
136
|
+
);
|
|
137
|
+
if (res.stopReason === "error" || res.stopReason === "aborted") {
|
|
138
|
+
throw new Error(res.errorMessage || `vision model ${res.stopReason}`);
|
|
139
|
+
}
|
|
140
|
+
const text = res.content.filter((c) => c?.type === "text").map((c) => c.text).join("\n").trim();
|
|
141
|
+
if (!text) throw new Error("vision model returned no description");
|
|
142
|
+
return text;
|
|
143
|
+
})();
|
|
144
|
+
cache.set(key, job);
|
|
145
|
+
// A failure must not stick: the next turn should get another try.
|
|
146
|
+
job.catch(() => cache.delete(key));
|
|
147
|
+
return job;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Rewrite `messages` so every image block becomes a text description from
|
|
152
|
+
* `delegate`. Returns undefined when there was nothing to rewrite. Never throws: an
|
|
153
|
+
* image that can't be described becomes a note saying so (Pi would have dropped it
|
|
154
|
+
* anyway), so one bad image can't fail the turn.
|
|
155
|
+
*/
|
|
156
|
+
export async function describeImagesInMessages(
|
|
157
|
+
messages: any[],
|
|
158
|
+
delegate: VisionModel,
|
|
159
|
+
registry: VisionRegistry,
|
|
160
|
+
opts: { currentSpec: string; signal?: AbortSignal; onDescribe?: (count: number) => void } = { currentSpec: "" },
|
|
161
|
+
): Promise<any[] | undefined> {
|
|
162
|
+
const jobs: Array<{ block: Block; hint: string }> = [];
|
|
163
|
+
for (const msg of messages) {
|
|
164
|
+
if (!msg || msg.role === "assistant" || !Array.isArray(msg.content)) continue;
|
|
165
|
+
const hint = (msg.content as Block[])
|
|
166
|
+
.filter((b) => b?.type === "text")
|
|
167
|
+
.map((b) => b.text ?? "")
|
|
168
|
+
.join("\n")
|
|
169
|
+
.replace(OMITTED_NOTE, "");
|
|
170
|
+
for (const b of msg.content as Block[]) if (b?.type === "image" && b.data) jobs.push({ block: b, hint });
|
|
171
|
+
}
|
|
172
|
+
if (jobs.length === 0) return undefined;
|
|
173
|
+
opts.onDescribe?.(jobs.length);
|
|
174
|
+
|
|
175
|
+
const results = new Map<Block, string>();
|
|
176
|
+
await Promise.all(
|
|
177
|
+
jobs.map(async ({ block, hint }) => {
|
|
178
|
+
try {
|
|
179
|
+
const text = await describe(registry, delegate, block, hint, opts.signal);
|
|
180
|
+
results.set(block, `[Image — ${opts.currentSpec || "this model"} can't see images, so ${specOf(delegate)} described it:]\n${text}`);
|
|
181
|
+
} catch (err) {
|
|
182
|
+
const why = err instanceof Error ? err.message : String(err);
|
|
183
|
+
results.set(block, `[Image omitted — ${specOf(delegate)} could not describe it: ${why}]`);
|
|
184
|
+
}
|
|
185
|
+
}),
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
return messages.map((msg) => {
|
|
189
|
+
if (!msg || msg.role === "assistant" || !Array.isArray(msg.content)) return msg;
|
|
190
|
+
if (!(msg.content as Block[]).some((b) => results.has(b))) return msg;
|
|
191
|
+
const content = (msg.content as Block[]).map((b) => {
|
|
192
|
+
const described = results.get(b);
|
|
193
|
+
if (described !== undefined) return { type: "text", text: described };
|
|
194
|
+
if (b?.type === "text" && b.text) return { ...b, text: b.text.replace(OMITTED_NOTE, "") };
|
|
195
|
+
return b;
|
|
196
|
+
});
|
|
197
|
+
return { ...msg, content };
|
|
198
|
+
});
|
|
199
|
+
}
|