@blockrun/franklin 3.20.2 → 3.21.0
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/context.js +8 -0
- package/dist/panel/html.js +115 -0
- package/dist/panel/server.js +48 -0
- package/dist/phone/call-log.d.ts +62 -0
- package/dist/phone/call-log.js +123 -0
- package/dist/skills-bundled/phone-call/SKILL.md +115 -0
- package/dist/tools/voice.js +79 -0
- package/package.json +1 -1
package/dist/agent/context.js
CHANGED
|
@@ -296,6 +296,14 @@ On-chain affiliate (20 bps in sell-token, force-set server-side) flows to BlockR
|
|
|
296
296
|
- \`/v1/modal/{...path}\` — Modal GPU sandbox passthrough (create/exec/etc.).
|
|
297
297
|
- \`/v1/pm/{...path}\` — prediction-market data passthrough.
|
|
298
298
|
|
|
299
|
+
**Phone & Voice (typed tools — prefer these over raw primitive calls)**
|
|
300
|
+
- \`ListPhoneNumbers\` (\$0.001) / \`BuyPhoneNumber\` (\$5, 30-day lease) / \`RenewPhoneNumber\` (\$5) / \`ReleasePhoneNumber\` (free) — lifecycle of wallet-owned BlockRun numbers.
|
|
301
|
+
- \`PhoneLookup\` (\$0.01) / \`PhoneFraudCheck\` (\$0.05) — carrier + risk lookup.
|
|
302
|
+
- \`VoiceCall\` (\$0.54, POST /v1/voice/call) — place an outbound AI-driven call. Async — returns \`call_id\` immediately.
|
|
303
|
+
- \`VoiceStatus\` (free, GET /v1/voice/call/{id}) — poll a previously-initiated call for status / transcript / recording / disposition.
|
|
304
|
+
- For end-to-end voice workflows including auto-poll, confirmation gates, and compliance reminders, prefer the bundled **\`/phone-call\`** skill — it walks through intent capture, caller-ID selection, task scripting, confirmation, and the polling loop. Calls auto-journal to ~/.blockrun/calls.jsonl (visible in the panel "Calls" tab).
|
|
305
|
+
- US/CA destinations only. Marketing/sales calls require prior express consent (TCPA).
|
|
306
|
+
|
|
299
307
|
**Surf — crypto data + chat (x402-paid)** via the generic \`BlockRun\` capability. ~55 curated endpoints. Tier-1 $0.001, Tier-2 $0.005, Tier-3 / chat $0.02.
|
|
300
308
|
- \`/v1/surf/exchange/*\` — CEX trading pairs, prices, perps, depth, klines, funding history, long/short ratio.
|
|
301
309
|
- \`/v1/surf/market/*\` — token rankings, fear/greed, futures, ETF flows, options skew, liquidations, on-chain indicators (NUPL/SOPR/MVRV), price indicators (RSI/MACD/BBANDS).
|
package/dist/panel/html.js
CHANGED
|
@@ -517,6 +517,10 @@ a:hover { text-decoration:underline; }
|
|
|
517
517
|
Phone
|
|
518
518
|
<span class="nav-badge" id="phone-nav-badge" style="display:none"></span>
|
|
519
519
|
</button>
|
|
520
|
+
<button class="nav-item" data-tab="calls">
|
|
521
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 10v4"/><path d="M7 8v8"/><path d="M11 5v14"/><path d="M15 8v8"/><path d="M19 10v4"/></svg>
|
|
522
|
+
Calls
|
|
523
|
+
</button>
|
|
520
524
|
<button class="nav-item" data-tab="sessions">
|
|
521
525
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
|
522
526
|
Sessions
|
|
@@ -779,6 +783,27 @@ a:hover { text-decoration:underline; }
|
|
|
779
783
|
</div>
|
|
780
784
|
</div>
|
|
781
785
|
|
|
786
|
+
<!-- Calls -->
|
|
787
|
+
<div class="tab" id="tab-calls">
|
|
788
|
+
<div class="content-header">
|
|
789
|
+
<h2>Calls</h2>
|
|
790
|
+
<p>Recent outbound voice calls fired through VoiceCall. Reads from ~/.blockrun/calls.jsonl — the journal Franklin keeps locally as it polls call status.</p>
|
|
791
|
+
</div>
|
|
792
|
+
|
|
793
|
+
<div class="card">
|
|
794
|
+
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;">
|
|
795
|
+
<h3 style="margin:0">Recent calls</h3>
|
|
796
|
+
<div style="display:flex;gap:8px;align-items:center">
|
|
797
|
+
<span class="phone-status" id="calls-list-status"></span>
|
|
798
|
+
<button class="btn btn-ghost" id="calls-refresh-btn" title="Reload from journal">Refresh</button>
|
|
799
|
+
</div>
|
|
800
|
+
</div>
|
|
801
|
+
<div id="calls-list" style="margin-top:12px">
|
|
802
|
+
<div class="phone-empty">Loading…</div>
|
|
803
|
+
</div>
|
|
804
|
+
</div>
|
|
805
|
+
</div>
|
|
806
|
+
|
|
782
807
|
<!-- Learnings -->
|
|
783
808
|
<div class="tab" id="tab-learnings">
|
|
784
809
|
<div class="content-header">
|
|
@@ -1947,6 +1972,96 @@ document.getElementById('phone-buy-btn')?.addEventListener('click', buyPhoneNumb
|
|
|
1947
1972
|
// clicks into the Phone tab. Cached read — no network cost.
|
|
1948
1973
|
loadPhone({});
|
|
1949
1974
|
|
|
1975
|
+
// ─── Calls tab ──────────────────────────────────────────────────────────
|
|
1976
|
+
// Read-only view of ~/.blockrun/calls.jsonl. VoiceCall and VoiceStatus tools
|
|
1977
|
+
// write to that journal; this tab just reads.
|
|
1978
|
+
|
|
1979
|
+
function formatCallStatus(status) {
|
|
1980
|
+
const s = String(status || '').toLowerCase();
|
|
1981
|
+
if (s === 'completed') return { label: 'completed', cls: 'green' };
|
|
1982
|
+
if (s === 'queued' || s === 'in_progress' || s === 'in-progress') return { label: s.replace('_',' '), cls: 'amber' };
|
|
1983
|
+
return { label: s || 'unknown', cls: 'red' };
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
function formatDuration(sec) {
|
|
1987
|
+
if (!sec || typeof sec !== 'number') return '—';
|
|
1988
|
+
const m = Math.floor(sec / 60);
|
|
1989
|
+
const s = sec % 60;
|
|
1990
|
+
return m > 0 ? m + 'm ' + s + 's' : s + 's';
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
function renderCallsList(calls) {
|
|
1994
|
+
const list = document.getElementById('calls-list');
|
|
1995
|
+
if (!list) return;
|
|
1996
|
+
if (!calls || calls.length === 0) {
|
|
1997
|
+
list.innerHTML = '<div class="phone-empty">' +
|
|
1998
|
+
'<strong>No calls yet</strong>' +
|
|
1999
|
+
'Outbound voice calls fired via the <code>VoiceCall</code> tool or the <code>/phone-call</code> skill ' +
|
|
2000
|
+
'will appear here. Each call costs $0.54 and requires a wallet-owned BlockRun phone number as caller ID.' +
|
|
2001
|
+
'</div>';
|
|
2002
|
+
return;
|
|
2003
|
+
}
|
|
2004
|
+
const html = calls.map(c => {
|
|
2005
|
+
const st = formatCallStatus(c.status);
|
|
2006
|
+
const human = formatPhoneNumber(c.to);
|
|
2007
|
+
const fromHuman = formatPhoneNumber(c.from);
|
|
2008
|
+
const when = new Date(c.timestamp).toLocaleString();
|
|
2009
|
+
const cost = c.paid_usd ? '$' + c.paid_usd.toFixed(2) : '—';
|
|
2010
|
+
const safeTask = (c.task || '').replace(/[<>&]/g, ch => ({'<':'<','>':'>','&':'&'}[ch]));
|
|
2011
|
+
const transcriptHtml = c.transcript
|
|
2012
|
+
? '<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 => ({'<':'<','>':'>','&':'&'}[ch])) + '</pre></details>'
|
|
2014
|
+
: '';
|
|
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>'
|
|
2017
|
+
: '';
|
|
2018
|
+
return ''
|
|
2019
|
+
+ '<div class="phone-row" style="grid-template-columns:auto 1fr auto;align-items:start">'
|
|
2020
|
+
+ ' <div class="phone-icon-bubble">'
|
|
2021
|
+
+ ' <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">'
|
|
2022
|
+
+ ' <path d="M3 10v4"/><path d="M7 8v8"/><path d="M11 5v14"/><path d="M15 8v8"/><path d="M19 10v4"/>'
|
|
2023
|
+
+ ' </svg>'
|
|
2024
|
+
+ ' </div>'
|
|
2025
|
+
+ ' <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>'
|
|
2027
|
+
+ ' <div class="phone-meta">'
|
|
2028
|
+
+ ' <span class="chip ' + st.cls + '">' + st.label + '</span>'
|
|
2029
|
+
+ ' <span class="chip">' + formatDuration(c.duration_sec) + '</span>'
|
|
2030
|
+
+ ' <span class="chip">' + cost + '</span>'
|
|
2031
|
+
+ ' <span style="font-size:11px;color:var(--text-dim)">' + when + '</span>'
|
|
2032
|
+
+ recordingHtml
|
|
2033
|
+
+ ' </div>'
|
|
2034
|
+
+ ' <div style="font-size:12px;color:var(--text-muted);margin-top:4px;line-height:1.5">' + (safeTask.slice(0, 200) + (safeTask.length > 200 ? '…' : '')) + '</div>'
|
|
2035
|
+
+ transcriptHtml
|
|
2036
|
+
+ ' </div>'
|
|
2037
|
+
+ '</div>';
|
|
2038
|
+
}).join('');
|
|
2039
|
+
list.innerHTML = html;
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
async function loadCalls() {
|
|
2043
|
+
const statusEl = document.getElementById('calls-list-status');
|
|
2044
|
+
if (statusEl) { statusEl.textContent = 'Loading…'; statusEl.className = 'phone-status'; }
|
|
2045
|
+
try {
|
|
2046
|
+
const r = await fetch('/api/calls?limit=50');
|
|
2047
|
+
const data = await r.json();
|
|
2048
|
+
if (!r.ok) {
|
|
2049
|
+
if (statusEl) { statusEl.textContent = data.error || 'Failed to load'; statusEl.className = 'phone-status err'; }
|
|
2050
|
+
return;
|
|
2051
|
+
}
|
|
2052
|
+
renderCallsList(data.calls || []);
|
|
2053
|
+
if (statusEl) {
|
|
2054
|
+
statusEl.className = 'phone-status';
|
|
2055
|
+
statusEl.textContent = data.count + ' call' + (data.count === 1 ? '' : 's');
|
|
2056
|
+
}
|
|
2057
|
+
} catch (err) {
|
|
2058
|
+
if (statusEl) { statusEl.textContent = 'Network error'; statusEl.className = 'phone-status err'; }
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
document.querySelector('[data-tab="calls"]')?.addEventListener('click', loadCalls);
|
|
2063
|
+
document.getElementById('calls-refresh-btn')?.addEventListener('click', loadCalls);
|
|
2064
|
+
|
|
1950
2065
|
loadOverview();
|
|
1951
2066
|
loadSessions();
|
|
1952
2067
|
loadMarkets();
|
package/dist/panel/server.js
CHANGED
|
@@ -575,6 +575,54 @@ export function createPanelServer(port) {
|
|
|
575
575
|
}
|
|
576
576
|
return;
|
|
577
577
|
}
|
|
578
|
+
// ─── Calls (voice call journal) ─────────────────────────────────────
|
|
579
|
+
// Read-only views of ~/.blockrun/calls.jsonl. VoiceCall and VoiceStatus
|
|
580
|
+
// tools write to that journal; this endpoint just summarizes it for the
|
|
581
|
+
// panel "Calls" tab. No x402, no wallet-mutating action — same loopback
|
|
582
|
+
// posture as the rest of the panel anyway since the journal can contain
|
|
583
|
+
// call transcripts and recipient numbers.
|
|
584
|
+
if (p === '/api/calls' && (!req.method || req.method === 'GET')) {
|
|
585
|
+
if (!isLocalPanelRequest(req)) {
|
|
586
|
+
json(res, { error: 'forbidden', calls: [] }, 403);
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
try {
|
|
590
|
+
const { CallLog } = await import('../phone/call-log.js');
|
|
591
|
+
const log = new CallLog();
|
|
592
|
+
const limit = Math.min(parseInt(url.searchParams.get('limit') || '50', 10), 200);
|
|
593
|
+
const calls = log.summary(limit);
|
|
594
|
+
json(res, { calls, count: calls.length });
|
|
595
|
+
}
|
|
596
|
+
catch (err) {
|
|
597
|
+
json(res, { error: err.message, calls: [] }, 500);
|
|
598
|
+
}
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
if (p.startsWith('/api/calls/') && (!req.method || req.method === 'GET')) {
|
|
602
|
+
if (!isLocalPanelRequest(req)) {
|
|
603
|
+
json(res, { error: 'forbidden' }, 403);
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
try {
|
|
607
|
+
const callId = decodeURIComponent(p.slice('/api/calls/'.length));
|
|
608
|
+
if (!callId) {
|
|
609
|
+
json(res, { error: 'call_id required' }, 400);
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
const { CallLog } = await import('../phone/call-log.js');
|
|
613
|
+
const log = new CallLog();
|
|
614
|
+
const entry = log.byCallId(callId);
|
|
615
|
+
if (!entry) {
|
|
616
|
+
json(res, { error: 'not found', call_id: callId }, 404);
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
json(res, entry);
|
|
620
|
+
}
|
|
621
|
+
catch (err) {
|
|
622
|
+
json(res, { error: err.message }, 500);
|
|
623
|
+
}
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
578
626
|
if (p === '/api/markets') {
|
|
579
627
|
// Snapshot of every active data provider for the Markets panel:
|
|
580
628
|
// pipeline wiring (which endpoint serves which asset class), live
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CallLog — JSONL persistent record of every outbound voice call the agent
|
|
3
|
+
* initiates through VoiceCall (and updates as VoiceStatus polls for status).
|
|
4
|
+
*
|
|
5
|
+
* Why a journal: BlockRun's gateway doesn't expose a "list my calls" endpoint —
|
|
6
|
+
* /v1/voice/call/{id} works but you need to remember the id. Without local
|
|
7
|
+
* persistence, the panel can't show a "recent calls" view and cross-session
|
|
8
|
+
* memory ("did I already leave a voicemail at this number this week?") is
|
|
9
|
+
* impossible.
|
|
10
|
+
*
|
|
11
|
+
* Why append-only with multiple rows per call_id: calls are async, status
|
|
12
|
+
* mutates over time (queued → in_progress → completed). Append-only avoids
|
|
13
|
+
* the JSONL-rewrite-race that an in-place update would introduce — readers
|
|
14
|
+
* pick the latest row by call_id when summarizing. Same approach trade-log
|
|
15
|
+
* uses for fills vs corrections.
|
|
16
|
+
*
|
|
17
|
+
* Schema (additive over time; readers tolerate missing optional fields):
|
|
18
|
+
* timestamp: ms epoch of THIS log row (not the call start)
|
|
19
|
+
* call_id: Bland.ai call identifier (stable across rows)
|
|
20
|
+
* to / from: E.164 numbers
|
|
21
|
+
* task: the natural-language instructions the AI followed
|
|
22
|
+
* voice / max_duration_min / language: caller-side preferences (queue row only)
|
|
23
|
+
* status: queued | in_progress | completed | failed | cancelled |
|
|
24
|
+
* busy | no-answer | voicemail
|
|
25
|
+
* duration_sec: actual call length once known
|
|
26
|
+
* transcript: full conversation text once completed
|
|
27
|
+
* recording_url: Bland-hosted MP3/WAV link
|
|
28
|
+
* paid_usd: 0.54 charged on the initial POST; later rows carry 0
|
|
29
|
+
* tx_hash: x402 settlement hash for the initial POST
|
|
30
|
+
*/
|
|
31
|
+
export type CallStatus = 'queued' | 'in_progress' | 'completed' | 'failed' | 'cancelled' | 'busy' | 'no-answer' | 'voicemail';
|
|
32
|
+
export interface CallLogEntry {
|
|
33
|
+
timestamp: number;
|
|
34
|
+
call_id: string;
|
|
35
|
+
to: string;
|
|
36
|
+
from: string;
|
|
37
|
+
task: string;
|
|
38
|
+
voice?: string;
|
|
39
|
+
max_duration_min?: number;
|
|
40
|
+
language?: string;
|
|
41
|
+
status: CallStatus;
|
|
42
|
+
duration_sec?: number;
|
|
43
|
+
transcript?: string;
|
|
44
|
+
recording_url?: string;
|
|
45
|
+
paid_usd: number;
|
|
46
|
+
tx_hash?: string;
|
|
47
|
+
}
|
|
48
|
+
export declare function isTerminalStatus(s: unknown): s is CallStatus;
|
|
49
|
+
export declare function defaultCallLogPath(): string;
|
|
50
|
+
export declare class CallLog {
|
|
51
|
+
private filePath;
|
|
52
|
+
constructor(filePath?: string);
|
|
53
|
+
append(entry: CallLogEntry): void;
|
|
54
|
+
/** Read every entry on disk in chronological (append) order. */
|
|
55
|
+
all(): CallLogEntry[];
|
|
56
|
+
/**
|
|
57
|
+
* Latest row for each call_id, newest first by initial-row timestamp.
|
|
58
|
+
* This is the canonical "list of calls" view for the panel.
|
|
59
|
+
*/
|
|
60
|
+
summary(limit?: number): CallLogEntry[];
|
|
61
|
+
byCallId(callId: string): CallLogEntry | null;
|
|
62
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CallLog — JSONL persistent record of every outbound voice call the agent
|
|
3
|
+
* initiates through VoiceCall (and updates as VoiceStatus polls for status).
|
|
4
|
+
*
|
|
5
|
+
* Why a journal: BlockRun's gateway doesn't expose a "list my calls" endpoint —
|
|
6
|
+
* /v1/voice/call/{id} works but you need to remember the id. Without local
|
|
7
|
+
* persistence, the panel can't show a "recent calls" view and cross-session
|
|
8
|
+
* memory ("did I already leave a voicemail at this number this week?") is
|
|
9
|
+
* impossible.
|
|
10
|
+
*
|
|
11
|
+
* Why append-only with multiple rows per call_id: calls are async, status
|
|
12
|
+
* mutates over time (queued → in_progress → completed). Append-only avoids
|
|
13
|
+
* the JSONL-rewrite-race that an in-place update would introduce — readers
|
|
14
|
+
* pick the latest row by call_id when summarizing. Same approach trade-log
|
|
15
|
+
* uses for fills vs corrections.
|
|
16
|
+
*
|
|
17
|
+
* Schema (additive over time; readers tolerate missing optional fields):
|
|
18
|
+
* timestamp: ms epoch of THIS log row (not the call start)
|
|
19
|
+
* call_id: Bland.ai call identifier (stable across rows)
|
|
20
|
+
* to / from: E.164 numbers
|
|
21
|
+
* task: the natural-language instructions the AI followed
|
|
22
|
+
* voice / max_duration_min / language: caller-side preferences (queue row only)
|
|
23
|
+
* status: queued | in_progress | completed | failed | cancelled |
|
|
24
|
+
* busy | no-answer | voicemail
|
|
25
|
+
* duration_sec: actual call length once known
|
|
26
|
+
* transcript: full conversation text once completed
|
|
27
|
+
* recording_url: Bland-hosted MP3/WAV link
|
|
28
|
+
* paid_usd: 0.54 charged on the initial POST; later rows carry 0
|
|
29
|
+
* tx_hash: x402 settlement hash for the initial POST
|
|
30
|
+
*/
|
|
31
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
32
|
+
import { dirname, join } from 'node:path';
|
|
33
|
+
import { homedir } from 'node:os';
|
|
34
|
+
/** Set of statuses that mean "no further polling needed". */
|
|
35
|
+
const TERMINAL_STATUSES = new Set([
|
|
36
|
+
'completed',
|
|
37
|
+
'failed',
|
|
38
|
+
'cancelled',
|
|
39
|
+
'busy',
|
|
40
|
+
'no-answer',
|
|
41
|
+
'voicemail',
|
|
42
|
+
]);
|
|
43
|
+
export function isTerminalStatus(s) {
|
|
44
|
+
return typeof s === 'string' && TERMINAL_STATUSES.has(s);
|
|
45
|
+
}
|
|
46
|
+
export function defaultCallLogPath() {
|
|
47
|
+
return join(homedir(), '.blockrun', 'calls.jsonl');
|
|
48
|
+
}
|
|
49
|
+
export class CallLog {
|
|
50
|
+
filePath;
|
|
51
|
+
constructor(filePath = defaultCallLogPath()) {
|
|
52
|
+
this.filePath = filePath;
|
|
53
|
+
}
|
|
54
|
+
append(entry) {
|
|
55
|
+
try {
|
|
56
|
+
mkdirSync(dirname(this.filePath), { recursive: true });
|
|
57
|
+
appendFileSync(this.filePath, JSON.stringify(entry) + '\n', 'utf-8');
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
/* best-effort persistence; never block a call on disk failure */
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** Read every entry on disk in chronological (append) order. */
|
|
64
|
+
all() {
|
|
65
|
+
if (!existsSync(this.filePath))
|
|
66
|
+
return [];
|
|
67
|
+
let raw;
|
|
68
|
+
try {
|
|
69
|
+
raw = readFileSync(this.filePath, 'utf-8');
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
const out = [];
|
|
75
|
+
for (const line of raw.split('\n')) {
|
|
76
|
+
if (!line.trim())
|
|
77
|
+
continue;
|
|
78
|
+
try {
|
|
79
|
+
const obj = JSON.parse(line);
|
|
80
|
+
if (typeof obj?.timestamp === 'number' &&
|
|
81
|
+
typeof obj?.call_id === 'string' &&
|
|
82
|
+
typeof obj?.to === 'string' &&
|
|
83
|
+
typeof obj?.from === 'string' &&
|
|
84
|
+
typeof obj?.task === 'string' &&
|
|
85
|
+
typeof obj?.status === 'string' &&
|
|
86
|
+
typeof obj?.paid_usd === 'number') {
|
|
87
|
+
out.push(obj);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
/* corrupt line — skip */
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Latest row for each call_id, newest first by initial-row timestamp.
|
|
98
|
+
* This is the canonical "list of calls" view for the panel.
|
|
99
|
+
*/
|
|
100
|
+
summary(limit = 50) {
|
|
101
|
+
const all = this.all();
|
|
102
|
+
const latest = new Map();
|
|
103
|
+
for (const e of all) {
|
|
104
|
+
const cur = latest.get(e.call_id);
|
|
105
|
+
// Keep the row with the FRESHEST timestamp per call_id (status updates).
|
|
106
|
+
if (!cur || e.timestamp >= cur.timestamp)
|
|
107
|
+
latest.set(e.call_id, e);
|
|
108
|
+
}
|
|
109
|
+
// Sort newest-first by the latest-row timestamp.
|
|
110
|
+
const list = Array.from(latest.values()).sort((a, b) => b.timestamp - a.timestamp);
|
|
111
|
+
return list.slice(0, limit);
|
|
112
|
+
}
|
|
113
|
+
byCallId(callId) {
|
|
114
|
+
let best = null;
|
|
115
|
+
for (const e of this.all()) {
|
|
116
|
+
if (e.call_id !== callId)
|
|
117
|
+
continue;
|
|
118
|
+
if (!best || e.timestamp >= best.timestamp)
|
|
119
|
+
best = e;
|
|
120
|
+
}
|
|
121
|
+
return best;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: phone-call
|
|
3
|
+
description: Place an outbound AI-driven voice call (Bland.ai via BlockRun). Walks through intent capture, caller-ID selection, task scripting, and confirmation; fires VoiceCall, then auto-polls VoiceStatus until completion and surfaces the transcript. $0.54 per call, US/CA destinations only, charged from your wallet. Real-world action — use with prior consent.
|
|
4
|
+
triggers:
|
|
5
|
+
- "make a phone call"
|
|
6
|
+
- "call this number"
|
|
7
|
+
- "call them"
|
|
8
|
+
- "place a call"
|
|
9
|
+
- "outbound call"
|
|
10
|
+
- "phone call"
|
|
11
|
+
- "leave a voicemail"
|
|
12
|
+
argument-hint: <recipient + what to say>
|
|
13
|
+
cost-receipt: true
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
You are running inside Franklin on **{{wallet_chain}}**. This skill is Franklin's real-world action surface — it picks up a real phone, charges a real $0.54 from the user's wallet, and the recipient is a real human (or their voicemail). Be deliberate.
|
|
17
|
+
|
|
18
|
+
## Workflow
|
|
19
|
+
|
|
20
|
+
### 1 · Capture intent
|
|
21
|
+
|
|
22
|
+
Read what the user said below under "The user said". Extract two things:
|
|
23
|
+
|
|
24
|
+
- **Recipient phone number** in E.164 (e.g. `+14155552671`). US/CA only — if the country code isn't `+1`, stop and tell the user the surface is US/CA only today.
|
|
25
|
+
- **Task** — what should the AI say / do on the call? Concrete enough that a stranger reading it cold could execute. 10–4000 chars.
|
|
26
|
+
|
|
27
|
+
If either is vague, ask **one** clarifying question. Do not fan out into multi-question interviews — phone calls aren't worth a 5-turn interrogation.
|
|
28
|
+
|
|
29
|
+
### 2 · Pick the caller-ID
|
|
30
|
+
|
|
31
|
+
Call `ListPhoneNumbers` ($0.001) to see what the wallet owns.
|
|
32
|
+
|
|
33
|
+
- **0 active numbers** → tell the user they need to provision one first via `BuyPhoneNumber` ($5, 30-day lease). Stop. Do not auto-buy a number unless the user explicitly says so — $5 is a meaningful spend.
|
|
34
|
+
- **1 active number** → use it as `from`. Mention which number you're using.
|
|
35
|
+
- **>1 active number** → list them with expiry dates; ask the user which to use as caller-ID.
|
|
36
|
+
|
|
37
|
+
### 3 · Craft the task script
|
|
38
|
+
|
|
39
|
+
Reasonable template — adapt to the user's intent:
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
Greet the recipient briefly. Identify yourself as <"calling on behalf of <user>" or
|
|
43
|
+
similar>. State the purpose in one sentence: <purpose>. <Key facts the AI needs to
|
|
44
|
+
convey or ask>. If you reach voicemail, leave a short message: <voicemail script>.
|
|
45
|
+
Stay polite and end the call once the objective is met.
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The task is verbatim instructions to the AI — every phrase you write WILL be spoken by the AI on the call. Don't include placeholders the AI can't resolve. Don't include private data the recipient shouldn't hear ("the user is buying a $40,000 car" is between Franklin and the user, not the recipient).
|
|
49
|
+
|
|
50
|
+
### 4 · Confirm before firing
|
|
51
|
+
|
|
52
|
+
Show the user, in plain text:
|
|
53
|
+
|
|
54
|
+
- **To:** \`<recipient E.164>\`
|
|
55
|
+
- **From:** \`<caller-ID E.164>\` (\<days-left\> days on lease)
|
|
56
|
+
- **Cost:** $0.54 from wallet
|
|
57
|
+
- **Voice:** \`<preset>\` (default \`maya\`)
|
|
58
|
+
- **Max duration:** \<N\> minutes (default 5)
|
|
59
|
+
- **Task summary:** first 1–2 sentences
|
|
60
|
+
|
|
61
|
+
Ask: "Place the call? Reply \`yes\` to proceed." Wait for explicit confirmation. Anything other than yes → stop.
|
|
62
|
+
|
|
63
|
+
### 5 · Place the call
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
VoiceCall({
|
|
67
|
+
to: "<E.164>",
|
|
68
|
+
from: "<E.164 wallet-owned>",
|
|
69
|
+
task: "<full task script>",
|
|
70
|
+
voice: "<preset>", // optional, default maya
|
|
71
|
+
max_duration: <N>, // optional, default 5
|
|
72
|
+
language: "<code>" // optional, default en-US
|
|
73
|
+
})
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Tool returns a `call_id` immediately. Surface it to the user along with "polling now."
|
|
77
|
+
|
|
78
|
+
### 6 · Auto-poll status
|
|
79
|
+
|
|
80
|
+
Loop, every ~30 seconds, calling `VoiceStatus({ call_id })`:
|
|
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.
|
|
85
|
+
|
|
86
|
+
VoiceStatus is **free** — poll as often as needed.
|
|
87
|
+
|
|
88
|
+
### 7 · Surface the result
|
|
89
|
+
|
|
90
|
+
When polling ends:
|
|
91
|
+
|
|
92
|
+
- One-line outcome: status, duration (sec → MM:SS), disposition.
|
|
93
|
+
- Full transcript (or first 2000 chars + a note if longer).
|
|
94
|
+
- Recording URL if returned.
|
|
95
|
+
- Total cost ($0.54 — the polls were free).
|
|
96
|
+
|
|
97
|
+
If the call failed before connecting (busy, no-answer, voicemail without leaving a message), tell the user clearly. Don't dress up a failed call as a partial success.
|
|
98
|
+
|
|
99
|
+
## Compliance — non-negotiable
|
|
100
|
+
|
|
101
|
+
- **US/CA destinations only.** Anything else, refuse.
|
|
102
|
+
- **Daytime preference.** Estimate the recipient's local time from the area code; if it's outside 9 am – 9 pm local, raise the question in the confirmation step (not after firing). User can override.
|
|
103
|
+
- **Marketing / sales calls require prior express consent.** If the task script reads like outbound marketing (selling something, soliciting sign-ups, promotional offers), refuse unless the user explicitly attests in their message that the recipient has prior consent. TCPA in the US is not a guideline; it's a statute with private right of action.
|
|
104
|
+
- **Don't auto-fire follow-ups.** If a call fails or ends in voicemail, ask the user whether to retry, don't loop automatically.
|
|
105
|
+
|
|
106
|
+
## Anti-patterns
|
|
107
|
+
|
|
108
|
+
- Placing a call to a number the user didn't explicitly type. If they said "call them about Y", clarify who "them" is — don't grep history for the most-recent-looking number.
|
|
109
|
+
- Writing a task script that pressures the recipient (false urgency, fake authority, manipulation). The AI on the call WILL execute whatever's in the script.
|
|
110
|
+
- Sequential cold calls without consent. One call per skill invocation; if the user wants a sequence, ask them to confirm each one.
|
|
111
|
+
- Calling 911 or any emergency line. Both Franklin and BlockRun block this; don't try to test it.
|
|
112
|
+
|
|
113
|
+
## The user said
|
|
114
|
+
|
|
115
|
+
$ARGUMENTS
|
package/dist/tools/voice.js
CHANGED
|
@@ -17,6 +17,40 @@
|
|
|
17
17
|
import { getOrCreateWallet, getOrCreateSolanaWallet, createPaymentPayload, createSolanaPaymentPayload, parsePaymentRequired, extractPaymentDetails, solanaKeyToBytes, SOLANA_NETWORK, } from '@blockrun/llm';
|
|
18
18
|
import { loadChain, API_URLS, VERSION } from '../config.js';
|
|
19
19
|
import { logger } from '../logger.js';
|
|
20
|
+
import { CallLog } from '../phone/call-log.js';
|
|
21
|
+
/** Singleton, lazy — paths are computed at first use so tests can stub homedir. */
|
|
22
|
+
let _callLog = null;
|
|
23
|
+
function callLog() {
|
|
24
|
+
if (!_callLog)
|
|
25
|
+
_callLog = new CallLog();
|
|
26
|
+
return _callLog;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Normalize whatever string Bland.ai returns as `status` (or `disposition` /
|
|
30
|
+
* `call_state` — the field name has drifted across upstream versions) into
|
|
31
|
+
* the CallStatus union our journal stores. Unknown / missing → 'queued' so
|
|
32
|
+
* the row still gets written and a later poll can refine it.
|
|
33
|
+
*/
|
|
34
|
+
function normalizeStatus(raw) {
|
|
35
|
+
if (typeof raw !== 'string')
|
|
36
|
+
return 'queued';
|
|
37
|
+
const s = raw.toLowerCase().trim();
|
|
38
|
+
if (s === 'completed')
|
|
39
|
+
return 'completed';
|
|
40
|
+
if (s === 'failed' || s === 'error')
|
|
41
|
+
return 'failed';
|
|
42
|
+
if (s === 'cancelled' || s === 'canceled')
|
|
43
|
+
return 'cancelled';
|
|
44
|
+
if (s === 'busy')
|
|
45
|
+
return 'busy';
|
|
46
|
+
if (s === 'no-answer' || s === 'no_answer' || s === 'noanswer')
|
|
47
|
+
return 'no-answer';
|
|
48
|
+
if (s === 'voicemail')
|
|
49
|
+
return 'voicemail';
|
|
50
|
+
if (s === 'in-progress' || s === 'in_progress' || s === 'inprogress' || s === 'ringing')
|
|
51
|
+
return 'in_progress';
|
|
52
|
+
return 'queued';
|
|
53
|
+
}
|
|
20
54
|
const VOICE_TIMEOUT_MS = 30_000;
|
|
21
55
|
// ─── Shared x402 helpers (paid POST + free GET) ───────────────────────────
|
|
22
56
|
async function postWithPayment(path, body, ctx) {
|
|
@@ -223,6 +257,26 @@ export const voiceCallCapability = {
|
|
|
223
257
|
try {
|
|
224
258
|
const res = await postWithPayment('/v1/voice/call', body, ctx);
|
|
225
259
|
const callId = (res.call_id || res.id);
|
|
260
|
+
// Persist a "queued" row so the panel sees the call before VoiceStatus polls.
|
|
261
|
+
// Best-effort — if disk write fails we still surface the call_id to the agent.
|
|
262
|
+
if (callId) {
|
|
263
|
+
try {
|
|
264
|
+
callLog().append({
|
|
265
|
+
timestamp: Date.now(),
|
|
266
|
+
call_id: callId,
|
|
267
|
+
to: String(input.to),
|
|
268
|
+
from: String(input.from),
|
|
269
|
+
task: String(input.task),
|
|
270
|
+
voice: typeof input.voice === 'string' ? input.voice : undefined,
|
|
271
|
+
max_duration_min: typeof input.max_duration === 'number' ? input.max_duration : undefined,
|
|
272
|
+
language: typeof input.language === 'string' ? input.language : undefined,
|
|
273
|
+
status: normalizeStatus(res.status),
|
|
274
|
+
paid_usd: 0.54,
|
|
275
|
+
tx_hash: typeof res.tx_hash === 'string' ? res.tx_hash : undefined,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
catch { /* best-effort */ }
|
|
279
|
+
}
|
|
226
280
|
return {
|
|
227
281
|
output: `## Voice call initiated ($0.54 USDC charged)\n\n` +
|
|
228
282
|
(callId
|
|
@@ -261,6 +315,31 @@ export const voiceStatusCapability = {
|
|
|
261
315
|
}
|
|
262
316
|
try {
|
|
263
317
|
const res = await getNoPayment(`/v1/voice/call/${encodeURIComponent(input.call_id)}`, ctx);
|
|
318
|
+
// Patch the local journal with whatever fields the gateway returned —
|
|
319
|
+
// transcript / recording / duration / disposition. Append-only schema
|
|
320
|
+
// means we just write a new row; CallLog.summary() picks the latest.
|
|
321
|
+
try {
|
|
322
|
+
const prior = callLog().byCallId(input.call_id);
|
|
323
|
+
if (prior) {
|
|
324
|
+
const recording = typeof res.recording_url === 'string' ? res.recording_url :
|
|
325
|
+
typeof res.recording === 'string' ? res.recording : undefined;
|
|
326
|
+
const duration = typeof res.call_length === 'number' ? Math.round(res.call_length) :
|
|
327
|
+
typeof res.duration === 'number' ? Math.round(res.duration) :
|
|
328
|
+
typeof res.duration_sec === 'number' ? Math.round(res.duration_sec) : undefined;
|
|
329
|
+
const transcript = typeof res.concatenated_transcript === 'string' ? res.concatenated_transcript :
|
|
330
|
+
typeof res.transcript === 'string' ? res.transcript : undefined;
|
|
331
|
+
callLog().append({
|
|
332
|
+
...prior,
|
|
333
|
+
timestamp: Date.now(),
|
|
334
|
+
paid_usd: 0, // status polls are free; only the initial POST charges
|
|
335
|
+
status: normalizeStatus(res.status ?? res.queue_status ?? res.disposition),
|
|
336
|
+
duration_sec: duration ?? prior.duration_sec,
|
|
337
|
+
transcript: transcript ?? prior.transcript,
|
|
338
|
+
recording_url: recording ?? prior.recording_url,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
catch { /* best-effort */ }
|
|
264
343
|
return {
|
|
265
344
|
output: `## Voice call status\n\n` +
|
|
266
345
|
'```json\n' + JSON.stringify(res, null, 2) + '\n```',
|
package/package.json
CHANGED