Skip to content

Commit 7599fb8

Browse files
committed
vscode-extension: v0.4.5 — new mascot + configurable spend cap + i2i timeout
UX - New mascot artwork on the empty state: a bigger AI+coin themed Franklin pixel-art portrait. The PNG ships with a flood-filled alpha channel so the mascot composites directly onto whichever theme background sits behind the panel — no rounded-rectangle frame, no mix-blend-mode hack. Original PNG (with the dark frame) also kept on disk as franklin-mascot.png for record / fallback. - Settings popover gains a "Per-turn spend cap (USD)" field. Empty keeps the existing $0.25 default; "0" disables the cap entirely; any positive number sets it. Local mirror of core PR #20. Core (cherry-picks of in-flight PRs) - src/tools/imagegen.ts: image-to-image gets a 180s timeout (was shared 60s with text-to-image — gpt-image-2 edits on a few-MB reference image consistently hit AbortError before completion). Mirror of PR #19. - src/agent/loop.ts + src/commands/config.ts: MAX_TURN_SPEND_USD is no longer hard-coded; reads max-turn-spend-usd from config with fallback to $0.25. Mirror of PR #20. Bumps vscode-extension to 0.4.5; updates README changelog.
1 parent 013b8e1 commit 7599fb8

10 files changed

Lines changed: 143 additions & 176 deletions

File tree

