@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.
- package/dist/agent/permissions.js +35 -3
- package/dist/skills-bundled/phone-call/SKILL.md +5 -7
- package/dist/tools/voice.js +105 -36
- package/dist/ui/app.js +12 -1
- 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 {
|
|
@@ -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/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
|
-
|
|
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