@blockrun/franklin 3.8.34 → 3.8.36

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.
Files changed (40) hide show
  1. package/README.md +4 -2
  2. package/dist/agent/commands.js +1 -1
  3. package/dist/agent/compact.js +1 -1
  4. package/dist/agent/context.js +1 -1
  5. package/dist/agent/loop.js +19 -0
  6. package/dist/agent/media-router.d.ts +33 -0
  7. package/dist/agent/media-router.js +87 -9
  8. package/dist/agent/optimize.js +1 -0
  9. package/dist/agent/permissions.js +10 -1
  10. package/dist/agent/tokens.js +4 -0
  11. package/dist/agent/types.d.ts +22 -1
  12. package/dist/commands/balance.js +1 -1
  13. package/dist/commands/daemon.js +23 -16
  14. package/dist/commands/plugin.d.ts +1 -1
  15. package/dist/commands/plugin.js +10 -10
  16. package/dist/commands/stats.d.ts +1 -1
  17. package/dist/commands/stats.js +2 -2
  18. package/dist/index.js +2 -2
  19. package/dist/panel/server.js +7 -6
  20. package/dist/plugin-sdk/index.d.ts +2 -2
  21. package/dist/plugin-sdk/index.js +2 -2
  22. package/dist/plugin-sdk/plugin.d.ts +4 -4
  23. package/dist/plugins/registry.d.ts +3 -3
  24. package/dist/plugins/registry.js +6 -6
  25. package/dist/pricing.js +1 -0
  26. package/dist/proxy/server.js +5 -3
  27. package/dist/router/index.js +3 -3
  28. package/dist/session/storage.js +2 -2
  29. package/dist/tools/imagegen.d.ts +14 -0
  30. package/dist/tools/imagegen.js +175 -24
  31. package/dist/tools/read.js +29 -2
  32. package/dist/tools/videogen.d.ts +14 -3
  33. package/dist/tools/videogen.js +181 -31
  34. package/dist/tools/webhook.js +2 -1
  35. package/dist/trading/providers/coingecko/client.js +2 -1
  36. package/dist/ui/app.js +12 -12
  37. package/dist/ui/model-picker.js +7 -4
  38. package/dist/wallet/index.d.ts +17 -0
  39. package/dist/wallet/index.js +22 -0
  40. package/package.json +7 -5
@@ -1,7 +1,7 @@
1
1
  /**
2
- * RunCode Plugin SDK — public surface for plugins.
2
+ * Franklin Plugin SDK — public surface for plugins.
3
3
  *
4
- * Plugins import ONLY from '@blockrun/runcode/plugin-sdk' (or this barrel).
4
+ * Plugins import ONLY from '@blockrun/franklin/plugin-sdk' (or this barrel).
5
5
  * They MUST NOT import from src/** of core or other plugins.
6
6
  *
7
7
  * Core stays plugin-agnostic: adding a plugin should never require editing core.
@@ -1,7 +1,7 @@
1
1
  /**
2
- * RunCode Plugin SDK — public surface for plugins.
2
+ * Franklin Plugin SDK — public surface for plugins.
3
3
  *
4
- * Plugins import ONLY from '@blockrun/runcode/plugin-sdk' (or this barrel).
4
+ * Plugins import ONLY from '@blockrun/franklin/plugin-sdk' (or this barrel).
5
5
  * They MUST NOT import from src/** of core or other plugins.
6
6
  *
7
7
  * Core stays plugin-agnostic: adding a plugin should never require editing core.
@@ -26,8 +26,8 @@ export interface PluginManifest {
26
26
  homepage?: string;
27
27
  /** License */
28
28
  license?: string;
29
- /** Required runcode version (semver range) */
30
- runcodeVersion?: string;
29
+ /** Required Franklin version (semver range) */
30
+ franklinVersion?: string;
31
31
  }