src/agent/loop.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { resetToolSessionState } from '../tools/index.js';
1717
import { CORE_TOOL_NAMES, dynamicToolsEnabled } from '../tools/tool-categories.js';
1818
import { createActivateToolCapability } from '../tools/activate.js';
1919
import { recordUsage } from '../stats/tracker.js';
20+
import { loadConfig } from '../commands/config.js';
2021
import { recordSessionUsage } from '../stats/session-tracker.js';
2122
import { appendAudit, extractLastUserPrompt } from '../stats/audit.js';
2223
import { estimateCost, OPUS_PRICING } from '../pricing.js';
@@ -546,7 +547,18 @@ export async function interactiveSession(
546547
let consecutiveTinyResponses = 0; // Count of consecutive calls with <10 output tokens
547548
const MAX_TINY_RESPONSES = 2; // Break after N tiny responses — if 2 calls return near-empty, something is wrong
548549
let turnSpend = 0; // Cost spent this user turn (USD)
549-
const MAX_TURN_SPEND_USD = 0.25; // Hard circuit breaker per user message (lowered — user wallets are real money)
550+
// Hard circuit breaker per user message — defends user wallets against
551+
// a runaway model+tool combo on a single prompt. User-overridable via
552+
// `franklin config set max-turn-spend-usd <number>` (or the gear-icon
553+
// settings panel in the VS Code extension). A value of "0" (or
554+
// negative / non-numeric) disables the cap entirely.
555+
const MAX_TURN_SPEND_USD = (() => {
556+
const raw = loadConfig()['max-turn-spend-usd'];
557+
if (raw == null) return 0.25;
558+
const parsed = Number(raw);
559+
if (!Number.isFinite(parsed) || parsed <= 0) return Infinity;
560+
return parsed;
561+
})();
550562

551563
// ── Turn analysis (one classifier call, drives routing + prefetch) ──
552564
// Single LLM pass that answers every routing-adjacent question the

src/commands/config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const VALID_KEYS = [
1616
'smart-routing',
1717
'permission-mode',
1818
'max-turns',
19+
'max-turn-spend-usd',
1920
'auto-compact',
2021
'session-save',
2122
'debug',
@@ -33,6 +34,13 @@ export interface AppConfig {
3334
'smart-routing'?: string;
3435
'permission-mode'?: string;
3536
'max-turns'?: string;
37+
/**
38+
* Hard per-turn spend ceiling in USD (default $0.25). Numeric string,
39+
* e.g. "0.5" or "2". Set to "0" to disable the cap. The agent loop
40+
* stops a turn the moment cumulative cost crosses this threshold,
41+
* preventing a runaway model + tool combo from draining the wallet.
42+
*/
43+
'max-turn-spend-usd'?: string;
3644
'auto-compact'?: string;
3745
'session-save'?: string;
3846
'debug'?: string;

src/tools/imagegen.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,14 @@ function buildExecute(deps: ImageGenDeps) {
290290
};
291291

292292
const controller = new AbortController();
293-
const timeout = setTimeout(() => controller.abort(), 60_000); // 60s timeout
293+
// Reference-image mode (gpt-image-2 edits) is meaningfully slower than
294+
// pure text-to-image: the model is reasoning-driven and the request
295+
// body carries a few MB of base64. The shared 60s budget covered both
296+
// x402 retry attempts and the actual generation, which made image-to-
297+
// image effectively always time out. Image-to-image gets 3 minutes;
298+
// text-to-image keeps the original 60s.
299+
const timeoutMs = referenceImage ? 180_000 : 60_000;
300+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
294301

295302
try {
296303
// First request — will get 402
@@ -411,7 +418,12 @@ function buildExecute(deps: ImageGenDeps) {
411418
} catch (err) {
412419
const msg = (err as Error).message || '';
413420
if (msg.includes('abort')) {
414-
return { output: 'Image generation timed out (60s limit). Try a simpler prompt.', isError: true };
421+
return {
422+
output: referenceImage
423+
? 'Image-to-image timed out (180s limit). The reference image may be too large or the model under load — try a smaller/simpler image.'
424+
: 'Image generation timed out (60s limit). Try a simpler prompt.',
425+
isError: true,
426+
};
415427
}
416428
return { output: `Error: ${msg}`, isError: true };
417429
} finally {

vscode-extension/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ Franklin is an autonomous AI agent that runs directly in VS Code. It doesn't jus
4141

4242
## Changelog
4343

44+
### 0.4.5
45+
- **New mascot artwork on the empty state** — bigger, transparent-background AI+coin themed Franklin pixel-art portrait (no more dark rounded-rectangle frame, blends seamlessly into any theme background)
46+
- **Per-turn spend cap is now configurable** — new ⚙️ settings field "Per-turn spend cap (USD)" lets you raise the default \$0.25 limit (or set 0 to disable) without editing source; mirrors the new `franklin config set max-turn-spend-usd <n>` CLI key
47+
- **Image-to-image timeout fix**`gpt-image-2` reference-image edits no longer abort after 60s (the old shared budget couldn't cover reasoning-driven edits + base64 upload + x402 retry); image-to-image now gets 180s, text-to-image keeps 60s
48+
- Synced with Franklin core: PR #19 (i2i timeout), PR #20 (configurable spend cap), PR #21 (README VS Code section)
49+
4450
### 0.4.3
4551
- **History replay shows generated media inline** — closing and reopening a conversation now re-renders any images / videos as preview cards instead of dropping them
4652
- **"+" New chat truly resets the session** — previously only the UI cleared while the agent kept the same `sessionId`, leaking tool guards (`ImageGen disabled`) and prior context into what looked like a new chat
477 KB
Loading
1.06 MB
Loading

vscode-extension/out/extension.cjs

Lines changed: 54 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -180320,7 +180320,8 @@ No USDC was spent. Choose a cheaper model/size or raise the content budget befor
180320180320
"User-Agent": `franklin/${VERSION3}`
180321180321
};
180322180322
const controller = new AbortController();
180323-
const timeout = setTimeout(() => controller.abort(), 6e4);
180323+
const timeoutMs = referenceImage ? 18e4 : 6e4;
180324+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
180324180325
try {
180325180326
let response = await fetch(endpoint, {
180326180327
method: "POST",
@@ -180417,7 +180418,10 @@ Open with: open ${outPath}${contentSummary}`
180417180418
} catch (err) {
180418180419
const msg = err.message || "";
180419180420
if (msg.includes("abort")) {
180420-
return { output: "Image generation timed out (60s limit). Try a simpler prompt.", isError: true };
180421+
return {
180422+
output: referenceImage ? "Image-to-image timed out (180s limit). The reference image may be too large or the model under load \u2014 try a smaller/simpler image." : "Image generation timed out (60s limit). Try a simpler prompt.",
180423+
isError: true
180424+
};
180421180425
}
180422180426
return { output: `Error: ${msg}`, isError: true };
180423180427
} finally {
@@ -186516,7 +186520,15 @@ async function interactiveSession(config2, getUserInput, onEvent, onAbortReady)
186516186520
let consecutiveTinyResponses = 0;
186517186521
const MAX_TINY_RESPONSES = 2;
186518186522
let turnSpend = 0;
186519-
const MAX_TURN_SPEND_USD = 0.25;
186523+
const MAX_TURN_SPEND_USD = (() => {
186524+
const raw = loadConfig()["max-turn-spend-usd"];
186525+
if (raw == null)
186526+
return 0.25;
186527+
const parsed = Number(raw);
186528+
if (!Number.isFinite(parsed) || parsed <= 0)
186529+
return Infinity;
186530+
return parsed;
186531+
})();
186520186532
let turnAnalysis = null;
186521186533
try {
186522186534
const lastAssistantText = (() => {
@@ -188253,7 +188265,8 @@ var FranklinChatProvider = class {
188253188265
current: {
188254188266
chain: chain4,
188255188267
"default-image-model": config2["default-image-model"] ?? null,
188256-
"default-video-model": config2["default-video-model"] ?? null
188268+
"default-video-model": config2["default-video-model"] ?? null,
188269+
"max-turn-spend-usd": config2["max-turn-spend-usd"] ?? ""
188257188270
},
188258188271
imageModels: imageModels.map(toOption),
188259188272
videoModels: videoModels.map(toOption)
@@ -188281,6 +188294,15 @@ var FranklinChatProvider = class {
188281188294
else delete config2["default-image-model"];
188282188295
if (vid && vid !== "__unset__") config2["default-video-model"] = vid;
188283188296
else delete config2["default-video-model"];
188297+
const cap = settings["max-turn-spend-usd"];
188298+
if (cap != null && cap.trim() !== "") {
188299+
const parsed = Number(cap);
188300+
if (Number.isFinite(parsed) && parsed >= 0) {
188301+
config2["max-turn-spend-usd"] = String(parsed);
188302+
}
188303+
} else {
188304+
delete config2["max-turn-spend-usd"];
188305+
}
188284188306
saveConfig(config2);
188285188307
void this.webview?.postMessage({ type: "settingsSaved" });
188286188308
}
@@ -188493,9 +188515,13 @@ function getNonce() {
188493188515
}
188494188516
function getWebviewHtml(webview, extensionUri) {
188495188517
const nonce = getNonce();
188518+
const mascotUri = webview.asWebviewUri(
188519+
vscode.Uri.joinPath(extensionUri, "media", "franklin-mascot-transparent.png")
188520+
);
188496188521
const portraitUri = webview.asWebviewUri(
188497188522
vscode.Uri.joinPath(extensionUri, "media", "franklin-portrait.jpg")
188498188523
);
188524+
void portraitUri;
188499188525
const csp = [
188500188526
"default-src 'none'",
188501188527
"style-src 'unsafe-inline'",
@@ -189012,6 +189038,10 @@ function getWebviewHtml(webview, extensionUri) {
189012189038
background: var(--vscode-input-background); color: var(--vscode-input-foreground);
189013189039
border: 1px solid var(--vscode-input-border, rgba(128,128,128,0.35)); border-radius: 3px;
189014189040
}
189041+
.settings-hint {
189042+
margin-top: 4px; font-size: 10px; line-height: 1.4;
189043+
color: var(--vscode-descriptionForeground);
189044+
}
189015189045
.settings-actions {
189016189046
display: flex; align-items: center; justify-content: space-between; gap: 8px;
189017189047
}
@@ -189493,10 +189523,14 @@ function getWebviewHtml(webview, extensionUri) {
189493189523
}
189494189524
#empty-state.fk-fade-in { animation: fk-fade-in 0.28s ease-out; }
189495189525
#empty-state .pixel-portrait {
189496-
width: 96px;
189497-
height: 96px;
189498-
image-rendering: pixelated;
189499-
image-rendering: crisp-edges;
189526+
width: 280px;
189527+
height: 280px;
189528+
max-width: 90%;
189529+
object-fit: contain;
189530+
/* The PNG itself has its frame flood-filled to alpha=0, so it
189531+
* composites directly onto whatever theme background is behind
189532+
* the panel \u2014 light, dark, or high-contrast all look the same. */
189533+
filter: drop-shadow(0 8px 24px rgba(0,0,0,0.45));
189500189534
}
189501189535
#empty-state .brand-name {
189502189536
font-size: 40px;
@@ -189720,85 +189754,8 @@ function getWebviewHtml(webview, extensionUri) {
189720189754
</div>
189721189755
<div id="log">
189722189756
<div id="empty-state">
189723-
<svg class="pixel-portrait" viewBox="0 0 16 16" shape-rendering="crispEdges" aria-hidden="true">
189724-
<!-- hair (gray, colonial side-wave) -->
189725-
<g fill="#c9c9c9">
189726-
<rect x="4" y="1" width="8" height="1"/>
189727-
<rect x="3" y="2" width="10" height="1"/>
189728-
<rect x="2" y="3" width="2" height="1"/>
189729-
<rect x="12" y="3" width="2" height="1"/>
189730-
<rect x="2" y="4" width="1" height="2"/>
189731-
<rect x="13" y="4" width="1" height="2"/>
189732-
<rect x="1" y="6" width="2" height="3"/>
189733-
<rect x="13" y="6" width="2" height="3"/>
189734-
<rect x="2" y="9" width="1" height="1"/>
189735-
<rect x="13" y="9" width="1" height="1"/>
189736-
</g>
189737-
<!-- hair highlight -->
189738-
<g fill="#e8e8e8">
189739-
<rect x="5" y="2" width="2" height="1"/>
189740-
<rect x="9" y="2" width="2" height="1"/>
189741-
</g>
189742-
<!-- face skin -->
189743-
<g fill="#e0b080">
189744-
<rect x="4" y="3" width="8" height="1"/>
189745-
<rect x="3" y="4" width="10" height="1"/>
189746-
<rect x="3" y="5" width="10" height="1"/>
189747-
<rect x="3" y="6" width="10" height="1"/>
189748-
<rect x="3" y="7" width="10" height="1"/>
189749-
<rect x="4" y="8" width="8" height="1"/>
189750-
<rect x="4" y="9" width="8" height="1"/>
189751-
<rect x="5" y="10" width="6" height="1"/>
189752-
</g>
189753-
<!-- cheek blush -->
189754-
<g fill="#d98f6a">
189755-
<rect x="3" y="7" width="1" height="1"/>
189756-
<rect x="12" y="7" width="1" height="1"/>
189757-
</g>
189758-
<!-- glasses frames -->
189759-
<g fill="#2a2018">
189760-
<rect x="4" y="5" width="3" height="1"/>
189761-
<rect x="4" y="7" width="3" height="1"/>
189762-
<rect x="4" y="6" width="1" height="1"/>
189763-
<rect x="6" y="6" width="1" height="1"/>
189764-
<rect x="9" y="5" width="3" height="1"/>
189765-
<rect x="9" y="7" width="3" height="1"/>
189766-
<rect x="9" y="6" width="1" height="1"/>
189767-
<rect x="11" y="6" width="1" height="1"/>
189768-
<rect x="7" y="6" width="2" height="1"/>
189769-
</g>
189770-
<!-- eyes -->
189771-
<g fill="#1a1a1a">
189772-
<rect x="5" y="6" width="1" height="1"/>
189773-
<rect x="10" y="6" width="1" height="1"/>
189774-
</g>
189775-
<!-- mouth -->
189776-
<g fill="#8a3a20">
189777-
<rect x="7" y="9" width="2" height="1"/>
189778-
</g>
189779-
<!-- white shirt / cravat -->
189780-
<g fill="#f0e8d0">
189781-
<rect x="5" y="11" width="6" height="1"/>
189782-
<rect x="6" y="12" width="4" height="1"/>
189783-
<rect x="7" y="13" width="2" height="2"/>
189784-
</g>
189785-
<!-- coat (brown) -->
189786-
<g fill="#5a3820">
189787-
<rect x="1" y="11" width="4" height="5"/>
189788-
<rect x="11" y="11" width="4" height="5"/>
189789-
<rect x="5" y="12" width="1" height="4"/>
189790-
<rect x="10" y="12" width="1" height="4"/>
189791-
<rect x="6" y="13" width="1" height="3"/>
189792-
<rect x="9" y="13" width="1" height="3"/>
189793-
<rect x="7" y="15" width="2" height="1"/>
189794-
</g>
189795-
<!-- coat buttons (gold) -->
189796-
<g fill="#caa45a">
189797-
<rect x="5" y="14" width="1" height="1"/>
189798-
<rect x="10" y="14" width="1" height="1"/>
189799-
</g>
189800-
</svg>
189801-
<div class="brand-name">Franklin</div>
189757+
<!-- mascot \u2014 see media/franklin-mascot.png; original pixel-art SVG kept in git history -->
189758+
<img class="pixel-portrait" src="${mascotUri}" alt="Franklin mascot" />
189802189759
<div class="brand-slogan">The AI agent with a <span class="accent">wallet</span>.</div>
189803189760
<div id="loading-step" style="font-size:11px;color:var(--vscode-descriptionForeground);margin-top:8px;opacity:0.7;">Initializing\u2026</div>
189804189761
<div id="example-prompts">
@@ -189870,6 +189827,11 @@ function getWebviewHtml(webview, extensionUri) {
189870189827
<label class="settings-label" for="settings-vid">Default video model</label>
189871189828
<select id="settings-vid" class="settings-select"><option value="__unset__">Ask each time</option></select>
189872189829
</div>
189830+
<div class="settings-row">
189831+
<label class="settings-label" for="settings-spend-cap">Per-turn spend cap (USD)</label>
189832+
<input id="settings-spend-cap" class="settings-select" type="number" min="0" step="0.05" placeholder="0.25 (default) \u2014 0 to disable" />
189833+
<div class="settings-hint">Stops a single user message once cumulative cost crosses this. Empty = $0.25 default. 0 = no cap.</div>
189834+
</div>
189873189835
<div class="settings-actions">
189874189836
<span id="settings-status"></span>
189875189837
<button type="button" id="settings-save" class="settings-save">Save</button>
@@ -190134,6 +190096,7 @@ function getWebviewHtml(webview, extensionUri) {
190134190096
var settingsPanel = document.getElementById('settings-panel');
190135190097
var settingsImgSel = document.getElementById('settings-img');
190136190098
var settingsVidSel = document.getElementById('settings-vid');
190099+
var settingsSpendCap = document.getElementById('settings-spend-cap');
190137190100
var settingsStatusEl = document.getElementById('settings-status');
190138190101
var pendingChain = 'base';
190139190102

@@ -190193,6 +190156,7 @@ function getWebviewHtml(webview, extensionUri) {
190193190156
chain: pendingChain,
190194190157
'default-image-model': settingsImgSel.value,
190195190158
'default-video-model': settingsVidSel.value,
190159+
'max-turn-spend-usd': (settingsSpendCap && settingsSpendCap.value) || '',
190196190160
},
190197190161
});
190198190162
// Close the popover \u2014 dismissing itself is the save confirmation.
@@ -191212,6 +191176,9 @@ function getWebviewHtml(webview, extensionUri) {
191212191176
setChainToggleActive(m.current.chain);
191213191177
populateModelSelect(settingsImgSel, m.imageModels, m.current['default-image-model']);
191214191178
populateModelSelect(settingsVidSel, m.videoModels, m.current['default-video-model']);
191179+
if (settingsSpendCap) {
191180+
settingsSpendCap.value = m.current['max-turn-spend-usd'] || '';
191181+
}
191215191182
return;
191216191183
}
191217191184
if (m.type === 'settingsSaved') {

vscode-extension/out/extension.cjs.map

Lines changed: 2 additions & 2 deletions
Large diffs are not rendered by default.

vscode-extension/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "franklin-vscode",
33
"displayName": "Franklin",
44
"description": "The AI agent with a wallet. Autonomous marketing & trading agent powered by x402 micropayments.",
5-
"version": "0.4.3",
5+
"version": "0.4.5",
66
"publisher": "blockrun",
77
"icon": "images/icon.png",
88
"repository": {

0 commit comments

Comments
 (0)