@blockrun/franklin 3.8.4 → 3.8.6
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 +27 -22
- package/dist/agent/loop.js +9 -0
- package/dist/commands/start.js +7 -0
- package/dist/commands/telemetry.d.ts +14 -0
- package/dist/commands/telemetry.js +150 -0
- package/dist/index.js +7 -0
- package/dist/session/storage.d.ts +7 -0
- package/dist/session/storage.js +3 -0
- package/dist/telemetry/store.d.ts +86 -0
- package/dist/telemetry/store.js +158 -0
- package/dist/tools/exa.d.ts +23 -0
- package/dist/tools/exa.js +258 -0
- package/dist/tools/index.js +10 -0
- package/dist/tools/musicgen.d.ts +23 -0
- package/dist/tools/musicgen.js +248 -0
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
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://github.com/
|
|
19
|
+
<a href="https://github.com/BlockRunAI/Franklin/stargazers"><img src="https://img.shields.io/github/stars/BlockRunAI/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
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>
|
|
22
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>
|
|
@@ -154,9 +154,9 @@ Live data from CoinGecko. RSI, MACD, Bollinger, and volatility computed locally.
|
|
|
154
154
|
|
|
155
155
|
Generates images via DALL-E / GPT Image directly from the CLI. Paid from your wallet — no OpenAI API key needed.
|
|
156
156
|
|
|
157
|
-
###
|
|
157
|
+
### 📱 Remote control via Telegram
|
|
158
158
|
|
|
159
|
-
|
|
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.
|
|
160
160
|
|
|
161
161
|
### 🔎 Research, code, anything with a budget
|
|
162
162
|
|
|
@@ -315,7 +315,7 @@ Trained on 2M+ real requests. Classifies your task and picks the best model from
|
|
|
315
315
|
<td width="50%" valign="top">
|
|
316
316
|
|
|
317
317
|
**🛠 16 built-in tools**
|
|
318
|
-
Read, Write, Edit, Bash, Glob, Grep, WebFetch, WebSearch, Task, ImageGen, AskUser, SubAgent, TradingSignal, TradingMarket,
|
|
318
|
+
Read, Write, Edit, Bash, Glob, Grep, WebFetch, WebSearch, Task, ImageGen, VideoGen, MemoryRecall, AskUser, SubAgent, TradingSignal, TradingMarket, TradingPortfolio, TradingOpenPosition, TradingClosePosition, TradingHistory.
|
|
319
319
|
|
|
320
320
|
**💾 Persistent sessions**
|
|
321
321
|
Every turn is streamed to disk with metadata. Resume any session by ID. Survives crashes, reboots, and compaction.
|
|
@@ -401,19 +401,21 @@ src/
|
|
|
401
401
|
├── index.ts CLI entry (franklin + runcode alias)
|
|
402
402
|
├── banner.ts Ben Franklin portrait + FRANKLIN gradient text
|
|
403
403
|
├── agent/ Agent loop, LLM client, compaction, commands
|
|
404
|
-
├── tools/
|
|
405
|
-
│ WebFetch/WebSearch/Task/ImageGen/
|
|
406
|
-
│
|
|
404
|
+
├── tools/ 20+ built-in tools (Read/Write/Edit/Bash/Glob/Grep/
|
|
405
|
+
│ WebFetch/WebSearch/Task/ImageGen/VideoGen/
|
|
406
|
+
│ MemoryRecall/AskUser/SubAgent/Trading*/Content*)
|
|
407
407
|
├── trading/ Market data (CoinGecko) + technical indicators
|
|
408
|
-
├──
|
|
409
|
-
├──
|
|
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
|
|
410
412
|
├── plugin-sdk/ Public plugin contract (Workflow/Plugin/Channel)
|
|
411
413
|
├── plugins/ Plugin registry + runner (plugin-agnostic)
|
|
412
|
-
├── session/ Persistent sessions + search
|
|
414
|
+
├── session/ Persistent sessions + search + channel tags
|
|
413
415
|
├── stats/ Usage tracking + insights engine
|
|
414
416
|
├── ui/ Ink-based terminal UI
|
|
415
417
|
├── proxy/ Payment proxy for external tools
|
|
416
|
-
├── router/ Learned model router (
|
|
418
|
+
├── router/ Learned model router (55+ models, Elo scoring)
|
|
417
419
|
├── wallet/ Wallet management (Base + Solana)
|
|
418
420
|
├── mcp/ MCP server auto-discovery
|
|
419
421
|
└── commands/ CLI subcommands
|
|
@@ -433,20 +435,23 @@ When you fund the wallet, Franklin gets more purchasing power: Sonnet, Opus, GPT
|
|
|
433
435
|
|
|
434
436
|
---
|
|
435
437
|
|
|
436
|
-
##
|
|
438
|
+
## Remote control via Telegram
|
|
437
439
|
|
|
438
|
-
|
|
440
|
+
Drive Franklin from anywhere with a bot token:
|
|
439
441
|
|
|
440
442
|
```bash
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
franklin
|
|
444
|
-
franklin social run # dry-run — preview drafts
|
|
445
|
-
franklin social run --live # actually post to X
|
|
446
|
-
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)
|
|
447
446
|
```
|
|
448
447
|
|
|
449
|
-
|
|
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.
|
|
450
455
|
|
|
451
456
|
---
|
|
452
457
|
|
|
@@ -463,14 +468,14 @@ The chat-based social tools (`SearchX`, `PostToX`) and the batch CLI (`franklin
|
|
|
463
468
|
|
|
464
469
|
- [Telegram](https://t.me/blockrunAI) — realtime help, bug reports, feature requests
|
|
465
470
|
- [@BlockRunAI](https://x.com/BlockRunAI) — release notes, demos
|
|
466
|
-
- [Issues](https://github.com/
|
|
471
|
+
- [Issues](https://github.com/BlockRunAI/Franklin/issues) — bugs and feature requests
|
|
467
472
|
|
|
468
473
|
---
|
|
469
474
|
|
|
470
475
|
## Development
|
|
471
476
|
|
|
472
477
|
```bash
|
|
473
|
-
git clone https://github.com/
|
|
478
|
+
git clone https://github.com/BlockRunAI/Franklin.git
|
|
474
479
|
cd franklin
|
|
475
480
|
npm install
|
|
476
481
|
npm run build
|
package/dist/agent/loop.js
CHANGED
|
@@ -358,6 +358,10 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
|
|
|
358
358
|
let sessionOutputTokens = 0;
|
|
359
359
|
let sessionCostUsd = 0;
|
|
360
360
|
let sessionSavedVsOpus = 0;
|
|
361
|
+
// Per-tool call counts aggregated across every turn. Session-scope, not
|
|
362
|
+
// per-turn. Counts the *name* of each tool invocation only — no inputs,
|
|
363
|
+
// outputs, or paths. Fed into opt-in telemetry at session end.
|
|
364
|
+
const sessionToolCounts = new Map();
|
|
361
365
|
const toolGuard = new SessionToolGuard();
|
|
362
366
|
const persistSessionMeta = () => {
|
|
363
367
|
updateSessionMeta(sessionId, {
|
|
@@ -370,6 +374,9 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
|
|
|
370
374
|
costUsd: sessionCostUsd,
|
|
371
375
|
savedVsOpusUsd: sessionSavedVsOpus,
|
|
372
376
|
...(config.sessionChannel !== undefined ? { channel: config.sessionChannel } : {}),
|
|
377
|
+
...(sessionToolCounts.size > 0
|
|
378
|
+
? { toolCallCounts: Object.fromEntries(sessionToolCounts) }
|
|
379
|
+
: {}),
|
|
373
380
|
});
|
|
374
381
|
};
|
|
375
382
|
const persistSessionMessage = (message) => {
|
|
@@ -1039,6 +1046,8 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
|
|
|
1039
1046
|
for (const [inv] of results) {
|
|
1040
1047
|
const name = inv.name;
|
|
1041
1048
|
turnToolCounts.set(name, (turnToolCounts.get(name) || 0) + 1);
|
|
1049
|
+
// Session-scope aggregate (drives telemetry opt-in export).
|
|
1050
|
+
sessionToolCounts.set(name, (sessionToolCounts.get(name) || 0) + 1);
|
|
1042
1051
|
// Read file dedup: track paths already read
|
|
1043
1052
|
if (name === 'Read' && inv.input.file_path) {
|
|
1044
1053
|
readFileCache.add(inv.input.file_path);
|
package/dist/commands/start.js
CHANGED
|
@@ -313,6 +313,13 @@ async function runWithInkUI(agentConfig, model, workDir, version, walletInfo, on
|
|
|
313
313
|
}
|
|
314
314
|
ui.cleanup();
|
|
315
315
|
flushStats();
|
|
316
|
+
// Opt-in telemetry — no-op unless user has run `franklin telemetry enable`.
|
|
317
|
+
// Appends a sanitized session summary to ~/.blockrun/telemetry.jsonl.
|
|
318
|
+
try {
|
|
319
|
+
const { recordLatestSessionIfEnabled } = await import('../telemetry/store.js');
|
|
320
|
+
recordLatestSessionIfEnabled(process.cwd(), agentConfig.chain);
|
|
321
|
+
}
|
|
322
|
+
catch { /* telemetry is best-effort */ }
|
|
316
323
|
// Extract learnings from the session (async, 10s timeout, never blocks exit)
|
|
317
324
|
if (sessionHistory && sessionHistory.length >= 4) {
|
|
318
325
|
try {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `franklin telemetry` — manage the opt-in local telemetry subsystem.
|
|
3
|
+
*
|
|
4
|
+
* Subcommands:
|
|
5
|
+
* status — print whether telemetry is enabled, where the log lives,
|
|
6
|
+
* and a one-line summary of what's been recorded
|
|
7
|
+
* enable — turn on local recording (default is OFF)
|
|
8
|
+
* disable — stop future recording; existing data stays on disk
|
|
9
|
+
* view — print every record in the log as pretty JSONL so the
|
|
10
|
+
* user can see exactly what was captured
|
|
11
|
+
* summary — aggregate all records into tool-usage histograms so
|
|
12
|
+
* positioning decisions can be made from real data
|
|
13
|
+
*/
|
|
14
|
+
export declare function telemetryCommand(action?: string): Promise<void>;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `franklin telemetry` — manage the opt-in local telemetry subsystem.
|
|
3
|
+
*
|
|
4
|
+
* Subcommands:
|
|
5
|
+
* status — print whether telemetry is enabled, where the log lives,
|
|
6
|
+
* and a one-line summary of what's been recorded
|
|
7
|
+
* enable — turn on local recording (default is OFF)
|
|
8
|
+
* disable — stop future recording; existing data stays on disk
|
|
9
|
+
* view — print every record in the log as pretty JSONL so the
|
|
10
|
+
* user can see exactly what was captured
|
|
11
|
+
* summary — aggregate all records into tool-usage histograms so
|
|
12
|
+
* positioning decisions can be made from real data
|
|
13
|
+
*/
|
|
14
|
+
import chalk from 'chalk';
|
|
15
|
+
import { isTelemetryEnabled, setTelemetryEnabled, readConsent, readAllRecords, telemetryPaths, } from '../telemetry/store.js';
|
|
16
|
+
export async function telemetryCommand(action) {
|
|
17
|
+
const cmd = (action || 'status').toLowerCase();
|
|
18
|
+
switch (cmd) {
|
|
19
|
+
case 'status':
|
|
20
|
+
return statusCmd();
|
|
21
|
+
case 'enable':
|
|
22
|
+
case 'on':
|
|
23
|
+
return enableCmd();
|
|
24
|
+
case 'disable':
|
|
25
|
+
case 'off':
|
|
26
|
+
return disableCmd();
|
|
27
|
+
case 'view':
|
|
28
|
+
case 'log':
|
|
29
|
+
return viewCmd();
|
|
30
|
+
case 'summary':
|
|
31
|
+
return summaryCmd();
|
|
32
|
+
default:
|
|
33
|
+
console.log(chalk.yellow(`Unknown subcommand: ${action}`));
|
|
34
|
+
console.log(chalk.dim('Try: franklin telemetry [status|enable|disable|view|summary]'));
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function statusCmd() {
|
|
39
|
+
const enabled = isTelemetryEnabled();
|
|
40
|
+
const consent = readConsent();
|
|
41
|
+
const records = readAllRecords();
|
|
42
|
+
console.log(chalk.bold('Franklin telemetry'));
|
|
43
|
+
console.log(` state: ${enabled ? chalk.green('enabled') : chalk.dim('disabled (default)')}`);
|
|
44
|
+
if (consent.enabledAt) {
|
|
45
|
+
console.log(` enabled at: ${chalk.dim(new Date(consent.enabledAt).toISOString())}`);
|
|
46
|
+
}
|
|
47
|
+
if (consent.disabledAt && !enabled) {
|
|
48
|
+
console.log(` disabled at: ${chalk.dim(new Date(consent.disabledAt).toISOString())}`);
|
|
49
|
+
}
|
|
50
|
+
console.log(` records: ${chalk.cyan(records.length.toString())} session${records.length === 1 ? '' : 's'} on disk`);
|
|
51
|
+
console.log(` log file: ${chalk.dim(telemetryPaths.log)}`);
|
|
52
|
+
console.log();
|
|
53
|
+
console.log(chalk.dim('Telemetry is local-only: no network transmission.'));
|
|
54
|
+
console.log(chalk.dim('Records contain tool-usage counts and cost totals — NOT prompts, tool inputs, tool outputs, paths, or wallet addresses.'));
|
|
55
|
+
console.log();
|
|
56
|
+
console.log(chalk.dim('Commands:'));
|
|
57
|
+
console.log(chalk.dim(' franklin telemetry enable turn on local recording'));
|
|
58
|
+
console.log(chalk.dim(' franklin telemetry disable stop future recording (keeps existing data)'));
|
|
59
|
+
console.log(chalk.dim(' franklin telemetry view print every stored record verbatim'));
|
|
60
|
+
console.log(chalk.dim(' franklin telemetry summary aggregate tool-usage histograms'));
|
|
61
|
+
}
|
|
62
|
+
function enableCmd() {
|
|
63
|
+
if (isTelemetryEnabled()) {
|
|
64
|
+
console.log(chalk.dim('Telemetry is already enabled.'));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
setTelemetryEnabled(true);
|
|
68
|
+
console.log(chalk.green('Telemetry enabled.'));
|
|
69
|
+
console.log(chalk.dim('Each session end appends one JSON line to ' + telemetryPaths.log));
|
|
70
|
+
console.log(chalk.dim('Inspect with: franklin telemetry view'));
|
|
71
|
+
console.log(chalk.dim('Disable with: franklin telemetry disable'));
|
|
72
|
+
}
|
|
73
|
+
function disableCmd() {
|
|
74
|
+
if (!isTelemetryEnabled()) {
|
|
75
|
+
console.log(chalk.dim('Telemetry is already disabled.'));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
setTelemetryEnabled(false);
|
|
79
|
+
console.log(chalk.green('Telemetry disabled. Future sessions will not be recorded.'));
|
|
80
|
+
console.log(chalk.dim('Existing data at ' + telemetryPaths.log + ' is untouched.'));
|
|
81
|
+
console.log(chalk.dim('Delete it manually if you want to clear history.'));
|
|
82
|
+
}
|
|
83
|
+
function viewCmd() {
|
|
84
|
+
const records = readAllRecords();
|
|
85
|
+
if (records.length === 0) {
|
|
86
|
+
console.log(chalk.dim('No telemetry records yet.'));
|
|
87
|
+
if (!isTelemetryEnabled()) {
|
|
88
|
+
console.log(chalk.dim('Telemetry is disabled — enable with: franklin telemetry enable'));
|
|
89
|
+
}
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
for (const r of records) {
|
|
93
|
+
console.log(JSON.stringify(r, null, 2));
|
|
94
|
+
console.log(chalk.dim('─'.repeat(60)));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function summaryCmd() {
|
|
98
|
+
const records = readAllRecords();
|
|
99
|
+
if (records.length === 0) {
|
|
100
|
+
console.log(chalk.dim('No telemetry records yet.'));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const toolCounts = new Map();
|
|
104
|
+
const modelCounts = new Map();
|
|
105
|
+
const driverCounts = new Map();
|
|
106
|
+
let totalCost = 0;
|
|
107
|
+
let totalSaved = 0;
|
|
108
|
+
let totalTurns = 0;
|
|
109
|
+
for (const r of records) {
|
|
110
|
+
if (r.toolCallCounts) {
|
|
111
|
+
for (const [tool, n] of Object.entries(r.toolCallCounts)) {
|
|
112
|
+
toolCounts.set(tool, (toolCounts.get(tool) || 0) + n);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
modelCounts.set(r.model, (modelCounts.get(r.model) || 0) + 1);
|
|
116
|
+
driverCounts.set(r.driver, (driverCounts.get(r.driver) || 0) + 1);
|
|
117
|
+
totalCost += r.costUsd;
|
|
118
|
+
totalSaved += r.savedVsOpusUsd;
|
|
119
|
+
totalTurns += r.turns;
|
|
120
|
+
}
|
|
121
|
+
console.log(chalk.bold(`\nFranklin telemetry summary — ${records.length} sessions\n`));
|
|
122
|
+
console.log(` total turns: ${totalTurns}`);
|
|
123
|
+
console.log(` total USDC cost: $${totalCost.toFixed(4)}`);
|
|
124
|
+
console.log(` saved vs Opus: $${totalSaved.toFixed(4)}`);
|
|
125
|
+
console.log();
|
|
126
|
+
console.log(chalk.bold(' Tool usage (session aggregate):'));
|
|
127
|
+
const sortedTools = [...toolCounts.entries()].sort((a, b) => b[1] - a[1]);
|
|
128
|
+
if (sortedTools.length === 0) {
|
|
129
|
+
console.log(chalk.dim(' (no tool calls recorded)'));
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
const maxCount = sortedTools[0][1];
|
|
133
|
+
for (const [tool, n] of sortedTools) {
|
|
134
|
+
const bar = '█'.repeat(Math.max(1, Math.round((n / maxCount) * 20)));
|
|
135
|
+
console.log(` ${tool.padEnd(22)} ${chalk.cyan(bar)} ${n}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
console.log();
|
|
139
|
+
console.log(chalk.bold(' Models:'));
|
|
140
|
+
const sortedModels = [...modelCounts.entries()].sort((a, b) => b[1] - a[1]);
|
|
141
|
+
for (const [model, n] of sortedModels) {
|
|
142
|
+
console.log(` ${model.padEnd(36)} ${n}`);
|
|
143
|
+
}
|
|
144
|
+
console.log();
|
|
145
|
+
console.log(chalk.bold(' Drivers:'));
|
|
146
|
+
for (const [driver, n] of driverCounts.entries()) {
|
|
147
|
+
console.log(` ${driver.padEnd(22)} ${n}`);
|
|
148
|
+
}
|
|
149
|
+
console.log();
|
|
150
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -166,6 +166,13 @@ program
|
|
|
166
166
|
});
|
|
167
167
|
}
|
|
168
168
|
}
|
|
169
|
+
program
|
|
170
|
+
.command('telemetry [action]')
|
|
171
|
+
.description('Manage opt-in local telemetry (status|enable|disable|view|summary)')
|
|
172
|
+
.action(async (action) => {
|
|
173
|
+
const { telemetryCommand } = await import('./commands/telemetry.js');
|
|
174
|
+
await telemetryCommand(action);
|
|
175
|
+
});
|
|
169
176
|
program
|
|
170
177
|
.command('telegram')
|
|
171
178
|
.description('Drive Franklin from Telegram (requires TELEGRAM_BOT_TOKEN + TELEGRAM_OWNER_ID env vars)')
|
|
@@ -21,6 +21,13 @@ export interface SessionMeta {
|
|
|
21
21
|
* Lets findLatestSessionByChannel pick up the right session on bot restart.
|
|
22
22
|
*/
|
|
23
23
|
channel?: string;
|
|
24
|
+
/**
|
|
25
|
+
* Per-tool invocation counts for this session, aggregated across every
|
|
26
|
+
* turn. Populated by the agent loop at each tool-call batch. Used by the
|
|
27
|
+
* opt-in telemetry subsystem to aggregate vertical-usage signals — do NOT
|
|
28
|
+
* add any tool inputs or outputs here, just the count per tool name.
|
|
29
|
+
*/
|
|
30
|
+
toolCallCounts?: Record<string, number>;
|
|
24
31
|
}
|
|
25
32
|
/** Get the absolute path to a session's JSONL file (for external readers like search). */
|
|
26
33
|
export declare function getSessionFilePath(id: string): string;
|
package/dist/session/storage.js
CHANGED
|
@@ -95,6 +95,9 @@ export function updateSessionMeta(sessionId, meta) {
|
|
|
95
95
|
...(meta.channel !== undefined || existing?.channel !== undefined
|
|
96
96
|
? { channel: meta.channel ?? existing?.channel }
|
|
97
97
|
: {}),
|
|
98
|
+
...(meta.toolCallCounts !== undefined || existing?.toolCallCounts !== undefined
|
|
99
|
+
? { toolCallCounts: meta.toolCallCounts ?? existing?.toolCallCounts }
|
|
100
|
+
: {}),
|
|
98
101
|
};
|
|
99
102
|
// Atomic write: tmp file + rename. Prevents corruption when parent
|
|
100
103
|
// and sub-agent update the same session meta concurrently.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in local telemetry.
|
|
3
|
+
*
|
|
4
|
+
* Principles (non-negotiable):
|
|
5
|
+
* 1. Opt-in only — default is off. A fresh install collects nothing.
|
|
6
|
+
* 2. Local only — this module writes to ~/.blockrun/telemetry.jsonl.
|
|
7
|
+
* No network transmission, ever. A future opt-in "upload" feature
|
|
8
|
+
* would be a separate module with its own consent gate.
|
|
9
|
+
* 3. No content — never log prompts, tool inputs, tool outputs, file
|
|
10
|
+
* paths, or wallet addresses. Count-level aggregates only.
|
|
11
|
+
* 4. Inspectable — the log is plain JSONL, one record per session.
|
|
12
|
+
* `franklin telemetry view` prints it. Users see exactly what was
|
|
13
|
+
* recorded before ever considering sharing it.
|
|
14
|
+
* 5. Revocable — `franklin telemetry disable` stops future writes
|
|
15
|
+
* and leaves historical data intact. `franklin telemetry reset`
|
|
16
|
+
* (future) would wipe the log.
|
|
17
|
+
*
|
|
18
|
+
* Data model is a sanitized projection of SessionMeta. Nothing original
|
|
19
|
+
* is stored here that isn't already derivable from the session meta
|
|
20
|
+
* files — telemetry is just a stable, aggregation-friendly view of
|
|
21
|
+
* information the user already has.
|
|
22
|
+
*/
|
|
23
|
+
import type { SessionMeta } from '../session/storage.js';
|
|
24
|
+
interface ConsentRecord {
|
|
25
|
+
enabled: boolean;
|
|
26
|
+
enabledAt?: number;
|
|
27
|
+
disabledAt?: number;
|
|
28
|
+
}
|
|
29
|
+
/** Sanitized projection of a session used for telemetry. No content. */
|
|
30
|
+
export interface TelemetryRecord {
|
|
31
|
+
/** Stable per-install random UUID. Not tied to wallet or email. */
|
|
32
|
+
installId: string;
|
|
33
|
+
/** Franklin version at the time this session ran. */
|
|
34
|
+
version: string;
|
|
35
|
+
/** Session timestamp (ISO string). */
|
|
36
|
+
ts: string;
|
|
37
|
+
/** Number of user turns. */
|
|
38
|
+
turns: number;
|
|
39
|
+
/** Number of message entries (user + assistant + tool_result). */
|
|
40
|
+
messages: number;
|
|
41
|
+
/** Input / output tokens for the whole session. */
|
|
42
|
+
inputTokens: number;
|
|
43
|
+
outputTokens: number;
|
|
44
|
+
/** Cost in USDC. */
|
|
45
|
+
costUsd: number;
|
|
46
|
+
/** Savings vs Opus-tier baseline in USDC. */
|
|
47
|
+
savedVsOpusUsd: number;
|
|
48
|
+
/** Last-active model id for the session. */
|
|
49
|
+
model: string;
|
|
50
|
+
/** Chain the session settled on (base / solana). */
|
|
51
|
+
chain?: string;
|
|
52
|
+
/** Session driver — "cli" for normal use, or the channel tag for Telegram/etc. */
|
|
53
|
+
driver: string;
|
|
54
|
+
/** Per-tool invocation counts (names only, no content). */
|
|
55
|
+
toolCallCounts?: Record<string, number>;
|
|
56
|
+
}
|
|
57
|
+
/** Enabled-state check. Default: false. */
|
|
58
|
+
export declare function isTelemetryEnabled(): boolean;
|
|
59
|
+
export declare function setTelemetryEnabled(enabled: boolean): void;
|
|
60
|
+
export declare function readConsent(): ConsentRecord;
|
|
61
|
+
/** Stable per-install random UUID. Generated lazily on first write. */
|
|
62
|
+
export declare function getOrCreateInstallId(): string;
|
|
63
|
+
/**
|
|
64
|
+
* Sanitize a SessionMeta into a telemetry record. No content is added here
|
|
65
|
+
* that isn't already present in the meta — the sanitization rule is that
|
|
66
|
+
* every field must be count-level or identifier-level, never user content.
|
|
67
|
+
*/
|
|
68
|
+
export declare function sessionMetaToRecord(meta: SessionMeta, installId: string, chain?: string): TelemetryRecord;
|
|
69
|
+
/** Append one record to the telemetry log. Silent no-op if disabled. */
|
|
70
|
+
export declare function recordSession(meta: SessionMeta, chain?: string): void;
|
|
71
|
+
/**
|
|
72
|
+
* Locate the session that just finished by ID or by "newest in the sessions
|
|
73
|
+
* directory whose workDir matches", then record it. Used by start.ts at
|
|
74
|
+
* exit since interactiveSession() doesn't currently thread the session id
|
|
75
|
+
* back to the caller.
|
|
76
|
+
*/
|
|
77
|
+
export declare function recordLatestSessionIfEnabled(workingDir: string, chain?: string): void;
|
|
78
|
+
/** Read every record in the log. Returns [] if the file is missing. */
|
|
79
|
+
export declare function readAllRecords(): TelemetryRecord[];
|
|
80
|
+
/** File paths — surfaced so the CLI can show users where data lives. */
|
|
81
|
+
export declare const telemetryPaths: {
|
|
82
|
+
consent: string;
|
|
83
|
+
log: string;
|
|
84
|
+
installId: string;
|
|
85
|
+
};
|
|
86
|
+
export {};
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in local telemetry.
|
|
3
|
+
*
|
|
4
|
+
* Principles (non-negotiable):
|
|
5
|
+
* 1. Opt-in only — default is off. A fresh install collects nothing.
|
|
6
|
+
* 2. Local only — this module writes to ~/.blockrun/telemetry.jsonl.
|
|
7
|
+
* No network transmission, ever. A future opt-in "upload" feature
|
|
8
|
+
* would be a separate module with its own consent gate.
|
|
9
|
+
* 3. No content — never log prompts, tool inputs, tool outputs, file
|
|
10
|
+
* paths, or wallet addresses. Count-level aggregates only.
|
|
11
|
+
* 4. Inspectable — the log is plain JSONL, one record per session.
|
|
12
|
+
* `franklin telemetry view` prints it. Users see exactly what was
|
|
13
|
+
* recorded before ever considering sharing it.
|
|
14
|
+
* 5. Revocable — `franklin telemetry disable` stops future writes
|
|
15
|
+
* and leaves historical data intact. `franklin telemetry reset`
|
|
16
|
+
* (future) would wipe the log.
|
|
17
|
+
*
|
|
18
|
+
* Data model is a sanitized projection of SessionMeta. Nothing original
|
|
19
|
+
* is stored here that isn't already derivable from the session meta
|
|
20
|
+
* files — telemetry is just a stable, aggregation-friendly view of
|
|
21
|
+
* information the user already has.
|
|
22
|
+
*/
|
|
23
|
+
import fs from 'node:fs';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import crypto from 'node:crypto';
|
|
26
|
+
import { BLOCKRUN_DIR, VERSION } from '../config.js';
|
|
27
|
+
const CONSENT_FILE = path.join(BLOCKRUN_DIR, 'telemetry-consent.json');
|
|
28
|
+
const LOG_FILE = path.join(BLOCKRUN_DIR, 'telemetry.jsonl');
|
|
29
|
+
const INSTALL_ID_FILE = path.join(BLOCKRUN_DIR, 'telemetry-install-id.txt');
|
|
30
|
+
function ensureDir() {
|
|
31
|
+
fs.mkdirSync(BLOCKRUN_DIR, { recursive: true });
|
|
32
|
+
}
|
|
33
|
+
/** Enabled-state check. Default: false. */
|
|
34
|
+
export function isTelemetryEnabled() {
|
|
35
|
+
try {
|
|
36
|
+
const raw = fs.readFileSync(CONSENT_FILE, 'utf-8');
|
|
37
|
+
const record = JSON.parse(raw);
|
|
38
|
+
return record.enabled === true;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export function setTelemetryEnabled(enabled) {
|
|
45
|
+
ensureDir();
|
|
46
|
+
const existing = readConsent();
|
|
47
|
+
const next = enabled
|
|
48
|
+
? { enabled: true, enabledAt: Date.now() }
|
|
49
|
+
: { enabled: false, enabledAt: existing.enabledAt, disabledAt: Date.now() };
|
|
50
|
+
fs.writeFileSync(CONSENT_FILE, JSON.stringify(next, null, 2));
|
|
51
|
+
}
|
|
52
|
+
export function readConsent() {
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(fs.readFileSync(CONSENT_FILE, 'utf-8'));
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return { enabled: false };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** Stable per-install random UUID. Generated lazily on first write. */
|
|
61
|
+
export function getOrCreateInstallId() {
|
|
62
|
+
try {
|
|
63
|
+
const raw = fs.readFileSync(INSTALL_ID_FILE, 'utf-8').trim();
|
|
64
|
+
if (raw.length > 0)
|
|
65
|
+
return raw;
|
|
66
|
+
}
|
|
67
|
+
catch { /* first run */ }
|
|
68
|
+
ensureDir();
|
|
69
|
+
const id = crypto.randomUUID();
|
|
70
|
+
fs.writeFileSync(INSTALL_ID_FILE, id);
|
|
71
|
+
return id;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Sanitize a SessionMeta into a telemetry record. No content is added here
|
|
75
|
+
* that isn't already present in the meta — the sanitization rule is that
|
|
76
|
+
* every field must be count-level or identifier-level, never user content.
|
|
77
|
+
*/
|
|
78
|
+
export function sessionMetaToRecord(meta, installId, chain) {
|
|
79
|
+
return {
|
|
80
|
+
installId,
|
|
81
|
+
version: VERSION,
|
|
82
|
+
ts: new Date(meta.updatedAt).toISOString(),
|
|
83
|
+
turns: meta.turnCount ?? 0,
|
|
84
|
+
messages: meta.messageCount ?? 0,
|
|
85
|
+
inputTokens: meta.inputTokens ?? 0,
|
|
86
|
+
outputTokens: meta.outputTokens ?? 0,
|
|
87
|
+
costUsd: meta.costUsd ?? 0,
|
|
88
|
+
savedVsOpusUsd: meta.savedVsOpusUsd ?? 0,
|
|
89
|
+
model: meta.model ?? 'unknown',
|
|
90
|
+
chain,
|
|
91
|
+
// "cli" if no channel tag, else the channel string (e.g. "telegram:12345").
|
|
92
|
+
// Channel may include an owner id; we deliberately keep it because the
|
|
93
|
+
// install id is already user-agnostic and linking a driver to a user
|
|
94
|
+
// is necessary to distinguish "single user with Telegram" from "many
|
|
95
|
+
// users with CLI" in aggregate data. Users who don't want this strip
|
|
96
|
+
// it by running telemetry disable.
|
|
97
|
+
driver: meta.channel ?? 'cli',
|
|
98
|
+
...(meta.toolCallCounts ? { toolCallCounts: meta.toolCallCounts } : {}),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/** Append one record to the telemetry log. Silent no-op if disabled. */
|
|
102
|
+
export function recordSession(meta, chain) {
|
|
103
|
+
if (!isTelemetryEnabled())
|
|
104
|
+
return;
|
|
105
|
+
ensureDir();
|
|
106
|
+
const record = sessionMetaToRecord(meta, getOrCreateInstallId(), chain);
|
|
107
|
+
try {
|
|
108
|
+
fs.appendFileSync(LOG_FILE, JSON.stringify(record) + '\n');
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// Telemetry is best-effort — never block a user session on a disk write.
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Locate the session that just finished by ID or by "newest in the sessions
|
|
116
|
+
* directory whose workDir matches", then record it. Used by start.ts at
|
|
117
|
+
* exit since interactiveSession() doesn't currently thread the session id
|
|
118
|
+
* back to the caller.
|
|
119
|
+
*/
|
|
120
|
+
export function recordLatestSessionIfEnabled(workingDir, chain) {
|
|
121
|
+
if (!isTelemetryEnabled())
|
|
122
|
+
return;
|
|
123
|
+
// Lazy import to avoid a circular session/storage <-> telemetry dependency.
|
|
124
|
+
// Using require() here keeps this module synchronous for tests.
|
|
125
|
+
/* eslint-disable @typescript-eslint/no-require-imports */
|
|
126
|
+
const { listSessions } = require('../session/storage.js');
|
|
127
|
+
/* eslint-enable @typescript-eslint/no-require-imports */
|
|
128
|
+
const sessions = listSessions();
|
|
129
|
+
const match = sessions.find(s => s.workDir === workingDir);
|
|
130
|
+
if (!match)
|
|
131
|
+
return;
|
|
132
|
+
recordSession(match, chain);
|
|
133
|
+
}
|
|
134
|
+
/** Read every record in the log. Returns [] if the file is missing. */
|
|
135
|
+
export function readAllRecords() {
|
|
136
|
+
try {
|
|
137
|
+
const raw = fs.readFileSync(LOG_FILE, 'utf-8');
|
|
138
|
+
const out = [];
|
|
139
|
+
for (const line of raw.split('\n')) {
|
|
140
|
+
if (!line.trim())
|
|
141
|
+
continue;
|
|
142
|
+
try {
|
|
143
|
+
out.push(JSON.parse(line));
|
|
144
|
+
}
|
|
145
|
+
catch { /* skip corrupt */ }
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return [];
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/** File paths — surfaced so the CLI can show users where data lives. */
|
|
154
|
+
export const telemetryPaths = {
|
|
155
|
+
consent: CONSENT_FILE,
|
|
156
|
+
log: LOG_FILE,
|
|
157
|
+
installId: INSTALL_ID_FILE,
|
|
158
|
+
};
|
|
@@ -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
|
+
};
|
package/dist/tools/index.js
CHANGED
|
@@ -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
|
+
"version": "3.8.6",
|
|
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": "
|
|
19
|
-
"runcode": "
|
|
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://github.com/
|
|
55
|
+
"url": "git+https://github.com/BlockRunAI/Franklin.git"
|
|
56
56
|
},
|
|
57
57
|
"bugs": {
|
|
58
|
-
"url": "https://github.com/
|
|
58
|
+
"url": "https://github.com/BlockRunAI/Franklin/issues"
|
|
59
59
|
},
|
|
60
60
|
"homepage": "https://Franklin.run",
|
|
61
61
|
"engines": {
|