@blockrun/franklin 3.21.3 → 3.21.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -33,12 +33,44 @@ function isCommonDevCommand(cmd) {
33
33
  return COMMON_DEV_PATTERNS.some(p => p.test(trimmed));
34
34
  }
35
35
  // ─── Default Rules ─────────────────────────────────────────────────────────
36
- const READ_ONLY_TOOLS = new Set(['Read', 'Glob', 'Grep', 'WebSearch', 'Task', 'AskUser', 'ActivateTool', 'ImageGen', 'TradingSignal', 'TradingMarket', 'SearchX', 'BrowserX']);
36
+ const READ_ONLY_TOOLS = new Set([
37
+ 'Read', 'Glob', 'Grep', 'WebSearch', 'Task', 'AskUser', 'ActivateTool',
38
+ 'ImageGen', 'TradingSignal', 'TradingMarket', 'SearchX', 'BrowserX',
39
+ // Phone & Voice — side-effect-free queries. None of these dial anyone,
40
+ // hold a phone number, or mutate gateway state. ListPhoneNumbers is a
41
+ // cached read ($0.001), VoiceStatus is a free GET poll on an existing
42
+ // call, PhoneLookup / PhoneFraudCheck are pure metadata lookups.
43
+ // Pricing here is orthogonal to side-effect category — WebSearch /
44
+ // ImageGen also cost money but live here because they don't change the
45
+ // world outside the gateway.
46
+ 'VoiceStatus',
47
+ 'ListPhoneNumbers',
48
+ 'PhoneLookup',
49
+ 'PhoneFraudCheck',
50
+ ]);
37
51
  const DESTRUCTIVE_TOOLS = new Set(['Write', 'Edit', 'Bash']);
38
52
  const DEFAULT_RULES = {
39
- allow: ['Read', 'Glob', 'Grep', 'WebSearch', 'WebFetch', 'Task', 'AskUser', 'ActivateTool', 'ImageGen', 'TradingSignal', 'TradingMarket', 'SearchX', 'BrowserX'],
53
+ allow: [
54
+ 'Read', 'Glob', 'Grep', 'WebSearch', 'WebFetch', 'Task', 'AskUser',
55
+ 'ActivateTool', 'ImageGen', 'TradingSignal', 'TradingMarket', 'SearchX',
56
+ 'BrowserX',
57
+ // See READ_ONLY_TOOLS above for the side-effect-free rationale.
58
+ 'VoiceStatus', 'ListPhoneNumbers', 'PhoneLookup', 'PhoneFraudCheck',
59
+ ],
40
60
  deny: [],
41
- ask: ['Write', 'Edit', 'Bash', 'Agent', 'PostToX'],
61
+ ask: [
62
+ 'Write', 'Edit', 'Bash', 'Agent', 'PostToX',
63
+ // Phone & Voice — real-world side effects. VoiceCall dials a real human
64
+ // ($0.54). BuyPhoneNumber / RenewPhoneNumber hold a real Twilio number
65
+ // for 30 days ($5). ReleasePhoneNumber permanently returns the number
66
+ // to the pool (free but irreversible — user could lose a number they
67
+ // care about). All four must prompt every time, matching the
68
+ // Write/Edit/Bash policy: any agent-initiated real-world action goes
69
+ // through explicit user consent. Without this, the agent silently
70
+ // dials people / spends $5 on phone numbers / releases numbers the
71
+ // user is using — no recovery path.
72
+ 'VoiceCall', 'BuyPhoneNumber', 'RenewPhoneNumber', 'ReleasePhoneNumber',
73
+ ],
42
74
  };
43
75
  // ─── Permission Manager ────────────────────────────────────────────────────
