@blockrun/franklin 3.8.2 → 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.
Files changed (46) hide show
  1. package/README.md +23 -36
  2. package/dist/agent/commands.js +1 -1
  3. package/dist/agent/llm.d.ts +6 -0
  4. package/dist/agent/llm.js +103 -14
  5. package/dist/agent/loop.d.ts +9 -0
  6. package/dist/agent/loop.js +85 -0
  7. package/dist/agent/think-tag-stripper.d.ts +27 -0
  8. package/dist/agent/think-tag-stripper.js +75 -0
  9. package/dist/agent/tokens.js +2 -1
  10. package/dist/agent/types.d.ts +7 -0
  11. package/dist/brain/index.d.ts +1 -1
  12. package/dist/brain/index.js +1 -1
  13. package/dist/brain/store.d.ts +13 -1
  14. package/dist/brain/store.js +74 -5
  15. package/dist/channel/telegram.d.ts +46 -0
  16. package/dist/channel/telegram.js +367 -0
  17. package/dist/commands/migrate.d.ts +5 -3
  18. package/dist/commands/migrate.js +17 -15
  19. package/dist/commands/stats.js +1 -1
  20. package/dist/commands/telegram.d.ts +15 -0
  21. package/dist/commands/telegram.js +95 -0
  22. package/dist/content/library.js +2 -2
  23. package/dist/index.js +9 -0
  24. package/dist/panel/html.js +1 -1
  25. package/dist/router/index.js +5 -5
  26. package/dist/session/storage.d.ts +12 -0
  27. package/dist/session/storage.js +11 -0
  28. package/dist/social/ai.d.ts +3 -2
  29. package/dist/social/ai.js +3 -2
  30. package/dist/stats/insights.d.ts +1 -1
  31. package/dist/stats/tracker.js +1 -1
  32. package/dist/tools/content-execute.d.ts +1 -1
  33. package/dist/tools/content-execute.js +1 -1
  34. package/dist/tools/index.js +11 -3
  35. package/dist/tools/memory.d.ts +16 -0
  36. package/dist/tools/memory.js +86 -0
  37. package/dist/tools/trading-execute.d.ts +2 -2
  38. package/dist/tools/trading-execute.js +2 -2
  39. package/dist/tools/videogen.d.ts +17 -0
  40. package/dist/tools/videogen.js +237 -0
  41. package/dist/trading/trade-log.d.ts +2 -2
  42. package/dist/trading/trade-log.js +2 -2
  43. package/dist/ui/app.js +38 -3
  44. package/dist/ui/markdown.d.ts +16 -0
  45. package/dist/ui/markdown.js +26 -2
  46. 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
- * Claude Code and Cursor can't answer any of these — they have no
12
- * persistent economic memory across sessions. Franklin can.
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
- * Claude Code and Cursor can't answer any of these — they have no
12
- * persistent economic memory across sessions. Franklin can.
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 })), _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
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 && (_jsx(Box, { marginTop: 0, marginBottom: 0, children: _jsx(Text, { wrap: "wrap", children: renderMarkdown(streamText) }) })), responsePreview && !streamText && (_jsx(Box, { flexDirection: "column", marginBottom: 0, children: _jsx(Text, { wrap: "wrap", children: renderMarkdown(responsePreview) }) })), inPicker && (() => {
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++;
@@ -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
  */
@@ -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
- .replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => chalk.blue.underline(label) + chalk.dim(` (${url})`));
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.2",
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://github.com/BlockRunAI/franklin"
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": {