@blockrun/franklin 3.8.3 → 3.8.5

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 CHANGED
@@ -16,9 +16,8 @@
16
16
  <p>
17
17
  <a href="https://npmjs.com/package/@blockrun/franklin"><img src="https://img.shields.io/npm/v/@blockrun/franklin.svg?style=flat-square&color=FFD700&label=npm" alt="npm"></a>
18
18
  <a href="https://npmjs.com/package/@blockrun/franklin"><img src="https://img.shields.io/npm/dm/@blockrun/franklin.svg?style=flat-square&color=10B981&label=downloads" alt="downloads"></a>
19
- <a href="https://gitlab.com/blockrunai/franklin"><img src="https://img.shields.io/gitlab/stars/blockrunai/franklin?style=flat-square&color=FFD700&label=stars" alt="stars"></a>
19
+ <a href="https://github.com/RunFranklin/franklin/stargazers"><img src="https://img.shields.io/github/stars/RunFranklin/franklin?style=flat-square&color=FFD700&label=stars" alt="stars"></a>
20
20
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache_2.0-blue?style=flat-square" alt="license"></a>
21
- <a href="https://gitlab.com/blockrunai/franklin/-/pipelines"><img src="https://img.shields.io/gitlab/pipeline-status/blockrunai%2Ffranklin?branch=main&style=flat-square&label=ci" alt="ci"></a>
22
21
  <a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-strict-3178C6?style=flat-square&logo=typescript&logoColor=white" alt="TypeScript"></a>
23
22
  <a href="https://nodejs.org/"><img src="https://img.shields.io/badge/Node-%E2%89%A520-339933?style=flat-square&logo=node.js&logoColor=white" alt="Node"></a>
24
23
  <a href="https://x402.org"><img src="https://img.shields.io/badge/x402-native-10B981?style=flat-square" alt="x402"></a>
@@ -155,9 +154,9 @@ Live data from CoinGecko. RSI, MACD, Bollinger, and volatility computed locally.
155
154
 
156
155
  Generates images via DALL-E / GPT Image directly from the CLI. Paid from your wallet — no OpenAI API key needed.
157
156
 
158
- ### 🎯 Social growth (with setup)
157
+ ### 📱 Remote control via Telegram
159
158
 
160
- After running `franklin social setup && franklin social login x`, Franklin can search X, draft replies, and post with your confirmation no X API key or developer account needed.
159
+ Run `franklin telegram` on an always-on machine (set `TELEGRAM_BOT_TOKEN` + `TELEGRAM_OWNER_ID`) and drive Franklin from your phone. Owner-locked, session-resumable across restarts, slash commands (`/new`, `/balance`, `/status`). Trading, content, dev work all reachable from a Telegram chat.
161
160
 
162
161
  ### 🔎 Research, code, anything with a budget
163
162
 
@@ -316,7 +315,7 @@ Trained on 2M+ real requests. Classifies your task and picks the best model from
316
315
  <td width="50%" valign="top">
317
316
 
318
317
  **🛠 16 built-in tools**
319
- Read, Write, Edit, Bash, Glob, Grep, WebFetch, WebSearch, Task, ImageGen, AskUser, SubAgent, TradingSignal, TradingMarket, SearchX, PostToX.
318
+ Read, Write, Edit, Bash, Glob, Grep, WebFetch, WebSearch, Task, ImageGen, VideoGen, MemoryRecall, AskUser, SubAgent, TradingSignal, TradingMarket, TradingPortfolio, TradingOpenPosition, TradingClosePosition, TradingHistory.
320
319
 
321
320
  **💾 Persistent sessions**
322
321
  Every turn is streamed to disk with metadata. Resume any session by ID. Survives crashes, reboots, and compaction.
@@ -402,19 +401,21 @@ src/
402
401
  ├── index.ts CLI entry (franklin + runcode alias)
403
402
  ├── banner.ts Ben Franklin portrait + FRANKLIN gradient text
404
403
  ├── agent/ Agent loop, LLM client, compaction, commands
405
- ├── tools/ 16 built-in tools (Read/Write/Edit/Bash/Glob/Grep/
406
- │ WebFetch/WebSearch/Task/ImageGen/AskUser/SubAgent/
407
- TradingSignal/TradingMarket/SearchX/PostToX)
404
+ ├── tools/ 20+ built-in tools (Read/Write/Edit/Bash/Glob/Grep/
405
+ │ WebFetch/WebSearch/Task/ImageGen/VideoGen/
406
+ MemoryRecall/AskUser/SubAgent/Trading*/Content*)
408
407
  ├── trading/ Market data (CoinGecko) + technical indicators
