@blockrun/franklin 3.8.1 → 3.8.3
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 +23 -36
- package/dist/agent/commands.js +1 -1
- package/dist/agent/llm.d.ts +6 -0
- package/dist/agent/llm.js +103 -14
- package/dist/agent/loop.d.ts +9 -0
- package/dist/agent/loop.js +85 -0
- package/dist/agent/think-tag-stripper.d.ts +27 -0
- package/dist/agent/think-tag-stripper.js +75 -0
- package/dist/agent/tokens.js +2 -1
- package/dist/agent/types.d.ts +7 -0
- package/dist/brain/index.d.ts +1 -1
- package/dist/brain/index.js +1 -1
- package/dist/brain/store.d.ts +13 -1
- package/dist/brain/store.js +74 -5
- package/dist/channel/telegram.d.ts +46 -0
- package/dist/channel/telegram.js +367 -0
- package/dist/commands/migrate.d.ts +5 -3
- package/dist/commands/migrate.js +17 -15
- package/dist/commands/stats.js +1 -1
- package/dist/commands/telegram.d.ts +15 -0
- package/dist/commands/telegram.js +95 -0
- package/dist/content/library.js +2 -2
- package/dist/index.js +9 -0
- package/dist/panel/html.js +1 -1
- package/dist/router/index.js +5 -5
- package/dist/session/storage.d.ts +12 -0
- package/dist/session/storage.js +11 -0
- package/dist/social/ai.d.ts +3 -2
- package/dist/social/ai.js +3 -2
- package/dist/stats/insights.d.ts +1 -1
- package/dist/stats/tracker.js +1 -1
- package/dist/tools/content-execute.d.ts +1 -1
- package/dist/tools/content-execute.js +1 -1
- package/dist/tools/index.js +11 -3
- package/dist/tools/memory.d.ts +16 -0
- package/dist/tools/memory.js +86 -0
- package/dist/tools/trading-execute.d.ts +2 -2
- package/dist/tools/trading-execute.js +2 -2
- package/dist/tools/videogen.d.ts +17 -0
- package/dist/tools/videogen.js +237 -0
- package/dist/trading/trade-log.d.ts +2 -2
- package/dist/trading/trade-log.js +2 -2
- package/dist/ui/app.js +38 -3
- package/dist/ui/markdown.d.ts +16 -0
- package/dist/ui/markdown.js +26 -2
- package/package.json +5 -2
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Video Generation capability — generate short MP4 videos via the BlockRun
|
|
3
|
+
* /v1/videos/generations endpoint. Uses x402 payment (Base or Solana).
|
|
4
|
+
*
|
|
5
|
+
* Default model `xai/grok-imagine-video` returns an 8-second clip for ~$0.42.
|
|
6
|
+
* The endpoint is synchronous-over-polling: the HTTP connection stays open
|
|
7
|
+
* until the upstream xAI job finishes (typically 20–60s, timeout 180s), so
|
|
8
|
+
* the caller only needs to issue a single POST.
|
|
9
|
+
*/
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { getOrCreateWallet, getOrCreateSolanaWallet, createPaymentPayload, createSolanaPaymentPayload, parsePaymentRequired, extractPaymentDetails, solanaKeyToBytes, SOLANA_NETWORK, } from '@blockrun/llm';
|
|
13
|
+
import { loadChain, API_URLS, VERSION } from '../config.js';
|
|
14
|
+
const DEFAULT_MODEL = 'xai/grok-imagine-video';
|
|
15
|
+
const DEFAULT_DURATION = 8;
|
|
16
|
+
const PRICE_PER_SECOND_USD = 0.05;
|
|
17
|
+
// Long ceiling — the endpoint synchronously waits for xAI's async job (up to
|
|
18
|
+
// ~180s). Give ourselves a bit of headroom for the GCS backup + settle step.
|
|
19
|
+
const GEN_TIMEOUT_MS = 210_000;
|
|
20
|
+
const DOWNLOAD_TIMEOUT_MS = 60_000;
|
|
21
|
+
function estimateVideoCostUsd(durationSeconds = DEFAULT_DURATION) {
|
|
22
|
+
return Math.max(1, durationSeconds) * PRICE_PER_SECOND_USD;
|
|
23
|
+
}
|
|
24
|
+
function buildExecute(deps) {
|
|
25
|
+
return async function execute(input, ctx) {
|
|
26
|
+
const { prompt, output_path, model, image_url, duration_seconds, contentId } = input;
|
|
27
|
+
if (!prompt)
|
|
28
|
+
return { output: 'Error: prompt is required', isError: true };
|
|
29
|
+
const videoModel = model || DEFAULT_MODEL;
|
|
30
|
+
const duration = duration_seconds ?? DEFAULT_DURATION;
|
|
31
|
+
const estCost = estimateVideoCostUsd(duration);
|
|
32
|
+
if (contentId && deps.library) {
|
|
33
|
+
const content = deps.library.get(contentId);
|
|
34
|
+
if (!content) {
|
|
35
|
+
return { output: `Content ${contentId} not found. No USDC was spent.` };
|
|
36
|
+
}
|
|
37
|
+
if (content.spentUsd + estCost > content.budgetUsd + 1e-9) {
|
|
38
|
+
return {
|
|
39
|
+
output: `## Video generation skipped\n` +
|
|
40
|
+
`- Would exceed budget: spent $${content.spentUsd.toFixed(2)} + estimated ` +
|
|
41
|
+
`$${estCost.toFixed(2)} > cap $${content.budgetUsd.toFixed(2)}\n\n` +
|
|
42
|
+
`No USDC was spent.`,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const chain = loadChain();
|
|
47
|
+
const apiUrl = API_URLS[chain];
|
|
48
|
+
const endpoint = `${apiUrl}/v1/videos/generations`;
|
|
49
|
+
const outPath = output_path
|
|
50
|
+
? (path.isAbsolute(output_path) ? output_path : path.resolve(ctx.workingDir, output_path))
|
|
51
|
+
: path.resolve(ctx.workingDir, `generated-${Date.now()}.mp4`);
|
|
52
|
+
const body = JSON.stringify({
|
|
53
|
+
model: videoModel,
|
|
54
|
+
prompt,
|
|
55
|
+
...(image_url ? { image_url } : {}),
|
|
56
|
+
...(duration_seconds ? { duration_seconds } : {}),
|
|
57
|
+
});
|
|
58
|
+
const headers = {
|
|
59
|
+
'Content-Type': 'application/json',
|
|
60
|
+
'User-Agent': `franklin/${VERSION}`,
|
|
61
|
+
};
|
|
62
|
+
const controller = new AbortController();
|
|
63
|
+
const timeout = setTimeout(() => controller.abort(), GEN_TIMEOUT_MS);
|
|
64
|
+
// Abort on user cancel too
|
|
65
|
+
const onAbort = () => controller.abort();
|
|
66
|
+
ctx.abortSignal.addEventListener('abort', onAbort, { once: true });
|
|
67
|
+
try {
|
|
68
|
+
let response = await fetch(endpoint, {
|
|
69
|
+
method: 'POST',
|
|
70
|
+
signal: controller.signal,
|
|
71
|
+
headers,
|
|
72
|
+
body,
|
|
73
|
+
});
|
|
74
|
+
if (response.status === 402) {
|
|
75
|
+
const paymentHeaders = await signPayment(response, chain, endpoint);
|
|
76
|
+
if (!paymentHeaders) {
|
|
77
|
+
return { output: 'Payment failed. Check wallet balance with: franklin balance', isError: true };
|
|
78
|
+
}
|
|
79
|
+
response = await fetch(endpoint, {
|
|
80
|
+
method: 'POST',
|
|
81
|
+
signal: controller.signal,
|
|
82
|
+
headers: { ...headers, ...paymentHeaders },
|
|
83
|
+
body,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
if (!response.ok) {
|
|
87
|
+
const errText = await response.text().catch(() => '');
|
|
88
|
+
return {
|
|
89
|
+
output: `Video generation failed (${response.status}): ${errText.slice(0, 300)}`,
|
|
90
|
+
isError: true,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
const result = (await response.json());
|
|
94
|
+
const videoData = result.data?.[0];
|
|
95
|
+
if (!videoData?.url) {
|
|
96
|
+
return { output: 'No video URL returned from API', isError: true };
|
|
97
|
+
}
|
|
98
|
+
// Download the MP4
|
|
99
|
+
const dlCtrl = new AbortController();
|
|
100
|
+
const dlTimeout = setTimeout(() => dlCtrl.abort(), DOWNLOAD_TIMEOUT_MS);
|
|
101
|
+
const vidResp = await fetch(videoData.url, { signal: dlCtrl.signal });
|
|
102
|
+
clearTimeout(dlTimeout);
|
|
103
|
+
if (!vidResp.ok) {
|
|
104
|
+
return { output: `Video fetched URL but download failed (${vidResp.status}): ${videoData.url}`, isError: true };
|
|
105
|
+
}
|
|
106
|
+
const buffer = Buffer.from(await vidResp.arrayBuffer());
|
|
107
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
108
|
+
fs.writeFileSync(outPath, buffer);
|
|
109
|
+
const fileSize = fs.statSync(outPath).size;
|
|
110
|
+
const sizeMB = (fileSize / 1_048_576).toFixed(1);
|
|
111
|
+
const dur = videoData.duration_seconds ?? duration;
|
|
112
|
+
let contentSummary = '';
|
|
113
|
+
if (contentId && deps.library) {
|
|
114
|
+
const rec = deps.library.addAsset(contentId, {
|
|
115
|
+
kind: 'video',
|
|
116
|
+
source: videoModel,
|
|
117
|
+
costUsd: estimateVideoCostUsd(dur),
|
|
118
|
+
data: outPath,
|
|
119
|
+
});
|
|
120
|
+
if (rec.ok) {
|
|
121
|
+
if (deps.onContentChange)
|
|
122
|
+
await deps.onContentChange();
|
|
123
|
+
const c = deps.library.get(contentId);
|
|
124
|
+
contentSummary =
|
|
125
|
+
`\n\n## Content updated\n` +
|
|
126
|
+
`- Attached to \`${contentId}\` at est. $${estimateVideoCostUsd(dur).toFixed(2)}\n` +
|
|
127
|
+
(c
|
|
128
|
+
? `- Spent: $${c.spentUsd.toFixed(2)} / $${c.budgetUsd.toFixed(2)} cap ` +
|
|
129
|
+
`(remaining $${(c.budgetUsd - c.spentUsd).toFixed(2)})`
|
|
130
|
+
: '');
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
contentSummary =
|
|
134
|
+
`\n\n## Content NOT updated\n` +
|
|
135
|
+
`- ${rec.reason}\n` +
|
|
136
|
+
`- The video was generated and saved locally; cost was NOT recorded ` +
|
|
137
|
+
`against the content budget.`;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
output: `Video saved to ${outPath} (${sizeMB}MB, ${dur}s, ${videoModel})\n\n` +
|
|
142
|
+
`Open with: open ${outPath}${contentSummary}`,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
const msg = err.message || '';
|
|
147
|
+
if (msg.includes('abort')) {
|
|
148
|
+
return {
|
|
149
|
+
output: `Video generation timed out or was aborted (limit ${Math.round(GEN_TIMEOUT_MS / 1000)}s).`,
|
|
150
|
+
isError: true,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
return { output: `Error: ${msg}`, isError: true };
|
|
154
|
+
}
|
|
155
|
+
finally {
|
|
156
|
+
clearTimeout(timeout);
|
|
157
|
+
ctx.abortSignal.removeEventListener('abort', onAbort);
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
// ─── Payment ───────────────────────────────────────────────────────────────
|
|
162
|
+
async function signPayment(response, chain, endpoint) {
|
|
163
|
+
try {
|
|
164
|
+
const paymentHeader = await extractPaymentReq(response);
|
|
165
|
+
if (!paymentHeader)
|
|
166
|
+
return null;
|
|
167
|
+
if (chain === 'solana') {
|
|
168
|
+
const wallet = await getOrCreateSolanaWallet();
|
|
169
|
+
const paymentRequired = parsePaymentRequired(paymentHeader);
|
|
170
|
+
const details = extractPaymentDetails(paymentRequired, SOLANA_NETWORK);
|
|
171
|
+
const secretBytes = await solanaKeyToBytes(wallet.privateKey);
|
|
172
|
+
const feePayer = details.extra?.feePayer || details.recipient;
|
|
173
|
+
const payload = await createSolanaPaymentPayload(secretBytes, wallet.address, details.recipient, details.amount, feePayer, {
|
|
174
|
+
resourceUrl: details.resource?.url || endpoint,
|
|
175
|
+
resourceDescription: details.resource?.description || 'Franklin video generation',
|
|
176
|
+
maxTimeoutSeconds: details.maxTimeoutSeconds || 300,
|
|
177
|
+
extra: details.extra,
|
|
178
|
+
});
|
|
179
|
+
return { 'PAYMENT-SIGNATURE': payload };
|
|
180
|
+
}
|
|
181
|
+
const wallet = getOrCreateWallet();
|
|
182
|
+
const paymentRequired = parsePaymentRequired(paymentHeader);
|
|
183
|
+
const details = extractPaymentDetails(paymentRequired);
|
|
184
|
+
const payload = await createPaymentPayload(wallet.privateKey, wallet.address, details.recipient, details.amount, details.network || 'eip155:8453', {
|
|
185
|
+
resourceUrl: details.resource?.url || endpoint,
|
|
186
|
+
resourceDescription: details.resource?.description || 'Franklin video generation',
|
|
187
|
+
maxTimeoutSeconds: details.maxTimeoutSeconds || 300,
|
|
188
|
+
extra: details.extra,
|
|
189
|
+
});
|
|
190
|
+
return { 'PAYMENT-SIGNATURE': payload };
|
|
191
|
+
}
|
|
192
|
+
catch (err) {
|
|
193
|
+
console.error(`[franklin] Video payment error: ${err.message}`);
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
async function extractPaymentReq(response) {
|
|
198
|
+
let header = response.headers.get('payment-required');
|
|
199
|
+
if (!header) {
|
|
200
|
+
try {
|
|
201
|
+
const body = (await response.json());
|
|
202
|
+
if (body.x402 || body.accepts)
|
|
203
|
+
header = btoa(JSON.stringify(body));
|
|
204
|
+
}
|
|
205
|
+
catch { /* ignore */ }
|
|
206
|
+
}
|
|
207
|
+
return header;
|
|
208
|
+
}
|
|
209
|
+
// ─── Export ────────────────────────────────────────────────────────────────
|
|
210
|
+
export function createVideoGenCapability(deps = {}) {
|
|
211
|
+
return {
|
|
212
|
+
spec: {
|
|
213
|
+
name: 'VideoGen',
|
|
214
|
+
description: "Generate a short MP4 video from a text prompt (optional seed image). " +
|
|
215
|
+
"Calls BlockRun's /v1/videos/generations. Costs USDC — default model " +
|
|
216
|
+
"xai/grok-imagine-video bills $0.05/s (8s default ≈ $0.42). Generation " +
|
|
217
|
+
"takes ~20–60s. ALWAYS confirm with the user before calling — videos " +
|
|
218
|
+
"are expensive and slow. Pass contentId to attach to a Content piece " +
|
|
219
|
+
"(budget is checked before paying; asset is recorded on success).",
|
|
220
|
+
input_schema: {
|
|
221
|
+
type: 'object',
|
|
222
|
+
properties: {
|
|
223
|
+
prompt: { type: 'string', description: 'Text description of the video to generate' },
|
|
224
|
+
output_path: { type: 'string', description: 'Where to save the MP4. Default: generated-<timestamp>.mp4 in working directory' },
|
|
225
|
+
model: { type: 'string', description: 'Video model. Default: xai/grok-imagine-video' },
|
|
226
|
+
image_url: { type: 'string', description: 'Optional seed image URL (image-to-video)' },
|
|
227
|
+
duration_seconds: { type: 'number', description: 'Duration billed for. Default depends on model (8s for grok-imagine-video).' },
|
|
228
|
+
contentId: { type: 'string', description: 'Optional Content id to attach and budget against.' },
|
|
229
|
+
},
|
|
230
|
+
required: ['prompt'],
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
execute: buildExecute(deps),
|
|
234
|
+
concurrent: false,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
export const videoGenCapability = createVideoGenCapability();
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
* - "Am I up or down over the last 30 days?"
|
|
9
9
|
* - "How many times did I flip BTC in the last session?"
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* Coding-only agents can't answer any of these — they have no persistent
|
|
12
|
+
* economic memory across sessions. Franklin can.
|
|
13
13
|
*
|
|
14
14
|
* Format: one JSON object per line, append-only. Reads parse lazily and
|
|
15
15
|
* skip malformed lines rather than crash, so a partial write from a
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
* - "Am I up or down over the last 30 days?"
|
|
9
9
|
* - "How many times did I flip BTC in the last session?"
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* Coding-only agents can't answer any of these — they have no persistent
|
|
12
|
+
* economic memory across sessions. Franklin can.
|
|
13
13
|
*
|
|
14
14
|
* Format: one JSON object per line, append-only. Reads parse lazily and
|
|
15
15
|
* skip malformed lines rather than crash, so a partial write from a
|
package/dist/ui/app.js
CHANGED
|
@@ -9,7 +9,7 @@ import { render, Static, Box, Text, useApp, useInput, useStdout } from 'ink';
|
|
|
9
9
|
import Spinner from 'ink-spinner';
|
|
10
10
|
import TextInput from 'ink-text-input';
|
|
11
11
|
import VimInput from './vim-input.js';
|
|
12
|
-
import { renderMarkdown } from './markdown.js';
|
|
12
|
+
import { renderMarkdown, renderMarkdownStreaming } from './markdown.js';
|
|
13
13
|
import { resolveModel, PICKER_CATEGORIES, PICKER_MODELS_FLAT, } from './model-picker.js';
|
|
14
14
|
import { estimateCost } from '../pricing.js';
|
|
15
15
|
import { formatTokens, shortModelName } from '../stats/format.js';
|
|
@@ -107,6 +107,13 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
|
|
|
107
107
|
const pendingTextRef = useRef('');
|
|
108
108
|
const pendingThinkingRef = useRef('');
|
|
109
109
|
const flushTimerRef = useRef(null);
|
|
110
|
+
// Per-turn reasoning meter: first thinking delta starts the clock, first text
|
|
111
|
+
// delta (or turn_done) stops it. Persists on the committed response as
|
|
112
|
+
// "✻ Thought for 3.2s · ~420 tokens" so users see the cost of reasoning even
|
|
113
|
+
// after the live thinking spinner collapses.
|
|
114
|
+
const thinkStartRef = useRef(null);
|
|
115
|
+
const thinkCharsRef = useRef(0);
|
|
116
|
+
const thinkMsRef = useRef(null);
|
|
110
117
|
const flushPendingText = useCallback(() => {
|
|
111
118
|
flushTimerRef.current = null;
|
|
112
119
|
const text = pendingTextRef.current;
|
|
@@ -115,12 +122,19 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
|
|
|
115
122
|
pendingTextRef.current = '';
|
|
116
123
|
setWaiting(false);
|
|
117
124
|
setThinking(false);
|
|
125
|
+
// Text started: freeze the reasoning meter
|
|
126
|
+
if (thinkStartRef.current !== null && thinkMsRef.current === null) {
|
|
127
|
+
thinkMsRef.current = Date.now() - thinkStartRef.current;
|
|
128
|
+
}
|
|
118
129
|
setStreamText(prev => prev + text);
|
|
119
130
|
}
|
|
120
131
|
if (thinking) {
|
|
121
132
|
pendingThinkingRef.current = '';
|
|
122
133
|
setWaiting(false);
|
|
123
134
|
setThinking(true);
|
|
135
|
+
if (thinkStartRef.current === null)
|
|
136
|
+
thinkStartRef.current = Date.now();
|
|
137
|
+
thinkCharsRef.current += thinking.length;
|
|
124
138
|
setThinkingText(prev => {
|
|
125
139
|
const updated = prev + thinking;
|
|
126
140
|
return updated.length > 500 ? updated.slice(-500) : updated;
|
|
@@ -162,6 +176,12 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
|
|
|
162
176
|
const commitResponse = useCallback((text, tokens = turnTokensRef.current, cost = turnCostRef.current) => {
|
|
163
177
|
if (!text.trim())
|
|
164
178
|
return;
|
|
179
|
+
// Snapshot the thinking meter for this turn (reset happens in turn_done,
|
|
180
|
+
// which covers the empty-response-but-thinking case too)
|
|
181
|
+
const thinkMs = thinkMsRef.current ?? (thinkStartRef.current !== null
|
|
182
|
+
? Date.now() - thinkStartRef.current
|
|
183
|
+
: undefined);
|
|
184
|
+
const thinkChars = thinkCharsRef.current || undefined;
|
|
165
185
|
setCommittedResponses((rs) => {
|
|
166
186
|
const next = [...rs, {
|
|
167
187
|
key: String(Date.now() + Math.random()),
|
|
@@ -171,6 +191,8 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
|
|
|
171
191
|
model: turnModelRef.current,
|
|
172
192
|
tier: turnTierRef.current,
|
|
173
193
|
savings: turnSavingsRef.current,
|
|
194
|
+
thinkMs,
|
|
195
|
+
thinkChars,
|
|
174
196
|
}];
|
|
175
197
|
// Cap at 300 items — older items are already in terminal scrollback
|
|
176
198
|
return next.length > 300 ? next.slice(-300) : next;
|
|
@@ -597,6 +619,10 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
|
|
|
597
619
|
pendingTextRef.current = '';
|
|
598
620
|
}
|
|
599
621
|
pendingThinkingRef.current = '';
|
|
622
|
+
// Freeze reasoning meter if turn ended while thinking (no text emitted)
|
|
623
|
+
if (thinkStartRef.current !== null && thinkMsRef.current === null) {
|
|
624
|
+
thinkMsRef.current = Date.now() - thinkStartRef.current;
|
|
625
|
+
}
|
|
600
626
|
// Flush expandable tool to Static before committing response
|
|
601
627
|
setExpandableTool(prev => {
|
|
602
628
|
if (prev)
|
|
@@ -625,6 +651,10 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
|
|
|
625
651
|
setWaiting(false);
|
|
626
652
|
setThinking(false);
|
|
627
653
|
setThinkingText('');
|
|
654
|
+
// Reset reasoning meter for the next turn
|
|
655
|
+
thinkStartRef.current = null;
|
|
656
|
+
thinkMsRef.current = null;
|
|
657
|
+
thinkCharsRef.current = 0;
|
|
628
658
|
// Trigger balance refresh after each completed turn
|
|
629
659
|
turnDoneCallbackRef.current?.();
|
|
630
660
|
// Ring the terminal bell so the user knows the AI finished
|
|
@@ -670,7 +700,9 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
|
|
|
670
700
|
: _jsx(Text, { color: "green", children: "\u2713" }), ' ', _jsx(Text, { bold: true, children: tool.name }), tool.preview ? _jsxs(Text, { dimColor: true, children: ["(", tool.preview.slice(0, 80), ")"] }) : null, _jsxs(Text, { dimColor: true, children: [" ", elapsedFmt] })] }), tool.diff && !tool.error && tool.diff.oldLines.length <= 8 && tool.diff.newLines.length <= 8 && (_jsxs(Box, { flexDirection: "column", marginLeft: 2, children: [tool.diff.oldLines.map((line, i) => (_jsxs(Text, { color: "red", wrap: "truncate-end", children: ['⎿ ', "- ", line.slice(0, 120)] }, `old-${i}`))), tool.diff.newLines.map((line, i) => (_jsxs(Text, { color: "green", wrap: "truncate-end", children: ['⎿ ', "+ ", line.slice(0, 120)] }, `new-${i}`)))] })), tool.diff && !tool.error && (tool.diff.oldLines.length > 8 || tool.diff.newLines.length > 8) && (_jsx(Box, { marginLeft: 2, children: _jsxs(Text, { dimColor: true, children: ['⎿ ', tool.diff.oldLines.length, " lines \u2192 ", tool.diff.newLines.length, " lines"] }) })), tool.error && tool.fullOutput && (_jsx(Box, { flexDirection: "column", marginLeft: 2, children: tool.fullOutput.split('\n').filter(Boolean).slice(0, 3).map((line, i) => (_jsxs(Text, { color: "red", wrap: "truncate-end", children: ['⎿ ', line.slice(0, 120)] }, i))) }))] }, tool.key));
|
|
671
701
|
} }), _jsx(Static, { items: committedResponses, children: (r) => {
|
|
672
702
|
const isUserMsg = r.key.startsWith('user-');
|
|
673
|
-
return (_jsxs(Box, { flexDirection: "column", children: [!isUserMsg && (r.tokens.input > 0 || r.tokens.output > 0) && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: '─'.repeat(60) }) })), isUserMsg && (_jsx(Box, { marginTop: 1 })),
|
|
703
|
+
return (_jsxs(Box, { flexDirection: "column", children: [!isUserMsg && (r.tokens.input > 0 || r.tokens.output > 0) && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: '─'.repeat(60) }) })), isUserMsg && (_jsx(Box, { marginTop: 1 })), !isUserMsg && r.thinkMs !== undefined && r.thinkMs >= 500 && (_jsx(Box, { paddingLeft: 2, children: _jsxs(Text, { color: "magenta", dimColor: true, children: ["\u273B Thought for ", (r.thinkMs / 1000).toFixed(1), "s", r.thinkChars && r.thinkChars > 20
|
|
704
|
+
? ` · ~${Math.round(r.thinkChars / 4)} tokens`
|
|
705
|
+
: ''] }) })), _jsx(Box, { paddingLeft: isUserMsg ? 0 : 2, children: _jsx(Text, { wrap: "wrap", children: renderMarkdown(r.text) }) }), (r.tokens.input > 0 || r.tokens.output > 0) && (_jsx(Box, { marginLeft: 1, marginBottom: 1, children: _jsxs(Text, { dimColor: true, children: [r.tier && _jsxs(Text, { color: "cyan", children: ["[", r.tier, "] "] }), r.model ? shortModelName(r.model) : '', r.model ? ' · ' : '', r.tokens.calls > 0 && r.tokens.input === 0
|
|
674
706
|
? `${r.tokens.calls} calls`
|
|
675
707
|
: `${formatTokens(r.tokens.input)} in / ${formatTokens(r.tokens.output)} out`, r.cost > 0 ? ` · $${r.cost.toFixed(4)}` : '', r.savings !== undefined && r.savings > 0 ? _jsxs(Text, { color: "green", children: [" saved ", Math.round(r.savings * 100), "%"] }) : ''] }) }))] }, r.key));
|
|
676
708
|
} }), permissionRequest && (_jsxs(Box, { flexDirection: "column", marginTop: 1, marginLeft: 1, children: [_jsx(Text, { color: "yellow", children: " \u256D\u2500 Permission required \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" }), _jsxs(Text, { color: "yellow", children: [" \u2502 ", _jsx(Text, { bold: true, children: permissionRequest.toolName })] }), permissionRequest.description.split('\n').map((line, i) => (_jsxs(Text, { dimColor: true, children: [" \u2502 ", line] }, i))), _jsx(Text, { color: "yellow", children: " \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" }), _jsx(Box, { marginLeft: 3, children: _jsxs(Text, { children: [_jsx(Text, { bold: true, color: "green", children: "[y]" }), _jsx(Text, { dimColor: true, children: " yes " }), _jsx(Text, { bold: true, color: "cyan", children: "[a]" }), _jsx(Text, { dimColor: true, children: " always " }), _jsx(Text, { bold: true, color: "red", children: "[n]" }), _jsx(Text, { dimColor: true, children: " no" })] }) })] })), askUserRequest && (_jsxs(Box, { flexDirection: "column", marginTop: 1, marginLeft: 1, children: [_jsx(Text, { color: "cyan", children: " \u256D\u2500 Question \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" }), _jsxs(Text, { color: "cyan", children: [" \u2502 ", _jsx(Text, { bold: true, children: askUserRequest.question })] }), askUserRequest.options && askUserRequest.options.length > 0 && (askUserRequest.options.map((opt, i) => (_jsxs(Text, { dimColor: true, children: [" \u2502 ", i + 1, ". ", opt] }, i)))), _jsx(Text, { color: "cyan", children: " \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" }), _jsxs(Box, { marginLeft: 3, children: [_jsx(Text, { bold: true, children: "answer> " }), _jsx(TextInput, { value: askUserInput, onChange: setAskUserInput, onSubmit: (val) => {
|
|
@@ -693,7 +725,10 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
|
|
|
693
725
|
}), thinking && (_jsxs(Box, { flexDirection: "column", marginLeft: 2, children: [_jsxs(Text, { color: "magenta", children: [_jsx(Spinner, { type: "dots" }), ' ', _jsx(Text, { bold: true, children: "thinking" }), completedTools.length > 0 ? _jsxs(Text, { dimColor: true, children: [' ', "\u00B7 step ", completedTools.length + 1] }) : null] }), process.env.FRANKLIN_SHOW_THINKING === '1' && thinkingText && (() => {
|
|
694
726
|
const lines = thinkingText.split('\n').filter(Boolean).slice(-3);
|
|
695
727
|
return (_jsx(Box, { flexDirection: "column", marginLeft: 2, children: lines.map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ['⎿ ', line.slice(0, 120)] }, i))) }));
|
|
696
|
-
})()] })), waiting && !thinking && tools.size === 0 && (_jsx(Box, { marginLeft: 2, children: _jsxs(Text, { color: "yellow", children: [_jsx(Spinner, { type: "dots" }), ' ', _jsxs(Text, { dimColor: true, children: [shortModelName(currentModel), completedTools.length > 0 ? ` · step ${completedTools.length + 1}` : ''] })] }) })), streamText && (
|
|
728
|
+
})()] })), waiting && !thinking && tools.size === 0 && (_jsx(Box, { marginLeft: 2, children: _jsxs(Text, { color: "yellow", children: [_jsx(Spinner, { type: "dots" }), ' ', _jsxs(Text, { dimColor: true, children: [shortModelName(currentModel), completedTools.length > 0 ? ` · step ${completedTools.length + 1}` : ''] })] }) })), streamText && (() => {
|
|
729
|
+
const { rendered, partial } = renderMarkdownStreaming(streamText);
|
|
730
|
+
return (_jsx(Box, { marginTop: 0, marginBottom: 0, children: _jsxs(Text, { wrap: "wrap", children: [rendered, rendered && partial ? '\n' : '', partial] }) }));
|
|
731
|
+
})(), responsePreview && !streamText && (_jsx(Box, { flexDirection: "column", marginBottom: 0, children: _jsx(Text, { wrap: "wrap", children: renderMarkdown(responsePreview) }) })), inPicker && (() => {
|
|
697
732
|
let flatIdx = 0;
|
|
698
733
|
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { marginLeft: 2, children: [_jsx(Text, { bold: true, children: "Select a model " }), _jsx(Text, { dimColor: true, children: "(\u2191\u2193 navigate, Enter select, Esc cancel)" })] }), PICKER_CATEGORIES.map((cat) => (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Box, { marginLeft: 2, children: _jsxs(Text, { dimColor: true, children: ["\u2500\u2500 ", cat.category, " \u2500\u2500"] }) }), cat.models.map((m) => {
|
|
699
734
|
const myIdx = flatIdx++;
|
package/dist/ui/markdown.d.ts
CHANGED
|
@@ -9,6 +9,22 @@
|
|
|
9
9
|
* - Nested blockquotes
|
|
10
10
|
* - Task lists (- [x] done, - [ ] todo)
|
|
11
11
|
*/
|
|
12
|
+
/**
|
|
13
|
+
* Render markdown for a *streaming* buffer: everything up to the last newline
|
|
14
|
+
* is treated as complete and rendered with full inline formatting; the
|
|
15
|
+
* trailing partial line is returned as plain text.
|
|
16
|
+
*
|
|
17
|
+
* Why this exists: running `renderInline` over a half-written `**bold`,
|
|
18
|
+
* ``code`` , or `[link](` pair produces broken/unbalanced ANSI, which Ink's
|
|
19
|
+
* word-wrap then mangles around the wrap boundary (observed as `1mMusic` /
|
|
20
|
+
* `[Epidemic Sound (https://…)` in terminal output). Keeping the unfinished
|
|
21
|
+
* line plain until a newline arrives avoids the mid-regex failure mode with
|
|
22
|
+
* zero latency penalty — the partial line re-renders on the very next delta.
|
|
23
|
+
*/
|
|
24
|
+
export declare function renderMarkdownStreaming(text: string): {
|
|
25
|
+
rendered: string;
|
|
26
|
+
partial: string;
|
|
27
|
+
};
|
|
12
28
|
/**
|
|
13
29
|
* Render a complete markdown string to ANSI-colored terminal output.
|
|
14
30
|
*/
|
package/dist/ui/markdown.js
CHANGED
|
@@ -26,6 +26,27 @@ const LANG_LABELS = {
|
|
|
26
26
|
swift: 'Swift', kt: 'Kotlin', kotlin: 'Kotlin',
|
|
27
27
|
tsx: 'TSX', jsx: 'JSX',
|
|
28
28
|
};
|
|
29
|
+
/**
|
|
30
|
+
* Render markdown for a *streaming* buffer: everything up to the last newline
|
|
31
|
+
* is treated as complete and rendered with full inline formatting; the
|
|
32
|
+
* trailing partial line is returned as plain text.
|
|
33
|
+
*
|
|
34
|
+
* Why this exists: running `renderInline` over a half-written `**bold`,
|
|
35
|
+
* ``code`` , or `[link](` pair produces broken/unbalanced ANSI, which Ink's
|
|
36
|
+
* word-wrap then mangles around the wrap boundary (observed as `1mMusic` /
|
|
37
|
+
* `[Epidemic Sound (https://…)` in terminal output). Keeping the unfinished
|
|
38
|
+
* line plain until a newline arrives avoids the mid-regex failure mode with
|
|
39
|
+
* zero latency penalty — the partial line re-renders on the very next delta.
|
|
40
|
+
*/
|
|
41
|
+
export function renderMarkdownStreaming(text) {
|
|
42
|
+
const lastNl = text.lastIndexOf('\n');
|
|
43
|
+
if (lastNl === -1)
|
|
44
|
+
return { rendered: '', partial: text };
|
|
45
|
+
return {
|
|
46
|
+
rendered: renderMarkdown(text.slice(0, lastNl)),
|
|
47
|
+
partial: text.slice(lastNl + 1),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
29
50
|
/**
|
|
30
51
|
* Render a complete markdown string to ANSI-colored terminal output.
|
|
31
52
|
*/
|
|
@@ -148,6 +169,9 @@ function renderInline(text) {
|
|
|
148
169
|
.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, (_, t) => chalk.italic(t))
|
|
149
170
|
// Strikethrough
|
|
150
171
|
.replace(/~~([^~]+)~~/g, (_, t) => chalk.strikethrough(t))
|
|
151
|
-
// Links — show label in blue, URL dimmed
|
|
152
|
-
|
|
172
|
+
// Links — show label in blue, URL dimmed. URL must not contain parens or
|
|
173
|
+
// whitespace; that's true for almost every real URL (parens in URLs are
|
|
174
|
+
// percent-encoded) and rejecting the pathological case keeps the regex
|
|
175
|
+
// greed from eating past a `)` in adjacent prose.
|
|
176
|
+
.replace(/\[([^\]]+)\]\(([^()\s]+)\)/g, (_, label, url) => chalk.blue.underline(label) + chalk.dim(` (${url})`));
|
|
153
177
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blockrun/franklin",
|
|
3
|
-
"version": "3.8.
|
|
3
|
+
"version": "3.8.3",
|
|
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": {
|
|
@@ -52,7 +52,10 @@
|
|
|
52
52
|
"license": "Apache-2.0",
|
|
53
53
|
"repository": {
|
|
54
54
|
"type": "git",
|
|
55
|
-
"url": "https://
|
|
55
|
+
"url": "https://gitlab.com/blockrunai/franklin"
|
|
56
|
+
},
|
|
57
|
+
"bugs": {
|
|
58
|
+
"url": "https://gitlab.com/blockrunai/franklin/-/issues"
|
|
56
59
|
},
|
|
57
60
|
"homepage": "https://Franklin.run",
|
|
58
61
|
"engines": {
|