@blockrun/franklin 3.21.2 → 3.21.4

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 {
@@ -1990,6 +1990,22 @@ function formatDuration(sec) {
1990
1990
  return m > 0 ? m + 'm ' + s + 's' : s + 's';
1991
1991
  }
1992
1992
 
1993
+ function escapeHtml(value) {
1994
+ return String(value == null ? '' : value).replace(/[&<>"']/g, ch => ({
1995
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
1996
+ }[ch]));
1997
+ }
1998
+
1999
+ function safeHttpUrl(value) {
2000
+ if (typeof value !== 'string') return '';
2001
+ try {
2002
+ const u = new URL(value);
2003
+ return (u.protocol === 'http:' || u.protocol === 'https:') ? u.href : '';
2004
+ } catch (e) {
2005
+ return '';
2006
+ }
2007
+ }
2008
+
1993
2009
  function renderCallsList(calls) {
1994
2010
  const list = document.getElementById('calls-list');
1995
2011
  if (!list) return;
@@ -2007,13 +2023,14 @@ function renderCallsList(calls) {
2007
2023
  const fromHuman = formatPhoneNumber(c.from);
2008
2024
  const when = new Date(c.timestamp).toLocaleString();
2009
2025
  const cost = c.paid_usd ? '$' + c.paid_usd.toFixed(2) : '—';
2010
- const safeTask = (c.task || '').replace(/[<>&]/g, ch => ({'<':'&lt;','>':'&gt;','&':'&amp;'}[ch]));
2026
+ const safeTask = escapeHtml(c.task || '');
2027
+ const recordingUrl = safeHttpUrl(c.recording_url);
2011
2028
  const transcriptHtml = c.transcript
2012
2029
  ? '<details style="margin-top:8px"><summary style="cursor:pointer;color:var(--text-dim);font-size:12px">Transcript</summary><pre style="white-space:pre-wrap;background:oklch(0 0 0 / 25%);padding:10px;border-radius:8px;font-size:12px;margin-top:6px;max-height:400px;overflow:auto">' +
2013
- c.transcript.replace(/[<>&]/g, ch => ({'<':'&lt;','>':'&gt;','&':'&amp;'}[ch])) + '</pre></details>'
2030
+ escapeHtml(c.transcript) + '</pre></details>'
2014
2031
  : '';
2015
- const recordingHtml = c.recording_url
2016
- ? '<a href="' + c.recording_url + '" target="_blank" rel="noopener" style="font-size:11px;color:var(--brand);text-decoration:none;margin-left:8px">▶ recording</a>'
2032
+ const recordingHtml = recordingUrl
2033
+ ? '<a href="' + escapeHtml(recordingUrl) + '" target="_blank" rel="noopener" style="font-size:11px;color:var(--brand);text-decoration:none;margin-left:8px">▶ recording</a>'
2017
2034
  : '';
2018
2035
  return ''
2019
2036
  + '<div class="phone-row" style="grid-template-columns:auto 1fr auto;align-items:start">'
@@ -2023,12 +2040,12 @@ function renderCallsList(calls) {
2023
2040
  + ' </svg>'
2024
2041
  + ' </div>'
2025
2042
  + ' <div class="phone-main">'
2026
- + ' <div class="phone-num">' + human + ' <span style="font-size:11px;color:var(--text-dim);font-weight:400">from ' + fromHuman + '</span></div>'
2043
+ + ' <div class="phone-num">' + escapeHtml(human) + ' <span style="font-size:11px;color:var(--text-dim);font-weight:400">from ' + escapeHtml(fromHuman) + '</span></div>'
2027
2044
  + ' <div class="phone-meta">'
2028
- + ' <span class="chip ' + st.cls + '">' + st.label + '</span>'
2045
+ + ' <span class="chip ' + st.cls + '">' + escapeHtml(st.label) + '</span>'
2029
2046
  + ' <span class="chip">' + formatDuration(c.duration_sec) + '</span>'
2030
2047
  + ' <span class="chip">' + cost + '</span>'
2031
- + ' <span style="font-size:11px;color:var(--text-dim)">' + when + '</span>'
2048
+ + ' <span style="font-size:11px;color:var(--text-dim)">' + escapeHtml(when) + '</span>'
2032
2049
  + recordingHtml
2033
2050
  + ' </div>'
2034
2051
  + ' <div style="font-size:12px;color:var(--text-muted);margin-top:4px;line-height:1.5">' + (safeTask.slice(0, 200) + (safeTask.length > 200 ? '…' : '')) + '</div>'
@@ -2074,6 +2091,7 @@ document.querySelector('[data-tab="markets"]')?.addEventListener('click', loadMa
2074
2091
  const initialHash = (location.hash || '').replace(/^#/, '');
2075
2092
  if (initialHash && initialHash !== 'overview' && document.getElementById('tab-' + initialHash)) {
2076
2093
  activateTab(initialHash);
2094
+ if (initialHash === 'calls') loadCalls();
2077
2095
  }
2078
2096
  }
2079
2097
 
@@ -100,24 +100,30 @@ export class CallLog {
100
100
  summary(limit = 50) {
101
101
  const all = this.all();
102
102
  const latest = new Map();
103
+ const paidByCall = new Map();
103
104
  for (const e of all) {
105
+ paidByCall.set(e.call_id, Math.max(paidByCall.get(e.call_id) ?? 0, e.paid_usd));
104
106
  const cur = latest.get(e.call_id);
105
107
  // Keep the row with the FRESHEST timestamp per call_id (status updates).
106
108
  if (!cur || e.timestamp >= cur.timestamp)
107
109
  latest.set(e.call_id, e);
108
110
  }
109
111
  // Sort newest-first by the latest-row timestamp.
110
- const list = Array.from(latest.values()).sort((a, b) => b.timestamp - a.timestamp);
112
+ const list = Array.from(latest.values())
113
+ .map(e => ({ ...e, paid_usd: paidByCall.get(e.call_id) ?? e.paid_usd }))
114
+ .sort((a, b) => b.timestamp - a.timestamp);
111
115
  return list.slice(0, limit);
112
116
  }
113
117
  byCallId(callId) {
114
118
  let best = null;
119
+ let paidUsd = 0;
115
120
  for (const e of this.all()) {
116
121
  if (e.call_id !== callId)
117
122
  continue;
123
+ paidUsd = Math.max(paidUsd, e.paid_usd);
118
124
  if (!best || e.timestamp >= best.timestamp)
119
125
  best = e;
120
126
  }
121
- return best;
127
+ return best ? { ...best, paid_usd: paidUsd } : null;
122
128
  }
123
129
  }
@@ -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: 0, // status polls are free; only the initial POST charges
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockrun/franklin",
3
- "version": "3.21.2",
3
+ "version": "3.21.4",
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": {