32
32
  export interface PluginProvides {
33
33
  /** This plugin contributes one or more workflows (e.g. "social", "trading") */
@@ -54,8 +54,8 @@ export interface Plugin {
54
54
  }
55
55
  /** Context passed to plugin lifecycle hooks */
56
56
  export interface PluginContext {
57
- /** RunCode version */
58
- runcodeVersion: string;
57
+ /** Franklin version */
58
+ franklinVersion: string;
59
59
  /** Plugin's own data directory (~/.blockrun/plugins/<id>/) */
60
60
  dataDir: string;
61
61
  /** Path to plugin's installation directory */
@@ -3,9 +3,9 @@
3
3
  *
4
4
  * Core stays plugin-agnostic: it knows about the *interface*, not specific plugins.
5
5
  * Plugins are discovered from:
6
- * 1. Bundled: <runcode>/plugins-bundled/* (ships with runcode)
7
- * 2. User: ~/.blockrun/plugins/* (installed via `runcode plugin install`)
8
- * 3. Local dev: $RUNCODE_PLUGINS_DIR/* (env var for development)
6
+ * 1. Bundled: <franklin>/plugins-bundled/* (ships with Franklin)
7
+ * 2. User: ~/.blockrun/plugins/*
8
+ * 3. Local dev: $FRANKLIN_PLUGINS_DIR/* (or legacy $RUNCODE_PLUGINS_DIR/*)
9
9
  */
10
10
  import type { Plugin, PluginManifest } from '../plugin-sdk/plugin.js';
11
11
  export declare function getBundledPluginsDir(): string;
@@ -3,9 +3,9 @@
3
3
  *
4
4
  * Core stays plugin-agnostic: it knows about the *interface*, not specific plugins.
5
5
  * Plugins are discovered from:
6
- * 1. Bundled: <runcode>/plugins-bundled/* (ships with runcode)
7
- * 2. User: ~/.blockrun/plugins/* (installed via `runcode plugin install`)
8
- * 3. Local dev: $RUNCODE_PLUGINS_DIR/* (env var for development)
6
+ * 1. Bundled: <franklin>/plugins-bundled/* (ships with Franklin)
7
+ * 2. User: ~/.blockrun/plugins/*
8
+ * 3. Local dev: $FRANKLIN_PLUGINS_DIR/* (or legacy $RUNCODE_PLUGINS_DIR/*)
9
9
  */
10
10
  import fs from 'node:fs';
11
11
  import path from 'node:path';
@@ -22,7 +22,7 @@ export function getUserPluginsDir() {
22
22
  return path.join(os.homedir(), '.blockrun', 'plugins');
23
23
  }
24
24
  function getDevPluginsDir() {
25
- return process.env.RUNCODE_PLUGINS_DIR || null;
25
+ return process.env.FRANKLIN_PLUGINS_DIR || process.env.RUNCODE_PLUGINS_DIR || null;
26
26
  }
27
27
  const loaded = new Map();
28
28
  // ─── Discovery ────────────────────────────────────────────────────────────
@@ -119,7 +119,7 @@ export async function loadAllPlugins() {
119
119
  if (plugin.onLoad) {
120
120
  try {
121
121
  await plugin.onLoad({
122
- runcodeVersion: getRuncodeVersion(),
122
+ franklinVersion: getFranklinVersion(),
123
123
  dataDir: path.join(os.homedir(), '.blockrun', 'plugins', manifest.id),
124
124
  pluginDir: dir,
125
125
  log: (msg) => process.stderr.write(`[${manifest.id}] ${msg}\n`),
@@ -149,7 +149,7 @@ export function listChannelPlugins() {
149
149
  return listPlugins().filter(p => p.plugin.channels && Object.keys(p.plugin.channels).length > 0);
150
150
  }
151
151
  // ─── Helpers ──────────────────────────────────────────────────────────────
152
- function getRuncodeVersion() {
152
+ function getFranklinVersion() {
153
153
  try {
154
154
  const pkgPath = path.resolve(__dirname, '..', '..', 'package.json');
155
155
  return JSON.parse(fs.readFileSync(pkgPath, 'utf-8')).version || '0.0.0';
package/dist/pricing.js CHANGED
@@ -46,6 +46,7 @@ export const MODEL_PRICING = {
46
46
  'openai/o3-mini': { input: 1.1, output: 4.4 },
47
47
  'openai/o4-mini': { input: 1.1, output: 4.4 },
48
48
  'openai/o1': { input: 15.0, output: 60.0 },
49
+ 'openai/gpt-5.5': { input: 5.0, output: 30.0 },
49
50
  'openai/gpt-5.2-pro': { input: 21.0, output: 168.0 },
50
51
  'openai/gpt-5.4-pro': { input: 30.0, output: 180.0 },
51
52
  // Google
@@ -67,9 +67,11 @@ const MODEL_SHORTCUTS = {
67
67
  'opus-4.6': 'anthropic/claude-opus-4.6',
68
68
  haiku: 'anthropic/claude-haiku-4.5',
69
69
  // OpenAI
70
- gpt: 'openai/gpt-5.4',
71
- gpt5: 'openai/gpt-5.4',
72
- 'gpt-5': 'openai/gpt-5.4',
70
+ // `gpt` / `gpt5` / `gpt-5` follow the gateway's flagship — currently 5.5.
71
+ gpt: 'openai/gpt-5.5',
72
+ gpt5: 'openai/gpt-5.5',
73
+ 'gpt-5': 'openai/gpt-5.5',
74
+ 'gpt-5.5': 'openai/gpt-5.5',
73
75
  'gpt-5.4': 'openai/gpt-5.4',
74
76
  'gpt-5.4-pro': 'openai/gpt-5.4-pro',
75
77
  'gpt-5.3': 'openai/gpt-5.3',
@@ -44,11 +44,11 @@ const AUTO_TIERS = {
44
44
  },
45
45
  MEDIUM: {
46
46
  primary: 'anthropic/claude-sonnet-4.6',
47
- fallback: ['openai/gpt-5.4', 'google/gemini-3.1-pro', 'moonshot/kimi-k2.6'],
47
+ fallback: ['openai/gpt-5.5', 'google/gemini-3.1-pro', 'moonshot/kimi-k2.6'],
48
48
  },
49
49
  COMPLEX: {
50
50
  primary: 'anthropic/claude-sonnet-4.6',
51
- fallback: ['openai/gpt-5.4', 'anthropic/claude-opus-4.7', 'moonshot/kimi-k2.6'],
51
+ fallback: ['openai/gpt-5.5', 'anthropic/claude-opus-4.7', 'moonshot/kimi-k2.6'],
52
52
  },
53
53
  REASONING: {
54
54
  // Opus 4.7: step-change improvement in agentic coding over 4.6 per
@@ -93,7 +93,7 @@ const PREMIUM_TIERS = {
93
93
  },
94
94
  COMPLEX: {
95
95
  primary: 'anthropic/claude-opus-4.7',
96
- fallback: ['anthropic/claude-opus-4.6', 'openai/gpt-5.4', 'anthropic/claude-sonnet-4.6'],
96
+ fallback: ['anthropic/claude-opus-4.6', 'openai/gpt-5.5', 'anthropic/claude-sonnet-4.6'],
97
97
  },
98
98
  REASONING: {
99
99
  primary: 'anthropic/claude-opus-4.7',
@@ -13,7 +13,7 @@ function getSessionsDir() {
13
13
  if (resolvedSessionsDir)
14
14
  return resolvedSessionsDir;
15
15
  const preferred = path.join(BLOCKRUN_DIR, 'sessions');
16
- const fallback = path.join(os.tmpdir(), 'runcode', 'sessions');
16
+ const fallback = path.join(os.tmpdir(), 'franklin', 'sessions');
17
17
  for (const dir of [preferred, fallback]) {
18
18
  try {
19
19
  fs.mkdirSync(dir, { recursive: true });
@@ -41,7 +41,7 @@ function metaPath(id) {
41
41
  }
42
42
  function withWritableSessionDir(action) {
43
43
  const preferred = path.join(BLOCKRUN_DIR, 'sessions');
44
- const fallback = path.join(os.tmpdir(), 'runcode', 'sessions');
44
+ const fallback = path.join(os.tmpdir(), 'franklin', 'sessions');
45
45
  try {
46
46
  action();
47
47
  }
@@ -4,6 +4,20 @@
4
4
  */
5
5
  import type { CapabilityHandler } from '../agent/types.js';
6
6
  import type { ContentLibrary } from '../content/library.js';
7
+ /**
8
+ * Models that accept a reference image via /v1/images/image2image. Currently
9
+ * limited to OpenAI's edit endpoint — Gemini Nano Banana Pro and Grok Imagine
10
+ * Image Pro need gateway-side support before they can be wired in here.
11
+ */
12
+ export declare const EDIT_SUPPORTED_MODELS: Set<string>;
13
+ export declare const REFERENCE_IMAGE_MAX_BYTES = 4000000;
14
+ /**
15
+ * Normalize a reference image into a base64 data URI for the gateway. The
16
+ * /v1/images/image2image endpoint validates `image` against /^data:image\//,
17
+ * so http(s) URLs and local paths both have to be inlined client-side before
18
+ * posting. Already-formed data URIs pass through.
19
+ */
20
+ export declare function resolveReferenceImage(input: string, workingDir: string): Promise<string>;
7
21
  export interface ImageGenDeps {
8
22
  /** Optional Content library for auto-recording generations into a piece. */
9
23
  library?: ContentLibrary;
@@ -9,21 +9,124 @@ import { loadChain, API_URLS, VERSION } from '../config.js';
9
9
  import { checkImageBudget, recordImageAsset } from '../content/record-image.js';
10
10
  import { ModelClient } from '../agent/llm.js';
11
11
  import { analyzeMediaRequest, renderProposalForAskUser } from '../agent/media-router.js';
12
+ import { recordUsage } from '../stats/tracker.js';
13
+ import { findModel, estimateCostUsd } from '../gateway-models.js';
14
+ /**
15
+ * Models that accept a reference image via /v1/images/image2image. Currently
16
+ * limited to OpenAI's edit endpoint — Gemini Nano Banana Pro and Grok Imagine
17
+ * Image Pro need gateway-side support before they can be wired in here.
18
+ */
19
+ export const EDIT_SUPPORTED_MODELS = new Set([
20
+ 'openai/gpt-image-1',
21
+ 'openai/gpt-image-2',
22
+ ]);
23
+ export const REFERENCE_IMAGE_MAX_BYTES = 4_000_000;
24
+ /**
25
+ * Normalize a reference image into a base64 data URI for the gateway. The
26
+ * /v1/images/image2image endpoint validates `image` against /^data:image\//,
27
+ * so http(s) URLs and local paths both have to be inlined client-side before
28
+ * posting. Already-formed data URIs pass through.
29
+ */
30
+ export async function resolveReferenceImage(input, workingDir) {
31
+ if (input.startsWith('data:image/'))
32
+ return input;
33
+ if (/^https?:\/\//i.test(input)) {
34
+ const ctrl = new AbortController();
35
+ const timeout = setTimeout(() => ctrl.abort(), 30_000);
36
+ try {
37
+ const resp = await fetch(input, { signal: ctrl.signal });
38
+ if (!resp.ok) {
39
+ throw new Error(`Reference image fetch failed: ${resp.status} ${resp.statusText}`);
40
+ }
41
+ const contentType = (resp.headers.get('content-type') || '').toLowerCase().split(';')[0].trim();
42
+ if (!contentType.startsWith('image/')) {
43
+ throw new Error(`Reference image URL returned non-image content-type: ${contentType || '(none)'}`);
44
+ }
45
+ const buf = Buffer.from(await resp.arrayBuffer());
46
+ if (buf.byteLength > REFERENCE_IMAGE_MAX_BYTES) {
47
+ throw new Error(`Reference image too large: ${(buf.byteLength / 1_000_000).toFixed(1)}MB > ${(REFERENCE_IMAGE_MAX_BYTES / 1_000_000).toFixed(1)}MB cap.`);
48
+ }
49
+ return `data:${contentType};base64,${buf.toString('base64')}`;
50
+ }
51
+ finally {
52
+ clearTimeout(timeout);
53
+ }
54
+ }
55
+ // Treat as local file path.
56
+ const resolved = path.isAbsolute(input) ? input : path.resolve(workingDir, input);
57
+ const stat = fs.statSync(resolved);
58
+ if (stat.size > REFERENCE_IMAGE_MAX_BYTES) {
59
+ throw new Error(`Reference image too large: ${(stat.size / 1_000_000).toFixed(1)}MB > ${(REFERENCE_IMAGE_MAX_BYTES / 1_000_000).toFixed(1)}MB cap. Resize or crop first.`);
60
+ }
61
+ const ext = path.extname(resolved).toLowerCase();
62
+ const mimeMap = {
63
+ '.png': 'image/png',
64
+ '.jpg': 'image/jpeg',
65
+ '.jpeg': 'image/jpeg',
66
+ '.gif': 'image/gif',
67
+ '.webp': 'image/webp',
68
+ };
69
+ const mime = mimeMap[ext];
70
+ if (!mime) {
71
+ throw new Error(`Unsupported reference image extension ${ext || '(none)'}. Use .png/.jpg/.jpeg/.gif/.webp.`);
72
+ }
73
+ const bytes = fs.readFileSync(resolved);
74
+ return `data:${mime};base64,${bytes.toString('base64')}`;
75
+ }
12
76
  function buildExecute(deps) {
13
77
  return async function execute(input, ctx) {
14
- const { prompt, output_path, size, model, contentId } = input;
15
- if (!prompt) {
78
+ const rawInput = input;
79
+ const { output_path, size, model, contentId, image_url } = rawInput;
80
+ if (!rawInput.prompt) {
16
81
  return { output: 'Error: prompt is required', isError: true };
17
82
  }
83
+ // Resolve the reference image (if any) before any paid call so we fail
84
+ // cheaply on bad paths / oversize attachments. Holds the resolved data URI
85
+ // / http URL that gets posted to /v1/images/image2image.
86
+ let referenceImage;
87
+ if (image_url) {
88
+ try {
89
+ referenceImage = await resolveReferenceImage(image_url, ctx.workingDir);
90
+ }
91
+ catch (err) {
92
+ return { output: `Error: ${err.message}`, isError: true };
93
+ }
94
+ }
95
+ // One-shot refinement opt-out: leading `///` tells Franklin "don't
96
+ // refine this prompt, I wrote it the way I want it." Strip the prefix
97
+ // and pass skipRefine through to the router.
98
+ let prompt = rawInput.prompt;
99
+ let skipRefine = false;
100
+ if (prompt.trimStart().startsWith('///')) {
101
+ prompt = prompt.replace(/^\s*\/\/\/\s?/, '');
102
+ skipRefine = true;
103
+ }
18
104
  // ── Media router + AskUser flow ────────────────────────────────────
19
105
  // If the caller explicitly named a model, or the env auto-approves, or
20
106
  // no AskUser bridge exists (batch / --prompt mode), skip the proposal
21
107
  // 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';
108
+ // model + rewrites the prompt, the preview goes to AskUser, user
109
+ // chooses or cancels.
110
+ // Reference-image mode forces an edit-capable model. If the caller named
111
+ // an unsupported one, fail loudly so we don't silently downgrade their
112
+ // request to text-only generation.
113
+ if (referenceImage && model && !EDIT_SUPPORTED_MODELS.has(model)) {
114
+ return {
115
+ output: `Error: model ${model} does not support reference images. ` +
116
+ `Use one of: ${[...EDIT_SUPPORTED_MODELS].join(', ')}.`,
117
+ isError: true,
118
+ };
119
+ }
120
+ let imageModel = model || (referenceImage ? 'openai/gpt-image-2' : 'openai/gpt-image-1');
24
121
  const imageSize = size || '1024x1024';
122
+ let chosenPrompt = prompt;
123
+ // Skip the proposal flow when a reference image is set: the media router
124
+ // doesn't know which models support image-to-image, so its suggestions
125
+ // would frequently be unusable (text-only models). Default to gpt-image-1
126
+ // for now; a future router upgrade can pick between the four edit-capable
127
+ // models based on the prompt.
25
128
  const autoApprove = process.env.FRANKLIN_MEDIA_AUTO_APPROVE_ALL === '1';
26
- if (!model && !autoApprove && ctx.onAskUser) {
129
+ if (!model && !autoApprove && ctx.onAskUser && !referenceImage) {
27
130
  try {
28
131
  const chain = loadChain();
29
132
  const client = new ModelClient({ apiUrl: API_URLS[chain], chain });
@@ -33,6 +136,7 @@ function buildExecute(deps) {
33
136
  quantity: 1,
34
137
  client,
35
138
  signal: ctx.abortSignal,
139
+ skipRefine,
36
140
  });
37
141
  if (proposal) {
38
142
  const { question, options } = renderProposalForAskUser(proposal, prompt);
@@ -51,9 +155,15 @@ function buildExecute(deps) {
51
155
  return {
52
156
  output: `## Image generation cancelled\n\nNo USDC was spent. Ask again when ready, or pass an explicit \`model\` to skip the confirmation step.`,
53
157
  };
158
+ case 'use-raw':
159
+ imageModel = proposal.recommended.model;
160
+ // chosenPrompt stays as the raw input
161
+ break;
54
162
  case 'recommended':
55
163
  default:
56
164
  imageModel = proposal.recommended.model;
165
+ if (proposal.refinedPrompt)
166
+ chosenPrompt = proposal.refinedPrompt;
57
167
  }
58
168
  }
59
169
  }
@@ -76,18 +186,30 @@ function buildExecute(deps) {
76
186
  }
77
187
  const chain = loadChain();
78
188
  const apiUrl = API_URLS[chain];
79
- const endpoint = `${apiUrl}/v1/images/generations`;
189
+ // Reference-image mode hits the dedicated /v1/images/image2image endpoint;
190
+ // otherwise stay on text-to-image generations.
191
+ const endpoint = referenceImage
192
+ ? `${apiUrl}/v1/images/image2image`
193
+ : `${apiUrl}/v1/images/generations`;
80
194
  // Default output path
81
195
  const outPath = output_path
82
196
  ? (path.isAbsolute(output_path) ? output_path : path.resolve(ctx.workingDir, output_path))
83
197
  : path.resolve(ctx.workingDir, `generated-${Date.now()}.png`);
84
- const body = JSON.stringify({
85
- model: imageModel,
86
- prompt,
87
- n: 1,
88
- size: imageSize,
89
- response_format: 'b64_json',
90
- });
198
+ const body = JSON.stringify(referenceImage
199
+ ? {
200
+ model: imageModel,
201
+ prompt: chosenPrompt,
202
+ image: referenceImage,
203
+ size: imageSize,
204
+ n: 1,
205
+ }
206
+ : {
207
+ model: imageModel,
208
+ prompt: chosenPrompt,
209
+ n: 1,
210
+ size: imageSize,
211
+ response_format: 'b64_json',
212
+ });
91
213
  const headers = {
92
214
  'Content-Type': 'application/json',
93
215
  'User-Agent': `franklin/${VERSION}`,
@@ -106,7 +228,7 @@ function buildExecute(deps) {
106
228
  if (response.status === 402) {
107
229
  const paymentHeaders = await signPayment(response, chain, endpoint);
108
230
  if (!paymentHeaders) {
109
- return { output: 'Payment failed. Check wallet balance with: runcode balance', isError: true };
231
+ return { output: 'Payment failed. Check wallet balance with: franklin balance', isError: true };
110
232
  }
111
233
  response = await fetch(endpoint, {
112
234
  method: 'POST',
@@ -124,12 +246,23 @@ function buildExecute(deps) {
124
246
  if (!imageData) {
125
247
  return { output: 'No image data returned from API', isError: true };
126
248
  }
127
- // Save image
249
+ // Save image. The /v1/images/image2image endpoint returns Gemini results
250
+ // as a data URI in `url`, so decode those locally instead of going through
251
+ // fetch — saves a network round-trip and avoids data:-URI fetch quirks.
128
252
  if (imageData.b64_json) {
129
253
  const buffer = Buffer.from(imageData.b64_json, 'base64');
130
254
  fs.mkdirSync(path.dirname(outPath), { recursive: true });
131
255
  fs.writeFileSync(outPath, buffer);
132
256
  }
257
+ else if (imageData.url && imageData.url.startsWith('data:')) {
258
+ const match = imageData.url.match(/^data:[^;]+;base64,(.+)$/);
259
+ if (!match) {
260
+ return { output: 'Malformed data URI in response', isError: true };
261
+ }
262
+ const buffer = Buffer.from(match[1], 'base64');
263
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
264
+ fs.writeFileSync(outPath, buffer);
265
+ }
133
266
  else if (imageData.url) {
134
267
  // Download from URL (with 30s timeout)
135
268
  const dlCtrl = new AbortController();
@@ -146,6 +279,20 @@ function buildExecute(deps) {
146
279
  const fileSize = fs.statSync(outPath).size;
147
280
  const sizeKB = (fileSize / 1024).toFixed(1);
148
281
  const revisedPrompt = imageData.revised_prompt ? `\nRevised prompt: ${imageData.revised_prompt}` : '';
282
+ // Stats: record this generation so it shows up in `franklin insights`
283
+ // alongside chat spend. Before this, media generations bypassed
284
+ // recordUsage entirely (only LLM chat calls were tracked), so the
285
+ // insights panel under-reported total spend and never surfaced
286
+ // image-generation models in its "top models" list. Fire-and-forget —
287
+ // stats write must not fail a user-visible generation.
288
+ void (async () => {
289
+ try {
290
+ const m = await findModel(imageModel);
291
+ const estCost = m ? estimateCostUsd(m, { quantity: 1 }) : 0;
292
+ recordUsage(imageModel, 0, 0, estCost, 0);
293
+ }
294
+ catch { /* ignore stats errors */ }
295
+ })();
149
296
  let contentSummary = '';
150
297
  if (contentId && deps.library) {
151
298
  const rec = recordImageAsset(deps.library, {
@@ -206,7 +353,7 @@ async function signPayment(response, chain, endpoint) {
206
353
  const feePayer = details.extra?.feePayer || details.recipient;
207
354
  const payload = await createSolanaPaymentPayload(secretBytes, wallet.address, details.recipient, details.amount, feePayer, {
208
355
  resourceUrl: details.resource?.url || endpoint,
209
- resourceDescription: details.resource?.description || 'RunCode image generation',
356
+ resourceDescription: details.resource?.description || 'Franklin image generation',
210
357
  maxTimeoutSeconds: details.maxTimeoutSeconds || 300,
211
358
  extra: details.extra,
212
359
  });
@@ -218,7 +365,7 @@ async function signPayment(response, chain, endpoint) {
218
365
  const details = extractPaymentDetails(paymentRequired);
219
366
  const payload = await createPaymentPayload(wallet.privateKey, wallet.address, details.recipient, details.amount, details.network || 'eip155:8453', {
220
367
  resourceUrl: details.resource?.url || endpoint,
221
- resourceDescription: details.resource?.description || 'RunCode image generation',
368
+ resourceDescription: details.resource?.description || 'Franklin image generation',
222
369
  maxTimeoutSeconds: details.maxTimeoutSeconds || 300,
223
370
  extra: details.extra,
224
371
  });
@@ -253,13 +400,16 @@ export function createImageGenCapability(deps = {}) {
253
400
  return {
254
401
  spec: {
255
402
  name: 'ImageGen',
256
- description: "Generate an image from a text prompt. Costs USDC from the user's wallet " +
257
- " confirm before generating. Saves to a local file. Default size: " +
258
- "1024x1024. Do NOT call repeatedly to iterate on style ask the user " +
259
- "first. Pass contentId to attach the result to an existing Content " +
260
- "piece: the content's budget is checked BEFORE paying, and on success " +
261
- "the image is recorded as an asset with its estimated cost. Skipping " +
262
- "contentId generates a one-off image with no budget tracking.",
403
+ description: "Generate an image from a text prompt optionally with a reference " +
404
+ "image for style transfer / character consistency / edits. Costs USDC " +
405
+ "from the user's wallet confirm before generating. Saves to a local " +
406
+ "file. Default size: 1024x1024. Do NOT call repeatedly to iterate on " +
407
+ "style — ask the user first. Pass contentId to attach the result to " +
408
+ "an existing Content piece: the content's budget is checked BEFORE " +
409
+ "paying, and on success the image is recorded as an asset with its " +
410
+ "estimated cost. Skipping contentId generates a one-off image with no " +
411
+ "budget tracking. When image_url is set, only edit-capable models " +
412
+ "(openai/gpt-image-1, openai/gpt-image-2) are accepted.",
263
413
  input_schema: {
264
414
  type: 'object',
265
415
  properties: {
@@ -267,6 +417,7 @@ export function createImageGenCapability(deps = {}) {
267
417
  output_path: { type: 'string', description: 'Where to save the image. Default: generated-<timestamp>.png in working directory' },
268
418
  size: { type: 'string', description: 'Image size: 1024x1024, 1792x1024, or 1024x1792. Default: 1024x1024' },
269
419
  model: { type: 'string', description: 'Image model to use. Default: openai/gpt-image-1' },
420
+ image_url: { type: 'string', description: 'Optional reference image (image-to-image / style transfer). Accepts an http(s) URL, a data URI, or a local file path. Only works with edit-capable models.' },
270
421
  contentId: { type: 'string', description: 'Optional Content id to attach this generation to. Pre-flight budget check + auto-record on success.' },
271
422
  },
272
423
  required: ['prompt'],
@@ -84,7 +84,34 @@ async function execute(input, ctx) {
84
84
  // (some binaries have no extension: `.env.enc`, `.data`, compiled tools
85
85
  // without suffixes, etc. Content sniff catches those.)
86
86
  const ext = path.extname(resolved).toLowerCase();
87
- const binaryExts = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.bmp', '.pdf', '.zip', '.tar', '.gz', '.woff', '.woff2', '.ttf', '.eot', '.mp3', '.mp4', '.wav', '.avi', '.mov', '.exe', '.dll', '.so', '.dylib']);
87
+ // Image extensions load as vision content so models with vision (Sonnet,
88
+ // GPT-4o, Gemini) actually see the bytes instead of a "Binary file" stub.
89
+ // The agent loop wraps `images` into tool_result.content for provider APIs.
90
+ const IMAGE_MEDIA_TYPES = {
91
+ '.png': 'image/png',
92
+ '.jpg': 'image/jpeg',
93
+ '.jpeg': 'image/jpeg',
94
+ '.gif': 'image/gif',
95
+ '.webp': 'image/webp',
96
+ };
97
+ if (IMAGE_MEDIA_TYPES[ext]) {
98
+ const sizeStr = stat.size >= 1024 ? `${(stat.size / 1024).toFixed(1)}KB` : `${stat.size}B`;
99
+ // Anthropic accepts up to 5MB base64; cap raw bytes at ~3.75MB to be safe.
100
+ const IMAGE_MAX_BYTES = 3_750_000;
101
+ if (stat.size > IMAGE_MAX_BYTES) {
102
+ return {
103
+ output: `Image file: ${resolved} (${ext}, ${sizeStr}). Too large to inline for vision (>${Math.round(IMAGE_MAX_BYTES / 1_000_000)}MB). Resize or crop first.`,
104
+ };
105
+ }
106
+ const bytes = fs.readFileSync(resolved);
107
+ const base64 = bytes.toString('base64');
108
+ fileReadTracker.set(resolved, { mtimeMs: stat.mtimeMs, readAt: Date.now() });
109
+ return {
110
+ output: `Image file: ${resolved} (${ext}, ${sizeStr}). Rendered below for vision-capable models.`,
111
+ images: [{ mediaType: IMAGE_MEDIA_TYPES[ext], base64 }],
112
+ };
113
+ }
114
+ const binaryExts = new Set(['.ico', '.bmp', '.pdf', '.zip', '.tar', '.gz', '.woff', '.woff2', '.ttf', '.eot', '.mp3', '.mp4', '.wav', '.avi', '.mov', '.exe', '.dll', '.so', '.dylib']);
88
115
  if (binaryExts.has(ext)) {
89
116
  const sizeStr = stat.size >= 1024 ? `${(stat.size / 1024).toFixed(1)}KB` : `${stat.size}B`;
90
117
  return { output: `Binary file: ${resolved} (${ext}, ${sizeStr}). Cannot display contents.` };
@@ -163,7 +190,7 @@ Usage:
163
190
  - This tool can only read files, not directories. To list a directory, use Glob or ls via Bash.
164
191
  - If you read a file that exists but has empty contents you will receive a warning.
165
192
  - Reads over 2MB are rejected — use offset/limit to read portions.
166
- - Cannot read binary files (images, PDFs, archives).
193
+ - Image files (.png, .jpg, .jpeg, .gif, .webp) are loaded as vision content — vision-capable models see the actual image. Other binary files (PDFs, archives, fonts) cannot be displayed.
167
194
  - You will regularly be asked to read screenshots or images. If the user provides a path, ALWAYS use this tool to view it.
168
195
 
169
196
  IMPORTANT: Always use Read instead of cat, head, or tail via Bash. This tool provides line numbers and integrates with Edit's read-before-edit enforcement.`,
@@ -3,9 +3,20 @@
3
3
  * /v1/videos/generations endpoint. Uses x402 payment (Base or Solana).
4
4
  *
5
5
  * Default model `xai/grok-imagine-video` returns an 8-second clip for ~$0.42.
6
- * The endpoint is synchronous-over-polling: the HTTP connection stays open
7
- * until the upstream xAI job finishes (typically 20–60s, timeout 180s), so
8
- * the caller only needs to issue a single POST.
6
+ * Seedance 2.0 (bytedance/seedance-2.0 and -fast) runs longer up to a few
7
+ * minutes for a 10s clip.
8
+ *
9
+ * Flow (async since blockrun@654cd35):
10
+ * 1. POST /v1/videos/generations with signed x-payment header. The server
11
+ * verifies payment (does NOT settle), submits the upstream job, and
12
+ * returns 202 { id, poll_url, status: "queued" }.
13
+ * 2. GET the poll_url with the SAME x-payment header every ~5s until
14
+ * status=completed. On the first completed poll the server backs up
15
+ * the MP4 to GCS, settles payment, and returns the video URL.
16
+ * 3. Download the MP4 and write it locally.
17
+ *
18
+ * If the upstream job fails, the server returns status=failed and no USDC
19
+ * is ever transferred. If the client never polls, no charge either.
9
20
  */
10
21
  import type { CapabilityHandler } from '../agent/types.js';
11
22
  import type { ContentLibrary } from '../content/library.js';