@blockrun/franklin 3.7.1 → 3.7.2
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/loop.js +13 -0
- package/dist/panel/html.js +80 -0
- package/dist/panel/server.js +39 -2
- package/dist/proxy/server.js +21 -0
- package/dist/stats/audit.d.ts +33 -0
- package/dist/stats/audit.js +86 -0
- package/package.json +1 -1
package/dist/agent/loop.js
CHANGED
|
@@ -14,6 +14,7 @@ import { classifyAgentError } from './error-classifier.js';
|
|
|
14
14
|
import { SessionToolGuard } from './tool-guard.js';
|
|
15
15
|
import { recordUsage } from '../stats/tracker.js';
|
|
16
16
|
import { recordSessionUsage } from '../stats/session-tracker.js';
|
|
17
|
+
import { appendAudit, extractLastUserPrompt } from '../stats/audit.js';
|
|
17
18
|
import { estimateCost, OPUS_PRICING } from '../pricing.js';
|
|
18
19
|
import { maybeMidSessionExtract } from '../learnings/extractor.js';
|
|
19
20
|
import { routeRequest, parseRoutingProfile } from '../router/index.js';
|
|
@@ -656,6 +657,18 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
|
|
|
656
657
|
const costEstimate = estimateCost(resolvedModel, inputTokens, usage.outputTokens, 1);
|
|
657
658
|
recordUsage(resolvedModel, inputTokens, usage.outputTokens, costEstimate, 0);
|
|
658
659
|
recordSessionUsage(resolvedModel, inputTokens, usage.outputTokens, costEstimate, routingTier);
|
|
660
|
+
appendAudit({
|
|
661
|
+
ts: Date.now(),
|
|
662
|
+
sessionId,
|
|
663
|
+
model: resolvedModel,
|
|
664
|
+
inputTokens,
|
|
665
|
+
outputTokens: usage.outputTokens,
|
|
666
|
+
costUsd: costEstimate,
|
|
667
|
+
source: 'agent',
|
|
668
|
+
workDir,
|
|
669
|
+
prompt: extractLastUserPrompt(history),
|
|
670
|
+
routingTier,
|
|
671
|
+
});
|
|
659
672
|
// Accumulate session-level totals for session meta
|
|
660
673
|
sessionInputTokens += inputTokens;
|
|
661
674
|
sessionOutputTokens += usage.outputTokens;
|
package/dist/panel/html.js
CHANGED
|
@@ -383,6 +383,10 @@ a:hover { text-decoration:underline; }
|
|
|
383
383
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>
|
|
384
384
|
Learnings
|
|
385
385
|
</button>
|
|
386
|
+
<button class="nav-item" data-tab="audit">
|
|
387
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/><path d="M11 8v3l2 1"/></svg>
|
|
388
|
+
Audit Log
|
|
389
|
+
</button>
|
|
386
390
|
</div>
|
|
387
391
|
|
|
388
392
|
<div class="sidebar-footer">
|
|
@@ -559,6 +563,31 @@ a:hover { text-decoration:underline; }
|
|
|
559
563
|
</div>
|
|
560
564
|
<div id="learnings-list"></div>
|
|
561
565
|
</div>
|
|
566
|
+
|
|
567
|
+
<!-- Audit Log -->
|
|
568
|
+
<div class="tab" id="tab-audit">
|
|
569
|
+
<div class="content-header">
|
|
570
|
+
<h2>Audit Log</h2>
|
|
571
|
+
<p>Every LLM call: prompt, model, tokens, cost. Where the money actually went.</p>
|
|
572
|
+
</div>
|
|
573
|
+
<div style="display:flex;gap:8px;align-items:center;margin-bottom:16px;flex-wrap:wrap;">
|
|
574
|
+
<label style="display:flex;align-items:center;gap:6px;font-size:13px;color:var(--text-dim);cursor:pointer;">
|
|
575
|
+
<input type="checkbox" id="audit-paid-only" style="margin:0;" /> Paid only
|
|
576
|
+
</label>
|
|
577
|
+
<select id="audit-since" style="padding:4px 8px;background:var(--bg-card);color:var(--text);border:1px solid var(--border);border-radius:6px;font-size:13px;">
|
|
578
|
+
<option value="0">All time</option>
|
|
579
|
+
<option value="3600000">Last hour</option>
|
|
580
|
+
<option value="86400000" selected>Last 24h</option>
|
|
581
|
+
<option value="604800000">Last 7 days</option>
|
|
582
|
+
<option value="2592000000">Last 30 days</option>
|
|
583
|
+
</select>
|
|
584
|
+
<input id="audit-model" placeholder="Filter by model…" style="padding:4px 8px;background:var(--bg-card);color:var(--text);border:1px solid var(--border);border-radius:6px;font-size:13px;width:180px;" />
|
|
585
|
+
<input id="audit-session" placeholder="Filter by session prefix…" style="padding:4px 8px;background:var(--bg-card);color:var(--text);border:1px solid var(--border);border-radius:6px;font-size:13px;width:180px;" />
|
|
586
|
+
<button id="audit-refresh" style="padding:4px 10px;background:var(--bg-card);color:var(--text);border:1px solid var(--border);border-radius:6px;font-size:13px;cursor:pointer;">Refresh</button>
|
|
587
|
+
<span id="audit-summary" style="margin-left:auto;font-size:13px;color:var(--text-dim);"></span>
|
|
588
|
+
</div>
|
|
589
|
+
<div id="audit-list" style="font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12px;"></div>
|
|
590
|
+
</div>
|
|
562
591
|
</div>
|
|
563
592
|
|
|
564
593
|
<script>
|
|
@@ -827,6 +856,57 @@ es.onmessage = (e) => {
|
|
|
827
856
|
try { if (JSON.parse(e.data).type === 'stats.updated') loadOverview(); } catch {}
|
|
828
857
|
};
|
|
829
858
|
|
|
859
|
+
async function loadAudit() {
|
|
860
|
+
const list = document.getElementById('audit-list');
|
|
861
|
+
const summary = document.getElementById('audit-summary');
|
|
862
|
+
if (!list) return;
|
|
863
|
+
const params = new URLSearchParams({ limit: '300' });
|
|
864
|
+
if (document.getElementById('audit-paid-only').checked) params.set('paidOnly', '1');
|
|
865
|
+
const sinceMs = parseInt(document.getElementById('audit-since').value, 10);
|
|
866
|
+
if (sinceMs > 0) params.set('since', String(Date.now() - sinceMs));
|
|
867
|
+
const model = document.getElementById('audit-model').value.trim();
|
|
868
|
+
if (model) params.set('model', model);
|
|
869
|
+
const session = document.getElementById('audit-session').value.trim();
|
|
870
|
+
if (session) params.set('session', session);
|
|
871
|
+
|
|
872
|
+
list.innerHTML = '<div style="color:var(--text-dim);padding:12px;">Loading…</div>';
|
|
873
|
+
const data = await fetch('/api/audit?' + params.toString()).then(r => r.json()).catch(() => null);
|
|
874
|
+
if (!data) { list.innerHTML = '<div style="color:var(--text-dim);padding:12px;">API offline</div>'; return; }
|
|
875
|
+
if (!data.entries.length) {
|
|
876
|
+
list.innerHTML = '<div style="color:var(--text-dim);padding:12px;">No audit entries match these filters. Run franklin and make a request.</div>';
|
|
877
|
+
summary.textContent = '0 calls';
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
summary.textContent = data.returned + ' / ' + data.total + ' calls · $' + data.totalCostUsd.toFixed(4) + ' · ' +
|
|
881
|
+
(data.totalInputTokens/1000).toFixed(1) + 'k in / ' + (data.totalOutputTokens/1000).toFixed(1) + 'k out';
|
|
882
|
+
|
|
883
|
+
list.innerHTML = data.entries.map(e => {
|
|
884
|
+
const ts = new Date(e.ts).toLocaleString('en-US', { hour12: false });
|
|
885
|
+
const cost = e.costUsd > 0
|
|
886
|
+
? '<span style="color:#fbbf24;">$' + e.costUsd.toFixed(4) + '</span>'
|
|
887
|
+
: '<span style="color:#10b981;">FREE</span>';
|
|
888
|
+
const fb = e.fallback ? ' <span style="color:#f97316;">·fb</span>' : '';
|
|
889
|
+
const sid = e.sessionId ? ' <span style="color:var(--text-dim);">' + esc(e.sessionId.slice(0,8)) + '</span>' : '';
|
|
890
|
+
const prompt = e.prompt
|
|
891
|
+
? '<div style="color:var(--text-dim);padding:2px 0 4px 16px;white-space:pre-wrap;word-break:break-word;">"' + esc(e.prompt) + '"</div>'
|
|
892
|
+
: '';
|
|
893
|
+
const dir = e.workDir ? '<div style="color:var(--text-dim);padding:0 0 0 16px;font-size:11px;">📁 ' + esc(e.workDir) + '</div>' : '';
|
|
894
|
+
return '<div style="padding:8px 12px;border-bottom:1px solid var(--border);">' +
|
|
895
|
+
'<div><span style="color:var(--text-dim);">' + ts + '</span> ' + cost + ' <span style="color:#60a5fa;">' + esc(e.model) + '</span> ' +
|
|
896
|
+
'<span style="color:var(--text-dim);">in=' + e.inputTokens + ' out=' + e.outputTokens + '</span> ' +
|
|
897
|
+
'<span style="color:var(--text-dim);">[' + esc(e.source) + ']' + fb + '</span>' + sid + '</div>' +
|
|
898
|
+
prompt + dir +
|
|
899
|
+
'</div>';
|
|
900
|
+
}).join('');
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
['audit-paid-only','audit-since','audit-model','audit-session'].forEach(id => {
|
|
904
|
+
const el = document.getElementById(id);
|
|
905
|
+
if (el) el.addEventListener(el.tagName === 'INPUT' && el.type === 'text' ? 'input' : 'change', () => loadAudit());
|
|
906
|
+
});
|
|
907
|
+
document.getElementById('audit-refresh')?.addEventListener('click', loadAudit);
|
|
908
|
+
document.querySelector('[data-tab="audit"]')?.addEventListener('click', loadAudit);
|
|
909
|
+
|
|
830
910
|
loadOverview();
|
|
831
911
|
loadSessions();
|
|
832
912
|
loadSocial();
|
package/dist/panel/server.js
CHANGED
|
@@ -12,6 +12,7 @@ import { generateInsights } from '../stats/insights.js';
|
|
|
12
12
|
import { listSessions, loadSessionHistory } from '../session/storage.js';
|
|
13
13
|
import { searchSessions } from '../session/search.js';
|
|
14
14
|
import { loadLearnings } from '../learnings/store.js';
|
|
15
|
+
import { readAudit } from '../stats/audit.js';
|
|
15
16
|
import { getStats as getSocialStats } from '../social/db.js';
|
|
16
17
|
import { getHTML } from './html.js';
|
|
17
18
|
const sseClients = new Set();
|
|
@@ -129,6 +130,37 @@ export function createPanelServer(port) {
|
|
|
129
130
|
json(res, report);
|
|
130
131
|
return;
|
|
131
132
|
}
|
|
133
|
+
if (p === '/api/audit') {
|
|
134
|
+
// Per-call LLM audit log — prompt, model, tokens, cost per call.
|
|
135
|
+
// Supports ?limit=N&paidOnly=1&since=<ms>&session=<prefix>&model=<substr>
|
|
136
|
+
const limit = Math.min(parseInt(url.searchParams.get('limit') || '200', 10), 2000);
|
|
137
|
+
const paidOnly = url.searchParams.get('paidOnly') === '1';
|
|
138
|
+
const since = parseInt(url.searchParams.get('since') || '0', 10);
|
|
139
|
+
const sessionFilter = url.searchParams.get('session') || '';
|
|
140
|
+
const modelFilter = url.searchParams.get('model') || '';
|
|
141
|
+
let entries = readAudit();
|
|
142
|
+
if (since > 0)
|
|
143
|
+
entries = entries.filter(e => e.ts >= since);
|
|
144
|
+
if (paidOnly)
|
|
145
|
+
entries = entries.filter(e => e.costUsd > 0);
|
|
146
|
+
if (sessionFilter)
|
|
147
|
+
entries = entries.filter(e => e.sessionId?.startsWith(sessionFilter));
|
|
148
|
+
if (modelFilter)
|
|
149
|
+
entries = entries.filter(e => e.model.includes(modelFilter));
|
|
150
|
+
const recent = entries.slice(-limit).reverse(); // newest first
|
|
151
|
+
const totalCost = entries.reduce((s, e) => s + e.costUsd, 0);
|
|
152
|
+
const totalIn = entries.reduce((s, e) => s + e.inputTokens, 0);
|
|
153
|
+
const totalOut = entries.reduce((s, e) => s + e.outputTokens, 0);
|
|
154
|
+
json(res, {
|
|
155
|
+
total: entries.length,
|
|
156
|
+
returned: recent.length,
|
|
157
|
+
totalCostUsd: totalCost,
|
|
158
|
+
totalInputTokens: totalIn,
|
|
159
|
+
totalOutputTokens: totalOut,
|
|
160
|
+
entries: recent,
|
|
161
|
+
});
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
132
164
|
if (p === '/api/sessions') {
|
|
133
165
|
const sessions = listSessions();
|
|
134
166
|
json(res, sessions);
|
|
@@ -310,13 +342,18 @@ export function createPanelServer(port) {
|
|
|
310
342
|
console.error('[panel] request error:', err.message);
|
|
311
343
|
}
|
|
312
344
|
});
|
|
313
|
-
// Swallow socket errors (client disconnects, etc.) so they don't crash the process
|
|
345
|
+
// Swallow socket errors (client disconnects, etc.) so they don't crash the process.
|
|
346
|
+
// ECONNRESET / EPIPE happen every time a browser tab closes an SSE stream — pure noise.
|
|
314
347
|
server.on('clientError', (err, socket) => {
|
|
315
348
|
try {
|
|
316
349
|
socket.destroy();
|
|
317
350
|
}
|
|
318
351
|
catch { /* already closed */ }
|
|
319
|
-
|
|
352
|
+
if (err.code === 'ECONNRESET' || err.code === 'EPIPE')
|
|
353
|
+
return;
|
|
354
|
+
if (process.env.FRANKLIN_PANEL_DEBUG) {
|
|
355
|
+
console.error('[panel] client error:', err.message);
|
|
356
|
+
}
|
|
320
357
|
});
|
|
321
358
|
// Watch stats file for changes → push to SSE clients
|
|
322
359
|
const statsFile = fs.existsSync(path.join(BLOCKRUN_DIR, 'franklin-stats.json'))
|
package/dist/proxy/server.js
CHANGED
|
@@ -4,6 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import { getOrCreateWallet, getOrCreateSolanaWallet, createPaymentPayload, createSolanaPaymentPayload, parsePaymentRequired, extractPaymentDetails, solanaKeyToBytes, SOLANA_NETWORK, } from '@blockrun/llm';
|
|
6
6
|
import { recordUsage } from '../stats/tracker.js';
|
|
7
|
+
import { appendAudit } from '../stats/audit.js';
|
|
7
8
|
import { fetchWithFallback, buildFallbackChain, DEFAULT_FALLBACK_CONFIG, ROUTING_PROFILES, } from './fallback.js';
|
|
8
9
|
import { routeRequest, parseRoutingProfile, } from '../router/index.js';
|
|
9
10
|
import { estimateCost } from '../pricing.js';
|
|
@@ -481,6 +482,16 @@ export function createProxy(options) {
|
|
|
481
482
|
const latencyMs = Date.now() - requestStartTime;
|
|
482
483
|
const cost = estimateCost(finalModel, inputTokens, outputTokens);
|
|
483
484
|
recordUsage(finalModel, inputTokens, outputTokens, cost, latencyMs, usedFallback);
|
|
485
|
+
appendAudit({
|
|
486
|
+
ts: Date.now(),
|
|
487
|
+
model: finalModel,
|
|
488
|
+
inputTokens,
|
|
489
|
+
outputTokens,
|
|
490
|
+
costUsd: cost,
|
|
491
|
+
latencyMs,
|
|
492
|
+
fallback: usedFallback,
|
|
493
|
+
source: 'proxy',
|
|
494
|
+
});
|
|
484
495
|
debug(options, `recorded: model=${finalModel} in=${inputTokens} out=${outputTokens} cost=$${cost.toFixed(4)} fallback=${usedFallback}`);
|
|
485
496
|
}
|
|
486
497
|
}
|
|
@@ -510,6 +521,16 @@ export function createProxy(options) {
|
|
|
510
521
|
const latencyMs = Date.now() - requestStartTime;
|
|
511
522
|
const cost = estimateCost(finalModel, inputTokens, outputTokens);
|
|
512
523
|
recordUsage(finalModel, inputTokens, outputTokens, cost, latencyMs, usedFallback);
|
|
524
|
+
appendAudit({
|
|
525
|
+
ts: Date.now(),
|
|
526
|
+
model: finalModel,
|
|
527
|
+
inputTokens,
|
|
528
|
+
outputTokens,
|
|
529
|
+
costUsd: cost,
|
|
530
|
+
latencyMs,
|
|
531
|
+
fallback: usedFallback,
|
|
532
|
+
source: 'proxy',
|
|
533
|
+
});
|
|
513
534
|
debug(options, `recorded: model=${finalModel} in=${inputTokens} out=${outputTokens} cost=$${cost.toFixed(4)} fallback=${usedFallback}`);
|
|
514
535
|
}
|
|
515
536
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audit log — append-only forensic record of every LLM call.
|
|
3
|
+
*
|
|
4
|
+
* Lives at ~/.blockrun/franklin-audit.jsonl. One line per call, JSONL.
|
|
5
|
+
* Unlike franklin-stats.json (aggregates), this file lets you answer
|
|
6
|
+
* "what was I actually doing when $1.50 disappeared on Apr 12?".
|
|
7
|
+
*
|
|
8
|
+
* Fields kept intentionally small (truncated prompt, no tool args) so the
|
|
9
|
+
* file stays readable and doesn't leak large tool outputs to disk.
|
|
10
|
+
*/
|
|
11
|
+
export interface AuditEntry {
|
|
12
|
+
ts: number;
|
|
13
|
+
sessionId?: string;
|
|
14
|
+
model: string;
|
|
15
|
+
inputTokens: number;
|
|
16
|
+
outputTokens: number;
|
|
17
|
+
costUsd: number;
|
|
18
|
+
latencyMs?: number;
|
|
19
|
+
fallback?: boolean;
|
|
20
|
+
source: 'agent' | 'proxy' | 'subagent' | 'moa' | 'plugin';
|
|
21
|
+
workDir?: string;
|
|
22
|
+
prompt?: string;
|
|
23
|
+
toolCalls?: string[];
|
|
24
|
+
routingTier?: string;
|
|
25
|
+
}
|
|
26
|
+
export declare function appendAudit(entry: AuditEntry): void;
|
|
27
|
+
export declare function getAuditFilePath(): string;
|
|
28
|
+
export declare function readAudit(): AuditEntry[];
|
|
29
|
+
/** Pull the last user message from a Dialogue history, flatten, and strip newlines. */
|
|
30
|
+
export declare function extractLastUserPrompt(history: Array<{
|
|
31
|
+
role: string;
|
|
32
|
+
content: unknown;
|
|
33
|
+
}>): string | undefined;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audit log — append-only forensic record of every LLM call.
|
|
3
|
+
*
|
|
4
|
+
* Lives at ~/.blockrun/franklin-audit.jsonl. One line per call, JSONL.
|
|
5
|
+
* Unlike franklin-stats.json (aggregates), this file lets you answer
|
|
6
|
+
* "what was I actually doing when $1.50 disappeared on Apr 12?".
|
|
7
|
+
*
|
|
8
|
+
* Fields kept intentionally small (truncated prompt, no tool args) so the
|
|
9
|
+
* file stays readable and doesn't leak large tool outputs to disk.
|
|
10
|
+
*/
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { BLOCKRUN_DIR } from '../config.js';
|
|
14
|
+
const AUDIT_FILE = path.join(BLOCKRUN_DIR, 'franklin-audit.jsonl');
|
|
15
|
+
const PROMPT_PREVIEW_CHARS = 240;
|
|
16
|
+
export function appendAudit(entry) {
|
|
17
|
+
try {
|
|
18
|
+
fs.mkdirSync(BLOCKRUN_DIR, { recursive: true });
|
|
19
|
+
const safe = {
|
|
20
|
+
...entry,
|
|
21
|
+
prompt: entry.prompt ? truncate(entry.prompt, PROMPT_PREVIEW_CHARS) : undefined,
|
|
22
|
+
};
|
|
23
|
+
fs.appendFileSync(AUDIT_FILE, JSON.stringify(safe) + '\n');
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
/* best-effort — never break the agent loop on audit-write failure */
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function getAuditFilePath() {
|
|
30
|
+
return AUDIT_FILE;
|
|
31
|
+
}
|
|
32
|
+
export function readAudit() {
|
|
33
|
+
try {
|
|
34
|
+
if (!fs.existsSync(AUDIT_FILE))
|
|
35
|
+
return [];
|
|
36
|
+
const lines = fs.readFileSync(AUDIT_FILE, 'utf-8').split('\n');
|
|
37
|
+
const out = [];
|
|
38
|
+
for (const line of lines) {
|
|
39
|
+
if (!line.trim())
|
|
40
|
+
continue;
|
|
41
|
+
try {
|
|
42
|
+
out.push(JSON.parse(line));
|
|
43
|
+
}
|
|
44
|
+
catch { /* skip malformed line */ }
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** Pull the last user message from a Dialogue history, flatten, and strip newlines. */
|
|
53
|
+
export function extractLastUserPrompt(history) {
|
|
54
|
+
for (let i = history.length - 1; i >= 0; i--) {
|
|
55
|
+
const msg = history[i];
|
|
56
|
+
if (msg.role !== 'user')
|
|
57
|
+
continue;
|
|
58
|
+
const text = flattenContent(msg.content);
|
|
59
|
+
if (!text)
|
|
60
|
+
continue;
|
|
61
|
+
return text.replace(/\s+/g, ' ').trim();
|
|
62
|
+
}
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
function flattenContent(content) {
|
|
66
|
+
if (typeof content === 'string')
|
|
67
|
+
return content;
|
|
68
|
+
if (!Array.isArray(content))
|
|
69
|
+
return '';
|
|
70
|
+
const parts = [];
|
|
71
|
+
for (const block of content) {
|
|
72
|
+
if (typeof block === 'string') {
|
|
73
|
+
parts.push(block);
|
|
74
|
+
}
|
|
75
|
+
else if (block && typeof block === 'object') {
|
|
76
|
+
const b = block;
|
|
77
|
+
// Skip tool_result blocks — they're tool output, not user intent
|
|
78
|
+
if (b.type === 'text' && typeof b.text === 'string')
|
|
79
|
+
parts.push(b.text);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return parts.join(' ');
|
|
83
|
+
}
|
|
84
|
+
function truncate(s, n) {
|
|
85
|
+
return s.length > n ? s.slice(0, n) + '…' : s;
|
|
86
|
+
}
|
package/package.json
CHANGED