@blockrun/franklin 3.16.4 → 3.18.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 +11 -1
- package/dist/agent/error-classifier.js +15 -0
- package/dist/agent/loop.js +8 -1
- package/dist/agent/permissions.js +2 -2
- package/dist/agent/tool-guard.js +33 -0
- package/dist/panel/html.js +403 -0
- package/dist/panel/server.js +217 -6
- package/dist/phone/cache.d.ts +44 -0
- package/dist/phone/cache.js +74 -0
- package/dist/phone/client.d.ts +50 -0
- package/dist/phone/client.js +162 -0
- package/dist/social/browser.js +97 -12
- package/dist/tools/browsex.d.ts +17 -0
- package/dist/tools/browsex.js +156 -0
- package/dist/tools/index.js +2 -0
- package/dist/tools/searchx.d.ts +1 -0
- package/dist/tools/searchx.js +121 -8
- package/dist/tools/webfetch.js +2 -2
- package/package.json +1 -1
package/dist/agent/context.js
CHANGED
|
@@ -150,7 +150,17 @@ RULES (violations will produce garbage output):
|
|
|
150
150
|
6. End with: "Reply to any? Give me the number."
|
|
151
151
|
7. Do NOT auto-post. Do NOT explain how the social system works.
|
|
152
152
|
|
|
153
|
-
When
|
|
153
|
+
When the user pastes a specific tweet URL (https://x.com/<user>/status/<id>): Call SearchX with the URL as the query. The tool auto-detects URL mode and reads the post directly. Do NOT search for the URL as a keyword (always returns empty), and do NOT try WebFetch on x.com.
|
|
154
|
+
|
|
155
|
+
When checking notifications/mentions: Use SearchX with mode="notifications". One call, done.
|
|
156
|
+
|
|
157
|
+
If SearchX returns empty or "no article extracted" on a URL/query you believe SHOULD have content (you can see the page in the browser, or the user confirms it exists), DO NOT give up — drop down to the BrowserX primitive and drive the browser yourself:
|
|
158
|
+
1. BrowserX action="snapshot" → see what's on screen right now
|
|
159
|
+
2. BrowserX action="scroll" dy=600 → trigger lazy-render / load more
|
|
160
|
+
3. BrowserX action="snapshot" again → re-inspect after scroll
|
|
161
|
+
4. BrowserX action="click" ref=<id> → follow a permalink (refs come from the last snapshot)
|
|
162
|
+
5. BrowserX action="open" url=<other> → try a different URL (e.g. /search?q=… or a profile page)
|
|
163
|
+
BrowserX shares the logged-in X session with SearchX, so authentication is already handled. Use BrowserX only for read/navigation; replies still go through PostToX with explicit user confirmation.`;
|
|
154
164
|
}
|
|
155
165
|
function getMissingAccessSection() {
|
|
156
166
|
return `# Missing Access
|
|
@@ -184,6 +184,21 @@ export function classifyAgentError(message) {
|
|
|
184
184
|
suggestion: 'Tool schema rejected by this model. Try /model to switch to a more permissive model (e.g. sonnet), or upgrade Franklin.',
|
|
185
185
|
};
|
|
186
186
|
}
|
|
187
|
+
// Unknown / typo'd model id — gateway returns HTTP 400 with a body like
|
|
188
|
+
// "Unknown model: moonshot/kimi-k2". Without this branch the error falls
|
|
189
|
+
// through to the catch-all 'unknown' category and shows the user a bare
|
|
190
|
+
// "Type: Unknown" with no actionable next step.
|
|
191
|
+
if (includesAny(err, [
|
|
192
|
+
'unknown model',
|
|
193
|
+
'model not found',
|
|
194
|
+
'model does not exist',
|
|
195
|
+
'no such model',
|
|
196
|
+
])) {
|
|
197
|
+
return {
|
|
198
|
+
category: 'schema', label: 'Schema', isTransient: false, maxRetries: 0,
|
|
199
|
+
suggestion: 'The gateway rejected the model id (unknown / typo). Use /model to pick a valid one, or upgrade Franklin if a fallback chain references a stale id.',
|
|
200
|
+
};
|
|
201
|
+
}
|
|
187
202
|
if (includesAny(err, [
|
|
188
203
|
'500',
|
|
189
204
|
'502',
|
package/dist/agent/loop.js
CHANGED
|
@@ -341,6 +341,13 @@ export function looksLikeStalledIntent(text) {
|
|
|
341
341
|
const trimmed = text.trim();
|
|
342
342
|
if (trimmed.length < 24)
|
|
343
343
|
return false;
|
|
344
|
+
// If the final non-empty line is a short question to the user, the model is
|
|
345
|
+
// explicitly deferring ("Which would you prefer?", "Want me to proceed?") —
|
|
346
|
+
// that's a handoff, not a stall. Avoid re-invoking on another model and
|
|
347
|
+
// billing twice for what is in fact correct behavior.
|
|
348
|
+
const lastLine = trimmed.split(/\n+/).map(s => s.trim()).filter(Boolean).pop() ?? '';
|
|
349
|
+
if (lastLine.length > 0 && lastLine.length <= 120 && /[??]\s*$/.test(lastLine))
|
|
350
|
+
return false;
|
|
344
351
|
// Look at the last ~400 chars only — intent-to-act lives near the end.
|
|
345
352
|
const tail = trimmed.slice(-400).toLowerCase();
|
|
346
353
|
// Strong "I'm about to do something" markers near the tail.
|
|
@@ -1336,7 +1343,7 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
|
|
|
1336
1343
|
// Excludes nvidia/* and *-coder-* — they're the source population.
|
|
1337
1344
|
const TOOL_USE_FALLBACK_MODELS = [
|
|
1338
1345
|
'anthropic/claude-haiku-4.5',
|
|
1339
|
-
'moonshot/kimi-k2',
|
|
1346
|
+
'moonshot/kimi-k2.6',
|
|
1340
1347
|
'openai/gpt-5',
|
|
1341
1348
|
'anthropic/claude-sonnet-4.6',
|
|
1342
1349
|
];
|
|
@@ -33,10 +33,10 @@ 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']);
|
|
36
|
+
const READ_ONLY_TOOLS = new Set(['Read', 'Glob', 'Grep', 'WebSearch', 'Task', 'AskUser', 'ActivateTool', 'ImageGen', 'TradingSignal', 'TradingMarket', 'SearchX', 'BrowserX']);
|
|
37
37
|
const DESTRUCTIVE_TOOLS = new Set(['Write', 'Edit', 'Bash']);
|
|
38
38
|
const DEFAULT_RULES = {
|
|
39
|
-
allow: ['Read', 'Glob', 'Grep', 'WebSearch', 'WebFetch', 'Task', 'AskUser', 'ActivateTool', 'ImageGen', 'TradingSignal', 'TradingMarket', 'SearchX'],
|
|
39
|
+
allow: ['Read', 'Glob', 'Grep', 'WebSearch', 'WebFetch', 'Task', 'AskUser', 'ActivateTool', 'ImageGen', 'TradingSignal', 'TradingMarket', 'SearchX', 'BrowserX'],
|
|
40
40
|
deny: [],
|
|
41
41
|
ask: ['Write', 'Edit', 'Bash', 'Agent', 'PostToX'],
|
|
42
42
|
};
|
package/dist/agent/tool-guard.js
CHANGED
|
@@ -183,6 +183,13 @@ export class SessionToolGuard {
|
|
|
183
183
|
startTurn() {
|
|
184
184
|
this.turn++;
|
|
185
185
|
this.webSearchesThisTurn = 0;
|
|
186
|
+
// The per-tool circuit breaker exists to stop a model from burning a
|
|
187
|
+
// whole turn re-attacking a wall. It must NOT outlive the user turn that
|
|
188
|
+
// earned the failures — a fresh prompt is a fresh intent. Without this
|
|
189
|
+
// reset, three failed Bash calls (e.g. `franklin social login x` on a
|
|
190
|
+
// host without the right env) permanently disable Bash for the rest of
|
|
191
|
+
// the session, even on completely unrelated follow-ups.
|
|
192
|
+
this.toolErrorCounts.clear();
|
|
186
193
|
for (const family of this.searchFamilies) {
|
|
187
194
|
family.turnSearches = 0;
|
|
188
195
|
}
|
|
@@ -233,6 +240,32 @@ export class SessionToolGuard {
|
|
|
233
240
|
const cmd = String(invocation.input.command ?? '').trim();
|
|
234
241
|
if (!cmd)
|
|
235
242
|
return null;
|
|
243
|
+
// Reject interactive franklin subcommands that require the human at the
|
|
244
|
+
// keyboard (they spawn a non-headless Chrome and wait for the user to
|
|
245
|
+
// close it). If the agent runs them via Bash they block until timeout,
|
|
246
|
+
// burn a tool-failure strike, and contribute nothing. Tell the agent to
|
|
247
|
+
// ask the user to run them in a separate terminal instead.
|
|
248
|
+
if (/^\s*franklin\s+social\s+(login|setup)\b/.test(cmd)) {
|
|
249
|
+
return {
|
|
250
|
+
output: 'Blocked: `franklin social login` / `franklin social setup` are INTERACTIVE — ' +
|
|
251
|
+
'they open a Chrome window the human must drive and close. They cannot run from ' +
|
|
252
|
+
'an agent Bash call (they will hang then time out). ' +
|
|
253
|
+
'Ask the user to run this in their own terminal, then continue once they say it is done.',
|
|
254
|
+
isError: true,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
// `franklin social run` is a batch poster/replier that loops over the
|
|
258
|
+
// user's configured search_queries — it is not the right tool for
|
|
259
|
+
// "read this specific tweet" or "draft replies to one post". Steer the
|
|
260
|
+
// agent to SearchX (now URL-aware) instead.
|
|
261
|
+
if (/^\s*franklin\s+social\s+run\b/.test(cmd)) {
|
|
262
|
+
return {
|
|
263
|
+
output: 'Blocked: `franklin social run` is a batch reply loop over the user\'s configured ' +
|
|
264
|
+
'queries, not a single-tweet reader. Use the SearchX tool instead — pass a tweet URL ' +
|
|
265
|
+
'as the query to read one post, or use mode="search"/"notifications" for discovery.',
|
|
266
|
+
isError: true,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
236
269
|
// Reject blocking poll-loops in foreground bash. A single bash call with
|
|
237
270
|
// `sleep N` inside a for/while/until loop blocks the agent for the full
|
|
238
271
|
// duration — the UI repeats the same status line and the user almost
|
package/dist/panel/html.js
CHANGED
|
@@ -410,6 +410,63 @@ a:hover { text-decoration:underline; }
|
|
|
410
410
|
.btn-danger { background:oklch(0.65 0.20 25 / 18%); color:var(--danger); border-color:oklch(0.65 0.20 25 / 35%); }
|
|
411
411
|
.btn-danger:hover { background:oklch(0.65 0.20 25 / 30%); }
|
|
412
412
|
|
|
413
|
+
.nav-badge {
|
|
414
|
+
margin-left:auto; font-size:10px; font-weight:700; letter-spacing:0.3px;
|
|
415
|
+
padding:2px 7px; border-radius:8px;
|
|
416
|
+
background:oklch(0.65 0.20 25 / 22%); color:var(--danger);
|
|
417
|
+
border:1px solid oklch(0.65 0.20 25 / 35%);
|
|
418
|
+
}
|
|
419
|
+
.nav-badge.warn { background:oklch(0.78 0.14 85 / 22%); color:var(--gold); border-color:oklch(0.78 0.14 85 / 35%); }
|
|
420
|
+
|
|
421
|
+
.phone-list { display:flex; flex-direction:column; gap:10px; }
|
|
422
|
+
.phone-row {
|
|
423
|
+
display:grid; grid-template-columns:auto 1fr auto; gap:14px; align-items:center;
|
|
424
|
+
padding:14px 16px; background:var(--bg-card); border:1px solid var(--border);
|
|
425
|
+
border-radius:var(--radius); transition:border-color 0.15s, background 0.15s;
|
|
426
|
+
}
|
|
427
|
+
.phone-row:hover { background:var(--bg-card-hover); }
|
|
428
|
+
.phone-row.warn { border-color:oklch(0.78 0.14 85 / 50%); }
|
|
429
|
+
.phone-row.crit { border-color:oklch(0.65 0.20 25 / 55%); }
|
|
430
|
+
.phone-row.expired { opacity:0.65; border-color:oklch(0.65 0.20 25 / 45%); }
|
|
431
|
+
.phone-icon-bubble {
|
|
432
|
+
width:36px; height:36px; border-radius:10px; display:grid; place-items:center;
|
|
433
|
+
background:oklch(0.68 0.16 260 / 18%); color:var(--brand);
|
|
434
|
+
}
|
|
435
|
+
.phone-main { display:flex; flex-direction:column; gap:3px; min-width:0; }
|
|
436
|
+
.phone-num {
|
|
437
|
+
font-family:var(--mono); font-size:15px; font-weight:600; color:var(--text);
|
|
438
|
+
letter-spacing:0.02em;
|
|
439
|
+
}
|
|
440
|
+
.phone-meta { font-size:12px; color:var(--text-muted); display:flex; gap:10px; flex-wrap:wrap; }
|
|
441
|
+
.phone-meta .chip {
|
|
442
|
+
display:inline-flex; align-items:center; gap:4px;
|
|
443
|
+
padding:2px 7px; border-radius:6px; background:oklch(0 0 0 / 25%);
|
|
444
|
+
font-size:10.5px; letter-spacing:0.5px; text-transform:uppercase; font-weight:700;
|
|
445
|
+
}
|
|
446
|
+
.phone-meta .chip.green { color:var(--success); background:oklch(0.65 0.18 145 / 18%); }
|
|
447
|
+
.phone-meta .chip.amber { color:var(--gold); background:oklch(0.78 0.14 85 / 20%); }
|
|
448
|
+
.phone-meta .chip.red { color:var(--danger); background:oklch(0.65 0.20 25 / 20%); }
|
|
449
|
+
.phone-row .phone-actions { display:flex; align-items:center; gap:6px; }
|
|
450
|
+
.phone-row.expired .phone-num { text-decoration:line-through; }
|
|
451
|
+
|
|
452
|
+
.phone-empty {
|
|
453
|
+
padding:24px; text-align:center; border:1px dashed var(--border);
|
|
454
|
+
border-radius:var(--radius); color:var(--text-muted); font-size:13px;
|
|
455
|
+
line-height:1.6;
|
|
456
|
+
}
|
|
457
|
+
.phone-empty strong { color:var(--text); font-weight:600; display:block; margin-bottom:6px; font-size:14px; }
|
|
458
|
+
|
|
459
|
+
.phone-buy-form { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin-top:10px; }
|
|
460
|
+
.phone-buy-form select, .phone-buy-form input {
|
|
461
|
+
padding:7px 10px; background:oklch(0 0 0 / 35%); color:var(--text);
|
|
462
|
+
border:1px solid var(--border); border-radius:7px; font-size:13px;
|
|
463
|
+
font-family:var(--mono);
|
|
464
|
+
}
|
|
465
|
+
.phone-buy-form input { width:120px; }
|
|
466
|
+
.phone-status { font-size:12px; color:var(--text-muted); }
|
|
467
|
+
.phone-status.ok { color:var(--success); }
|
|
468
|
+
.phone-status.err { color:var(--danger); }
|
|
469
|
+
|
|
413
470
|
@media (max-width:768px) {
|
|
414
471
|
body { flex-direction:column; }
|
|
415
472
|
.sidebar { width:100%; min-width:100%; flex-direction:row; padding:8px; overflow-x:auto; border-right:none; border-bottom:1px solid var(--border); }
|
|
@@ -455,6 +512,11 @@ a:hover { text-decoration:underline; }
|
|
|
455
512
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3v18h18"/><path d="M7 14l4-4 4 4 5-5"/></svg>
|
|
456
513
|
Markets
|
|
457
514
|
</button>
|
|
515
|
+
<button class="nav-item" data-tab="phone">
|
|
516
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></svg>
|
|
517
|
+
Phone
|
|
518
|
+
<span class="nav-badge" id="phone-nav-badge" style="display:none"></span>
|
|
519
|
+
</button>
|
|
458
520
|
<button class="nav-item" data-tab="sessions">
|
|
459
521
|
<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>
|
|
460
522
|
Sessions
|
|
@@ -677,6 +739,46 @@ a:hover { text-decoration:underline; }
|
|
|
677
739
|
</div>
|
|
678
740
|
</div>
|
|
679
741
|
|
|
742
|
+
<!-- Phone & Voice -->
|
|
743
|
+
<div class="tab" id="tab-phone">
|
|
744
|
+
<div class="content-header">
|
|
745
|
+
<h2>Phone & Voice</h2>
|
|
746
|
+
<p>Numbers your wallet owns. Leases run 30 days — renew before they expire or set auto-renew.</p>
|
|
747
|
+
</div>
|
|
748
|
+
|
|
749
|
+
<div class="card" style="margin-bottom:16px">
|
|
750
|
+
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;">
|
|
751
|
+
<h3 style="margin:0">Your numbers</h3>
|
|
752
|
+
<div style="display:flex;gap:8px;align-items:center">
|
|
753
|
+
<span class="phone-status" id="phone-list-status"></span>
|
|
754
|
+
<button class="btn btn-ghost" id="phone-refresh-btn" title="Refetch from BlockRun ($0.001)">Refresh</button>
|
|
755
|
+
</div>
|
|
756
|
+
</div>
|
|
757
|
+
<div id="phone-list" style="margin-top:12px">
|
|
758
|
+
<div class="phone-empty">Loading…</div>
|
|
759
|
+
</div>
|
|
760
|
+
</div>
|
|
761
|
+
|
|
762
|
+
<div class="card">
|
|
763
|
+
<h3 style="margin:0 0 6px">Add another number</h3>
|
|
764
|
+
<p class="wallet-hint">
|
|
765
|
+
$5 USDC for a fresh number, bound to your wallet for 30 days. <strong>This adds
|
|
766
|
+
a new number alongside any you already own — nothing is replaced.</strong>
|
|
767
|
+
Use it as caller ID for outbound AI voice calls, or (soon) to receive inbound calls.
|
|
768
|
+
Multiple numbers are fine; release any you no longer need to stop paying renewals on them.
|
|
769
|
+
</p>
|
|
770
|
+
<div class="phone-buy-form">
|
|
771
|
+
<select id="phone-buy-country">
|
|
772
|
+
<option value="US">United States (+1)</option>
|
|
773
|
+
<option value="CA">Canada (+1)</option>
|
|
774
|
+
</select>
|
|
775
|
+
<input id="phone-buy-areacode" placeholder="Area code (opt)" maxlength="6" />
|
|
776
|
+
<button class="btn btn-warn" id="phone-buy-btn">Buy for $5</button>
|
|
777
|
+
<span class="phone-status" id="phone-buy-status"></span>
|
|
778
|
+
</div>
|
|
779
|
+
</div>
|
|
780
|
+
</div>
|
|
781
|
+
|
|
680
782
|
<!-- Learnings -->
|
|
681
783
|
<div class="tab" id="tab-learnings">
|
|
682
784
|
<div class="content-header">
|
|
@@ -1544,6 +1646,307 @@ document.addEventListener('visibilitychange', () => {
|
|
|
1544
1646
|
|
|
1545
1647
|
document.getElementById('tasks-refresh-btn')?.addEventListener('click', fetchTasks);
|
|
1546
1648
|
|
|
1649
|
+
// ─── Phone & Voice ──────────────────────────────────────────────────────
|
|
1650
|
+
// Renders the user's wallet-owned numbers, days-remaining countdown,
|
|
1651
|
+
// renew / release / auto-renew controls, and the buy form. Drives the
|
|
1652
|
+
// sidebar nav badge so users with an expiring number see it even from
|
|
1653
|
+
// the Overview tab. Notification ladder uses the Notifications API,
|
|
1654
|
+
// dedupe-keyed in sessionStorage so we don't spam the user every open.
|
|
1655
|
+
|
|
1656
|
+
const phoneState = { data: null, countdownTimer: null };
|
|
1657
|
+
|
|
1658
|
+
function formatPhoneNumber(e164) {
|
|
1659
|
+
// E.164 → human, for display only. Keep raw value for actions.
|
|
1660
|
+
if (!e164) return '—';
|
|
1661
|
+
const m = String(e164).match(/^\\+1(\\d{3})(\\d{3})(\\d{4})$/);
|
|
1662
|
+
if (m) return '+1 (' + m[1] + ') ' + m[2] + '-' + m[3];
|
|
1663
|
+
return e164;
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
function daysLeft(expiresAt) {
|
|
1667
|
+
const expiry = new Date(expiresAt).getTime();
|
|
1668
|
+
if (isNaN(expiry)) return 0;
|
|
1669
|
+
return Math.floor((expiry - Date.now()) / 86400000);
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
function phoneTier(days) {
|
|
1673
|
+
if (days < 0) return 'expired';
|
|
1674
|
+
if (days <= 2) return 'crit';
|
|
1675
|
+
if (days <= 7) return 'warn';
|
|
1676
|
+
return 'ok';
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
function phoneChipClass(tier) {
|
|
1680
|
+
if (tier === 'expired' || tier === 'crit') return 'red';
|
|
1681
|
+
if (tier === 'warn') return 'amber';
|
|
1682
|
+
return 'green';
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
function phoneCountdownLabel(days) {
|
|
1686
|
+
if (days < 0) return 'expired ' + Math.abs(days) + 'd ago';
|
|
1687
|
+
if (days === 0) return 'expires today';
|
|
1688
|
+
if (days === 1) return '1 day left';
|
|
1689
|
+
return days + ' days left';
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
function updatePhoneNavBadge(numbers) {
|
|
1693
|
+
const badge = document.getElementById('phone-nav-badge');
|
|
1694
|
+
if (!badge) return;
|
|
1695
|
+
let worst = 999;
|
|
1696
|
+
let anyExpired = false;
|
|
1697
|
+
numbers.forEach(n => {
|
|
1698
|
+
const d = daysLeft(n.expires_at);
|
|
1699
|
+
if (d < 0) anyExpired = true;
|
|
1700
|
+
if (d < worst) worst = d;
|
|
1701
|
+
});
|
|
1702
|
+
if (anyExpired) {
|
|
1703
|
+
badge.textContent = '!'; badge.className = 'nav-badge'; badge.style.display = '';
|
|
1704
|
+
} else if (worst <= 2) {
|
|
1705
|
+
badge.textContent = worst + 'd'; badge.className = 'nav-badge'; badge.style.display = '';
|
|
1706
|
+
} else if (worst <= 7) {
|
|
1707
|
+
badge.textContent = worst + 'd'; badge.className = 'nav-badge warn'; badge.style.display = '';
|
|
1708
|
+
} else {
|
|
1709
|
+
badge.style.display = 'none';
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
function maybeNotifyExpiry(numbers) {
|
|
1714
|
+
if (typeof Notification === 'undefined') return;
|
|
1715
|
+
if (Notification.permission !== 'granted') return;
|
|
1716
|
+
numbers.forEach(n => {
|
|
1717
|
+
const d = daysLeft(n.expires_at);
|
|
1718
|
+
let mark = null;
|
|
1719
|
+
if (d < 0) mark = 'expired';
|
|
1720
|
+
else if (d <= 1) mark = 't1';
|
|
1721
|
+
else if (d <= 3) mark = 't3';
|
|
1722
|
+
else if (d <= 7) mark = 't7';
|
|
1723
|
+
if (!mark) return;
|
|
1724
|
+
const key = 'phone:notify:' + n.phone_number + ':' + mark;
|
|
1725
|
+
if (sessionStorage.getItem(key)) return;
|
|
1726
|
+
sessionStorage.setItem(key, '1');
|
|
1727
|
+
const human = formatPhoneNumber(n.phone_number);
|
|
1728
|
+
const title = 'Franklin: ' + human;
|
|
1729
|
+
const body = mark === 'expired'
|
|
1730
|
+
? 'This number has expired. Provision a new one in the Phone tab.'
|
|
1731
|
+
: (mark === 't1'
|
|
1732
|
+
? 'Expires in 1 day. Click to renew for $5.'
|
|
1733
|
+
: (mark === 't3'
|
|
1734
|
+
? 'Expires in 3 days. Click to renew for $5.'
|
|
1735
|
+
: 'Expires in a week. Renew when convenient.'));
|
|
1736
|
+
try {
|
|
1737
|
+
const notif = new Notification(title, { body, tag: key });
|
|
1738
|
+
notif.onclick = () => {
|
|
1739
|
+
try { window.focus(); } catch (e) {}
|
|
1740
|
+
location.hash = 'phone';
|
|
1741
|
+
activateTab('phone');
|
|
1742
|
+
notif.close();
|
|
1743
|
+
};
|
|
1744
|
+
} catch (e) { /* ignore */ }
|
|
1745
|
+
});
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
function renderPhoneNumbers(data) {
|
|
1749
|
+
const list = document.getElementById('phone-list');
|
|
1750
|
+
if (!list) return;
|
|
1751
|
+
const numbers = (data && data.numbers) || [];
|
|
1752
|
+
updatePhoneNavBadge(numbers);
|
|
1753
|
+
|
|
1754
|
+
if (!numbers.length) {
|
|
1755
|
+
list.innerHTML = '<div class="phone-empty">' +
|
|
1756
|
+
'<strong>No numbers yet</strong>' +
|
|
1757
|
+
'Provision a number below to give Franklin a phone identity. ' +
|
|
1758
|
+
'Numbers cost $5 for a 30-day lease and are bound to your wallet.' +
|
|
1759
|
+
'</div>';
|
|
1760
|
+
return;
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
const html = numbers.map(n => {
|
|
1764
|
+
const d = daysLeft(n.expires_at);
|
|
1765
|
+
const tier = phoneTier(d);
|
|
1766
|
+
const chipCls = phoneChipClass(tier);
|
|
1767
|
+
const rowCls = tier === 'ok' ? '' : (' ' + tier);
|
|
1768
|
+
const human = formatPhoneNumber(n.phone_number);
|
|
1769
|
+
const renewBtn = tier === 'expired'
|
|
1770
|
+
? ''
|
|
1771
|
+
: '<button class="btn btn-warn" data-phone-renew="' + n.phone_number + '">Renew $5</button>';
|
|
1772
|
+
const releaseLabel = tier === 'expired' ? 'Remove' : 'Release';
|
|
1773
|
+
return ''
|
|
1774
|
+
+ '<div class="phone-row' + rowCls + '">'
|
|
1775
|
+
+ ' <div class="phone-icon-bubble">'
|
|
1776
|
+
+ ' <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">'
|
|
1777
|
+
+ ' <path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/>'
|
|
1778
|
+
+ ' </svg>'
|
|
1779
|
+
+ ' </div>'
|
|
1780
|
+
+ ' <div class="phone-main">'
|
|
1781
|
+
+ ' <div class="phone-num">' + human + '</div>'
|
|
1782
|
+
+ ' <div class="phone-meta">'
|
|
1783
|
+
+ ' <span class="chip">' + (n.chain || '—') + '</span>'
|
|
1784
|
+
+ ' <span class="chip ' + chipCls + '">' + phoneCountdownLabel(d) + '</span>'
|
|
1785
|
+
+ ' </div>'
|
|
1786
|
+
+ ' </div>'
|
|
1787
|
+
+ ' <div class="phone-actions">'
|
|
1788
|
+
+ renewBtn
|
|
1789
|
+
+ ' <button class="btn btn-ghost" data-phone-release="' + n.phone_number + '" title="' + releaseLabel + ' this number">' + releaseLabel + '</button>'
|
|
1790
|
+
+ ' </div>'
|
|
1791
|
+
+ '</div>';
|
|
1792
|
+
}).join('');
|
|
1793
|
+
|
|
1794
|
+
list.innerHTML = html;
|
|
1795
|
+
|
|
1796
|
+
list.querySelectorAll('[data-phone-renew]').forEach(btn => {
|
|
1797
|
+
btn.addEventListener('click', () => renewPhoneNumber(btn.getAttribute('data-phone-renew')));
|
|
1798
|
+
});
|
|
1799
|
+
list.querySelectorAll('[data-phone-release]').forEach(btn => {
|
|
1800
|
+
btn.addEventListener('click', () => releasePhoneNumber(btn.getAttribute('data-phone-release')));
|
|
1801
|
+
});
|
|
1802
|
+
|
|
1803
|
+
maybeNotifyExpiry(numbers);
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
async function loadPhone(opts) {
|
|
1807
|
+
const force = !!(opts && opts.force);
|
|
1808
|
+
const statusEl = document.getElementById('phone-list-status');
|
|
1809
|
+
if (statusEl) statusEl.textContent = force ? 'Refreshing…' : 'Loading…';
|
|
1810
|
+
try {
|
|
1811
|
+
const url = '/api/phone/numbers';
|
|
1812
|
+
const r = force
|
|
1813
|
+
? await fetch('/api/phone/numbers/refresh', { method: 'POST' })
|
|
1814
|
+
: await fetch(url);
|
|
1815
|
+
const data = await r.json();
|
|
1816
|
+
if (!r.ok) {
|
|
1817
|
+
if (statusEl) { statusEl.textContent = data.error || 'Failed to load'; statusEl.className = 'phone-status err'; }
|
|
1818
|
+
const list = document.getElementById('phone-list');
|
|
1819
|
+
if (list) list.innerHTML = '<div class="phone-empty"><strong>Could not load numbers</strong>' + (data.error || 'Unknown error') + '</div>';
|
|
1820
|
+
return;
|
|
1821
|
+
}
|
|
1822
|
+
phoneState.data = data;
|
|
1823
|
+
renderPhoneNumbers(data);
|
|
1824
|
+
if (statusEl) {
|
|
1825
|
+
statusEl.className = 'phone-status';
|
|
1826
|
+
statusEl.textContent = data.fromCache
|
|
1827
|
+
? 'Cached ' + new Date(data.fetchedAt).toLocaleTimeString()
|
|
1828
|
+
: 'Synced ' + new Date(data.fetchedAt || Date.now()).toLocaleTimeString();
|
|
1829
|
+
}
|
|
1830
|
+
} catch (err) {
|
|
1831
|
+
if (statusEl) { statusEl.textContent = 'Network error'; statusEl.className = 'phone-status err'; }
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
async function renewPhoneNumber(num) {
|
|
1836
|
+
const statusEl = document.getElementById('phone-list-status');
|
|
1837
|
+
if (statusEl) { statusEl.textContent = 'Renewing ' + formatPhoneNumber(num) + '…'; statusEl.className = 'phone-status'; }
|
|
1838
|
+
try {
|
|
1839
|
+
const r = await fetch('/api/phone/numbers/renew', {
|
|
1840
|
+
method: 'POST',
|
|
1841
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1842
|
+
body: JSON.stringify({ phoneNumber: num }),
|
|
1843
|
+
});
|
|
1844
|
+
const data = await r.json();
|
|
1845
|
+
if (!r.ok) {
|
|
1846
|
+
if (statusEl) { statusEl.textContent = data.error || 'Renew failed'; statusEl.className = 'phone-status err'; }
|
|
1847
|
+
return;
|
|
1848
|
+
}
|
|
1849
|
+
// Clear dedupe keys so a renewed number can re-notify if it expires again later
|
|
1850
|
+
Object.keys(sessionStorage).filter(k => k.startsWith('phone:notify:' + num + ':')).forEach(k => sessionStorage.removeItem(k));
|
|
1851
|
+
if (statusEl) { statusEl.textContent = 'Renewed — new expiry ' + new Date(data.expires_at).toLocaleDateString(); statusEl.className = 'phone-status ok'; }
|
|
1852
|
+
await loadPhone({});
|
|
1853
|
+
} catch (err) {
|
|
1854
|
+
if (statusEl) { statusEl.textContent = 'Network error'; statusEl.className = 'phone-status err'; }
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
async function releasePhoneNumber(num) {
|
|
1859
|
+
if (!confirm('Release ' + formatPhoneNumber(num) + '? This permanently gives up the number and cannot be undone.')) return;
|
|
1860
|
+
const statusEl = document.getElementById('phone-list-status');
|
|
1861
|
+
if (statusEl) { statusEl.textContent = 'Releasing…'; statusEl.className = 'phone-status'; }
|
|
1862
|
+
try {
|
|
1863
|
+
const r = await fetch('/api/phone/numbers/release', {
|
|
1864
|
+
method: 'POST',
|
|
1865
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1866
|
+
body: JSON.stringify({ phoneNumber: num }),
|
|
1867
|
+
});
|
|
1868
|
+
const data = await r.json();
|
|
1869
|
+
if (!r.ok) {
|
|
1870
|
+
if (statusEl) { statusEl.textContent = data.error || 'Release failed'; statusEl.className = 'phone-status err'; }
|
|
1871
|
+
return;
|
|
1872
|
+
}
|
|
1873
|
+
if (statusEl) { statusEl.textContent = 'Released ' + formatPhoneNumber(num); statusEl.className = 'phone-status ok'; }
|
|
1874
|
+
await loadPhone({});
|
|
1875
|
+
} catch (err) {
|
|
1876
|
+
if (statusEl) { statusEl.textContent = 'Network error'; statusEl.className = 'phone-status err'; }
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
async function buyPhoneNumber() {
|
|
1881
|
+
const country = (document.getElementById('phone-buy-country') || {}).value || 'US';
|
|
1882
|
+
const areaCode = ((document.getElementById('phone-buy-areacode') || {}).value || '').trim();
|
|
1883
|
+
const statusEl = document.getElementById('phone-buy-status');
|
|
1884
|
+
const btn = document.getElementById('phone-buy-btn');
|
|
1885
|
+
const existingCount = ((phoneState.data && phoneState.data.numbers) || []).filter(n => daysLeft(n.expires_at) >= 0).length;
|
|
1886
|
+
const intro = existingCount > 0
|
|
1887
|
+
? 'You already own ' + existingCount + ' active number' + (existingCount === 1 ? '' : 's') + '. This will ADD a new number — nothing is replaced.\\n\\n'
|
|
1888
|
+
: '';
|
|
1889
|
+
if (!confirm(intro + 'Buy a new phone number for $5? It will be charged from your wallet immediately and last 30 days.')) return;
|
|
1890
|
+
if (statusEl) { statusEl.textContent = 'Provisioning…'; statusEl.className = 'phone-status'; }
|
|
1891
|
+
if (btn) btn.disabled = true;
|
|
1892
|
+
try {
|
|
1893
|
+
const r = await fetch('/api/phone/numbers/buy', {
|
|
1894
|
+
method: 'POST',
|
|
1895
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1896
|
+
body: JSON.stringify({ country, areaCode: areaCode || undefined }),
|
|
1897
|
+
});
|
|
1898
|
+
const data = await r.json();
|
|
1899
|
+
if (!r.ok) {
|
|
1900
|
+
if (statusEl) { statusEl.textContent = data.error || 'Purchase failed'; statusEl.className = 'phone-status err'; }
|
|
1901
|
+
return;
|
|
1902
|
+
}
|
|
1903
|
+
if (statusEl) { statusEl.textContent = 'Got ' + formatPhoneNumber(data.phone_number); statusEl.className = 'phone-status ok'; }
|
|
1904
|
+
await loadPhone({});
|
|
1905
|
+
} catch (err) {
|
|
1906
|
+
if (statusEl) { statusEl.textContent = 'Network error'; statusEl.className = 'phone-status err'; }
|
|
1907
|
+
} finally {
|
|
1908
|
+
if (btn) btn.disabled = false;
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
function startPhoneCountdown() {
|
|
1913
|
+
if (phoneState.countdownTimer) return;
|
|
1914
|
+
// Re-render every minute so countdown chips age in place. Cheap — no
|
|
1915
|
+
// network, just DOM. Pauses when tab not visible (see visibilitychange).
|
|
1916
|
+
phoneState.countdownTimer = setInterval(() => {
|
|
1917
|
+
if (document.visibilityState !== 'visible') return;
|
|
1918
|
+
if (phoneState.data) renderPhoneNumbers(phoneState.data);
|
|
1919
|
+
}, 60000);
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
function stopPhoneCountdown() {
|
|
1923
|
+
if (phoneState.countdownTimer) {
|
|
1924
|
+
clearInterval(phoneState.countdownTimer);
|
|
1925
|
+
phoneState.countdownTimer = null;
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
document.querySelector('[data-tab="phone"]')?.addEventListener('click', () => {
|
|
1930
|
+
// Ask once for notification permission when the user first opens the tab.
|
|
1931
|
+
// We never auto-prompt on page load — that would be annoying.
|
|
1932
|
+
if (typeof Notification !== 'undefined' && Notification.permission === 'default') {
|
|
1933
|
+
Notification.requestPermission().catch(() => {});
|
|
1934
|
+
}
|
|
1935
|
+
loadPhone({});
|
|
1936
|
+
startPhoneCountdown();
|
|
1937
|
+
});
|
|
1938
|
+
|
|
1939
|
+
document.addEventListener('tab:deactivated', (e) => {
|
|
1940
|
+
if (e.detail && e.detail.name === 'phone') stopPhoneCountdown();
|
|
1941
|
+
});
|
|
1942
|
+
|
|
1943
|
+
document.getElementById('phone-refresh-btn')?.addEventListener('click', () => loadPhone({ force: true }));
|
|
1944
|
+
document.getElementById('phone-buy-btn')?.addEventListener('click', buyPhoneNumber);
|
|
1945
|
+
|
|
1946
|
+
// Prime the nav badge so an expiring number is visible even before the user
|
|
1947
|
+
// clicks into the Phone tab. Cached read — no network cost.
|
|
1948
|
+
loadPhone({});
|
|
1949
|
+
|
|
1547
1950
|
loadOverview();
|
|
1548
1951
|
loadSessions();
|
|
1549
1952
|
loadMarkets();
|