409
- ├── social/ X browser automation (Playwright) + reply engine
410
- ├── events/ Internal event bus (signals, posting, workflow events)
408
+ ├── content/ Content library with budget-bound media generation
409
+ ├── brain/ Cross-session entity knowledge graph
410
+ ├── channel/ Non-CLI ingress drivers (Telegram today)
411
+ ├── events/ Internal event bus
411
412
  ├── plugin-sdk/ Public plugin contract (Workflow/Plugin/Channel)
412
413
  ├── plugins/ Plugin registry + runner (plugin-agnostic)
413
- ├── session/ Persistent sessions + search
414
+ ├── session/ Persistent sessions + search + channel tags
414
415
  ├── stats/ Usage tracking + insights engine
415
416
  ├── ui/ Ink-based terminal UI
416
417
  ├── proxy/ Payment proxy for external tools
417
- ├── router/ Learned model router (2M+ requests, Elo scoring)
418
+ ├── router/ Learned model router (55+ models, Elo scoring)
418
419
  ├── wallet/ Wallet management (Base + Solana)
419
420
  ├── mcp/ MCP server auto-discovery
420
421
  └── commands/ CLI subcommands
@@ -434,20 +435,23 @@ When you fund the wallet, Franklin gets more purchasing power: Sonnet, Opus, GPT
434
435
 
435
436
  ---
436
437
 
437
- ## Social automation (advanced)
438
+ ## Remote control via Telegram
438
439
 
439
- Once you've tuned Franklin's reply style in chat, you can graduate to **automated batch mode**:
440
+ Drive Franklin from anywhere with a bot token:
440
441
 
441
442
  ```bash
442
- franklin social setup # install Chromium, write default config
443
- franklin social login x # log in to X once (cookies persist)
444
- franklin social config edit # set handle, products, search queries
445
- franklin social run # dry-run — preview drafts
446
- franklin social run --live # actually post to X
447
- franklin social stats # posted / drafted / skipped / cost
443
+ export TELEGRAM_BOT_TOKEN=<from @BotFather>
444
+ export TELEGRAM_OWNER_ID=<your numeric Telegram user id>
445
+ franklin telegram # start the bot (owner-locked)
448
446
  ```
449
447
 
450
- The chat-based social tools (`SearchX`, `PostToX`) and the batch CLI (`franklin social run`) share the same engine. Chat first, automate later.
448
+ Session state resumes across process restarts (tagged by owner id).
449
+ Slash commands `/new`, `/balance`, `/status`, `/help` handled locally
450
+ by the bot layer; everything else forwards to the agent. Progressive
451
+ streaming flushes partial answers at paragraph boundaries so long
452
+ replies don't wait for turn-end.
453
+
454
+ Same wallet. Same tools. From your phone.
451
455
 
452
456
  ---
453
457
 