44
76
  export class PermissionManager {
@@ -73,17 +73,15 @@ VoiceCall({
73
73
  })
74
74
  ```
75
75
 
76
- Tool returns a `call_id` immediately. Surface it to the user along with "polling now."
76
+ Tool returns a `call_id` immediately. Surface it to the user along with "waiting for call to complete."
77
77
 
78
- ### 6 · Auto-poll status
78
+ ### 6 · Wait for completion
79
79
 
80
- Loop, every ~30 seconds, calling `VoiceStatus({ call_id })`:
80
+ Call `VoiceStatus({ call_id })` **once**. The tool blocks internally — it polls the gateway every 5 seconds for up to 35 minutes until the call reaches a terminal state (`completed` / `failed` / `cancelled` / `busy` / `no-answer` / `voicemail`), then returns the final transcript.
81
81
 
82
- - If status is `queued` or `in_progress` continue.
83
- - If status is one of `completed` / `failed` / `cancelled` / `busy` / `no-answer` / `voicemail` → stop polling.
84
- - Cap the loop at ~10 minutes total (20 polls). If you hit the cap, tell the user the call is still running and they can rerun `VoiceStatus call_id="…"` later.
82
+ **Do not loop VoiceStatus.** Franklin's signature-loop guard kills the turn after 5 identical inputs. One VoiceStatus call is sufficient the tool drives the poll cadence itself.
85
83
 
86
- VoiceStatus is **free** — poll as often as needed.
84
+ VoiceStatus is **free** — no USDC charged on polling.
87
85
 
88
86
  ### 7 · Surface the result
89
87
 
@@ -306,13 +306,73 @@ export const voiceCallCapability = {
306
306
  }
307
307
  },
308
308
  };
309
+ /** Statuses that mean the call has reached a final outcome (one of completed,
310
+ * failed, no-answer, busy, voicemail). Anything else (queued / ringing /
311
+ * in-progress / etc.) means the call is still running and we should keep
312
+ * polling. */
313
+ const VOICE_TERMINAL_STATUSES = new Set([
314
+ 'completed', 'failed', 'no-answer', 'busy', 'voicemail',
315
+ 'cancelled', 'no_answer', // Bland upstream uses both spellings
316
+ ]);
317
+ /** Poll cadence + ceiling. max_duration is capped at 30 min upstream, so
318
+ * 35 min is enough headroom even for the longest call to either complete
319
+ * or get force-cut by Bland. 5 s interval matches videogen.ts pattern. */
320
+ const VOICE_POLL_INTERVAL_MS = 5_000;
321
+ const VOICE_POLL_MAX_WAIT_MS = 35 * 60 * 1000;
322
+ async function voiceSleep(ms, signal) {
323
+ return new Promise((resolve, reject) => {
324
+ if (signal.aborted)
325
+ return reject(new Error('aborted'));
326
+ const t = setTimeout(() => {
327
+ signal.removeEventListener('abort', onAbort);
328
+ resolve();
329
+ }, ms);
330
+ const onAbort = () => {
331
+ clearTimeout(t);
332
+ reject(new Error('aborted'));
333
+ };
334
+ signal.addEventListener('abort', onAbort, { once: true });
335
+ });
336
+ }
337
+ /** Write whatever the gateway gave us back into the local CallLog so the
338
+ * panel Calls tab + future agent reads stay current. Append-only; latest
339
+ * row wins on read. Best-effort — journal write failures don't bubble. */
340
+ function patchCallJournal(callId, res) {
341
+ try {
342
+ const prior = callLog().byCallId(callId);
343
+ if (!prior)
344
+ return;
345
+ const recording = typeof res.recording_url === 'string' ? res.recording_url :
346
+ typeof res.recording === 'string' ? res.recording : undefined;
347
+ const duration = typeof res.call_length === 'number' ? Math.round(res.call_length) :
348
+ typeof res.duration === 'number' ? Math.round(res.duration) :
349
+ typeof res.duration_sec === 'number' ? Math.round(res.duration_sec) : undefined;
350
+ const transcript = typeof res.concatenated_transcript === 'string' ? res.concatenated_transcript :
351
+ typeof res.transcript === 'string' ? res.transcript : undefined;
352
+ callLog().append({
353
+ ...prior,
354
+ timestamp: Date.now(),
355
+ paid_usd: prior.paid_usd, // status polls are free; preserve per-call total
356
+ status: normalizeStatus(res.status ?? res.queue_status ?? res.disposition),
357
+ duration_sec: duration ?? prior.duration_sec,
358
+ transcript: transcript ?? prior.transcript,
359
+ recording_url: recording ?? prior.recording_url,
360
+ });
361
+ }
362
+ catch { /* best-effort */ }
363
+ }
309
364
  export const voiceStatusCapability = {
310
365
  spec: {
311
366
  name: 'VoiceStatus',
312
- description: 'Poll a previously-initiated voice call for its current status, transcript, recording URL, ' +
313
- 'and final disposition (completed / failed / no-answer / busy / voicemail). Free — no USDC ' +
314
- 'charged. Use the call_id returned by VoiceCall. Call this every 30–60 s until status is ' +
315
- 'a terminal state.',
367
+ description: 'Wait for a previously-initiated voice call to complete, then return ' +
368
+ 'the final status, transcript, recording URL, and disposition ' +
369
+ '(completed / failed / no-answer / busy / voicemail). Free no USDC ' +
370
+ 'charged. Use the call_id returned by VoiceCall.\n\n' +
371
+ 'CALL THIS ONCE. The tool blocks internally, polling the gateway every ' +
372
+ '5 s for up to 35 min until the call reaches a terminal state. Do NOT ' +
373
+ 'invoke VoiceStatus repeatedly in a loop — Franklin\'s signature-loop ' +
374
+ 'guard will kill the turn after 5 identical inputs. A single call is ' +
375
+ 'sufficient.',
316
376
  input_schema: {
317
377
  type: 'object',
318
378
  properties: {
@@ -328,40 +388,49 @@ export const voiceStatusCapability = {
328
388
  if (typeof input.call_id !== 'string') {
329
389
  return { output: 'call_id required', isError: true };
330
390
  }
331
- try {
332
- const res = await getNoPayment(`/v1/voice/call/${encodeURIComponent(input.call_id)}`, ctx, { tool: 'VoiceStatus', priceUsd: 0 });
333
- // Patch the local journal with whatever fields the gateway returned —
334
- // transcript / recording / duration / disposition. Append-only schema
335
- // means we just write a new row; CallLog.summary() picks the latest.
391
+ const callId = input.call_id;
392
+ const deadline = Date.now() + VOICE_POLL_MAX_WAIT_MS;
393
+ // Internal poll-until-terminal loop mirrors videogen.ts pollUntilReady
394
+ // and imagegen.ts pollImageJob. The agent emits one VoiceStatus tool_use
395
+ // and gets back the final transcript when the call ends. Without this
396
+ // loop the agent has to drive the poll cadence itself and will trip the
397
+ // signature-loop guard at 5 identical inputs.
398
+ let lastRes = null;
399
+ while (Date.now() < deadline) {
400
+ if (ctx.abortSignal.aborted) {
401
+ return { output: 'VoiceStatus aborted by user', isError: true };
402
+ }
336
403
  try {
337
- const prior = callLog().byCallId(input.call_id);
338
- if (prior) {
339
- const recording = typeof res.recording_url === 'string' ? res.recording_url :
340
- typeof res.recording === 'string' ? res.recording : undefined;
341
- const duration = typeof res.call_length === 'number' ? Math.round(res.call_length) :
342
- typeof res.duration === 'number' ? Math.round(res.duration) :
343
- typeof res.duration_sec === 'number' ? Math.round(res.duration_sec) : undefined;
344
- const transcript = typeof res.concatenated_transcript === 'string' ? res.concatenated_transcript :
345
- typeof res.transcript === 'string' ? res.transcript : undefined;
346
- callLog().append({
347
- ...prior,
348
- timestamp: Date.now(),
349
- paid_usd: prior.paid_usd, // status polls are free; preserve the per-call total
350
- status: normalizeStatus(res.status ?? res.queue_status ?? res.disposition),
351
- duration_sec: duration ?? prior.duration_sec,
352
- transcript: transcript ?? prior.transcript,
353
- recording_url: recording ?? prior.recording_url,
354
- });
355
- }
404
+ lastRes = await getNoPayment(`/v1/voice/call/${encodeURIComponent(callId)}`, ctx, { tool: 'VoiceStatus', priceUsd: 0 });
405
+ }
406
+ catch (err) {
407
+ return { output: `VoiceStatus failed: ${err.message}`, isError: true };
408
+ }
409
+ patchCallJournal(callId, lastRes);
410
+ const status = String(lastRes.status ?? lastRes.queue_status ?? '').toLowerCase();
411
+ if (VOICE_TERMINAL_STATUSES.has(status)) {
412
+ return {
413
+ output: `## Voice call status (terminal: ${status})\n\n` +
414
+ '```json\n' + JSON.stringify(lastRes, null, 2) + '\n```',
415
+ };
416
+ }
417
+ try {
418
+ await voiceSleep(VOICE_POLL_INTERVAL_MS, ctx.abortSignal);
419
+ }
420
+ catch {
421
+ return { output: 'VoiceStatus aborted by user', isError: true };
356
422
  }
