@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.
- package/dist/agent/permissions.js +35 -3
- package/dist/panel/html.js +25 -7
- package/dist/phone/call-log.js +8 -2
- package/dist/skills-bundled/phone-call/SKILL.md +5 -7
- package/dist/tools/voice.js +105 -36
- package/package.json +1 -1
|
@@ -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([
|
|
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: [
|
|
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: [
|
|
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 {
|
package/dist/panel/html.js
CHANGED
|
@@ -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
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
|
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 || '')
|
|
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
|
|
2030
|
+
escapeHtml(c.transcript) + '</pre></details>'
|
|
2014
2031
|
: '';
|
|
2015
|
-
const recordingHtml =
|
|
2016
|
-
? '<a href="' +
|
|
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
|
|
package/dist/phone/call-log.js
CHANGED
|
@@ -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())
|
|
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 "
|
|
76
|
+
Tool returns a `call_id` immediately. Surface it to the user along with "waiting for call to complete."
|
|
77
77
|
|
|
78
|
-
### 6 ·
|
|
78
|
+
### 6 · Wait for completion
|
|
79
79
|
|
|
80
|
-
|
|
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
|
-
-
|
|
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** —
|
|
84
|
+
VoiceStatus is **free** — no USDC charged on polling.
|
|
87
85
|
|
|
88
86
|
### 7 · Surface the result
|
|
89
87
|
|
package/dist/tools/voice.js
CHANGED
|
@@ -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: '
|
|
313
|
-
'
|
|
314
|
-
'
|
|
315
|
-
'
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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