@@ -464,14 +468,14 @@ The chat-based social tools (`SearchX`, `PostToX`) and the batch CLI (`franklin
464
468
 
465
469
  - [Telegram](https://t.me/blockrunAI) — realtime help, bug reports, feature requests
466
470
  - [@BlockRunAI](https://x.com/BlockRunAI) — release notes, demos
467
- - [Issues](https://gitlab.com/blockrunai/franklin/-/issues) — bugs and feature requests
471
+ - [Issues](https://github.com/RunFranklin/franklin/issues) — bugs and feature requests
468
472
 
469
473
  ---
470
474
 
471
475
  ## Development
472
476
 
473
477
  ```bash
474
- git clone https://gitlab.com/blockrunai/franklin.git
478
+ git clone https://github.com/RunFranklin/franklin.git
475
479
  cd franklin
476
480
  npm install
477
481
  npm run build
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Exa research capabilities — neural web search, cited Q&A, and batch
3
+ * URL content fetch via the BlockRun `/v1/exa/*` endpoints.
4
+ *
5
+ * Three tools:
6
+ * - ExaSearch — semantic search for URLs ($0.01/call)
7
+ * - ExaAnswer — synthesized answer with citations ($0.01/call)
8
+ * - ExaReadUrls — batch-fetch clean Markdown from URLs ($0.002/URL)
9
+ *
10
+ * Why these matter for an economic agent: ExaAnswer is Perplexity-in-a-
11
+ * tool — the agent gets a grounded reply with sources, avoiding the
12
+ * usual hallucination problem without needing to chain search + fetch
13
+ * + synthesize by hand. ExaReadUrls is roughly 5× cheaper than the
14
+ * Playwright-backed `WebFetch` for batch reading, and returns clean
15
+ * Markdown ready to drop into an LLM context window.
16
+ *
17
+ * All three share the same x402 payment flow (Base or Solana) — a
18
+ * 402 triggers a signed USDC transfer, retry succeeds.
19
+ */
20
+ import type { CapabilityHandler } from '../agent/types.js';
21
+ export declare const exaSearchCapability: CapabilityHandler;
22
+ export declare const exaAnswerCapability: CapabilityHandler;
23
+ export declare const exaReadUrlsCapability: CapabilityHandler;
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Exa research capabilities — neural web search, cited Q&A, and batch
3
+ * URL content fetch via the BlockRun `/v1/exa/*` endpoints.
4
+ *
5
+ * Three tools:
6
+ * - ExaSearch — semantic search for URLs ($0.01/call)
7
+ * - ExaAnswer — synthesized answer with citations ($0.01/call)
8
+ * - ExaReadUrls — batch-fetch clean Markdown from URLs ($0.002/URL)
9
+ *
10
+ * Why these matter for an economic agent: ExaAnswer is Perplexity-in-a-
11
+ * tool — the agent gets a grounded reply with sources, avoiding the
12
+ * usual hallucination problem without needing to chain search + fetch
13
+ * + synthesize by hand. ExaReadUrls is roughly 5× cheaper than the
14
+ * Playwright-backed `WebFetch` for batch reading, and returns clean
15
+ * Markdown ready to drop into an LLM context window.
16
+ *
17
+ * All three share the same x402 payment flow (Base or Solana) — a
18
+ * 402 triggers a signed USDC transfer, retry succeeds.
19
+ */
20
+ import { getOrCreateWallet, getOrCreateSolanaWallet, createPaymentPayload, createSolanaPaymentPayload, parsePaymentRequired, extractPaymentDetails, solanaKeyToBytes, SOLANA_NETWORK, } from '@blockrun/llm';
21
+ import { loadChain, API_URLS, VERSION } from '../config.js';
22
+ const GEN_TIMEOUT_MS = 30_000;
23
+ // ─── Shared payment flow ─────────────────────────────────────────────
24
+ async function postWithPayment(path, body, ctx) {
25
+ const chain = loadChain();
26
+ const apiUrl = API_URLS[chain];
27
+ const endpoint = `${apiUrl}${path}`;
28
+ const bodyStr = JSON.stringify(body);
29
+ const headers = {
30
+ 'Content-Type': 'application/json',
31
+ 'User-Agent': `franklin/${VERSION}`,
32
+ };
33
+ const controller = new AbortController();
34
+ const timeout = setTimeout(() => controller.abort(), GEN_TIMEOUT_MS);
35
+ const onAbort = () => controller.abort();
36
+ ctx.abortSignal.addEventListener('abort', onAbort, { once: true });
37
+ try {
38
+ let response = await fetch(endpoint, {
39
+ method: 'POST',
40
+ signal: controller.signal,
41
+ headers,
42
+ body: bodyStr,
43
+ });
44
+ if (response.status === 402) {
45
+ const paymentHeaders = await signPayment(response, chain, endpoint);
46
+ if (!paymentHeaders) {
47
+ throw new Error('Payment signing failed — check wallet balance');
48
+ }
49
+ response = await fetch(endpoint, {
50
+ method: 'POST',
51
+ signal: controller.signal,
52
+ headers: { ...headers, ...paymentHeaders },
53
+ body: bodyStr,
54
+ });
55
+ }
56
+ if (!response.ok) {
57
+ const errText = await response.text().catch(() => '');
58
+ throw new Error(`Exa ${path} failed (${response.status}): ${errText.slice(0, 200)}`);
59
+ }
60
+ return (await response.json());
61
+ }
62
+ finally {
63
+ clearTimeout(timeout);
64
+ ctx.abortSignal.removeEventListener('abort', onAbort);
65
+ }
66
+ }
67
+ async function signPayment(response, chain, endpoint) {
68
+ try {
69
+ const paymentHeader = await extractPaymentReq(response);
70
+ if (!paymentHeader)
71
+ return null;
72
+ if (chain === 'solana') {
73
+ const wallet = await getOrCreateSolanaWallet();
74
+ const paymentRequired = parsePaymentRequired(paymentHeader);
75
+ const details = extractPaymentDetails(paymentRequired, SOLANA_NETWORK);
76
+ const secretBytes = await solanaKeyToBytes(wallet.privateKey);
77
+ const feePayer = details.extra?.feePayer || details.recipient;
78
+ const payload = await createSolanaPaymentPayload(secretBytes, wallet.address, details.recipient, details.amount, feePayer, {
79
+ resourceUrl: details.resource?.url || endpoint,
80
+ resourceDescription: details.resource?.description || 'Franklin Exa research',
81
+ maxTimeoutSeconds: details.maxTimeoutSeconds || 60,
82
+ extra: details.extra,
83
+ });
84
+ return { 'PAYMENT-SIGNATURE': payload };
85
+ }
86
+ const wallet = getOrCreateWallet();
87
+ const paymentRequired = parsePaymentRequired(paymentHeader);
88
+ const details = extractPaymentDetails(paymentRequired);
89
+ const payload = await createPaymentPayload(wallet.privateKey, wallet.address, details.recipient, details.amount, details.network || 'eip155:8453', {
90
+ resourceUrl: details.resource?.url || endpoint,
91
+ resourceDescription: details.resource?.description || 'Franklin Exa research',
92
+ maxTimeoutSeconds: details.maxTimeoutSeconds || 60,
93
+ extra: details.extra,
94
+ });
95
+ return { 'PAYMENT-SIGNATURE': payload };
96
+ }
97
+ catch (err) {
98
+ console.error(`[franklin] Exa payment error: ${err.message}`);
99
+ return null;
100
+ }
101
+ }
102
+ async function extractPaymentReq(response) {
103
+ let header = response.headers.get('payment-required');
104
+ if (!header) {
105
+ try {
106
+ const body = (await response.json());
107
+ if (body.x402 || body.accepts)
108
+ header = btoa(JSON.stringify(body));
109
+ }
110
+ catch { /* ignore */ }
111
+ }
112
+ return header;
113
+ }
114
+ export const exaSearchCapability = {
115
+ spec: {
116
+ name: 'ExaSearch',
117
+ description: 'Neural web search via Exa ($0.01/call). Returns a ranked list of ' +
118
+ 'URLs + titles for a natural-language query. Understands meaning, ' +
119
+ 'not just keywords. Optional `category` narrows to github / news / ' +
120
+ '`research paper` / tweet / pdf / company / etc. Prefer this over ' +
121
+ 'WebSearch when the query is semantic (e.g. "projects implementing ' +
122
+ 'x402 payment middleware") rather than a literal phrase.',
123
+ input_schema: {
124
+ type: 'object',
125
+ properties: {
126
+ query: { type: 'string', description: 'Natural-language search query' },
127
+ numResults: { type: 'number', description: 'Max results (default 10, max 100)' },
128
+ category: {
129
+ type: 'string',
130
+ description: 'Restrict to: github, news, research paper, linkedin profile, ' +
131
+ 'personal site, tweet, financial report, pdf, company',
132
+ },
133
+ startPublishedDate: { type: 'string', description: 'ISO 8601 lower bound (e.g. 2026-03-01)' },
134
+ endPublishedDate: { type: 'string', description: 'ISO 8601 upper bound' },
135
+ includeDomains: { type: 'array', items: { type: 'string' } },
136
+ excludeDomains: { type: 'array', items: { type: 'string' } },
137
+ },
138
+ required: ['query'],
139
+ },
140
+ },
141
+ execute: async (input, ctx) => {
142
+ const params = input;
143
+ if (!params.query)
144
+ return { output: 'Error: query is required', isError: true };
145
+ try {
146
+ const res = await postWithPayment('/v1/exa/search', params, ctx);
147
+ const hits = res.data?.results ?? [];
148
+ if (hits.length === 0) {
149
+ return { output: `No Exa results for "${params.query}".` };
150
+ }
151
+ const lines = [`## Exa search — ${hits.length} result${hits.length === 1 ? '' : 's'}`];
152
+ for (const h of hits) {
153
+ const date = h.publishedDate ? ` _(${h.publishedDate.slice(0, 10)})_` : '';
154
+ const score = h.score ? ` · score ${h.score.toFixed(2)}` : '';
155
+ lines.push(`\n**${h.title}**${date}${score}\n${h.url}`);
156
+ }
157
+ const cost = res.data?.costDollars?.total;
158
+ if (cost)
159
+ lines.push(`\n_Cost: $${cost.toFixed(4)}_`);
160
+ return { output: lines.join('\n') };
161
+ }
162
+ catch (err) {
163
+ return { output: `Error: ${err.message}`, isError: true };
164
+ }
165
+ },
166
+ concurrent: true,
167
+ };
168
+ export const exaAnswerCapability = {
169
+ spec: {
170
+ name: 'ExaAnswer',
171
+ description: "Ask a factual question, get a synthesized answer with real source " +
172
+ "citations ($0.01/call). Like Perplexity in a tool — grounded in " +
173
+ "live web content, not LLM memory. Best for 'what is X?', 'how does " +
174
+ "Y work?', 'what's the current state of Z?'. Prefer this over " +
175
+ "chaining ExaSearch + ExaReadUrls + LLM synthesis when the user " +
176
+ "just wants an answer with sources.",
177
+ input_schema: {
178
+ type: 'object',
179
+ properties: {
180
+ query: { type: 'string', description: 'The factual question to answer' },
181
+ },
182
+ required: ['query'],
183
+ },
184
+ },
185
+ execute: async (input, ctx) => {
186
+ const params = input;
187
+ if (!params.query)
188
+ return { output: 'Error: query is required', isError: true };
189
+ try {
190
+ const res = await postWithPayment('/v1/exa/answer', params, ctx);
191
+ const ans = res.data?.answer ?? '';
192
+ const cites = res.data?.citations ?? [];
193
+ const lines = [ans];
194
+ if (cites.length > 0) {
195
+ lines.push('\n**Sources**');
196
+ for (const c of cites)
197
+ lines.push(`- [${c.title}](${c.url})`);
198
+ }
199
+ const cost = res.data?.costDollars?.total;
200
+ if (cost)
201
+ lines.push(`\n_Cost: $${cost.toFixed(4)}_`);
202
+ return { output: lines.join('\n') };
203
+ }
204
+ catch (err) {
205
+ return { output: `Error: ${err.message}`, isError: true };
206
+ }
207
+ },
208
+ concurrent: true,
209
+ };
210
+ export const exaReadUrlsCapability = {
211
+ spec: {
212
+ name: 'ExaReadUrls',
213
+ description: "Batch-fetch clean Markdown content from a list of URLs ($0.002/URL). " +
214
+ "Up to 100 URLs per call. Much cheaper than chaining 100× WebFetch, " +
215
+ "and returns text already stripped of HTML/boilerplate — ready to " +
216
+ "feed into an LLM context window. Prefer over WebFetch when reading " +
217
+ "multiple URLs at once or when you want clean Markdown.",
218
+ input_schema: {
219
+ type: 'object',
220
+ properties: {
221
+ urls: {
222
+ type: 'array',
223
+ items: { type: 'string' },
224
+ description: 'URLs to fetch (up to 100)',
225
+ },
226
+ },
227
+ required: ['urls'],
228
+ },
229
+ },
230
+ execute: async (input, ctx) => {
231
+ const params = input;
232
+ if (!params.urls || params.urls.length === 0) {
233
+ return { output: 'Error: urls array is required and must be non-empty', isError: true };
234
+ }
235
+ if (params.urls.length > 100) {
236
+ return { output: `Error: max 100 URLs per call (got ${params.urls.length})`, isError: true };
237
+ }
238
+ try {
239
+ const res = await postWithPayment('/v1/exa/contents', params, ctx);
240
+ const results = res.data?.results ?? [];
241
+ if (results.length === 0) {
242
+ return { output: `No readable content returned for the ${params.urls.length} URL(s).` };
243
+ }
244
+ const lines = [`## Fetched ${results.length} URL${results.length === 1 ? '' : 's'}`];
245
+ for (const r of results) {
246
+ lines.push(`\n### ${r.title ?? r.url}\n_Source: ${r.url}_\n\n${r.text}`);
247
+ }
248
+ const cost = res.data?.costDollars?.total;
249
+ if (cost)
250
+ lines.push(`\n_Cost: $${cost.toFixed(4)}_`);
251
+ return { output: lines.join('\n') };
252
+ }
253
+ catch (err) {
254
+ return { output: `Error: ${err.message}`, isError: true };
255
+ }
256
+ },
257
+ concurrent: true,
258
+ };
@@ -14,7 +14,9 @@ import { webSearchCapability } from './websearch.js';
14
14
  import { taskCapability } from './task.js';
15
15
  import { createImageGenCapability } from './imagegen.js';
16
16
  import { createVideoGenCapability } from './videogen.js';
17
+ import { createMusicGenCapability } from './musicgen.js';
17
18
  import { memoryRecallCapability } from './memory.js';
19
+ import { exaSearchCapability, exaAnswerCapability, exaReadUrlsCapability } from './exa.js';
18
20
  import { askUserCapability } from './askuser.js';
19
21
  import { tradingSignalCapability, tradingMarketCapability } from './trading.js';
20
22
  import { searchXCapability } from './searchx.js';
@@ -96,6 +98,10 @@ const defaultVideoGenCapability = createVideoGenCapability({
96
98
  library: defaultContentLibrary,
97
99
  onContentChange: persistContentLibrary,
98
100
  });
101
+ const defaultMusicGenCapability = createMusicGenCapability({
102
+ library: defaultContentLibrary,
103
+ onContentChange: persistContentLibrary,
104
+ });
99
105
  /**
100
106
  * Reset module-level tool state that would otherwise leak between sessions
101
107
  * when the same process runs `interactiveSession()` more than once (library
@@ -119,7 +125,11 @@ export const allCapabilities = [
119
125
  taskCapability,
120
126
  defaultImageGenCapability,
121
127
  defaultVideoGenCapability,
128
+ defaultMusicGenCapability,
122
129
  memoryRecallCapability,
130
+ exaSearchCapability,
131
+ exaAnswerCapability,
132
+ exaReadUrlsCapability,
123
133
  askUserCapability,
124
134
  tradingSignalCapability,
125
135
  tradingMarketCapability,
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Music Generation capability — generate ~3-minute MP3 tracks via the
3
+ * BlockRun `/v1/audio/generations` endpoint. Uses x402 payment (Base
4
+ * or Solana) and shares the same pattern as VideoGen.
5
+ *
6
+ * Default model `minimax/music-2.5+` bills $0.1575/call and returns a
7
+ * ~3-minute track regardless of duration hint. Generation takes 1-3
8
+ * minutes — the HTTP connection stays open until the upstream job
9
+ * finishes, so the caller issues a single POST and waits.
10
+ *
11
+ * The generated URL is time-limited (~24h) from the upstream CDN, so
12
+ * the tool downloads the MP3 to disk immediately and stores the local
13
+ * path. Optional contentId integration records the track as a budget-
14
+ * tracked asset on a Content piece.
15
+ */
16
+ import type { CapabilityHandler } from '../agent/types.js';
17
+ import type { ContentLibrary } from '../content/library.js';
18
+ export interface MusicGenDeps {
19
+ library?: ContentLibrary;
20
+ onContentChange?: () => void | Promise<void>;
21
+ }
22
+ export declare function createMusicGenCapability(deps?: MusicGenDeps): CapabilityHandler;
23
+ export declare const musicGenCapability: CapabilityHandler;
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Music Generation capability — generate ~3-minute MP3 tracks via the
3
+ * BlockRun `/v1/audio/generations` endpoint. Uses x402 payment (Base
4
+ * or Solana) and shares the same pattern as VideoGen.
5
+ *
6
+ * Default model `minimax/music-2.5+` bills $0.1575/call and returns a
7
+ * ~3-minute track regardless of duration hint. Generation takes 1-3
8
+ * minutes — the HTTP connection stays open until the upstream job
9
+ * finishes, so the caller issues a single POST and waits.
10
+ *
11
+ * The generated URL is time-limited (~24h) from the upstream CDN, so
12
+ * the tool downloads the MP3 to disk immediately and stores the local
13
+ * path. Optional contentId integration records the track as a budget-
14
+ * tracked asset on a Content piece.
15
+ */
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { getOrCreateWallet, getOrCreateSolanaWallet, createPaymentPayload, createSolanaPaymentPayload, parsePaymentRequired, extractPaymentDetails, solanaKeyToBytes, SOLANA_NETWORK, } from '@blockrun/llm';
19
+ import { loadChain, API_URLS, VERSION } from '../config.js';
20
+ const DEFAULT_MODEL = 'minimax/music-2.5+';
21
+ const PRICE_USD = 0.1575;
22
+ // MiniMax generation is 1-3 minutes + small buffer for payment + download.
23
+ const GEN_TIMEOUT_MS = 240_000;
24
+ const DOWNLOAD_TIMEOUT_MS = 60_000;
25
+ function buildExecute(deps) {
26
+ return async function execute(input, ctx) {
27
+ const { prompt, output_path, model, instrumental, lyrics, duration_seconds, contentId } = input;
28
+ if (!prompt)
29
+ return { output: 'Error: prompt is required', isError: true };
30
+ if (instrumental === true && lyrics) {
31
+ return {
32
+ output: 'Error: cannot set both `instrumental: true` and `lyrics` — pick one',
33
+ isError: true,
34
+ };
35
+ }
36
+ const musicModel = model || DEFAULT_MODEL;
37
+ if (contentId && deps.library) {
38
+ const content = deps.library.get(contentId);
39
+ if (!content) {
40
+ return { output: `Content ${contentId} not found. No USDC was spent.` };
41
+ }
42
+ if (content.spentUsd + PRICE_USD > content.budgetUsd + 1e-9) {
43
+ return {
44
+ output: `## Music generation skipped\n` +
45
+ `- Would exceed budget: spent $${content.spentUsd.toFixed(2)} + fixed ` +
46
+ `$${PRICE_USD.toFixed(2)} > cap $${content.budgetUsd.toFixed(2)}\n\n` +
47
+ `No USDC was spent.`,
48
+ };
49
+ }
50
+ }
51
+ const chain = loadChain();
52
+ const apiUrl = API_URLS[chain];
53
+ const endpoint = `${apiUrl}/v1/audio/generations`;
54
+ const outPath = output_path
55
+ ? (path.isAbsolute(output_path) ? output_path : path.resolve(ctx.workingDir, output_path))
56
+ : path.resolve(ctx.workingDir, `generated-${Date.now()}.mp3`);
57
+ const body = JSON.stringify({
58
+ model: musicModel,
59
+ prompt,
60
+ ...(instrumental !== undefined ? { instrumental } : {}),
61
+ ...(lyrics ? { lyrics } : {}),
62
+ ...(duration_seconds ? { duration_seconds } : {}),
63
+ });
64
+ const headers = {
65
+ 'Content-Type': 'application/json',
66
+ 'User-Agent': `franklin/${VERSION}`,
67
+ };
68
+ const controller = new AbortController();
69
+ const timeout = setTimeout(() => controller.abort(), GEN_TIMEOUT_MS);
70
+ const onAbort = () => controller.abort();
71
+ ctx.abortSignal.addEventListener('abort', onAbort, { once: true });
72
+ try {
73
+ let response = await fetch(endpoint, {
74
+ method: 'POST',
75
+ signal: controller.signal,
76
+ headers,
77
+ body,
78
+ });
79
+ if (response.status === 402) {
80
+ const paymentHeaders = await signPayment(response, chain, endpoint);
81
+ if (!paymentHeaders) {
82
+ return { output: 'Payment failed. Check wallet balance with: franklin balance', isError: true };
83
+ }
84
+ response = await fetch(endpoint, {
85
+ method: 'POST',
86
+ signal: controller.signal,
87
+ headers: { ...headers, ...paymentHeaders },
88
+ body,
89
+ });
90
+ }
91
+ if (!response.ok) {
92
+ const errText = await response.text().catch(() => '');
93
+ return {
94
+ output: `Music generation failed (${response.status}): ${errText.slice(0, 300)}`,
95
+ isError: true,
96
+ };
97
+ }
98
+ const result = (await response.json());
99
+ const track = result.data?.[0];
100
+ if (!track?.url) {
101
+ return { output: 'No track URL returned from API', isError: true };
102
+ }
103
+ // CDN URLs expire in ~24h — download NOW.
104
+ const dlCtrl = new AbortController();
105
+ const dlTimeout = setTimeout(() => dlCtrl.abort(), DOWNLOAD_TIMEOUT_MS);
106
+ const mp3Resp = await fetch(track.url, { signal: dlCtrl.signal });
107
+ clearTimeout(dlTimeout);
108
+ if (!mp3Resp.ok) {
109
+ return {
110
+ output: `Music URL fetched but MP3 download failed (${mp3Resp.status}): ${track.url}`,
111
+ isError: true,
112
+ };
113
+ }
114
+ const buffer = Buffer.from(await mp3Resp.arrayBuffer());
115
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
116
+ fs.writeFileSync(outPath, buffer);
117
+ const fileSize = fs.statSync(outPath).size;
118
+ const sizeMB = (fileSize / 1_048_576).toFixed(1);
119
+ const dur = track.duration_seconds ?? 180;
120
+ const lyricsPreview = track.lyrics
121
+ ? `\n\n**Generated lyrics:**\n\n${track.lyrics.slice(0, 600)}${track.lyrics.length > 600 ? '\n...' : ''}`
122
+ : '';
123
+ let contentSummary = '';
124
+ if (contentId && deps.library) {
125
+ const rec = deps.library.addAsset(contentId, {
126
+ kind: 'audio',
127
+ source: musicModel,
128
+ costUsd: PRICE_USD,
129
+ data: outPath,
130
+ });
131
+ if (rec.ok) {
132
+ if (deps.onContentChange)
133
+ await deps.onContentChange();
134
+ const c = deps.library.get(contentId);
135
+ contentSummary =
136
+ `\n\n## Content updated\n` +
137
+ `- Attached to \`${contentId}\` at est. $${PRICE_USD.toFixed(2)}\n` +
138
+ (c
139
+ ? `- Spent: $${c.spentUsd.toFixed(2)} / $${c.budgetUsd.toFixed(2)} cap ` +
140
+ `(remaining $${(c.budgetUsd - c.spentUsd).toFixed(2)})`
141
+ : '');
142
+ }
143
+ else {
144
+ contentSummary =
145
+ `\n\n## Content NOT updated\n- ${rec.reason}\n- Track saved locally; ` +
146
+ `cost NOT recorded against the content budget.`;
147
+ }
148
+ }
149
+ return {
150
+ output: `Track saved to ${outPath} (${sizeMB}MB, ${dur}s, ${musicModel})\n\n` +
151
+ `Open with: open ${outPath}${lyricsPreview}${contentSummary}`,
152
+ };
153
+ }
154
+ catch (err) {
155
+ const msg = err.message || '';
156
+ if (msg.includes('abort')) {
157
+ return {
158
+ output: `Music generation timed out or was aborted (limit ${Math.round(GEN_TIMEOUT_MS / 1000)}s).`,
159
+ isError: true,
160
+ };
161
+ }
162
+ return { output: `Error: ${msg}`, isError: true };
163
+ }
164
+ finally {
165
+ clearTimeout(timeout);
166
+ ctx.abortSignal.removeEventListener('abort', onAbort);
167
+ }
168
+ };
169
+ }
170
+ // ─── Payment ───────────────────────────────────────────────────────
171
+ async function signPayment(response, chain, endpoint) {
172
+ try {
173
+ const paymentHeader = await extractPaymentReq(response);
174
+ if (!paymentHeader)
175
+ return null;
176
+ if (chain === 'solana') {
177
+ const wallet = await getOrCreateSolanaWallet();
178
+ const paymentRequired = parsePaymentRequired(paymentHeader);
179
+ const details = extractPaymentDetails(paymentRequired, SOLANA_NETWORK);
180
+ const secretBytes = await solanaKeyToBytes(wallet.privateKey);
181
+ const feePayer = details.extra?.feePayer || details.recipient;
182
+ const payload = await createSolanaPaymentPayload(secretBytes, wallet.address, details.recipient, details.amount, feePayer, {
183
+ resourceUrl: details.resource?.url || endpoint,
184
+ resourceDescription: details.resource?.description || 'Franklin music generation',
185
+ maxTimeoutSeconds: details.maxTimeoutSeconds || 300,
186
+ extra: details.extra,
187
+ });
188
+ return { 'PAYMENT-SIGNATURE': payload };
189
+ }
190
+ const wallet = getOrCreateWallet();
191
+ const paymentRequired = parsePaymentRequired(paymentHeader);
192
+ const details = extractPaymentDetails(paymentRequired);
193
+ const payload = await createPaymentPayload(wallet.privateKey, wallet.address, details.recipient, details.amount, details.network || 'eip155:8453', {
194
+ resourceUrl: details.resource?.url || endpoint,
195
+ resourceDescription: details.resource?.description || 'Franklin music generation',
196
+ maxTimeoutSeconds: details.maxTimeoutSeconds || 300,
197
+ extra: details.extra,
198
+ });
199
+ return { 'PAYMENT-SIGNATURE': payload };
200
+ }
201
+ catch (err) {
202
+ console.error(`[franklin] Music payment error: ${err.message}`);
203
+ return null;
204
+ }
205
+ }
206
+ async function extractPaymentReq(response) {
207
+ let header = response.headers.get('payment-required');
208
+ if (!header) {
209
+ try {
210
+ const body = (await response.json());
211
+ if (body.x402 || body.accepts)
212
+ header = btoa(JSON.stringify(body));
213
+ }
214
+ catch { /* ignore */ }
215
+ }
216
+ return header;
217
+ }
218
+ // ─── Export ────────────────────────────────────────────────────────
219
+ export function createMusicGenCapability(deps = {}) {
220
+ return {
221
+ spec: {
222
+ name: 'MusicGen',
223
+ description: "Generate a ~3-minute MP3 track from a text prompt (plus optional " +
224
+ "lyrics or instrumental flag). Calls BlockRun's /v1/audio/generations. " +
225
+ "Costs $0.1575 USDC per call — bills a flat rate, MiniMax ignores " +
226
+ "duration hints and always returns ~3 min. Generation takes 1–3 " +
227
+ "minutes. ALWAYS confirm with the user before calling — music is " +
228
+ "expensive and slow. Pass contentId to attach to a Content piece " +
229
+ "(budget is checked before paying).",
230
+ input_schema: {
231
+ type: 'object',
232
+ properties: {
233
+ prompt: { type: 'string', description: 'Music style / mood / description' },
234
+ output_path: { type: 'string', description: 'Where to save the MP3. Default: generated-<timestamp>.mp3' },
235
+ model: { type: 'string', description: 'Music model. Default: minimax/music-2.5+' },
236
+ instrumental: { type: 'boolean', description: 'No vocals. Cannot combine with `lyrics`.' },
237
+ lyrics: { type: 'string', description: 'Custom lyrics. Cannot combine with `instrumental: true`.' },
238
+ duration_seconds: { type: 'number', description: 'Duration hint (ignored by MiniMax — always ~3 min).' },
239
+ contentId: { type: 'string', description: 'Optional Content id to attach and budget against.' },
240
+ },
241
+ required: ['prompt'],
242
+ },
243
+ },
244
+ execute: buildExecute(deps),
245
+ concurrent: false,
246
+ };
247
+ }
248
+ export const musicGenCapability = createMusicGenCapability();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockrun/franklin",
3
- "version": "3.8.3",
3
+ "version": "3.8.5",
4
4
  "description": "Franklin — The AI agent with a wallet. Spends USDC autonomously to get real work done. Pay per action, no subscriptions.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -15,8 +15,8 @@
15
15
  "./package.json": "./package.json"
16
16
  },
17
17
  "bin": {
18
- "franklin": "./dist/index.js",
19
- "runcode": "./dist/index.js"
18
+ "franklin": "dist/index.js",
19
+ "runcode": "dist/index.js"
20
20
  },
21
21
  "files": [
22
22
  "dist",
@@ -52,10 +52,10 @@
52
52
  "license": "Apache-2.0",
53
53
  "repository": {
54
54
  "type": "git",
55
- "url": "https://gitlab.com/blockrunai/franklin"
55
+ "url": "git+https://github.com/RunFranklin/franklin.git"
56
56
  },
57
57
  "bugs": {
58
- "url": "https://gitlab.com/blockrunai/franklin/-/issues"
58
+ "url": "https://github.com/RunFranklin/franklin/issues"
59
59
  },
60
60
  "homepage": "https://Franklin.run",
61
61
  "engines": {