357
- catch { /* best-effort */ }
358
- return {
359
- output: `## Voice call status\n\n` +
360
- '```json\n' + JSON.stringify(res, null, 2) + '\n```',
361
- };
362
- }
363
- catch (err) {
364
- return { output: `VoiceStatus failed: ${err.message}`, isError: true };
365
423
  }
424
+ // Hit the 35-min ceiling without seeing a terminal state — return the
425
+ // latest snapshot we have so the agent + journal still have partial
426
+ // context, but flag it as still in progress.
427
+ return {
428
+ output: `## Voice call status (still in progress after ${Math.round(VOICE_POLL_MAX_WAIT_MS / 60_000)} min)\n\n` +
429
+ `Bland.ai upstream caps any single call at 30 min, so a call this long ` +
430
+ `is unusual — likely an upstream stall. Ask the user before reinvoking ` +
431
+ `VoiceStatus (would burn another full poll cycle).\n\n` +
432
+ '```json\n' + JSON.stringify(lastRes ?? {}, null, 2) + '\n```',
433
+ isError: true,
434
+ };
366
435
  },
367
436
  };
package/dist/ui/app.js CHANGED
@@ -23,6 +23,13 @@ const DISABLE_BRACKETED_PASTE = '\x1b[?2004l';
23
23
  const USER_PROMPT_COLOR = '#FFD700';
