@blockrun/franklin 3.8.30 → 3.8.32
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/README.md +1 -1
- package/dist/agent/context.js +3 -1
- package/dist/agent/media-router.d.ts +65 -0
- package/dist/agent/media-router.js +250 -0
- package/dist/gateway-models.d.ts +76 -0
- package/dist/gateway-models.js +139 -0
- package/dist/session/search.js +3 -3
- package/dist/session/storage.js +25 -22
- package/dist/tools/imagegen.js +47 -2
- package/dist/tools/videogen.js +47 -2
- package/dist/ui/app.js +10 -1
- package/dist/ui/session-picker.d.ts +11 -0
- package/dist/ui/session-picker.js +45 -16
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -428,7 +428,7 @@ src/
|
|
|
428
428
|
Start with **zero dollars**. Franklin defaults to free NVIDIA models that need no wallet funding.
|
|
429
429
|
|
|
430
430
|
```bash
|
|
431
|
-
franklin --model nvidia/
|
|
431
|
+
franklin --model nvidia/qwen3-next-80b-a3b-thinking
|
|
432
432
|
```
|
|
433
433
|
|
|
434
434
|
When you fund the wallet, Franklin gets more purchasing power: Sonnet, Opus, GPT, Gemini, Grok, and paid tools like Exa, DALL-E, and CoinGecko Pro.
|
package/dist/agent/context.js
CHANGED
|
@@ -187,7 +187,9 @@ Your training data is frozen in the past. Live-world questions MUST be answered
|
|
|
187
187
|
- "Please check Yahoo Finance / Google Finance / Bloomberg / your broker / etc."
|
|
188
188
|
- Any variant of "go look it up yourself" when TradingMarket / ExaAnswer / WebSearch would resolve it.
|
|
189
189
|
|
|
190
|
-
If you find yourself about to emit one of these, stop and call the tool instead. If you don't know which ticker the user means, call ExaSearch or AskUser — never deflect
|
|
190
|
+
If you find yourself about to emit one of these, stop and call the tool instead. If you don't know which ticker the user means, call ExaSearch or AskUser — never deflect.
|
|
191
|
+
|
|
192
|
+
**Media generation (ImageGen / VideoGen).** Pass just the user's descriptive prompt and the output path — do NOT pass \`model\`. The harness picks the right model for the requested style + budget and surfaces a cost proposal through AskUser before spending. Only pass \`model\` explicitly if the user named one specifically.`;
|
|
191
193
|
}
|
|
192
194
|
function getTokenEfficiencySection() {
|
|
193
195
|
return `# Token Efficiency
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Media router — one LLM call that picks which image/video model fits
|
|
3
|
+
* the user's request, with alternatives and cost estimates, so the agent
|
|
4
|
+
* can show a clean AskUser proposal before spending.
|
|
5
|
+
*
|
|
6
|
+
* Principle (matches turn-analyzer): harness orchestrates, free model
|
|
7
|
+
* decides. No keyword-to-model mapping in TypeScript. Classifier reads
|
|
8
|
+
* the prompt + the current gateway catalog (pulled dynamically), picks
|
|
9
|
+
* one recommended model plus a cheaper + a premium alternative, and
|
|
10
|
+
* explains the choice in one sentence.
|
|
11
|
+
*
|
|
12
|
+
* Cost estimates come from `gateway-models.ts` — always dynamic,
|
|
13
|
+
* margin-adjusted.
|
|
14
|
+
*/
|
|
15
|
+
import type { ModelClient } from './llm.js';
|
|
16
|
+
export type MediaKind = 'image' | 'video';
|
|
17
|
+
export type MediaStyle = 'photoreal' | 'illustration' | 'anime' | 'logo' | 'concept' | 'other';
|
|
18
|
+
export type MediaPriority = 'cost' | 'quality' | 'balanced';
|
|
19
|
+
export interface MediaChoice {
|
|
20
|
+
model: string;
|
|
21
|
+
estimatedCostUsd: number;
|
|
22
|
+
rationale: string;
|
|
23
|
+
}
|
|
24
|
+
export interface MediaProposal {
|
|
25
|
+
kind: MediaKind;
|
|
26
|
+
quantity: number;
|
|
27
|
+
durationSeconds?: number;
|
|
28
|
+
maxDurationSeconds?: number;
|
|
29
|
+
recommended: MediaChoice;
|
|
30
|
+
cheaper?: MediaChoice;
|
|
31
|
+
premium?: MediaChoice;
|
|
32
|
+
intent: {
|
|
33
|
+
style: MediaStyle;
|
|
34
|
+
priority: MediaPriority;
|
|
35
|
+
};
|
|
36
|
+
totalCostUsd: number;
|
|
37
|
+
}
|
|
38
|
+
export declare function clearMediaRouterCache(): void;
|
|
39
|
+
export interface AnalyzeMediaOpts {
|
|
40
|
+
kind: MediaKind;
|
|
41
|
+
prompt: string;
|
|
42
|
+
client: ModelClient;
|
|
43
|
+
quantity?: number;
|
|
44
|
+
durationSeconds?: number;
|
|
45
|
+
signal?: AbortSignal;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Pick the best model + alternatives for this media request. Returns null
|
|
49
|
+
* on any failure path (classifier timeout, parse error, empty catalog) so
|
|
50
|
+
* the caller can fall back to its old hardcoded default rather than
|
|
51
|
+
* blocking the user.
|
|
52
|
+
*/
|
|
53
|
+
export declare function analyzeMediaRequest(opts: AnalyzeMediaOpts): Promise<MediaProposal | null>;
|
|
54
|
+
/**
|
|
55
|
+
* Render a proposal as the user-facing AskUser question. Layout matches
|
|
56
|
+
* the spec from v3.8.31 planning: recommended first with • bullet,
|
|
57
|
+
* alternatives below with ○ bullets, prices include the 5% margin note.
|
|
58
|
+
*/
|
|
59
|
+
export declare function renderProposalForAskUser(p: MediaProposal, userPrompt: string): {
|
|
60
|
+
question: string;
|
|
61
|
+
options: Array<{
|
|
62
|
+
id: string;
|
|
63
|
+
label: string;
|
|
64
|
+
}>;
|
|
65
|
+
};
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Media router — one LLM call that picks which image/video model fits
|
|
3
|
+
* the user's request, with alternatives and cost estimates, so the agent
|
|
4
|
+
* can show a clean AskUser proposal before spending.
|
|
5
|
+
*
|
|
6
|
+
* Principle (matches turn-analyzer): harness orchestrates, free model
|
|
7
|
+
* decides. No keyword-to-model mapping in TypeScript. Classifier reads
|
|
8
|
+
* the prompt + the current gateway catalog (pulled dynamically), picks
|
|
9
|
+
* one recommended model plus a cheaper + a premium alternative, and
|
|
10
|
+
* explains the choice in one sentence.
|
|
11
|
+
*
|
|
12
|
+
* Cost estimates come from `gateway-models.ts` — always dynamic,
|
|
13
|
+
* margin-adjusted.
|
|
14
|
+
*/
|
|
15
|
+
import { getModelsByCategory, estimateCostUsd, defaultDurationSeconds, maxDurationSeconds, } from '../gateway-models.js';
|
|
16
|
+
// ─── Classifier ─────────────────────────────────────────────────────────
|
|
17
|
+
const CLASSIFIER_MODEL = process.env.FRANKLIN_MEDIA_ROUTER_MODEL || 'nvidia/llama-4-maverick';
|
|
18
|
+
const TIMEOUT_MS = 3_500; // slightly more lenient than the turn-analyzer — we're asking for JSON with reasoning
|
|
19
|
+
const MAX_TOKENS = 256;
|
|
20
|
+
function buildSystemPrompt(kind, catalog) {
|
|
21
|
+
const catalogLines = catalog.map(m => {
|
|
22
|
+
const p = m.pricing;
|
|
23
|
+
const price = kind === 'image'
|
|
24
|
+
? `$${(p.per_image ?? 0).toFixed(4)}/image`
|
|
25
|
+
: `$${(p.per_second ?? 0).toFixed(2)}/s (default ${p.default_duration_seconds ?? 8}s, max ${p.max_duration_seconds ?? 8}s)`;
|
|
26
|
+
return ` - ${m.id} · ${price} · ${m.description || m.name}`;
|
|
27
|
+
}).join('\n');
|
|
28
|
+
return `You pick the best ${kind} model for a user's Franklin request. Output ONE LINE of compact JSON. No markdown, no code fences, no explanation.
|
|
29
|
+
|
|
30
|
+
## Catalog (${catalog.length} available ${kind} models)
|
|
31
|
+
${catalogLines}
|
|
32
|
+
|
|
33
|
+
## Output schema
|
|
34
|
+
|
|
35
|
+
{"style":"photoreal|illustration|anime|logo|concept|other",
|
|
36
|
+
"priority":"cost|quality|balanced",
|
|
37
|
+
"recommended":{"model":"<id from catalog>","rationale":"<one sentence, <=140 chars>"},
|
|
38
|
+
"cheaper":{"model":"<id from catalog | null>","rationale":"<one sentence>"},
|
|
39
|
+
"premium":{"model":"<id from catalog | null>","rationale":"<one sentence>"}}
|
|
40
|
+
|
|
41
|
+
Rules:
|
|
42
|
+
- recommended is always set to an id from the catalog.
|
|
43
|
+
- cheaper / premium may be null if no strictly cheaper / better option exists.
|
|
44
|
+
- Never invent a model id. Use EXACTLY one of the catalog ids.
|
|
45
|
+
- Match style → model: anime/illustration prefers CogView, photoreal prefers Nano Banana Pro / Grok Imagine Pro, budget-conscious picks cheapest-acceptable.
|
|
46
|
+
- One sentence rationale, user-visible.
|
|
47
|
+
|
|
48
|
+
Examples:
|
|
49
|
+
|
|
50
|
+
Input: "a photo of a cat on Mars, photoreal"
|
|
51
|
+
Output: {"style":"photoreal","priority":"balanced","recommended":{"model":"google/nano-banana-pro","rationale":"Photoreal scenes — Nano Banana Pro has strong realism at moderate cost."},"cheaper":{"model":"google/nano-banana","rationale":"Same family, lower cost, slightly less detail."},"premium":{"model":"openai/gpt-image-2","rationale":"Best photoreal fidelity when budget allows."}}
|
|
52
|
+
|
|
53
|
+
Input: "赛博朋克风格的动漫角色"
|
|
54
|
+
Output: {"style":"anime","priority":"balanced","recommended":{"model":"zai/cogview-4","rationale":"CogView-4 specializes in stylized/anime imagery."},"cheaper":{"model":"google/nano-banana","rationale":"Cheaper but less stylized."},"premium":{"model":"xai/grok-imagine-image-pro","rationale":"Premium detail for complex scenes."}}
|
|
55
|
+
|
|
56
|
+
Input: "a 10-second cinematic drone shot over Tokyo at night"
|
|
57
|
+
Output: {"style":"concept","priority":"quality","recommended":{"model":"bytedance/seedance-2.0","rationale":"Seedance 2.0 delivers the best cinematic quality."},"cheaper":{"model":"bytedance/seedance-2.0-fast","rationale":"Faster + cheaper, minor quality trade-off."},"premium":{"model":null,"rationale":"2.0 is already the top tier."}}
|
|
58
|
+
|
|
59
|
+
Output JSON only, single line.`;
|
|
60
|
+
}
|
|
61
|
+
const cache = new Map();
|
|
62
|
+
const CACHE_TTL_MS = 30_000;
|
|
63
|
+
const CACHE_MAX = 32;
|
|
64
|
+
function hashKey(parts) {
|
|
65
|
+
const s = parts.join('|');
|
|
66
|
+
let h = 0;
|
|
67
|
+
for (let i = 0; i < s.length; i++)
|
|
68
|
+
h = ((h << 5) - h + s.charCodeAt(i)) | 0;
|
|
69
|
+
return String(h);
|
|
70
|
+
}
|
|
71
|
+
export function clearMediaRouterCache() { cache.clear(); }
|
|
72
|
+
// ─── Parser ─────────────────────────────────────────────────────────────
|
|
73
|
+
const VALID_STYLES = new Set(['photoreal', 'illustration', 'anime', 'logo', 'concept', 'other']);
|
|
74
|
+
const VALID_PRIORITIES = new Set(['cost', 'quality', 'balanced']);
|
|
75
|
+
function validateChoice(raw, catalog) {
|
|
76
|
+
if (!raw || typeof raw !== 'object')
|
|
77
|
+
return null;
|
|
78
|
+
const id = typeof raw.model === 'string' ? raw.model : '';
|
|
79
|
+
const model = catalog.get(id);
|
|
80
|
+
if (!model)
|
|
81
|
+
return null;
|
|
82
|
+
const rationale = typeof raw.rationale === 'string' ? raw.rationale.slice(0, 240) : '';
|
|
83
|
+
return { model, rationale };
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Pick the best model + alternatives for this media request. Returns null
|
|
87
|
+
* on any failure path (classifier timeout, parse error, empty catalog) so
|
|
88
|
+
* the caller can fall back to its old hardcoded default rather than
|
|
89
|
+
* blocking the user.
|
|
90
|
+
*/
|
|
91
|
+
export async function analyzeMediaRequest(opts) {
|
|
92
|
+
if (process.env.FRANKLIN_NO_MEDIA_ROUTER === '1')
|
|
93
|
+
return null;
|
|
94
|
+
const { kind, prompt, client } = opts;
|
|
95
|
+
if (!prompt || prompt.trim().length === 0)
|
|
96
|
+
return null;
|
|
97
|
+
// Pull catalog first — if the gateway doesn't have any models in this
|
|
98
|
+
// category, there's nothing to recommend.
|
|
99
|
+
const catalog = await getModelsByCategory(kind).catch(() => []);
|
|
100
|
+
if (catalog.length === 0)
|
|
101
|
+
return null;
|
|
102
|
+
// Cache check — classifier output is stable for a given prompt + catalog
|
|
103
|
+
// version, so re-asking within 30s is waste.
|
|
104
|
+
const quantity = Math.max(1, Math.floor(opts.quantity ?? 1));
|
|
105
|
+
const key = hashKey([kind, prompt.trim().slice(0, 500), String(quantity), String(opts.durationSeconds ?? '')]);
|
|
106
|
+
const hit = cache.get(key);
|
|
107
|
+
if (hit && hit.expiresAt > Date.now())
|
|
108
|
+
return hit.value;
|
|
109
|
+
// Call the classifier.
|
|
110
|
+
const catalogMap = new Map(catalog.map(m => [m.id, m]));
|
|
111
|
+
const system = buildSystemPrompt(kind, catalog);
|
|
112
|
+
const ctrl = new AbortController();
|
|
113
|
+
const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
|
|
114
|
+
const signal = opts.signal ? combineSignals([opts.signal, ctrl.signal]) : ctrl.signal;
|
|
115
|
+
let raw = '';
|
|
116
|
+
try {
|
|
117
|
+
const response = await client.complete({
|
|
118
|
+
model: CLASSIFIER_MODEL,
|
|
119
|
+
system,
|
|
120
|
+
messages: [{ role: 'user', content: prompt.slice(0, 1000) }],
|
|
121
|
+
tools: [],
|
|
122
|
+
max_tokens: MAX_TOKENS,
|
|
123
|
+
}, signal);
|
|
124
|
+
for (const part of response.content) {
|
|
125
|
+
if (typeof part === 'object' && part.type === 'text' && part.text)
|
|
126
|
+
raw += part.text;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
clearTimeout(timer);
|
|
134
|
+
}
|
|
135
|
+
// Parse one-line JSON (may be wrapped in stray text).
|
|
136
|
+
const match = raw.match(/\{[\s\S]*\}/);
|
|
137
|
+
if (!match)
|
|
138
|
+
return null;
|
|
139
|
+
let parsed;
|
|
140
|
+
try {
|
|
141
|
+
parsed = JSON.parse(match[0]);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
const style = typeof parsed.style === 'string' && VALID_STYLES.has(parsed.style)
|
|
147
|
+
? parsed.style : 'other';
|
|
148
|
+
const priority = typeof parsed.priority === 'string' && VALID_PRIORITIES.has(parsed.priority)
|
|
149
|
+
? parsed.priority : 'balanced';
|
|
150
|
+
const rec = validateChoice(parsed.recommended, catalogMap);
|
|
151
|
+
if (!rec)
|
|
152
|
+
return null;
|
|
153
|
+
const cheaperChoice = validateChoice(parsed.cheaper, catalogMap);
|
|
154
|
+
const premiumChoice = validateChoice(parsed.premium, catalogMap);
|
|
155
|
+
// Build proposal with live cost estimates.
|
|
156
|
+
const durationSeconds = kind === 'video'
|
|
157
|
+
? (opts.durationSeconds ?? defaultDurationSeconds(rec.model))
|
|
158
|
+
: undefined;
|
|
159
|
+
const maxDur = kind === 'video' ? (maxDurationSeconds(rec.model) ?? undefined) : undefined;
|
|
160
|
+
const toChoice = (c) => {
|
|
161
|
+
if (!c || c.model.id === rec.model.id)
|
|
162
|
+
return undefined;
|
|
163
|
+
return {
|
|
164
|
+
model: c.model.id,
|
|
165
|
+
estimatedCostUsd: estimateCostUsd(c.model, { quantity, duration_seconds: durationSeconds }),
|
|
166
|
+
rationale: c.rationale,
|
|
167
|
+
};
|
|
168
|
+
};
|
|
169
|
+
const recommended = {
|
|
170
|
+
model: rec.model.id,
|
|
171
|
+
estimatedCostUsd: estimateCostUsd(rec.model, { quantity, duration_seconds: durationSeconds }),
|
|
172
|
+
rationale: rec.rationale,
|
|
173
|
+
};
|
|
174
|
+
const proposal = {
|
|
175
|
+
kind,
|
|
176
|
+
quantity,
|
|
177
|
+
durationSeconds,
|
|
178
|
+
maxDurationSeconds: maxDur,
|
|
179
|
+
recommended,
|
|
180
|
+
cheaper: toChoice(cheaperChoice),
|
|
181
|
+
premium: toChoice(premiumChoice),
|
|
182
|
+
intent: { style, priority },
|
|
183
|
+
totalCostUsd: recommended.estimatedCostUsd,
|
|
184
|
+
};
|
|
185
|
+
// Evict oldest if bounded
|
|
186
|
+
if (cache.size >= CACHE_MAX) {
|
|
187
|
+
const firstKey = cache.keys().next().value;
|
|
188
|
+
if (firstKey)
|
|
189
|
+
cache.delete(firstKey);
|
|
190
|
+
}
|
|
191
|
+
cache.set(key, { value: proposal, expiresAt: Date.now() + CACHE_TTL_MS });
|
|
192
|
+
return proposal;
|
|
193
|
+
}
|
|
194
|
+
// ─── Presentation ───────────────────────────────────────────────────────
|
|
195
|
+
/**
|
|
196
|
+
* Render a proposal as the user-facing AskUser question. Layout matches
|
|
197
|
+
* the spec from v3.8.31 planning: recommended first with • bullet,
|
|
198
|
+
* alternatives below with ○ bullets, prices include the 5% margin note.
|
|
199
|
+
*/
|
|
200
|
+
export function renderProposalForAskUser(p, userPrompt) {
|
|
201
|
+
const lines = [];
|
|
202
|
+
lines.push(`*Media generation proposal*`);
|
|
203
|
+
lines.push('');
|
|
204
|
+
lines.push(`Prompt: "${userPrompt.trim().slice(0, 200)}"`);
|
|
205
|
+
if (p.kind === 'video' && p.durationSeconds) {
|
|
206
|
+
const maxNote = p.maxDurationSeconds ? ` (max ${p.maxDurationSeconds}s)` : '';
|
|
207
|
+
lines.push(`Duration: ${p.durationSeconds}s${maxNote}`);
|
|
208
|
+
}
|
|
209
|
+
else if (p.kind === 'image' && p.quantity > 1) {
|
|
210
|
+
lines.push(`Quantity: ${p.quantity} images`);
|
|
211
|
+
}
|
|
212
|
+
lines.push('');
|
|
213
|
+
lines.push(` ● Recommended ${p.recommended.model.padEnd(32)} ~${formatUsd(p.recommended.estimatedCostUsd)} ${p.recommended.rationale}`);
|
|
214
|
+
if (p.cheaper) {
|
|
215
|
+
lines.push(` ○ Cheaper ${p.cheaper.model.padEnd(32)} ~${formatUsd(p.cheaper.estimatedCostUsd)} ${p.cheaper.rationale}`);
|
|
216
|
+
}
|
|
217
|
+
if (p.premium) {
|
|
218
|
+
lines.push(` ○ Premium ${p.premium.model.padEnd(32)} ~${formatUsd(p.premium.estimatedCostUsd)} ${p.premium.rationale}`);
|
|
219
|
+
}
|
|
220
|
+
lines.push('');
|
|
221
|
+
lines.push(` (prices include the 5% gateway fee)`);
|
|
222
|
+
const options = [];
|
|
223
|
+
options.push({ id: 'recommended', label: `Continue with ${p.recommended.model}` });
|
|
224
|
+
if (p.cheaper)
|
|
225
|
+
options.push({ id: 'cheaper', label: `Use cheaper (${p.cheaper.model})` });
|
|
226
|
+
if (p.premium)
|
|
227
|
+
options.push({ id: 'premium', label: `Use premium (${p.premium.model})` });
|
|
228
|
+
options.push({ id: 'cancel', label: 'Cancel (no charge)' });
|
|
229
|
+
return { question: lines.join('\n'), options };
|
|
230
|
+
}
|
|
231
|
+
function formatUsd(n) {
|
|
232
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
233
|
+
return '$0.00';
|
|
234
|
+
if (n < 0.01)
|
|
235
|
+
return `$${n.toFixed(4)}`;
|
|
236
|
+
if (n < 1)
|
|
237
|
+
return `$${n.toFixed(3)}`;
|
|
238
|
+
return `$${n.toFixed(2)}`;
|
|
239
|
+
}
|
|
240
|
+
function combineSignals(signals) {
|
|
241
|
+
const ctrl = new AbortController();
|
|
242
|
+
for (const s of signals) {
|
|
243
|
+
if (s.aborted) {
|
|
244
|
+
ctrl.abort();
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
s.addEventListener('abort', () => ctrl.abort(), { once: true });
|
|
248
|
+
}
|
|
249
|
+
return ctrl.signal;
|
|
250
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dynamic model catalog from BlockRun Gateway.
|
|
3
|
+
*
|
|
4
|
+
* Pulls GET /api/v1/models once on first use, caches for 5 minutes, and
|
|
5
|
+
* exposes estimators + category filters. This replaces the hardcoded
|
|
6
|
+
* pricing/model tables Franklin used to carry — adding a new model or
|
|
7
|
+
* changing a price on BlockRun's side no longer requires a Franklin
|
|
8
|
+
* release. Gateway is the single source of truth.
|
|
9
|
+
*
|
|
10
|
+
* Per gateway team (2026-04-22): every model returns `billing_mode` and
|
|
11
|
+
* a mode-specific `pricing` object. Dispatch on billing_mode to compute
|
|
12
|
+
* an estimated charge. x402 adds a fixed 5% margin on top of base price,
|
|
13
|
+
* so actual charge = base * 1.05 (confirmed against a live 402 response
|
|
14
|
+
* on seedance-2.0-fast: 5s × $0.15 × 1.05 = $0.7875).
|
|
15
|
+
*/
|
|
16
|
+
export type BillingMode = 'paid' | 'free' | 'flat' | 'per_image' | 'per_second' | 'per_track';
|
|
17
|
+
export interface PaidPricing {
|
|
18
|
+
input: number;
|
|
19
|
+
output: number;
|
|
20
|
+
}
|
|
21
|
+
export interface FlatPricing {
|
|
22
|
+
flat: number;
|
|
23
|
+
}
|
|
24
|
+
export interface PerImagePricing {
|
|
25
|
+
per_image: number;
|
|
26
|
+
}
|
|
27
|
+
export interface PerSecondPricing {
|
|
28
|
+
per_second: number;
|
|
29
|
+
default_duration_seconds?: number;
|
|
30
|
+
max_duration_seconds?: number;
|
|
31
|
+
}
|
|
32
|
+
export interface PerTrackPricing {
|
|
33
|
+
per_track: number;
|
|
34
|
+
}
|
|
35
|
+
export type ModelPricing = PaidPricing | FlatPricing | PerImagePricing | PerSecondPricing | PerTrackPricing;
|
|
36
|
+
export interface GatewayModel {
|
|
37
|
+
id: string;
|
|
38
|
+
name: string;
|
|
39
|
+
description?: string;
|
|
40
|
+
owned_by?: string;
|
|
41
|
+
billing_mode: BillingMode;
|
|
42
|
+
categories: string[];
|
|
43
|
+
context_window?: number;
|
|
44
|
+
max_output?: number;
|
|
45
|
+
pricing: ModelPricing;
|
|
46
|
+
}
|
|
47
|
+
/** Test / reset helper. */
|
|
48
|
+
export declare function clearGatewayModelsCache(): void;
|
|
49
|
+
/**
|
|
50
|
+
* Fetch the model catalog, honoring the 5-minute cache. Concurrent callers
|
|
51
|
+
* during a cold cache share a single in-flight promise so we don't stampede
|
|
52
|
+
* the gateway at process start.
|
|
53
|
+
*/
|
|
54
|
+
export declare function getGatewayModels(): Promise<GatewayModel[]>;
|
|
55
|
+
/** Return models filtered to a specific category (e.g. 'image', 'video', 'music'). */
|
|
56
|
+
export declare function getModelsByCategory(category: string): Promise<GatewayModel[]>;
|
|
57
|
+
/** Find a single model by ID, or null if it's not in the current catalog. */
|
|
58
|
+
export declare function findModel(id: string): Promise<GatewayModel | null>;
|
|
59
|
+
/** x402 gateway's fixed margin percentage applied on top of the base price. */
|
|
60
|
+
export declare const GATEWAY_MARGIN = 1.05;
|
|
61
|
+
export interface EstimateContext {
|
|
62
|
+
/** Number of images (per_image). Default 1. */
|
|
63
|
+
quantity?: number;
|
|
64
|
+
/** Clip length in seconds (per_second). Falls back to model's default_duration_seconds, then 8. */
|
|
65
|
+
duration_seconds?: number;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Estimated USD charge to generate one response from this model under the
|
|
69
|
+
* given context. Includes the 5% gateway margin. Returns 0 for free and
|
|
70
|
+
* token-metered (paid) models where a pre-call estimate isn't meaningful.
|
|
71
|
+
*/
|
|
72
|
+
export declare function estimateCostUsd(model: GatewayModel, ctx?: EstimateContext): number;
|
|
73
|
+
/** Effective default duration for a per_second model (falls back to 8s). */
|
|
74
|
+
export declare function defaultDurationSeconds(model: GatewayModel): number;
|
|
75
|
+
/** Max duration the gateway will accept for a per_second model. */
|
|
76
|
+
export declare function maxDurationSeconds(model: GatewayModel): number | null;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dynamic model catalog from BlockRun Gateway.
|
|
3
|
+
*
|
|
4
|
+
* Pulls GET /api/v1/models once on first use, caches for 5 minutes, and
|
|
5
|
+
* exposes estimators + category filters. This replaces the hardcoded
|
|
6
|
+
* pricing/model tables Franklin used to carry — adding a new model or
|
|
7
|
+
* changing a price on BlockRun's side no longer requires a Franklin
|
|
8
|
+
* release. Gateway is the single source of truth.
|
|
9
|
+
*
|
|
10
|
+
* Per gateway team (2026-04-22): every model returns `billing_mode` and
|
|
11
|
+
* a mode-specific `pricing` object. Dispatch on billing_mode to compute
|
|
12
|
+
* an estimated charge. x402 adds a fixed 5% margin on top of base price,
|
|
13
|
+
* so actual charge = base * 1.05 (confirmed against a live 402 response
|
|
14
|
+
* on seedance-2.0-fast: 5s × $0.15 × 1.05 = $0.7875).
|
|
15
|
+
*/
|
|
16
|
+
import { loadChain, API_URLS, USER_AGENT } from './config.js';
|
|
17
|
+
// ─── Cache ──────────────────────────────────────────────────────────────
|
|
18
|
+
const CACHE_TTL_MS = 5 * 60_000; // 5 min — gateway rotates models, but not often
|
|
19
|
+
const FETCH_TIMEOUT_MS = 4_000; // one-shot on init; don't let a slow gateway hang startup
|
|
20
|
+
let cache = null;
|
|
21
|
+
let inflight = null;
|
|
22
|
+
/** Test / reset helper. */
|
|
23
|
+
export function clearGatewayModelsCache() {
|
|
24
|
+
cache = null;
|
|
25
|
+
inflight = null;
|
|
26
|
+
}
|
|
27
|
+
// ─── Fetch ──────────────────────────────────────────────────────────────
|
|
28
|
+
async function doFetch() {
|
|
29
|
+
const chain = loadChain();
|
|
30
|
+
const base = API_URLS[chain].replace(/\/api$/, '');
|
|
31
|
+
// The schema/JSON gate: without ?format=json the gateway returns a
|
|
32
|
+
// typed schema placeholder instead of the data envelope. Documented
|
|
33
|
+
// quirk across other endpoints too.
|
|
34
|
+
const url = `${base}/api/v1/models?format=json`;
|
|
35
|
+
const ctrl = new AbortController();
|
|
36
|
+
const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
|
|
37
|
+
try {
|
|
38
|
+
const res = await fetch(url, {
|
|
39
|
+
signal: ctrl.signal,
|
|
40
|
+
headers: { 'User-Agent': USER_AGENT, Accept: 'application/json' },
|
|
41
|
+
});
|
|
42
|
+
if (!res.ok)
|
|
43
|
+
throw new Error(`Gateway models list returned HTTP ${res.status}`);
|
|
44
|
+
const body = (await res.json());
|
|
45
|
+
if (!Array.isArray(body.data))
|
|
46
|
+
throw new Error('Gateway models list missing data[]');
|
|
47
|
+
return body.data;
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
clearTimeout(timer);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Fetch the model catalog, honoring the 5-minute cache. Concurrent callers
|
|
55
|
+
* during a cold cache share a single in-flight promise so we don't stampede
|
|
56
|
+
* the gateway at process start.
|
|
57
|
+
*/
|
|
58
|
+
export async function getGatewayModels() {
|
|
59
|
+
if (cache && cache.expiresAt > Date.now())
|
|
60
|
+
return cache.models;
|
|
61
|
+
if (inflight)
|
|
62
|
+
return inflight;
|
|
63
|
+
inflight = doFetch()
|
|
64
|
+
.then(models => {
|
|
65
|
+
cache = { models, expiresAt: Date.now() + CACHE_TTL_MS };
|
|
66
|
+
return models;
|
|
67
|
+
})
|
|
68
|
+
.catch(err => {
|
|
69
|
+
// On failure, keep the last good cache if we have one (serve stale
|
|
70
|
+
// rather than break the agent). Only hard-fail cold start.
|
|
71
|
+
if (cache)
|
|
72
|
+
return cache.models;
|
|
73
|
+
throw err;
|
|
74
|
+
})
|
|
75
|
+
.finally(() => { inflight = null; });
|
|
76
|
+
return inflight;
|
|
77
|
+
}
|
|
78
|
+
/** Return models filtered to a specific category (e.g. 'image', 'video', 'music'). */
|
|
79
|
+
export async function getModelsByCategory(category) {
|
|
80
|
+
const all = await getGatewayModels();
|
|
81
|
+
return all.filter(m => Array.isArray(m.categories) && m.categories.includes(category));
|
|
82
|
+
}
|
|
83
|
+
/** Find a single model by ID, or null if it's not in the current catalog. */
|
|
84
|
+
export async function findModel(id) {
|
|
85
|
+
const all = await getGatewayModels();
|
|
86
|
+
return all.find(m => m.id === id) ?? null;
|
|
87
|
+
}
|
|
88
|
+
// ─── Cost estimation ────────────────────────────────────────────────────
|
|
89
|
+
/** x402 gateway's fixed margin percentage applied on top of the base price. */
|
|
90
|
+
export const GATEWAY_MARGIN = 1.05;
|
|
91
|
+
/**
|
|
92
|
+
* Estimated USD charge to generate one response from this model under the
|
|
93
|
+
* given context. Includes the 5% gateway margin. Returns 0 for free and
|
|
94
|
+
* token-metered (paid) models where a pre-call estimate isn't meaningful.
|
|
95
|
+
*/
|
|
96
|
+
export function estimateCostUsd(model, ctx = {}) {
|
|
97
|
+
const p = model.pricing;
|
|
98
|
+
let base = 0;
|
|
99
|
+
switch (model.billing_mode) {
|
|
100
|
+
case 'per_image':
|
|
101
|
+
base = (p.per_image ?? 0) * (ctx.quantity ?? 1);
|
|
102
|
+
break;
|
|
103
|
+
case 'per_second': {
|
|
104
|
+
const dur = ctx.duration_seconds ?? p.default_duration_seconds ?? 8;
|
|
105
|
+
base = (p.per_second ?? 0) * dur;
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
case 'per_track':
|
|
109
|
+
base = p.per_track ?? 0;
|
|
110
|
+
break;
|
|
111
|
+
case 'flat':
|
|
112
|
+
base = p.flat ?? 0;
|
|
113
|
+
break;
|
|
114
|
+
case 'free':
|
|
115
|
+
base = 0;
|
|
116
|
+
break;
|
|
117
|
+
case 'paid':
|
|
118
|
+
// Token-metered — no pre-call estimate possible without counting
|
|
119
|
+
// the exact request/response tokens. Return 0 so the caller shows
|
|
120
|
+
// "~tokens" instead of a made-up number.
|
|
121
|
+
base = 0;
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
return +(base * GATEWAY_MARGIN).toFixed(6);
|
|
125
|
+
}
|
|
126
|
+
/** Effective default duration for a per_second model (falls back to 8s). */
|
|
127
|
+
export function defaultDurationSeconds(model) {
|
|
128
|
+
if (model.billing_mode !== 'per_second')
|
|
129
|
+
return 8;
|
|
130
|
+
const p = model.pricing;
|
|
131
|
+
return p.default_duration_seconds ?? 8;
|
|
132
|
+
}
|
|
133
|
+
/** Max duration the gateway will accept for a per_second model. */
|
|
134
|
+
export function maxDurationSeconds(model) {
|
|
135
|
+
if (model.billing_mode !== 'per_second')
|
|
136
|
+
return null;
|
|
137
|
+
const p = model.pricing;
|
|
138
|
+
return p.max_duration_seconds ?? null;
|
|
139
|
+
}
|
package/dist/session/search.js
CHANGED
|
@@ -11,9 +11,9 @@ import { listSessions, getSessionFilePath } from './storage.js';
|
|
|
11
11
|
function tokenize(text) {
|
|
12
12
|
return text
|
|
13
13
|
.toLowerCase()
|
|
14
|
-
.replace(/[^\
|
|
15
|
-
.split(/\s+/)
|
|
16
|
-
.filter(t => t.length > 1);
|
|
14
|
+
.replace(/[^\p{L}\p{N}_\s]/gu, ' ')
|
|
15
|
+
.split(/\s+/u)
|
|
16
|
+
.filter(t => t.length > 1 || /[^\x00-\x7F]/.test(t));
|
|
17
17
|
}
|
|
18
18
|
function parseQuery(query) {
|
|
19
19
|
const phrases = [];
|
package/dist/session/storage.js
CHANGED
|
@@ -158,10 +158,7 @@ export function loadSessionHistory(sessionId) {
|
|
|
158
158
|
return [];
|
|
159
159
|
}
|
|
160
160
|
}
|
|
161
|
-
|
|
162
|
-
* List all saved sessions, newest first.
|
|
163
|
-
*/
|
|
164
|
-
export function listSessions() {
|
|
161
|
+
function readSessionMetas(includeGhosts = false) {
|
|
165
162
|
const sessionsDir = getSessionsDir();
|
|
166
163
|
try {
|
|
167
164
|
const files = fs.readdirSync(sessionsDir)
|
|
@@ -174,14 +171,19 @@ export function listSessions() {
|
|
|
174
171
|
}
|
|
175
172
|
catch { /* skip corrupted */ }
|
|
176
173
|
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
return filtered.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
174
|
+
const visible = includeGhosts ? metas : metas.filter(m => m.messageCount > 0);
|
|
175
|
+
return visible.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
180
176
|
}
|
|
181
177
|
catch {
|
|
182
178
|
return [];
|
|
183
179
|
}
|
|
184
180
|
}
|
|
181
|
+
/**
|
|
182
|
+
* List all saved sessions, newest first.
|
|
183
|
+
*/
|
|
184
|
+
export function listSessions() {
|
|
185
|
+
return readSessionMetas(false);
|
|
186
|
+
}
|
|
185
187
|
/**
|
|
186
188
|
* Find the latest saved session tagged with a given channel (e.g.
|
|
187
189
|
* `telegram:12345`). Used by non-CLI drivers to resume across process
|
|
@@ -198,25 +200,26 @@ export function findLatestSessionByChannel(channel) {
|
|
|
198
200
|
* Accepts optional activeSessionId to protect from deletion.
|
|
199
201
|
*/
|
|
200
202
|
export function pruneOldSessions(activeSessionId) {
|
|
201
|
-
const sessions =
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
203
|
+
const sessions = readSessionMetas(false);
|
|
204
|
+
const allSessions = readSessionMetas(true);
|
|
205
|
+
if (sessions.length > MAX_SESSIONS) {
|
|
206
|
+
const toDelete = sessions
|
|
207
|
+
.slice(MAX_SESSIONS)
|
|
208
|
+
.filter(s => s.id !== activeSessionId); // Never delete active session
|
|
209
|
+
for (const s of toDelete) {
|
|
210
|
+
try {
|
|
211
|
+
fs.unlinkSync(sessionPath(s.id));
|
|
212
|
+
}
|
|
213
|
+
catch { /* ok */ }
|
|
214
|
+
try {
|
|
215
|
+
fs.unlinkSync(metaPath(s.id));
|
|
216
|
+
}
|
|
217
|
+
catch { /* ok */ }
|
|
214
218
|
}
|
|
215
|
-
catch { /* ok */ }
|
|
216
219
|
}
|
|
217
220
|
// Also clean up ghost sessions (0 messages, older than 5 minutes)
|
|
218
221
|
const fiveMinAgo = Date.now() - 5 * 60 * 1000;
|
|
219
|
-
for (const s of
|
|
222
|
+
for (const s of allSessions) {
|
|
220
223
|
if (s.id === activeSessionId)
|
|
221
224
|
continue;
|
|
222
225
|
if (s.messageCount === 0 && s.createdAt < fiveMinAgo) {
|
package/dist/tools/imagegen.js
CHANGED
|
@@ -7,15 +7,60 @@ import path from 'node:path';
|
|
|
7
7
|
import { getOrCreateWallet, getOrCreateSolanaWallet, createPaymentPayload, createSolanaPaymentPayload, parsePaymentRequired, extractPaymentDetails, solanaKeyToBytes, SOLANA_NETWORK, } from '@blockrun/llm';
|
|
8
8
|
import { loadChain, API_URLS, VERSION } from '../config.js';
|
|
9
9
|
import { checkImageBudget, recordImageAsset } from '../content/record-image.js';
|
|
10
|
+
import { ModelClient } from '../agent/llm.js';
|
|
11
|
+
import { analyzeMediaRequest, renderProposalForAskUser } from '../agent/media-router.js';
|
|
10
12
|
function buildExecute(deps) {
|
|
11
13
|
return async function execute(input, ctx) {
|
|
12
14
|
const { prompt, output_path, size, model, contentId } = input;
|
|
13
15
|
if (!prompt) {
|
|
14
16
|
return { output: 'Error: prompt is required', isError: true };
|
|
15
17
|
}
|
|
16
|
-
// ──
|
|
17
|
-
|
|
18
|
+
// ── Media router + AskUser flow ────────────────────────────────────
|
|
19
|
+
// If the caller explicitly named a model, or the env auto-approves, or
|
|
20
|
+
// no AskUser bridge exists (batch / --prompt mode), skip the proposal
|
|
21
|
+
// step and use the old default. Otherwise: classifier picks a fitting
|
|
22
|
+
// model, cost preview goes to AskUser, user chooses or cancels.
|
|
23
|
+
let imageModel = model || 'openai/gpt-image-1';
|
|
18
24
|
const imageSize = size || '1024x1024';
|
|
25
|
+
const autoApprove = process.env.FRANKLIN_MEDIA_AUTO_APPROVE_ALL === '1';
|
|
26
|
+
if (!model && !autoApprove && ctx.onAskUser) {
|
|
27
|
+
try {
|
|
28
|
+
const chain = loadChain();
|
|
29
|
+
const client = new ModelClient({ apiUrl: API_URLS[chain], chain });
|
|
30
|
+
const proposal = await analyzeMediaRequest({
|
|
31
|
+
kind: 'image',
|
|
32
|
+
prompt,
|
|
33
|
+
quantity: 1,
|
|
34
|
+
client,
|
|
35
|
+
signal: ctx.abortSignal,
|
|
36
|
+
});
|
|
37
|
+
if (proposal) {
|
|
38
|
+
const { question, options } = renderProposalForAskUser(proposal, prompt);
|
|
39
|
+
const labels = options.map(o => o.label);
|
|
40
|
+
const answer = await ctx.onAskUser(question, labels);
|
|
41
|
+
// Map the user's returned label back to an option id
|
|
42
|
+
const chosen = options.find(o => o.label === answer) ?? { id: 'cancel' };
|
|
43
|
+
switch (chosen.id) {
|
|
44
|
+
case 'cheaper':
|
|
45
|
+
imageModel = proposal.cheaper?.model ?? proposal.recommended.model;
|
|
46
|
+
break;
|
|
47
|
+
case 'premium':
|
|
48
|
+
imageModel = proposal.premium?.model ?? proposal.recommended.model;
|
|
49
|
+
break;
|
|
50
|
+
case 'cancel':
|
|
51
|
+
return {
|
|
52
|
+
output: `## Image generation cancelled\n\nNo USDC was spent. Ask again when ready, or pass an explicit \`model\` to skip the confirmation step.`,
|
|
53
|
+
};
|
|
54
|
+
case 'recommended':
|
|
55
|
+
default:
|
|
56
|
+
imageModel = proposal.recommended.model;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// Router / AskUser failed — fall back to default model silently.
|
|
62
|
+
}
|
|
63
|
+
}
|
|
19
64
|
if (contentId && deps.library) {
|
|
20
65
|
const decision = checkImageBudget(deps.library, contentId, imageModel, imageSize);
|
|
21
66
|
if (!decision.ok) {
|
package/dist/tools/videogen.js
CHANGED
|
@@ -11,6 +11,8 @@ import fs from 'node:fs';
|
|
|
11
11
|
import path from 'node:path';
|
|
12
12
|
import { getOrCreateWallet, getOrCreateSolanaWallet, createPaymentPayload, createSolanaPaymentPayload, parsePaymentRequired, extractPaymentDetails, solanaKeyToBytes, SOLANA_NETWORK, } from '@blockrun/llm';
|
|
13
13
|
import { loadChain, API_URLS, VERSION } from '../config.js';
|
|
14
|
+
import { ModelClient } from '../agent/llm.js';
|
|
15
|
+
import { analyzeMediaRequest, renderProposalForAskUser } from '../agent/media-router.js';
|
|
14
16
|
const DEFAULT_MODEL = 'xai/grok-imagine-video';
|
|
15
17
|
const DEFAULT_DURATION = 8;
|
|
16
18
|
const PRICE_PER_SECOND_USD = 0.05;
|
|
@@ -26,8 +28,51 @@ function buildExecute(deps) {
|
|
|
26
28
|
const { prompt, output_path, model, image_url, duration_seconds, contentId } = input;
|
|
27
29
|
if (!prompt)
|
|
28
30
|
return { output: 'Error: prompt is required', isError: true };
|
|
29
|
-
|
|
30
|
-
|
|
31
|
+
let videoModel = model || DEFAULT_MODEL;
|
|
32
|
+
let duration = duration_seconds ?? DEFAULT_DURATION;
|
|
33
|
+
// ── Media router + AskUser flow (video bills per second, always ask) ──
|
|
34
|
+
const autoApprove = process.env.FRANKLIN_MEDIA_AUTO_APPROVE_ALL === '1';
|
|
35
|
+
if (!model && !autoApprove && ctx.onAskUser) {
|
|
36
|
+
try {
|
|
37
|
+
const chain = loadChain();
|
|
38
|
+
const client = new ModelClient({ apiUrl: API_URLS[chain], chain });
|
|
39
|
+
const proposal = await analyzeMediaRequest({
|
|
40
|
+
kind: 'video',
|
|
41
|
+
prompt,
|
|
42
|
+
durationSeconds: duration_seconds,
|
|
43
|
+
client,
|
|
44
|
+
signal: ctx.abortSignal,
|
|
45
|
+
});
|
|
46
|
+
if (proposal) {
|
|
47
|
+
const { question, options } = renderProposalForAskUser(proposal, prompt);
|
|
48
|
+
const labels = options.map(o => o.label);
|
|
49
|
+
const answer = await ctx.onAskUser(question, labels);
|
|
50
|
+
const chosen = options.find(o => o.label === answer) ?? { id: 'cancel' };
|
|
51
|
+
switch (chosen.id) {
|
|
52
|
+
case 'cheaper':
|
|
53
|
+
videoModel = proposal.cheaper?.model ?? proposal.recommended.model;
|
|
54
|
+
break;
|
|
55
|
+
case 'premium':
|
|
56
|
+
videoModel = proposal.premium?.model ?? proposal.recommended.model;
|
|
57
|
+
break;
|
|
58
|
+
case 'cancel':
|
|
59
|
+
return {
|
|
60
|
+
output: `## Video generation cancelled\n\nNo USDC was spent.`,
|
|
61
|
+
};
|
|
62
|
+
case 'recommended':
|
|
63
|
+
default:
|
|
64
|
+
videoModel = proposal.recommended.model;
|
|
65
|
+
}
|
|
66
|
+
// Use the proposal's duration — the router honored the user's
|
|
67
|
+
// duration_seconds or filled in the model's default.
|
|
68
|
+
if (proposal.durationSeconds)
|
|
69
|
+
duration = proposal.durationSeconds;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// Router / AskUser failed — fall through to legacy default.
|
|
74
|
+
}
|
|
75
|
+
}
|
|
31
76
|
const estCost = estimateVideoCostUsd(duration);
|
|
32
77
|
if (contentId && deps.library) {
|
|
33
78
|
const content = deps.library.get(contentId);
|
package/dist/ui/app.js
CHANGED
|
@@ -614,7 +614,16 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
|
|
|
614
614
|
break;
|
|
615
615
|
}
|
|
616
616
|
case 'usage': {
|
|
617
|
-
setCurrentModel(event.model)
|
|
617
|
+
// DO NOT setCurrentModel(event.model) here. currentModel
|
|
618
|
+
// represents the user's selection (e.g. 'blockrun/auto'),
|
|
619
|
+
// not what the router resolved for this specific turn. The
|
|
620
|
+
// per-turn resolved model is already captured in
|
|
621
|
+
// turnModelRef (rendered in the turn-summary line below
|
|
622
|
+
// each response) and in onModelChange('system') when the
|
|
623
|
+
// loop itself decides to swap (empty-response / 402 fallback).
|
|
624
|
+
// Overriding currentModel from every usage event made the
|
|
625
|
+
// status bar permanently show the last resolved model and
|
|
626
|
+
// create a false impression that auto mode was stuck.
|
|
618
627
|
setTurnTokens(prev => ({
|
|
619
628
|
input: prev.input + event.inputTokens,
|
|
620
629
|
output: prev.output + event.outputTokens,
|
|
@@ -3,6 +3,15 @@
|
|
|
3
3
|
* Lists recent sessions (newest first) and returns the selected ID.
|
|
4
4
|
*/
|
|
5
5
|
import { type SessionMeta } from '../session/storage.js';
|
|
6
|
+
type SessionPickerSelection = {
|
|
7
|
+
kind: 'cancel';
|
|
8
|
+
} | {
|
|
9
|
+
kind: 'selected';
|
|
10
|
+
id: string;
|
|
11
|
+
} | {
|
|
12
|
+
kind: 'invalid';
|
|
13
|
+
message: string;
|
|
14
|
+
};
|
|
6
15
|
/**
|
|
7
16
|
* Resolve a user-provided session identifier to a full session ID.
|
|
8
17
|
* Supports exact match and unambiguous prefix match (minimum 8 chars).
|
|
@@ -16,6 +25,7 @@ export declare function resolveSessionIdInput(input: string): {
|
|
|
16
25
|
error: 'not-found' | 'ambiguous';
|
|
17
26
|
candidates: SessionMeta[];
|
|
18
27
|
};
|
|
28
|
+
export declare function resolvePickerSelection(input: string, shown: SessionMeta[], sessions: SessionMeta[]): SessionPickerSelection;
|
|
19
29
|
/**
|
|
20
30
|
* Find the most recent session for a given working directory.
|
|
21
31
|
* Returns null if none exists.
|
|
@@ -28,3 +38,4 @@ export declare function findLatestSessionForDir(workDir: string): SessionMeta |
|
|
|
28
38
|
export declare function pickSession(opts?: {
|
|
29
39
|
workDir?: string;
|
|
30
40
|
}): Promise<string | null>;
|
|
41
|
+
export {};
|
|
@@ -61,6 +61,33 @@ export function resolveSessionIdInput(input) {
|
|
|
61
61
|
}
|
|
62
62
|
return { ok: false, error: 'not-found', candidates: [] };
|
|
63
63
|
}
|
|
64
|
+
export function resolvePickerSelection(input, shown, sessions) {
|
|
65
|
+
const trimmed = input.trim();
|
|
66
|
+
if (!trimmed)
|
|
67
|
+
return { kind: 'cancel' };
|
|
68
|
+
const num = parseInt(trimmed, 10);
|
|
69
|
+
if (!isNaN(num) && num >= 1 && num <= shown.length) {
|
|
70
|
+
return { kind: 'selected', id: shown[num - 1].id };
|
|
71
|
+
}
|
|
72
|
+
const exact = sessions.find((s) => s.id === trimmed);
|
|
73
|
+
if (exact)
|
|
74
|
+
return { kind: 'selected', id: exact.id };
|
|
75
|
+
const resolved = resolveSessionIdInput(trimmed);
|
|
76
|
+
if (resolved.ok) {
|
|
77
|
+
return { kind: 'selected', id: resolved.id };
|
|
78
|
+
}
|
|
79
|
+
if (resolved.error === 'ambiguous') {
|
|
80
|
+
const preview = resolved.candidates
|
|
81
|
+
.slice(0, 3)
|
|
82
|
+
.map((candidate) => candidate.id)
|
|
83
|
+
.join(', ');
|
|
84
|
+
return {
|
|
85
|
+
kind: 'invalid',
|
|
86
|
+
message: `Ambiguous session prefix: ${trimmed}${preview ? ` (${preview}${resolved.candidates.length > 3 ? ', …' : ''})` : ''}`,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
return { kind: 'invalid', message: `No session found matching: ${trimmed}` };
|
|
90
|
+
}
|
|
64
91
|
/**
|
|
65
92
|
* Find the most recent session for a given working directory.
|
|
66
93
|
* Returns null if none exists.
|
|
@@ -109,21 +136,23 @@ export async function pickSession(opts = {}) {
|
|
|
109
136
|
terminal: process.stdin.isTTY ?? false,
|
|
110
137
|
});
|
|
111
138
|
return new Promise((resolve) => {
|
|
112
|
-
|
|
113
|
-
rl.
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
139
|
+
const prompt = () => {
|
|
140
|
+
rl.question(chalk.bold(' session> '), (answer) => {
|
|
141
|
+
const selection = resolvePickerSelection(answer, shown, sessions);
|
|
142
|
+
if (selection.kind === 'cancel') {
|
|
143
|
+
rl.close();
|
|
144
|
+
resolve(null);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (selection.kind === 'selected') {
|
|
148
|
+
rl.close();
|
|
149
|
+
resolve(selection.id);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
console.error(chalk.yellow(` ${selection.message}`));
|
|
153
|
+
prompt();
|
|
154
|
+
});
|
|
155
|
+
};
|
|
156
|
+
prompt();
|
|
128
157
|
});
|
|
129
158
|
}
|
package/package.json
CHANGED