privateer-agent 0.12.22 → 0.12.23
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-launch.mjs +2 -2
- package/extensions/privateer-privacy.ts +8 -4
- package/package.json +1 -1
- package/patches/@earendil-works+pi-coding-agent+0.84.1.patch +211 -18
- package/src/engine/errors.ts +130 -0
- package/src/permissions/classify.ts +14 -1
- package/src/providers/account.ts +10 -5
- package/src/providers/defaultModel.ts +42 -9
- package/src/providers/vision.ts +95 -0
- package/src/tools/media.ts +299 -0
package/bin/privateer-launch.mjs
CHANGED
|
@@ -405,11 +405,11 @@ else {
|
|
|
405
405
|
const signedIn = fs.existsSync(CRED);
|
|
406
406
|
// Mirrors TINFOIL_MODEL_ID in src/providers/defaultModel.ts — keep them in step; that
|
|
407
407
|
// file carries the measurements behind the choice.
|
|
408
|
-
const ACCOUNT_MODEL = "privateer/tinfoil/
|
|
408
|
+
const ACCOUNT_MODEL = "privateer/tinfoil/gemma4-31b";
|
|
409
409
|
const MODEL = process.env.PRIVATEER_MODEL
|
|
410
410
|
? process.env.PRIVATEER_MODEL
|
|
411
411
|
: haveTinfoilKey()
|
|
412
|
-
? "tinfoil/
|
|
412
|
+
? "tinfoil/gemma4-31b"
|
|
413
413
|
: signedIn
|
|
414
414
|
? ACCOUNT_MODEL
|
|
415
415
|
: haveKey("ANTHROPIC_API_KEY")
|
|
@@ -24,19 +24,20 @@
|
|
|
24
24
|
// display/resolution + routing list — posture and attestation are dispatcher-bound and
|
|
25
25
|
// unaffected by the model set.
|
|
26
26
|
import { registerAccountModels } from "../src/providers/account.ts";
|
|
27
|
+
import { visionInput } from "../src/providers/vision.ts";
|
|
27
28
|
import { privacyExtension } from "../src/config/privacyPolicy.ts";
|
|
28
29
|
|
|
29
|
-
// Tinfoil's live chat models (inference.tinfoil.sh/v1/models),
|
|
30
|
-
// launcher's default. Non-chat endpoints (embeddings, tts, whisper, websearch,
|
|
30
|
+
// Tinfoil's live chat models (inference.tinfoil.sh/v1/models), gemma4-31b first — the
|
|
31
|
+
// launcher's default, and the only one here that can see an image. Non-chat endpoints (embeddings, tts, whisper, websearch,
|
|
31
32
|
// doc-upload) are intentionally omitted. Refresh from the live catalog if Tinfoil adds
|
|
32
33
|
// models; this static list just needs to cover what we default to and commonly pick.
|
|
33
34
|
const TINFOIL_MODELS = [
|
|
35
|
+
"gemma4-31b",
|
|
34
36
|
"kimi-k2-6",
|
|
35
37
|
"glm-5-2",
|
|
36
38
|
"deepseek-v4-pro",
|
|
37
39
|
"gpt-oss-120b",
|
|
38
40
|
"gpt-oss-safeguard-120b",
|
|
39
|
-
"gemma4-31b",
|
|
40
41
|
"llama3-3-70b",
|
|
41
42
|
];
|
|
42
43
|
|
|
@@ -45,7 +46,10 @@ function tinfoilModel(id: string) {
|
|
|
45
46
|
id,
|
|
46
47
|
name: id,
|
|
47
48
|
reasoning: false,
|
|
48
|
-
|
|
49
|
+
// Tinfoil ids are bare here (`gemma4-31b`), so scope it before asking — the
|
|
50
|
+
// allowlist is written against full `provider/model` ids. Getting this wrong is
|
|
51
|
+
// not cosmetic: `input` is what Pi checks before it will send an image at all.
|
|
52
|
+
input: visionInput(`tinfoil/${id}`),
|
|
49
53
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
50
54
|
contextWindow: 128000,
|
|
51
55
|
maxTokens: 4096,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "privateer-agent",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.23",
|
|
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",
|
|
@@ -68,10 +68,10 @@ index 4600b23..075ecae 100644
|
|
|
68
68
|
export const ENV_AGENT_DIR = `${APP_NAME.toUpperCase()}_CODING_AGENT_DIR`;
|
|
69
69
|
export const ENV_SESSION_DIR = `${APP_NAME.toUpperCase()}_CODING_AGENT_SESSION_DIR`;
|
|
70
70
|
diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js b/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js
|
|
71
|
-
index ce8a9a2..
|
|
71
|
+
index ce8a9a2..679e584 100644
|
|
72
72
|
--- a/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js
|
|
73
73
|
+++ b/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js
|
|
74
|
-
@@ -38,6 +38,
|
|
74
|
+
@@ -38,6 +38,111 @@ import { createLocalBashOperations } from "./tools/bash.js";
|
|
75
75
|
import { createAllToolDefinitions } from "./tools/index.js";
|
|
76
76
|
import { createToolDefinitionFromAgentTool } from "./tools/tool-definition-wrapper.js";
|
|
77
77
|
import { addUsageToTotals, createUsageTotals } from "./usage-totals.js";
|
|
@@ -138,11 +138,61 @@ index ce8a9a2..b7b339e 100644
|
|
|
138
138
|
+ return false;
|
|
139
139
|
+ const status = Number(m[1]);
|
|
140
140
|
+ return status >= 400 && status < 500 && !PV_TRANSIENT_CLIENT_STATUS.has(status);
|
|
141
|
+
+}
|
|
142
|
+
+// Privateer patch: throttle handling. Mirrors isThrottleFailure / retryAfterMs /
|
|
143
|
+
+// retryDelayMs in src/engine/errors.ts — see the incident note there.
|
|
144
|
+
+export function isThrottleFailure(text) {
|
|
145
|
+
+ return /^\s*429\b/.test(typeof text === "string" ? text : "");
|
|
146
|
+
+}
|
|
147
|
+
+const PV_MAX_RETRY_DELAY_MS = 60_000;
|
|
148
|
+
+const PV_RETRY_AFTER_PATTERNS = [
|
|
149
|
+
+ /retry-after(?:-ms)?["'\s:=]+(\d+(?:\.\d+)?)/i,
|
|
150
|
+
+ /(?:retry|try) again in (\d+(?:\.\d+)?)\s*(m?s|seconds?|minutes?)/i,
|
|
151
|
+
+ /retry after (\d+(?:\.\d+)?)\s*(m?s|seconds?|minutes?)/i,
|
|
152
|
+
+];
|
|
153
|
+
+export function retryAfterMs(text) {
|
|
154
|
+
+ const s = typeof text === "string" ? text : "";
|
|
155
|
+
+ for (const re of PV_RETRY_AFTER_PATTERNS) {
|
|
156
|
+
+ const m = re.exec(s);
|
|
157
|
+
+ if (!m)
|
|
158
|
+
+ continue;
|
|
159
|
+
+ const value = Number.parseFloat(m[1]);
|
|
160
|
+
+ if (!Number.isFinite(value) || value <= 0)
|
|
161
|
+
+ continue;
|
|
162
|
+
+ const unit = (m[2] ?? "").toLowerCase();
|
|
163
|
+
+ const ms = unit === "ms" || /retry-after-ms/i.test(m[0])
|
|
164
|
+
+ ? value
|
|
165
|
+
+ : unit.startsWith("m") && unit !== "ms"
|
|
166
|
+
+ ? value * 60_000
|
|
167
|
+
+ : value * 1000;
|
|
168
|
+
+ return Math.min(Math.max(Math.round(ms), 1_000), PV_MAX_RETRY_DELAY_MS);
|
|
169
|
+
+ }
|
|
170
|
+
+ return null;
|
|
171
|
+
+}
|
|
172
|
+
+// A server-stated delay wins outright — it is the only number here that is a fact.
|
|
173
|
+
+// The jitter is the point of the rest: without it every parallel request that tripped
|
|
174
|
+
+// the same limit wakes at the identical millisecond and trips it again.
|
|
175
|
+
+export function retryDelayMs(errorText, attempt, baseDelayMs) {
|
|
176
|
+
+ const stated = retryAfterMs(errorText);
|
|
177
|
+
+ if (stated != null)
|
|
178
|
+
+ return stated;
|
|
179
|
+
+ const n = Math.max(1, Math.floor(attempt));
|
|
180
|
+
+ const backoff = Math.min(baseDelayMs * 2 ** (n - 1), PV_MAX_RETRY_DELAY_MS);
|
|
181
|
+
+ return Math.round(backoff * (1 - Math.random() * 0.25));
|
|
141
182
|
+}
|
|
142
183
|
/**
|
|
143
184
|
* Parse a skill block from message text.
|
|
144
185
|
* Returns null if the text doesn't contain a skill block.
|
|
145
|
-
@@ -
|
|
186
|
+
@@ -102,6 +207,8 @@ export class AgentSession {
|
|
187
|
+
// Retry state
|
|
188
|
+
_retryAbortController = undefined;
|
|
189
|
+
_retryAttempt = 0;
|
|
190
|
+
+ // Privateer patch: when the provider last exhausted the retry budget on a 429.
|
|
191
|
+
+ _pvThrottledAt = 0;
|
|
192
|
+
// Bash execution state
|
|
193
|
+
_bashAbortControllers = new Set();
|
|
194
|
+
_pendingBashMessages = [];
|
|
195
|
+
@@ -186,6 +293,14 @@ export class AgentSession {
|
|
146
196
|
}
|
|
147
197
|
const isOAuth = this._modelRuntime.isUsingOAuth(model.provider);
|
|
148
198
|
if (isOAuth) {
|
|
@@ -157,7 +207,7 @@ index ce8a9a2..b7b339e 100644
|
|
|
157
207
|
throw new Error(`Authentication failed for "${model.provider}". ` +
|
|
158
208
|
`Credentials may have expired or network is unavailable. ` +
|
|
159
209
|
`Run '/login ${model.provider}' to re-authenticate.`);
|
|
160
|
-
@@ -360,6 +
|
|
210
|
+
@@ -360,6 +475,16 @@ export class AgentSession {
|
|
161
211
|
}
|
|
162
212
|
}
|
|
163
213
|
}
|
|
@@ -174,10 +224,18 @@ index ce8a9a2..b7b339e 100644
|
|
|
174
224
|
// Emit to extensions first
|
|
175
225
|
await this._emitExtensionEvent(event);
|
|
176
226
|
// Notify all listeners
|
|
177
|
-
@@ -772,6 +
|
|
227
|
+
@@ -772,6 +897,33 @@ export class AgentSession {
|
|
178
228
|
finalError: msg.errorMessage,
|
|
179
229
|
});
|
|
180
230
|
this._retryAttempt = 0;
|
|
231
|
+
+ // Privateer patch: remember that the endpoint is throttling us. The
|
|
232
|
+
+ // pre-prompt compaction in prompt() is an LLM call to this same endpoint,
|
|
233
|
+
+ // and firing it now just spends the summarizer budget on another 429 —
|
|
234
|
+
+ // which is what answered the user's next message with "Auto-compaction
|
|
235
|
+
+ // cancelled" instead of a reply. See the guard in prompt().
|
|
236
|
+
+ if (isThrottleFailure(msg.errorMessage)) {
|
|
237
|
+
+ this._pvThrottledAt = Date.now();
|
|
238
|
+
+ }
|
|
181
239
|
+ // Privateer patch: a transient error that survived the full retry budget
|
|
182
240
|
+ // is terminal. Ending the turn here (instead of falling through to
|
|
183
241
|
+ // compaction / queued-message continuation) prevents the agent loop from
|
|
@@ -200,7 +258,7 @@ index ce8a9a2..b7b339e 100644
|
|
|
200
258
|
}
|
|
201
259
|
if (await this._checkCompaction(msg)) {
|
|
202
260
|
return true;
|
|
203
|
-
@@ -852,6 +
|
|
261
|
+
@@ -852,6 +1004,14 @@ export class AgentSession {
|
|
204
262
|
if (!hasConfiguredAuth) {
|
|
205
263
|
const isOAuth = this._modelRuntime.isUsingOAuth(this.model.provider);
|
|
206
264
|
if (isOAuth) {
|
|
@@ -215,7 +273,22 @@ index ce8a9a2..b7b339e 100644
|
|
|
215
273
|
throw new Error(`Authentication failed for "${this.model.provider}". ` +
|
|
216
274
|
`Credentials may have expired or network is unavailable. ` +
|
|
217
275
|
`Run '/login ${this.model.provider}' to re-authenticate.`);
|
|
218
|
-
@@ -
|
|
276
|
+
@@ -861,7 +1021,13 @@ export class AgentSession {
|
|
277
|
+
// Check if we need to compact before sending (catches aborted responses).
|
|
278
|
+
// The user's new prompt is sent below, so do not call agent.continue() here.
|
|
279
|
+
const lastAssistant = this._findLastAssistantMessage();
|
|
280
|
+
- if (lastAssistant) {
|
|
281
|
+
+ // Privateer patch: skip this while the provider is still throttling us.
|
|
282
|
+
+ // Compaction here is another LLM call to the endpoint that just spent our
|
|
283
|
+
+ // whole retry budget on 429s; it cannot succeed, and its failure is reported
|
|
284
|
+
+ // as "Auto-compaction cancelled" — burying the user's actual prompt. One
|
|
285
|
+
+ // rate-limit window of patience, then we try again as normal. The context is
|
|
286
|
+
+ // not lost: the next turn re-checks compaction once the window has passed.
|
|
287
|
+
+ if (lastAssistant && Date.now() - this._pvThrottledAt >= PV_MAX_RETRY_DELAY_MS) {
|
|
288
|
+
await this._checkCompaction(lastAssistant, false);
|
|
289
|
+
}
|
|
290
|
+
// Build messages array (custom message if any, then user message)
|
|
291
|
+
@@ -2084,6 +2250,27 @@ export class AgentSession {
|
|
219
292
|
// Context overflow is handled by compaction, not retry.
|
|
220
293
|
if (isContextOverflow(message, this.model?.contextWindow ?? 0))
|
|
221
294
|
return false;
|
|
@@ -243,6 +316,19 @@ index ce8a9a2..b7b339e 100644
|
|
|
243
316
|
return isRetryableAssistantError(message);
|
|
244
317
|
}
|
|
245
318
|
/**
|
|
319
|
+
@@ -2129,7 +2316,11 @@ export class AgentSession {
|
|
320
|
+
this._retryAttempt--;
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
- const delayMs = settings.baseDelayMs * 2 ** (this._retryAttempt - 1);
|
|
324
|
+
+ // Privateer patch: honour a server-stated `retry-after` and jitter the fallback.
|
|
325
|
+
+ // Stock Pi's fixed 2s/4s/8s ladder spends its whole budget inside a rate-limit
|
|
326
|
+
+ // window it was never long enough to outlast, and fires every parallel retry at
|
|
327
|
+
+ // the same instant. See retryDelayMs above.
|
|
328
|
+
+ const delayMs = retryDelayMs(message.errorMessage, this._retryAttempt, settings.baseDelayMs);
|
|
329
|
+
this._emit({
|
|
330
|
+
type: "auto_retry_start",
|
|
331
|
+
attempt: this._retryAttempt,
|
|
246
332
|
diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/core/auth-guidance.js b/node_modules/@earendil-works/pi-coding-agent/dist/core/auth-guidance.js
|
|
247
333
|
index 197bccc..bc9ac3f 100644
|
|
248
334
|
--- a/node_modules/@earendil-works/pi-coding-agent/dist/core/auth-guidance.js
|
|
@@ -681,7 +767,7 @@ index 97af6cd..74d5d52 100644
|
|
|
681
767
|
}
|
|
682
768
|
const globalPath = join(this.agentDir, "APPEND_SYSTEM.md");
|
|
683
769
|
diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/core/settings-manager.js b/node_modules/@earendil-works/pi-coding-agent/dist/core/settings-manager.js
|
|
684
|
-
index cb06c10..
|
|
770
|
+
index cb06c10..41fb90c 100644
|
|
685
771
|
--- a/node_modules/@earendil-works/pi-coding-agent/dist/core/settings-manager.js
|
|
686
772
|
+++ b/node_modules/@earendil-works/pi-coding-agent/dist/core/settings-manager.js
|
|
687
773
|
@@ -2,7 +2,7 @@ import { randomUUID } from "crypto";
|
|
@@ -822,6 +908,23 @@ index cb06c10..930a0de 100644
|
|
|
822
908
|
const dir = dirname(path);
|
|
823
909
|
let release;
|
|
824
910
|
try {
|
|
911
|
+
@@ -568,7 +672,15 @@ export class SettingsManager {
|
|
912
|
+
getProviderRetrySettings() {
|
|
913
|
+
return {
|
|
914
|
+
timeoutMs: this.settings.retry?.provider?.timeoutMs,
|
|
915
|
+
- maxRetries: this.settings.retry?.provider?.maxRetries,
|
|
916
|
+
+ // Privateer patch: give the provider-level retry a real default. pi-ai's
|
|
917
|
+
+ // retryProviderRequest is the only layer that reads `retry-after` and jitters,
|
|
918
|
+
+ // but it takes `options.maxRetries ?? 0` and stock Pi hands it `undefined` — so
|
|
919
|
+
+ // it ran ZERO retries and every throttle fell through to the session-level loop
|
|
920
|
+
+ // (3 attempts, fixed 2s/4s/8s, no retry-after), which cannot outlast a 60s rate
|
|
921
|
+
+ // limit window. A bare `429` from the account edge exhausted the budget in ~14s.
|
|
922
|
+
+ // 2 here is deliberately small: it is a *per-attempt* budget that nests inside
|
|
923
|
+
+ // the session retry, and each of its waits already honours the server's number.
|
|
924
|
+
+ maxRetries: this.settings.retry?.provider?.maxRetries ?? 2,
|
|
925
|
+
maxRetryDelayMs: this.settings.retry?.provider?.maxRetryDelayMs ?? 60000,
|
|
926
|
+
};
|
|
927
|
+
}
|
|
825
928
|
diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/core/skills.js b/node_modules/@earendil-works/pi-coding-agent/dist/core/skills.js
|
|
826
929
|
index 4e7e784..7383a2b 100644
|
|
827
930
|
--- a/node_modules/@earendil-works/pi-coding-agent/dist/core/skills.js
|
|
@@ -1307,7 +1410,7 @@ index 1d9f046..c0326e3 100644
|
|
|
1307
1410
|
return lines;
|
|
1308
1411
|
}
|
|
1309
1412
|
diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/tool-execution.js b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/tool-execution.js
|
|
1310
|
-
index 3f93cc6..
|
|
1413
|
+
index 3f93cc6..50a2a99 100644
|
|
1311
1414
|
--- a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/tool-execution.js
|
|
1312
1415
|
+++ b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/tool-execution.js
|
|
1313
1416
|
@@ -1,8 +1,35 @@
|
|
@@ -1347,7 +1450,23 @@ index 3f93cc6..bbe2c10 100644
|
|
|
1347
1450
|
export class ToolExecutionComponent extends Container {
|
|
1348
1451
|
contentBox;
|
|
1349
1452
|
contentText;
|
|
1350
|
-
@@ -
|
|
1453
|
+
@@ -43,8 +70,13 @@ export class ToolExecutionComponent extends Container {
|
|
1454
|
+
// Always create all shell variants. contentBox is used for default renderer-based composition.
|
|
1455
|
+
// selfRenderContainer is used when the tool renders its own framing.
|
|
1456
|
+
// contentText is reserved for generic fallback rendering when no tool definition exists.
|
|
1457
|
+
- this.contentBox = new Box(1, 1, (text) => theme.bg("toolPendingBg", text));
|
|
1458
|
+
- this.contentText = new Text("", 1, 1, (text) => theme.bg("toolPendingBg", text));
|
|
1459
|
+
+ // Privateer: paddingY is 0 because we flatten the tool-box washes (dark.json /
|
|
1460
|
+
+ // light.json). With no background to fill, upstream's paddingY=1 reads as a blank
|
|
1461
|
+
+ // line above AND below every call, stacking with the Spacer(1) into three empty
|
|
1462
|
+
+ // rows between consecutive tools. 0 leaves exactly the one Spacer row, which also
|
|
1463
|
+
+ // matches what the self-render path above emits (a single lines.push("")).
|
|
1464
|
+
+ this.contentBox = new Box(1, 0, (text) => theme.bg("toolPendingBg", text));
|
|
1465
|
+
+ this.contentText = new Text("", 1, 0, (text) => theme.bg("toolPendingBg", text));
|
|
1466
|
+
this.selfRenderContainer = new Container();
|
|
1467
|
+
if (this.hasRendererDefinition()) {
|
|
1468
|
+
this.addChild(this.getRenderShell() === "self" ? this.selfRenderContainer : this.contentBox);
|
|
1469
|
+
@@ -106,6 +138,13 @@ export class ToolExecutionComponent extends Container {
|
|
1351
1470
|
createCallFallback() {
|
|
1352
1471
|
return new Text(theme.fg("toolTitle", theme.bold(this.toolName)), 0, 0);
|
|
1353
1472
|
}
|
|
@@ -1361,7 +1480,7 @@ index 3f93cc6..bbe2c10 100644
|
|
|
1361
1480
|
createResultFallback() {
|
|
1362
1481
|
const output = this.getTextOutput();
|
|
1363
1482
|
if (!output) {
|
|
1364
|
-
@@ -218,19 +
|
|
1483
|
+
@@ -218,19 +257,22 @@ export class ToolExecutionComponent extends Container {
|
|
1365
1484
|
renderContainer.clear();
|
|
1366
1485
|
const callRenderer = this.getCallRenderer();
|
|
1367
1486
|
if (!callRenderer) {
|
|
@@ -1387,7 +1506,7 @@ index 3f93cc6..bbe2c10 100644
|
|
|
1387
1506
|
hasContent = true;
|
|
1388
1507
|
}
|
|
1389
1508
|
}
|
|
1390
|
-
@@ -302,7 +
|
|
1509
|
+
@@ -302,7 +344,8 @@ export class ToolExecutionComponent extends Container {
|
|
1391
1510
|
return getRenderedTextOutput(this.result, this.showImages);
|
|
1392
1511
|
}
|
|
1393
1512
|
formatToolExecution() {
|
|
@@ -1398,7 +1517,7 @@ index 3f93cc6..bbe2c10 100644
|
|
|
1398
1517
|
if (content) {
|
|
1399
1518
|
text += `\n\n${content}`;
|
|
1400
1519
|
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
|
|
1401
|
-
index 42e655d..
|
|
1520
|
+
index 42e655d..606c7cc 100644
|
|
1402
1521
|
--- a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js
|
|
1403
1522
|
+++ b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js
|
|
1404
1523
|
@@ -10,7 +10,7 @@ import * as TuiLayouts from "@earendil-works/pi-tui";
|
|
@@ -1424,7 +1543,62 @@ index 42e655d..d00bec9 100644
|
|
|
1424
1543
|
if (!sessionManager.usesDefaultSessionDir()) {
|
|
1425
1544
|
args.push("--session-dir", quoteIfNeeded(sessionManager.getSessionDir()));
|
|
1426
1545
|
}
|
|
1427
|
-
@@ -
|
|
1546
|
+
@@ -207,6 +212,54 @@ export function createInteractiveTuiReference(getTui) {
|
|
1547
|
+
getPrototypeOf: () => Reflect.getPrototypeOf(getTui()),
|
|
1548
|
+
});
|
|
1549
|
+
}
|
|
1550
|
+
+// Privateer patch: describe an error we only have the TEXT of. Mirrors
|
|
1551
|
+
+// describeErrorText in src/engine/errors.ts — see the incident note there.
|
|
1552
|
+
+const PV_RETRY_AFTER_PATTERNS = [
|
|
1553
|
+
+ /retry-after(?:-ms)?["'\s:=]+(\d+(?:\.\d+)?)/i,
|
|
1554
|
+
+ /(?:retry|try) again in (\d+(?:\.\d+)?)\s*(m?s|seconds?|minutes?)/i,
|
|
1555
|
+
+ /retry after (\d+(?:\.\d+)?)\s*(m?s|seconds?|minutes?)/i,
|
|
1556
|
+
+];
|
|
1557
|
+
+function pvRetryAfterMs(text) {
|
|
1558
|
+
+ const s = typeof text === "string" ? text : "";
|
|
1559
|
+
+ for (const re of PV_RETRY_AFTER_PATTERNS) {
|
|
1560
|
+
+ const m = re.exec(s);
|
|
1561
|
+
+ if (!m)
|
|
1562
|
+
+ continue;
|
|
1563
|
+
+ const value = Number.parseFloat(m[1]);
|
|
1564
|
+
+ if (!Number.isFinite(value) || value <= 0)
|
|
1565
|
+
+ continue;
|
|
1566
|
+
+ const unit = (m[2] ?? "").toLowerCase();
|
|
1567
|
+
+ const ms = unit === "ms" || /retry-after-ms/i.test(m[0])
|
|
1568
|
+
+ ? value
|
|
1569
|
+
+ : unit.startsWith("m") && unit !== "ms"
|
|
1570
|
+
+ ? value * 60_000
|
|
1571
|
+
+ : value * 1000;
|
|
1572
|
+
+ return Math.min(Math.max(Math.round(ms), 1_000), 60_000);
|
|
1573
|
+
+ }
|
|
1574
|
+
+ return null;
|
|
1575
|
+
+}
|
|
1576
|
+
+function pvDescribeErrorText(text) {
|
|
1577
|
+
+ const s = typeof text === "string" ? text : "";
|
|
1578
|
+
+ const status = Number(/^\s*(\d{3})\b/.exec(s)?.[1] ?? NaN);
|
|
1579
|
+
+ if (!Number.isFinite(status))
|
|
1580
|
+
+ return null;
|
|
1581
|
+
+ if (status === 429) {
|
|
1582
|
+
+ const stated = pvRetryAfterMs(s);
|
|
1583
|
+
+ return {
|
|
1584
|
+
+ message: "Rate limited (429).",
|
|
1585
|
+
+ hint: stated
|
|
1586
|
+
+ ? `The provider asked for ${Math.ceil(stated / 1000)}s. Wait that long and send it again.`
|
|
1587
|
+
+ : "Wait a moment and send it again — or run /model to switch to another provider.",
|
|
1588
|
+
+ };
|
|
1589
|
+
+ }
|
|
1590
|
+
+ if (status === 401 || status === 403)
|
|
1591
|
+
+ return { message: s, hint: "Check your credentials — run /login to re-authenticate." };
|
|
1592
|
+
+ if (status === 404)
|
|
1593
|
+
+ return { message: s, hint: "Check the model id — run /model to switch." };
|
|
1594
|
+
+ if (status >= 500)
|
|
1595
|
+
+ return { message: s, hint: "Usually transient — retry shortly." };
|
|
1596
|
+
+ return null;
|
|
1597
|
+
+}
|
|
1598
|
+
export class InteractiveMode {
|
|
1599
|
+
runtimeHost;
|
|
1600
|
+
renderer;
|
|
1601
|
+
@@ -405,9 +458,15 @@ export class InteractiveMode {
|
|
1428
1602
|
}
|
|
1429
1603
|
getBuiltInCommandConflictDiagnostics(extensionRunner) {
|
|
1430
1604
|
const builtinNames = new Set(BUILTIN_SLASH_COMMANDS.map((command) => command.name));
|
|
@@ -1441,7 +1615,7 @@ index 42e655d..d00bec9 100644
|
|
|
1441
1615
|
.map((command) => ({
|
|
1442
1616
|
type: "warning",
|
|
1443
1617
|
message: command.invocationName === command.name
|
|
1444
|
-
@@ -754,20 +
|
|
1618
|
+
@@ -754,20 +813,18 @@ export class InteractiveMode {
|
|
1445
1619
|
this.showNewVersionNotification(newRelease);
|
|
1446
1620
|
}
|
|
1447
1621
|
});
|
|
@@ -1474,7 +1648,7 @@ index 42e655d..d00bec9 100644
|
|
|
1474
1648
|
// Check tmux keyboard setup asynchronously
|
|
1475
1649
|
this.checkTmuxKeyboardSetup().then((warning) => {
|
|
1476
1650
|
if (warning) {
|
|
1477
|
-
@@ -2310,7 +
|
|
1651
|
+
@@ -2310,7 +2367,17 @@ export class InteractiveMode {
|
|
1478
1652
|
if (text === "/model" || text.startsWith("/model ")) {
|
|
1479
1653
|
const searchTerm = text.startsWith("/model ") ? text.slice(7).trim() : undefined;
|
|
1480
1654
|
this.editor.setText("");
|
|
@@ -1493,7 +1667,7 @@ index 42e655d..d00bec9 100644
|
|
|
1493
1667
|
return;
|
|
1494
1668
|
}
|
|
1495
1669
|
if (text === "/export" || text.startsWith("/export ")) {
|
|
1496
|
-
@@ -2376,12 +
|
|
1670
|
+
@@ -2376,12 +2443,42 @@ export class InteractiveMode {
|
|
1497
1671
|
if (text === "/login" || text.startsWith("/login ")) {
|
|
1498
1672
|
const providerRef = text.startsWith("/login ") ? text.slice(7).trim() : undefined;
|
|
1499
1673
|
this.editor.setText("");
|
|
@@ -1538,7 +1712,7 @@ index 42e655d..d00bec9 100644
|
|
|
1538
1712
|
return;
|
|
1539
1713
|
}
|
|
1540
1714
|
if (text === "/new") {
|
|
1541
|
-
@@ -3024,7 +
|
|
1715
|
+
@@ -3024,7 +3121,7 @@ export class InteractiveMode {
|
|
1542
1716
|
if (this.chatContainer.children.length > 0) {
|
|
1543
1717
|
this.chatContainer.addChild(new Spacer(1));
|
|
1544
1718
|
}
|
|
@@ -1547,6 +1721,25 @@ index 42e655d..d00bec9 100644
|
|
|
1547
1721
|
}
|
|
1548
1722
|
async getUserInput() {
|
|
1549
1723
|
const queuedInput = this.pendingUserInputs.shift();
|
|
1724
|
+
@@ -3363,7 +3460,17 @@ export class InteractiveMode {
|
|
1725
|
+
}
|
|
1726
|
+
showError(errorMessage) {
|
|
1727
|
+
this.chatContainer.addChild(new Spacer(1));
|
|
1728
|
+
- this.chatContainer.addChild(new Text(theme.fg("error", `Error: ${errorMessage}`), this.outputPad, 0));
|
|
1729
|
+
+ // Privateer patch: by the time an error reaches here it is only a string — the
|
|
1730
|
+
+ // structured fields describeError() reads are long gone, so the user was shown
|
|
1731
|
+
+ // the SDK's own words ("429 status code (no body)"), which say nothing about
|
|
1732
|
+
+ // what to do. The status is still readable at the front of that string; recover
|
|
1733
|
+
+ // it and say what describeError would. Mirrors describeErrorText in
|
|
1734
|
+
+ // src/engine/errors.ts. Anything without a leading status prints unchanged.
|
|
1735
|
+
+ const described = pvDescribeErrorText(errorMessage);
|
|
1736
|
+
+ this.chatContainer.addChild(new Text(theme.fg("error", `Error: ${described?.message ?? errorMessage}`), this.outputPad, 0));
|
|
1737
|
+
+ if (described?.hint) {
|
|
1738
|
+
+ this.chatContainer.addChild(new Text(theme.fg("muted", described.hint), this.outputPad, 0));
|
|
1739
|
+
+ }
|
|
1740
|
+
this.ui.requestRender();
|
|
1741
|
+
}
|
|
1742
|
+
showWarning(warningMessage) {
|
|
1550
1743
|
diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/theme/dark.json b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/theme/dark.json
|
|
1551
1744
|
index 9db9cbd..b370180 100644
|
|
1552
1745
|
--- a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/theme/dark.json
|
package/src/engine/errors.ts
CHANGED
|
@@ -348,3 +348,133 @@ export function describeError(err: unknown): DescribedError {
|
|
|
348
348
|
// arrives, so it goes through the compactor first.
|
|
349
349
|
return out({ message: compactProviderError(text) });
|
|
350
350
|
}
|
|
351
|
+
|
|
352
|
+
// ── Throttles: waiting the right amount, and only once ───────────────────────
|
|
353
|
+
//
|
|
354
|
+
// The incident this exists for: the account channel's edge answered a burst of
|
|
355
|
+
// turns with a bare `429` — no body, no JSON, just the status. Two retry layers
|
|
356
|
+
// exist and the wrong one was live. pi-ai's `retryProviderRequest` is the good
|
|
357
|
+
// one (it reads `retry-after`, jitters, caps at 60s) but it takes `maxRetries ??
|
|
358
|
+
// 0` and Pi's settings gave it no default, so it ran ZERO retries. All the
|
|
359
|
+
// patience came from the session-level loop: 3 attempts at 2s/4s/8s, no
|
|
360
|
+
// `retry-after`, no jitter — ~14 seconds against a window that is conventionally
|
|
361
|
+
// 60. Exhaustion was arithmetic, not bad luck. Then the pre-prompt compaction
|
|
362
|
+
// fired the summarizer into the same throttled endpoint and died there too, so
|
|
363
|
+
// the user's next message was answered with "Auto-compaction cancelled".
|
|
364
|
+
//
|
|
365
|
+
// The fix is three-part and mirrored into the Pi patch: give the provider layer a
|
|
366
|
+
// real retry budget, make the session layer's backoff honour a server-stated
|
|
367
|
+
// delay and jitter, and don't summarise into an endpoint that just throttled us.
|
|
368
|
+
|
|
369
|
+
/** A throttle can clear on its own — but only after the server's stated window. */
|
|
370
|
+
export function isThrottleFailure(text: string | null | undefined): boolean {
|
|
371
|
+
return /^\s*429\b/.test(typeof text === "string" ? text : "");
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Longest we will sit on a single backoff. Past this the user deserves the turn back. */
|
|
375
|
+
export const MAX_RETRY_DELAY_MS = 60_000;
|
|
376
|
+
|
|
377
|
+
// A bare 429 carries nothing, but a provider that bothers to explain itself puts the
|
|
378
|
+
// number in the text one of a few ways. Read it where it is offered; guess otherwise.
|
|
379
|
+
const RETRY_AFTER_PATTERNS = [
|
|
380
|
+
/retry-after(?:-ms)?["'\s:=]+(\d+(?:\.\d+)?)/i,
|
|
381
|
+
/(?:retry|try) again in (\d+(?:\.\d+)?)\s*(m?s|seconds?|minutes?)/i,
|
|
382
|
+
/retry after (\d+(?:\.\d+)?)\s*(m?s|seconds?|minutes?)/i,
|
|
383
|
+
];
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* The server's requested delay in ms, or null when it did not state one.
|
|
387
|
+
*
|
|
388
|
+
* `retry-after` is seconds by convention and `retry-after-ms` is milliseconds; a
|
|
389
|
+
* prose "try again in 30 seconds" carries its own unit. Values are clamped rather
|
|
390
|
+
* than rejected — a provider asking for an hour gets our ceiling, not a crash.
|
|
391
|
+
*/
|
|
392
|
+
export function retryAfterMs(text: string | null | undefined): number | null {
|
|
393
|
+
const s = typeof text === "string" ? text : "";
|
|
394
|
+
for (const re of RETRY_AFTER_PATTERNS) {
|
|
395
|
+
const m = re.exec(s);
|
|
396
|
+
if (!m) continue;
|
|
397
|
+
const value = Number.parseFloat(m[1]);
|
|
398
|
+
if (!Number.isFinite(value) || value <= 0) continue;
|
|
399
|
+
const unit = (m[2] ?? "").toLowerCase();
|
|
400
|
+
const ms =
|
|
401
|
+
unit === "ms" || /retry-after-ms/i.test(m[0])
|
|
402
|
+
? value
|
|
403
|
+
: unit.startsWith("m") && unit !== "ms"
|
|
404
|
+
? value * 60_000
|
|
405
|
+
: value * 1000;
|
|
406
|
+
return Math.min(Math.max(Math.round(ms), 1_000), MAX_RETRY_DELAY_MS);
|
|
407
|
+
}
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* How long the session-level retry should wait before attempt `attempt` (1-based).
|
|
413
|
+
*
|
|
414
|
+
* A server-stated delay wins outright — it is the only number here that is a fact.
|
|
415
|
+
* Otherwise back off exponentially from `baseDelayMs`, capped, and then jitter DOWN
|
|
416
|
+
* by up to 25%. The jitter is the point of the exercise: without it every parallel
|
|
417
|
+
* request that tripped the same limit wakes at the identical millisecond and trips
|
|
418
|
+
* it again, which is how one throttled turn becomes a throttled session.
|
|
419
|
+
*
|
|
420
|
+
* `rand` is injectable so the schedule can be asserted in a test.
|
|
421
|
+
*/
|
|
422
|
+
export function retryDelayMs(
|
|
423
|
+
errorText: string | null | undefined,
|
|
424
|
+
attempt: number,
|
|
425
|
+
baseDelayMs: number,
|
|
426
|
+
rand: () => number = Math.random,
|
|
427
|
+
): number {
|
|
428
|
+
const stated = retryAfterMs(errorText);
|
|
429
|
+
if (stated != null) return stated;
|
|
430
|
+
const n = Math.max(1, Math.floor(attempt));
|
|
431
|
+
const backoff = Math.min(baseDelayMs * 2 ** (n - 1), MAX_RETRY_DELAY_MS);
|
|
432
|
+
return Math.round(backoff * (1 - rand() * 0.25));
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Describe an error we only have the TEXT of, for the one place that has nothing else.
|
|
437
|
+
*
|
|
438
|
+
* `describeError` above reads structured fields off the error object; by the time a
|
|
439
|
+
* message reaches the TUI's `showError` those are gone and all that survives is a
|
|
440
|
+
* string the SDK built status-first ("429 status code (no body)"). That string is
|
|
441
|
+
* what the user was shown six times during the incident. Recover the status from it
|
|
442
|
+
* and say the same thing `describeError` would. Returns null when there is no leading
|
|
443
|
+
* status to read, so ordinary messages print unchanged.
|
|
444
|
+
*/
|
|
445
|
+
export function describeErrorText(text: string | null | undefined): DescribedError | null {
|
|
446
|
+
const s = typeof text === "string" ? text : "";
|
|
447
|
+
const status = Number(/^\s*(\d{3})\b/.exec(s)?.[1] ?? NaN);
|
|
448
|
+
if (!Number.isFinite(status)) return null;
|
|
449
|
+
|
|
450
|
+
if (status === 429) {
|
|
451
|
+
const stated = retryAfterMs(s);
|
|
452
|
+
return {
|
|
453
|
+
message: redactText(`Rate limited (429).`),
|
|
454
|
+
hint: stated
|
|
455
|
+
? `The provider asked for ${Math.ceil(stated / 1000)}s. Wait that long and send it again.`
|
|
456
|
+
: "Wait a moment and send it again — or run /model to switch to another provider.",
|
|
457
|
+
retryable: true,
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
if (status === 401 || status === 403) {
|
|
461
|
+
return {
|
|
462
|
+
message: redactText(compactProviderError(s)),
|
|
463
|
+
hint: "Check your credentials — run /login to re-authenticate.",
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
if (status === 404) {
|
|
467
|
+
return {
|
|
468
|
+
message: redactText(compactProviderError(s)),
|
|
469
|
+
hint: "Check the model id — run /model to switch.",
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
if (status >= 500) {
|
|
473
|
+
return {
|
|
474
|
+
message: redactText(compactProviderError(s)),
|
|
475
|
+
hint: "Usually transient — retry shortly.",
|
|
476
|
+
retryable: true,
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
return null;
|
|
480
|
+
}
|
|
@@ -175,6 +175,7 @@ const MEDIA_TOOLS = new Set([
|
|
|
175
175
|
"generate_image",
|
|
176
176
|
"generate_video",
|
|
177
177
|
"generate_model",
|
|
178
|
+
"generate_sprite",
|
|
178
179
|
"generate_speech",
|
|
179
180
|
"generate_music",
|
|
180
181
|
"generate_sfx",
|
|
@@ -199,6 +200,7 @@ export const BILLED_MEDIA_TOOLS: ReadonlySet<string> = new Set([
|
|
|
199
200
|
"generate_image",
|
|
200
201
|
"generate_video",
|
|
201
202
|
"generate_model",
|
|
203
|
+
"generate_sprite",
|
|
202
204
|
"generate_speech",
|
|
203
205
|
"generate_music",
|
|
204
206
|
"generate_sfx",
|
|
@@ -210,6 +212,11 @@ const MEDIA_TITLES: Record<string, string> = {
|
|
|
210
212
|
// options, so the title says so out loud rather than leaving the human to
|
|
211
213
|
// work it out from a JSON blob of flags.
|
|
212
214
|
generate_model: "Generate a 3D model (billed; $0.14-$2.41 a mesh depending on the model)",
|
|
215
|
+
// The one whose price is a MULTIPLE rather than a rate: it renders a video per
|
|
216
|
+
// facing, so approving it can be approving five video generations at once. The
|
|
217
|
+
// title says the multiplier out loud, because "generate a sprite" reads like
|
|
218
|
+
// one cheap call and it is not.
|
|
219
|
+
generate_sprite: "Generate a sprite animation (billed; 1, 3 or 5 video generations depending on facings)",
|
|
213
220
|
generate_speech: "Generate speech (billed to your Privateer account)",
|
|
214
221
|
generate_music: "Generate music (billed; music prompts have no zero-retention option)",
|
|
215
222
|
// Cheap per call and therefore the one most likely to be called twenty times in a
|
|
@@ -410,7 +417,13 @@ export function classifyToolCall(
|
|
|
410
417
|
const outsideInputs = resolvedInputs.filter((a) => isOutsideScope(scope, a));
|
|
411
418
|
const protectedInputs = resolvedInputs.filter((a) => isProtectedPath(a));
|
|
412
419
|
|
|
413
|
-
|
|
420
|
+
// `dir` is generate_sprite's output: it unpacks a whole bundle (sheet, frames
|
|
421
|
+
// and the .tres) into a DIRECTORY rather than writing one named file. Without
|
|
422
|
+
// it here the gate finds no output path and fails safe — which reads to the
|
|
423
|
+
// user as an unexplained denial on a tool that is in fact just writing where
|
|
424
|
+
// they asked. The directory is the right thing to show and to judge for
|
|
425
|
+
// scope: everything the tool writes lands inside it.
|
|
426
|
+
const outPath = str(obj.path ?? obj.output ?? obj.dir);
|
|
414
427
|
// `probe` reads and writes nothing; so does any composition call with no output
|
|
415
428
|
// (which the tool itself rejects). Gate those only when they touch a sensitive input.
|
|
416
429
|
if (!outPath) {
|
package/src/providers/account.ts
CHANGED
|
@@ -28,6 +28,7 @@ import { globalDir } from "../config/paths.ts";
|
|
|
28
28
|
import { canOpenBrowser, openInBrowser } from "../util/openBrowser.ts";
|
|
29
29
|
import { interpretReport, teePosture, tierFromTeePosture, type PrivacyTier } from "pi-privacy";
|
|
30
30
|
import { ACCOUNT_DEFAULT_MODEL_ID, ACCOUNT_NEAR_MODEL_ID, ensurePiDefaultModel } from "./defaultModel.ts";
|
|
31
|
+
import { visionInput } from "./vision.ts";
|
|
31
32
|
import { piAuthStore } from "./piAuthStore.ts";
|
|
32
33
|
import {
|
|
33
34
|
sealedEnabled,
|
|
@@ -49,10 +50,11 @@ import type { PhalaEnclaveIdentity } from "./phalaSeal.ts";
|
|
|
49
50
|
const DEFAULT_MODELS = [
|
|
50
51
|
ACCOUNT_DEFAULT_MODEL_ID,
|
|
51
52
|
ACCOUNT_NEAR_MODEL_ID,
|
|
52
|
-
//
|
|
53
|
-
// so a user who saved
|
|
54
|
-
// launch, rather than falling through to "first model with
|
|
55
|
-
// dead end this seed list exists to prevent.
|
|
53
|
+
// All three former defaults (see TINFOIL_MODEL_ID for the dates). They stay in the
|
|
54
|
+
// floor so a user who saved any of them as their own default still resolves it
|
|
55
|
+
// synchronously at launch, rather than falling through to "first model with
|
|
56
|
+
// configured auth" — the BYO dead end this seed list exists to prevent.
|
|
57
|
+
"tinfoil/gpt-oss-120b",
|
|
56
58
|
"tinfoil/kimi-k2-6",
|
|
57
59
|
"tinfoil/glm-5-2",
|
|
58
60
|
"anthropic/claude-opus-5",
|
|
@@ -78,7 +80,10 @@ function seedModel(id: string) {
|
|
|
78
80
|
// reasoning + how to steer it, for the enclave models where we verified the
|
|
79
81
|
// control shape live; `reasoning: false` (Pi's "not a thinking model") for the rest.
|
|
80
82
|
...(thinkingProfile(id) ?? { reasoning: false as const }),
|
|
81
|
-
|
|
83
|
+
// Honest modalities. This was hardcoded to text for the whole catalog, which made
|
|
84
|
+
// Pi strip every image from every account request — including the ones `read`
|
|
85
|
+
// attaches when the user points at a screenshot. See providers/vision.ts.
|
|
86
|
+
input: visionInput(id),
|
|
82
87
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
83
88
|
contextWindow: 128000,
|
|
84
89
|
maxTokens: 16384,
|
|
@@ -15,15 +15,16 @@ import { join } from "node:path";
|
|
|
15
15
|
import { hasCredentials } from "../auth/privateer.ts";
|
|
16
16
|
import { agentDir } from "../config/paths.ts";
|
|
17
17
|
|
|
18
|
-
// A capable Tinfoil chat model, and Privateer's default everywhere. Tinfoil
|
|
19
|
-
// inside an attestable TEE (the serving enclave's quote is published and the
|
|
20
|
-
// key is bound to it), which is the strongest privacy tier we offer — so a
|
|
21
|
-
// model on that tier is what a privacy-first agent should boot on.
|
|
18
|
+
// A capable MULTIMODAL Tinfoil chat model, and Privateer's default everywhere. Tinfoil
|
|
19
|
+
// runs it inside an attestable TEE (the serving enclave's quote is published and the
|
|
20
|
+
// live TLS key is bound to it), which is the strongest privacy tier we offer — so a
|
|
21
|
+
// capable model on that tier is what a privacy-first agent should boot on.
|
|
22
22
|
// One definition, three consumers: this resolver, providers/account.ts's seed catalog,
|
|
23
23
|
// and bin/privateer-launch.mjs (which mirrors the id — keep them in step).
|
|
24
24
|
//
|
|
25
|
-
// This has moved
|
|
26
|
-
// two axes — first-token latency and reasoning control — pulling in
|
|
25
|
+
// This has moved three times. The history matters, because the first two moves were
|
|
26
|
+
// about the same two axes — first-token latency and reasoning control — pulling in
|
|
27
|
+
// opposite directions, and the third added a third axis that outranks both:
|
|
27
28
|
//
|
|
28
29
|
// • until 2026-08-01 — glm-5-2.
|
|
29
30
|
// • 2026-08-01 → 2026-08-06 — kimi-k2-6, a LATENCY swap, not a capability one. Over
|
|
@@ -55,10 +56,41 @@ import { agentDir } from "../config/paths.ts";
|
|
|
55
56
|
// three that gives the user a working dial. It is a smaller model than GLM 5.2 and
|
|
56
57
|
// Kimi K2.6; that capability trade was made knowingly. It also serves from NEAR as
|
|
57
58
|
// well as Tinfoil — the only capable model here with two attested homes.
|
|
59
|
+
// • 2026-08-27 — gemma4-31b, on SIGHT. Every model above is text-only, and the
|
|
60
|
+
// default is what a signed-in user actually runs, so the agent could be pointed at
|
|
61
|
+
// a screenshot, a design mock or a sheet it had just generated and would answer
|
|
62
|
+
// about a picture it was never sent (Pi drops image blocks a model doesn't declare
|
|
63
|
+
// — see providers/vision.ts). "Reads what you show it" beats a reasoning dial on a
|
|
64
|
+
// tool whose whole job is looking at the user's work.
|
|
65
|
+
//
|
|
66
|
+
// The candidate set is small: of the account catalog's confidential-compute
|
|
67
|
+
// models, only Gemma 4 (Tinfoil, NEAR and Phala all serve it) and
|
|
68
|
+
// near/Qwen/Qwen3-VL-30B-A3B-Instruct take images at all. Gemma 4 31B wins on the
|
|
69
|
+
// invariant below — it is in Tinfoil's own catalog, so the direct and subscription
|
|
70
|
+
// routes stay the SAME model, which Qwen3-VL (NEAR only) would have broken.
|
|
71
|
+
//
|
|
72
|
+
// Two things this costs, both knowingly:
|
|
73
|
+
// - the reasoning dial. thinkingProfile() annotates gpt-oss and the GLM/Qwen
|
|
74
|
+
// chat-template family and nothing else, so Gemma registers `reasoning: false`
|
|
75
|
+
// — as would Qwen3-VL, whose `-Instruct` id is the non-thinking variant. No
|
|
76
|
+
// vision model in the confidential tier has a dial we have verified, so this
|
|
77
|
+
// was not a choice between sight and thinking control; there was no option
|
|
78
|
+
// with both.
|
|
79
|
+
// - size. 31B against gpt-oss-120b, continuing the trade the 2026-08-06 entry
|
|
80
|
+
// started.
|
|
81
|
+
//
|
|
82
|
+
// NOT re-measured for latency: the daily message cap (25/day, free tier) refused
|
|
83
|
+
// every probe on 2026-08-27 with 429 DAILY_CAP_HIT, so the TTFT table above has no
|
|
84
|
+
// gemma4-31b row. It is the same enclave provider and transport as the two models
|
|
85
|
+
// that never stalled, which is the reason to expect it behaves like them and not
|
|
86
|
+
// like glm-5-2 — but that is an expectation, not a measurement. Run the probe when
|
|
87
|
+
// the cap allows and record the row here.
|
|
58
88
|
//
|
|
59
89
|
// Re-measure before moving this again. The stall behaviour is a property of a
|
|
60
90
|
// provider's deployment, not of a model, and it has already changed under us twice.
|
|
61
|
-
|
|
91
|
+
// And whatever replaces it must still accept images: acceptsImages() in
|
|
92
|
+
// providers/vision.ts is the test, and defaultModel.test.ts asserts it.
|
|
93
|
+
export const TINFOIL_MODEL_ID = "tinfoil/gemma4-31b";
|
|
62
94
|
|
|
63
95
|
// Same model, reached two ways:
|
|
64
96
|
// - TINFOIL_DEFAULT_SPEC — direct to inference.tinfoil.sh with the user's own
|
|
@@ -175,8 +207,9 @@ export function resolveDefaultModel(opts: ResolveDefaultModelOptions = {}): stri
|
|
|
175
207
|
// A terminal launched with a BYO key (or an explicit --model) is pinned to whatever it
|
|
176
208
|
// resolved at launch; without an in-session switch a mid-session /login changes nothing
|
|
177
209
|
// visible and the user is left wondering what signing in bought them. This resolves the
|
|
178
|
-
// model sign-in should activate RIGHT AWAY:
|
|
179
|
-
// is present and over the subscription otherwise — no BYO key needed.
|
|
210
|
+
// model sign-in should activate RIGHT AWAY: TINFOIL_MODEL_ID, direct when a Tinfoil key
|
|
211
|
+
// is present and over the subscription otherwise — no BYO key needed. Being signed in
|
|
212
|
+
// is what buys sight, so this is also the switch that makes images work mid-session.
|
|
180
213
|
// PRIVATEER_MODEL still wins — a deliberate override is never stomped.
|
|
181
214
|
// `saved: null` on purpose: this is the sign-in TARGET, and the target is always the
|
|
182
215
|
// confidential model. Whether to actually move a session that sits on a deliberate
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Which models can actually LOOK at an image — the one place that answers it, for
|
|
2
|
+
// every catalog we register.
|
|
3
|
+
//
|
|
4
|
+
// The bug this fixes: every model we register — the whole 270-model account catalog
|
|
5
|
+
// (providers/account.ts seedModel) and Tinfoil's direct catalog
|
|
6
|
+
// (extensions/privateer-privacy.ts tinfoilModel) — declared `input: ["text"]`,
|
|
7
|
+
// copied from pi-privacy's own seed shape. That field is not decoration. Pi gates
|
|
8
|
+
// image handling on it in four places:
|
|
9
|
+
//
|
|
10
|
+
// • pi-coding-agent core/tools/read.js — reading a PNG appends "[Current model does
|
|
11
|
+
// not support images. The image will be omitted from this request.]" and drops it;
|
|
12
|
+
// • pi-ai api/openai-completions.js — `hasImages && model.input.includes("image")`,
|
|
13
|
+
// so image blocks are stripped from the request body;
|
|
14
|
+
// • api/transform-messages.js and api/openai-responses-shared.js — same test again.
|
|
15
|
+
//
|
|
16
|
+
// So a signed-in user could point the agent at a screenshot, a design mock, or a
|
|
17
|
+
// generated sprite sheet and get a confident answer about a picture the model was
|
|
18
|
+
// never sent. Not a refusal — a silent omission, which is worse. Declaring the
|
|
19
|
+
// modality honestly is what makes `read` on an image, and every media tool that hands
|
|
20
|
+
// one back, actually reach the model.
|
|
21
|
+
//
|
|
22
|
+
// WHY AN ALLOWLIST AND NOT A SERVER FIELD: `GET /api/models` returns
|
|
23
|
+
// `{ modelId, displayName, provider, rate*, enabled, privacy: { tier } }` and no
|
|
24
|
+
// modality at all (checked against the live listing, 273 models, 2026-08-27). Until it
|
|
25
|
+
// carries one, the client has to decide, and the two failure directions are NOT
|
|
26
|
+
// symmetric:
|
|
27
|
+
//
|
|
28
|
+
// • miss a vision model → images are dropped, i.e. exactly today's behaviour;
|
|
29
|
+
// • claim one that isn't → pi-ai sends image_url blocks the upstream rejects and the
|
|
30
|
+
// whole turn 400s.
|
|
31
|
+
//
|
|
32
|
+
// So this list is deliberately conservative: a family goes in when the id itself says
|
|
33
|
+
// vision (`-vl-`, a trailing `v` on a GLM), or when every member of that family has
|
|
34
|
+
// shipped multimodal. Anything uncertain stays out and keeps the old behaviour.
|
|
35
|
+
|
|
36
|
+
// Ids arrive in three shapes and all three must match the same rules:
|
|
37
|
+
// `anthropic/claude-opus-5` — account catalog, vendor/model
|
|
38
|
+
// `near/Qwen/Qwen3-VL-30B-A3B-Instruct` — account catalog, tee/vendor/model
|
|
39
|
+
// `gemma4-31b` — Tinfoil's direct catalog, bare id
|
|
40
|
+
// Callers holding a bare id should prefix it with its provider (see visionInput's use
|
|
41
|
+
// in privateer-privacy.ts); the patterns below are anchored loosely enough that a
|
|
42
|
+
// bare `gemma4-31b` still matches on its own.
|
|
43
|
+
const VISION_PATTERNS: RegExp[] = [
|
|
44
|
+
// ── Named in the id ────────────────────────────────────────────────────────
|
|
45
|
+
// Qwen's vision line (qwen3-vl-*, qwen2.5-vl-72b, near/Qwen/Qwen3-VL-30B-A3B) and
|
|
46
|
+
// Baidu's (ernie-4.5-vl-424b). Qwen ships VL as a SEPARATE line, which is also why
|
|
47
|
+
// the plain qwen3.x ids below are deliberately absent.
|
|
48
|
+
/-vl[-_.]/i,
|
|
49
|
+
// Z.ai marks vision with a trailing v on the version: glm-4.5v, glm-4.6v,
|
|
50
|
+
// glm-5v-turbo. Same reasoning — the unsuffixed glm-5.x are their text siblings.
|
|
51
|
+
/\bglm-\d+(?:\.\d+)?v\b/i,
|
|
52
|
+
// ByteDance's GUI agent reads screenshots; that is the whole point of it.
|
|
53
|
+
/\bui-tars\b/i,
|
|
54
|
+
|
|
55
|
+
// ── Families that are multimodal throughout ────────────────────────────────
|
|
56
|
+
// Every Claude from 3 onwards takes images, and 3 is the oldest in the catalog.
|
|
57
|
+
/(^|\/)anthropic\/claude-/i,
|
|
58
|
+
// Gemini has been multimodal since 1.0.
|
|
59
|
+
/(^|\/)google\/gemini-/i,
|
|
60
|
+
// Gemma from 3 on is multimodal; gemma-2 is not, so the version is part of the
|
|
61
|
+
// match. Covers google/gemma-3-27b-it, google/gemma-4-31b-it, tinfoil/gemma4-31b,
|
|
62
|
+
// near/google/gemma-4-31B-it, phala/google/gemma-4-31b-it and the Phala
|
|
63
|
+
// gemma-4 derivatives.
|
|
64
|
+
/gemma-?[34]/i,
|
|
65
|
+
// gpt-4o, gpt-4.1 and the whole gpt-5 line. Deliberately NOT `openai/gpt-4`
|
|
66
|
+
// (the original is text-only), not gpt-3.5, and not gpt-oss — the open-weights
|
|
67
|
+
// models are text-only, which is what our own default was until this change.
|
|
68
|
+
/(^|\/)openai\/gpt-(?:4o|4\.1|5)/i,
|
|
69
|
+
// Llama 4 (maverick, scout) is natively multimodal; 3.x is not.
|
|
70
|
+
/(^|\/)meta-llama\/llama-4-/i,
|
|
71
|
+
// Nova lite/pro/premier take images; nova-micro is text-only.
|
|
72
|
+
/(^|\/)amazon\/nova-(?:2-)?(?:lite|pro|premier)/i,
|
|
73
|
+
// Grok 4 and up.
|
|
74
|
+
/(^|\/)x-ai\/grok-4/i,
|
|
75
|
+
// Mistral's multimodal tiers: small 3.2 and the medium 3 line.
|
|
76
|
+
/(^|\/)mistralai\/mistral-(?:small-3\.2|medium-3)/i,
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
/** Whether `modelId` can be sent an image. See the note above on why it's an allowlist. */
|
|
80
|
+
export function acceptsImages(modelId: string): boolean {
|
|
81
|
+
return VISION_PATTERNS.some((re) => re.test(modelId));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The `input` modality array for a registered model entry.
|
|
86
|
+
*
|
|
87
|
+
* pi-ai's ModelData only knows `"text" | "image"` — there is no video or pdf modality
|
|
88
|
+
* to declare, so a clip or a document reaches the model through a TOOL (media.ts's
|
|
89
|
+
* generators, `read` for a file) rather than as an input block. Image is therefore the
|
|
90
|
+
* whole of what this field can say, and saying it correctly is what lets `read` attach
|
|
91
|
+
* a screenshot instead of quietly dropping it.
|
|
92
|
+
*/
|
|
93
|
+
export function visionInput(modelId: string): ("text" | "image")[] {
|
|
94
|
+
return acceptsImages(modelId) ? ["text", "image"] : ["text"];
|
|
95
|
+
}
|
package/src/tools/media.ts
CHANGED
|
@@ -47,6 +47,7 @@ export const MEDIA_TOOL_NAMES = [
|
|
|
47
47
|
"generate_image",
|
|
48
48
|
"generate_video",
|
|
49
49
|
"generate_model",
|
|
50
|
+
"generate_sprite",
|
|
50
51
|
"generate_speech",
|
|
51
52
|
"generate_music",
|
|
52
53
|
"generate_sfx",
|
|
@@ -63,6 +64,12 @@ const VIDEO_POLL_INTERVAL_MS = 5_000;
|
|
|
63
64
|
// id is reported on timeout so the caller can resume the poll rather than pay
|
|
64
65
|
// for a second generation.
|
|
65
66
|
const MESH_POLL_TIMEOUT_MS = Number(process.env.PRIVATEER_MESH_TIMEOUT_MS) || 10 * 60_000;
|
|
67
|
+
// A sprite job renders one clip per BILLED facing, sequentially, so an eight-way
|
|
68
|
+
// set waits on five video generations rather than one. The ceiling is
|
|
69
|
+
// correspondingly generous; as with video, the job id is reported on timeout so
|
|
70
|
+
// the caller can resume the poll rather than pay for another run.
|
|
71
|
+
const SPRITE_POLL_TIMEOUT_MS = Number(process.env.PRIVATEER_SPRITE_TIMEOUT_MS) || 25 * 60_000;
|
|
72
|
+
const SPRITE_POLL_INTERVAL_MS = 6_000;
|
|
66
73
|
const MESH_POLL_INTERVAL_MS = 5_000;
|
|
67
74
|
// Four reference views at 8 MB each would be ~43 MB of base64 — past the
|
|
68
75
|
// server's own body limit, so the request would be refused by a JSON parser with
|
|
@@ -1036,6 +1043,297 @@ export const mediaCapabilitiesToolDefinition = {
|
|
|
1036
1043
|
},
|
|
1037
1044
|
};
|
|
1038
1045
|
|
|
1046
|
+
interface SpriteSubmitResponse {
|
|
1047
|
+
id?: string;
|
|
1048
|
+
status?: string;
|
|
1049
|
+
billed_facings?: number;
|
|
1050
|
+
mirrored_facings?: number;
|
|
1051
|
+
animations?: number;
|
|
1052
|
+
message?: string;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
interface SpriteStatusResponse {
|
|
1056
|
+
id?: string;
|
|
1057
|
+
status?: string;
|
|
1058
|
+
zip_base64?: string;
|
|
1059
|
+
bytes?: number;
|
|
1060
|
+
sheet?: { width: number; height: number; columns: number; rows: number; frame_width: number; frame_height: number };
|
|
1061
|
+
animations?: { name: string; direction: string; origin: string }[];
|
|
1062
|
+
res_path?: string;
|
|
1063
|
+
key_residue?: number;
|
|
1064
|
+
error?: { message?: string };
|
|
1065
|
+
message?: string;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/**
|
|
1069
|
+
* Unpack the bundle into a directory.
|
|
1070
|
+
*
|
|
1071
|
+
* A hand-rolled reader rather than a dependency, and it is about thirty lines
|
|
1072
|
+
* because the archive is STORED — Privateer writes no compressed entries (the
|
|
1073
|
+
* payload is already PNG, so deflating it twice buys nothing), which means every
|
|
1074
|
+
* entry is a header followed by its bytes verbatim.
|
|
1075
|
+
*
|
|
1076
|
+
* ZIP-SLIP: entry names come off the wire, so each resolved path is checked to
|
|
1077
|
+
* be inside the destination before anything is written. A `..` segment here
|
|
1078
|
+
* would let a generated archive write anywhere the agent can reach, which on an
|
|
1079
|
+
* unattended run is the user's whole machine.
|
|
1080
|
+
*/
|
|
1081
|
+
export function extractStoredZip(zip: Buffer, destDir: string): string[] {
|
|
1082
|
+
const written: string[] = [];
|
|
1083
|
+
const root = resolve(destDir);
|
|
1084
|
+
let at = 0;
|
|
1085
|
+
|
|
1086
|
+
while (at + 30 <= zip.length && zip.readUInt32LE(at) === 0x04034b50) {
|
|
1087
|
+
const method = zip.readUInt16LE(at + 8);
|
|
1088
|
+
const size = zip.readUInt32LE(at + 18);
|
|
1089
|
+
const nameLen = zip.readUInt16LE(at + 26);
|
|
1090
|
+
const extraLen = zip.readUInt16LE(at + 28);
|
|
1091
|
+
const name = zip.toString("utf8", at + 30, at + 30 + nameLen);
|
|
1092
|
+
const dataAt = at + 30 + nameLen + extraLen;
|
|
1093
|
+
|
|
1094
|
+
if (method !== 0) throw new Error(`archive entry "${name}" is compressed; only stored entries are expected`);
|
|
1095
|
+
if (dataAt + size > zip.length) throw new Error(`archive entry "${name}" is truncated`);
|
|
1096
|
+
|
|
1097
|
+
const target = resolve(root, name);
|
|
1098
|
+
if (target !== root && !target.startsWith(root + "/")) {
|
|
1099
|
+
throw new Error(`archive entry "${name}" escapes the destination directory`);
|
|
1100
|
+
}
|
|
1101
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
1102
|
+
writeFileSync(target, zip.subarray(dataAt, dataAt + size));
|
|
1103
|
+
written.push(name);
|
|
1104
|
+
|
|
1105
|
+
at = dataAt + size;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
if (!written.length) throw new Error("the archive contained no files");
|
|
1109
|
+
return written;
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
/**
|
|
1113
|
+
* Guess the res:// path from where the files are being written.
|
|
1114
|
+
*
|
|
1115
|
+
* The .tres refers to its sheet by an absolute res:// path, so getting it wrong
|
|
1116
|
+
* means the caller opens Godot and edits a line by hand. In the overwhelmingly
|
|
1117
|
+
* common case the agent is running AT the project root, so the directory
|
|
1118
|
+
* relative to cwd IS the res:// path — deriving it is right far more often than
|
|
1119
|
+
* a fixed default would be. Returns undefined when the target is outside cwd,
|
|
1120
|
+
* where the guess would be worse than letting the server default.
|
|
1121
|
+
*/
|
|
1122
|
+
export function guessResPath(cwd: string, dir: string): string | undefined {
|
|
1123
|
+
const target = resolve(abs(cwd, dir));
|
|
1124
|
+
const root = resolve(cwd);
|
|
1125
|
+
if (target === root || !target.startsWith(root + "/")) return undefined;
|
|
1126
|
+
return `res://${target.slice(root.length + 1).split("\\").join("/")}/`;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
export const generateSpriteToolDefinition = {
|
|
1130
|
+
name: "generate_sprite",
|
|
1131
|
+
label: "Generate Sprite Animation",
|
|
1132
|
+
description:
|
|
1133
|
+
"Generate a 2D SPRITE ANIMATION for a game engine — a packed sprite sheet, the individual frame " +
|
|
1134
|
+
"PNGs, and a Godot 4 SpriteFrames .tres resource — from ONE picture of a character plus a " +
|
|
1135
|
+
"description of how it moves. The files are written straight into your project directory, so an " +
|
|
1136
|
+
"AnimatedSprite2D can use them without any further conversion. There is no text-to-sprite here: " +
|
|
1137
|
+
"generate or find the character art first (generate_image works), look at it, and pass that path " +
|
|
1138
|
+
"as `image` — every facing is derived from that one picture, which is what stops the character " +
|
|
1139
|
+
"changing between frames.\n" +
|
|
1140
|
+
"HOW IT IS BILLED, because it is not one generation: the motion is rendered as a short video per " +
|
|
1141
|
+
"FACING and then sampled into frames. `directions: 'one'` costs one video generation, 'four' " +
|
|
1142
|
+
"costs THREE, and 'eight' costs FIVE — the left-facing animations are mirrored from the " +
|
|
1143
|
+
"right-facing ones rather than rendered, which is why eight animations cost five clips and not " +
|
|
1144
|
+
"eight. Each clip is charged at the account's video rate, so an eight-way set is genuinely " +
|
|
1145
|
+
"expensive; say the total to the user before batching characters.\n" +
|
|
1146
|
+
"It takes several minutes (the clips render sequentially) and this tool waits. Frame count, cell " +
|
|
1147
|
+
"size and frame rate are chosen here and cost nothing extra. AVAILABILITY: this needs a video " +
|
|
1148
|
+
"decoder on the Privateer API and some deployments do not have one — call media_capabilities and " +
|
|
1149
|
+
"check `sprites.available` before spending, or you will get a clear refusal instead of a sheet. " +
|
|
1150
|
+
"PRIVACY: video and image models have no zero-retention option, so this is gated the way 3D is — " +
|
|
1151
|
+
"a ZDR account must have enabled non-ZDR media.",
|
|
1152
|
+
parameters: Type.Object({
|
|
1153
|
+
image: Type.String({
|
|
1154
|
+
description:
|
|
1155
|
+
"Path to ONE picture of the character, ideally full-body, centred and facing the viewer. " +
|
|
1156
|
+
"Every other facing is generated as an edit of this image, so its framing sets the framing of " +
|
|
1157
|
+
"the whole sheet.",
|
|
1158
|
+
}),
|
|
1159
|
+
prompt: Type.String({
|
|
1160
|
+
description:
|
|
1161
|
+
"How the character MOVES, not what it looks like — 'walking at a steady pace', 'swinging a " +
|
|
1162
|
+
"sword overhead', 'idle, breathing'. The appearance comes from `image`; describing it again " +
|
|
1163
|
+
"here only competes with the picture.",
|
|
1164
|
+
}),
|
|
1165
|
+
dir: Type.String({
|
|
1166
|
+
description:
|
|
1167
|
+
"Directory to write the sheet, frames and .tres into, relative to cwd or absolute " +
|
|
1168
|
+
"(e.g. 'sprites/knight'). It is created if missing. When it sits inside cwd, the res:// path " +
|
|
1169
|
+
"baked into the .tres is derived from it, so running at your Godot project root means the " +
|
|
1170
|
+
"resource resolves with nothing to edit.",
|
|
1171
|
+
}),
|
|
1172
|
+
action: Type.Optional(
|
|
1173
|
+
Type.String({
|
|
1174
|
+
description:
|
|
1175
|
+
"The animation-name stem, e.g. 'walk' — GDScript will play \"walk_down\", \"walk_left\" and so " +
|
|
1176
|
+
"on. Defaults to 'anim'. Keep it lowercase and ASCII; it ends up in game code.",
|
|
1177
|
+
}),
|
|
1178
|
+
),
|
|
1179
|
+
directions: Type.Optional(
|
|
1180
|
+
Type.String({
|
|
1181
|
+
description:
|
|
1182
|
+
"'one' (default, one animation, ONE clip billed), 'four' (down/right/up/left, THREE billed) " +
|
|
1183
|
+
"or 'eight' (adds the diagonals, FIVE billed). Four is the usual choice for a top-down or " +
|
|
1184
|
+
"2.5D character; eight only if the game actually turns that finely.",
|
|
1185
|
+
}),
|
|
1186
|
+
),
|
|
1187
|
+
frames: Type.Optional(
|
|
1188
|
+
Type.Number({
|
|
1189
|
+
description:
|
|
1190
|
+
"Frames per animation, 2-24 (default 8). Sampled out of the rendered clip, so this costs " +
|
|
1191
|
+
"nothing extra and can be chosen for the look: 8 is a classic walk cycle, 12+ is smoother " +
|
|
1192
|
+
"and makes a larger sheet.",
|
|
1193
|
+
}),
|
|
1194
|
+
),
|
|
1195
|
+
frame_size: Type.Optional(
|
|
1196
|
+
Type.Number({
|
|
1197
|
+
description:
|
|
1198
|
+
"Cell size in pixels, 8-512 (default 64). Frames are downscaled with nearest-neighbour, so " +
|
|
1199
|
+
"pixel art stays crisp. Pick the size the game actually draws at.",
|
|
1200
|
+
}),
|
|
1201
|
+
),
|
|
1202
|
+
fps: Type.Optional(
|
|
1203
|
+
Type.Number({ description: "Playback rate written into the resource, 1-120 (default 12)." }),
|
|
1204
|
+
),
|
|
1205
|
+
loop: Type.Optional(
|
|
1206
|
+
Type.Boolean({ description: "Whether the animations loop (default true)." }),
|
|
1207
|
+
),
|
|
1208
|
+
name: Type.Optional(
|
|
1209
|
+
Type.String({ description: "Name for the sprite; sets the file names. Defaults to `action`." }),
|
|
1210
|
+
),
|
|
1211
|
+
res_path: Type.Optional(
|
|
1212
|
+
Type.String({
|
|
1213
|
+
description:
|
|
1214
|
+
"Override the res:// folder the .tres points at, e.g. 'res://art/mobs/'. Only needed when " +
|
|
1215
|
+
"`dir` is not inside your Godot project root — otherwise it is derived from `dir`.",
|
|
1216
|
+
}),
|
|
1217
|
+
),
|
|
1218
|
+
model: Type.Optional(
|
|
1219
|
+
Type.String({ description: "Video model id to render the motion with. Omit for the account default." }),
|
|
1220
|
+
),
|
|
1221
|
+
}),
|
|
1222
|
+
async execute(
|
|
1223
|
+
_toolCallId: string,
|
|
1224
|
+
params: {
|
|
1225
|
+
image: string; prompt: string; dir: string; action?: string; directions?: string;
|
|
1226
|
+
frames?: number; frame_size?: number; fps?: number; loop?: boolean;
|
|
1227
|
+
name?: string; res_path?: string; model?: string;
|
|
1228
|
+
},
|
|
1229
|
+
signal?: AbortSignal,
|
|
1230
|
+
_onUpdate?: unknown,
|
|
1231
|
+
ctx?: { cwd?: string },
|
|
1232
|
+
) {
|
|
1233
|
+
const cwd = ctx?.cwd ?? process.cwd();
|
|
1234
|
+
if (!params.image) return text("Error: image is required — sprite generation derives every facing from one picture.");
|
|
1235
|
+
if (!params.prompt?.trim()) return text("Error: prompt is required — describe how the character moves.");
|
|
1236
|
+
if (!params.dir) return text("Error: dir is required — say where to write the sheet and the .tres.");
|
|
1237
|
+
|
|
1238
|
+
let seed: { data: string; mimeType: string };
|
|
1239
|
+
try {
|
|
1240
|
+
seed = readInputImage(cwd, params.image);
|
|
1241
|
+
} catch (e) {
|
|
1242
|
+
return text(`Error: ${e instanceof Error ? e.message : String(e)}`);
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
const submitted = await callAccount<SpriteSubmitResponse>("/api/agent/media/sprites", {
|
|
1246
|
+
method: "POST",
|
|
1247
|
+
signal,
|
|
1248
|
+
body: {
|
|
1249
|
+
image: seed.data,
|
|
1250
|
+
prompt: params.prompt,
|
|
1251
|
+
...(params.action ? { action: params.action } : {}),
|
|
1252
|
+
...(params.name ? { name: params.name } : {}),
|
|
1253
|
+
...(params.directions ? { directions: params.directions } : {}),
|
|
1254
|
+
...(params.frames != null ? { frames: params.frames } : {}),
|
|
1255
|
+
...(params.frame_size != null ? { frame_size: params.frame_size } : {}),
|
|
1256
|
+
...(params.fps != null ? { fps: params.fps } : {}),
|
|
1257
|
+
...(params.loop != null ? { loop: params.loop } : {}),
|
|
1258
|
+
// The caller's own res:// wins; otherwise derive it from where the files
|
|
1259
|
+
// are going, which is right whenever the agent runs at the project root.
|
|
1260
|
+
...(params.res_path
|
|
1261
|
+
? { res_path: params.res_path }
|
|
1262
|
+
: (() => {
|
|
1263
|
+
const guessed = guessResPath(cwd, params.dir);
|
|
1264
|
+
return guessed ? { res_path: guessed } : {};
|
|
1265
|
+
})()),
|
|
1266
|
+
...(params.model ? { model: params.model } : {}),
|
|
1267
|
+
},
|
|
1268
|
+
});
|
|
1269
|
+
if (!submitted.ok) return text(`Sprite generation failed: ${submitted.message}`);
|
|
1270
|
+
const jobId = submitted.data.id;
|
|
1271
|
+
if (!jobId) return text("Sprite generation failed: Privateer did not return a job id.");
|
|
1272
|
+
|
|
1273
|
+
const billed = submitted.data.billed_facings;
|
|
1274
|
+
const deadline = Date.now() + SPRITE_POLL_TIMEOUT_MS;
|
|
1275
|
+
// The clips are charged as they land, so an abandoned poll still costs money —
|
|
1276
|
+
// hence every exit below names the job id and says so plainly.
|
|
1277
|
+
const cancelled = () =>
|
|
1278
|
+
text(`Sprite job ${jobId} was submitted but the wait was cancelled. Its ${billed ?? "queued"} clip(s) are still rendering and will still be billed.`);
|
|
1279
|
+
|
|
1280
|
+
for (;;) {
|
|
1281
|
+
if (signal?.aborted) return cancelled();
|
|
1282
|
+
await sleep(SPRITE_POLL_INTERVAL_MS, signal);
|
|
1283
|
+
if (signal?.aborted) return cancelled();
|
|
1284
|
+
|
|
1285
|
+
const poll = await callAccount<SpriteStatusResponse>(
|
|
1286
|
+
`/api/agent/media/sprites/${encodeURIComponent(jobId)}`,
|
|
1287
|
+
{ method: "GET", signal },
|
|
1288
|
+
);
|
|
1289
|
+
if (!poll.ok) return text(`Sprite job ${jobId} could not be polled: ${poll.message}`);
|
|
1290
|
+
|
|
1291
|
+
const status = String(poll.data.status ?? "").toLowerCase();
|
|
1292
|
+
if (status === "failed") {
|
|
1293
|
+
return text(`Sprite generation failed: ${poll.data.error?.message ?? poll.data.message ?? "the provider reported a failure"}.`);
|
|
1294
|
+
}
|
|
1295
|
+
if (status === "completed") {
|
|
1296
|
+
if (!poll.data.zip_base64) {
|
|
1297
|
+
return text(`Sprite job ${jobId} already delivered its bytes on an earlier poll; they were not saved. Generate again if the files are missing.`);
|
|
1298
|
+
}
|
|
1299
|
+
const destination = abs(cwd, params.dir);
|
|
1300
|
+
let written: string[];
|
|
1301
|
+
try {
|
|
1302
|
+
written = extractStoredZip(Buffer.from(poll.data.zip_base64, "base64"), destination);
|
|
1303
|
+
} catch (e) {
|
|
1304
|
+
return text(`Sprite job ${jobId} rendered but the bundle could not be unpacked: ${e instanceof Error ? e.message : String(e)}`);
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
const tres = written.find((f) => f.endsWith(".tres"));
|
|
1308
|
+
const anims = poll.data.animations ?? [];
|
|
1309
|
+
const mirrored = anims.filter((a) => a.origin === "mirrored").length;
|
|
1310
|
+
const sheet = poll.data.sheet;
|
|
1311
|
+
|
|
1312
|
+
const lines = [
|
|
1313
|
+
`Generated sprite animation: ${written.length} files in ${destination}`,
|
|
1314
|
+
sheet ? `Sheet ${sheet.width}x${sheet.height}px, ${sheet.frame_width}x${sheet.frame_height} cells, ${sheet.columns}x${sheet.rows} grid.` : "",
|
|
1315
|
+
anims.length ? `Animations: ${anims.map((a) => a.name).join(", ")}${mirrored ? ` (${mirrored} mirrored, not billed)` : ""}.` : "",
|
|
1316
|
+
tres ? `Set an AnimatedSprite2D's Sprite Frames to ${poll.data.res_path ?? "res://"}${tres.split("/").pop()}.` : "",
|
|
1317
|
+
"Set the sheet's texture Filter to Nearest in the Import dock, or the pixel art imports blurry.",
|
|
1318
|
+
// Surfaced rather than swallowed: the flat backdrop the clip was asked
|
|
1319
|
+
// for is a prompt the model can ignore, and when it does the key leaves
|
|
1320
|
+
// a rim. The caller can see it here instead of finding it in-game.
|
|
1321
|
+
poll.data.key_residue != null && poll.data.key_residue > 0.08
|
|
1322
|
+
? `NOTE: the background did not key cleanly (residue ${poll.data.key_residue.toFixed(2)}) — the frames may have a fringe. Re-run, or clean them up before shipping.`
|
|
1323
|
+
: "",
|
|
1324
|
+
].filter(Boolean);
|
|
1325
|
+
return text(lines.join("\n"));
|
|
1326
|
+
}
|
|
1327
|
+
if (Date.now() > deadline) {
|
|
1328
|
+
return text(
|
|
1329
|
+
`Sprite job ${jobId} is still ${status || "running"} after ${Math.round(SPRITE_POLL_TIMEOUT_MS / 60000)} minutes. ` +
|
|
1330
|
+
"It will still complete and still be billed; nothing was saved here.",
|
|
1331
|
+
);
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
},
|
|
1335
|
+
};
|
|
1336
|
+
|
|
1039
1337
|
/**
|
|
1040
1338
|
* Extension factory registering every account-backed media tool. Used by the surfaces
|
|
1041
1339
|
* that build their session from an explicit `extensionFactories` list (harbor, channels,
|
|
@@ -1051,6 +1349,7 @@ export function makeMediaTools() {
|
|
|
1051
1349
|
pi.registerTool?.(generateImageToolDefinition);
|
|
1052
1350
|
pi.registerTool?.(generateVideoToolDefinition);
|
|
1053
1351
|
pi.registerTool?.(generateModelToolDefinition);
|
|
1352
|
+
pi.registerTool?.(generateSpriteToolDefinition);
|
|
1054
1353
|
pi.registerTool?.(generateSpeechToolDefinition);
|
|
1055
1354
|
pi.registerTool?.(generateMusicToolDefinition);
|
|
1056
1355
|
pi.registerTool?.(generateSfxToolDefinition);
|