24
24
  const PASTE_BLOCK_START = '\uE000PASTE:';
25
25
  const PASTE_BLOCK_END = ':PASTE\uE001';
26
+ // Only collapse pastes of >= this many lines into a [Pasted ~N lines] block.
27
+ // Short pastes (one-liners, 2-4 line snippets) inline as plain text so the
28
+ // model sees them verbatim and the user can read what they pasted in the
29
+ // input box. 5 lines is a sweet spot — long enough to skip multi-line code
30
+ // dumps and log tails, short enough that ordinary prose pastes still show
31
+ // inline.
32
+ const PASTE_COLLAPSE_LINE_THRESHOLD = 5;
26
33
  const DISABLE_AUTO_WRAP = '\x1b[?7l';
27
34
  const ENABLE_AUTO_WRAP = '\x1b[?7h';
28
35
  function stripPasteMarkers(input) {
@@ -193,7 +200,11 @@ function PromptTextInput({ value, onChange, onSubmit, placeholder = '', focus =
193
200
  pasteBufferRef.current += text;
194
201
  if (!hasPasteEnd)
195
202
  return;
196
- text = encodePasteBlock(pasteBufferRef.current);
203
+ const buffered = pasteBufferRef.current;
204
+ const lineCount = buffered.length === 0 ? 0 : buffered.split('\n').length;
205
+ text = lineCount >= PASTE_COLLAPSE_LINE_THRESHOLD
206
+ ? encodePasteBlock(buffered)
207
+ : buffered;
197
208
  pasteBufferRef.current = '';
198
209
  pasteActiveRef.current = false;
199
210
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockrun/franklin",
3
- "version": "3.21.3",
3
+ "version": "3.21.5",
4
4
  "description": "Franklin Agent — 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": {