@blockrun/franklin 3.15.87 → 3.15.89

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.
@@ -1,6 +1,8 @@
1
1
  import chalk from 'chalk';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
2
4
  import { getOrCreateWallet, getOrCreateSolanaWallet } from '@blockrun/llm';
3
- import { loadChain, API_URLS } from '../config.js';
5
+ import { BLOCKRUN_DIR, loadChain, API_URLS } from '../config.js';
4
6
  import { retryFetchBalance } from './balance-retry.js';
5
7
  import { flushStats, loadStats } from '../stats/tracker.js';
6
8
  import { OPUS_PRICING, MODEL_PRICING } from '../pricing.js';
@@ -647,7 +649,18 @@ async function startPanelBackground(startPort) {
647
649
  });
648
650
  server.listen(port, '127.0.0.1', () => {
649
651
  server.unref?.();
650
- resolve(`http://localhost:${port}`);
652
+ const url = `http://localhost:${port}`;
653
+ // Persist the bound URL so the agent context (assembled per-turn)
654
+ // can point users at /#wallet for funding without baking in the
655
+ // 3100 default — the panel auto-increments past EADDRINUSE.
656
+ // Best-effort write: a stale file from a crashed run is harmless,
657
+ // since the user just sees a dead link.
658
+ try {
659
+ fs.mkdirSync(BLOCKRUN_DIR, { recursive: true });
660
+ fs.writeFileSync(path.join(BLOCKRUN_DIR, 'panel-url'), url, 'utf8');
661
+ }
662
+ catch { /* best-effort */ }
663
+ resolve(url);
651
664
  });
652
665
  };
653
666
  tryListen(startPort, 0);
@@ -21,7 +21,7 @@ const VALID_CATEGORIES = new Set([
21
21
  const EXTRACTION_PROMPT = `You are analyzing a conversation between a user and an AI coding agent. Extract user preferences, behavioral patterns, and project knowledge that would help personalize future interactions.
22
22
 
23
23
  Analyze for:
24
- 1. Language — what language does the user write in? (English, Chinese, mixed?)
24
+ 1. Language — what language does the user write in? (English, another language, mixed?)
25
25
  2. Model preferences — did they switch models or express a preference?
26
26
  3. Coding style — did they correct the agent's code style? (naming, formatting, conventions)
27
27
  4. Communication — are they terse or verbose? Do they want explanations or just code?
@@ -1,6 +1,7 @@
1
1
  import http from 'node:http';
2
2
  import { getOrCreateWallet, getOrCreateSolanaWallet, createPaymentPayload, createSolanaPaymentPayload, parsePaymentRequired, extractPaymentDetails, solanaKeyToBytes, SOLANA_NETWORK, } from '@blockrun/llm';
3
3
  import { recordUsage } from '../stats/tracker.js';
4
+ import { appendSettlementRow } from '../stats/cost-log.js';
4
5
  import { appendAudit } from '../stats/audit.js';
5
6
  import { buildFallbackChain, DEFAULT_FALLBACK_CONFIG, ROUTING_PROFILES, } from './fallback.js';
6
7
  import { routeRequest, parseRoutingProfile, } from '../router/index.js';
@@ -430,6 +431,11 @@ export function createProxy(options) {
430
431
  };
431
432
  let response;
432
433
  let finalModel = requestModel;
434
+ // Real x402 charge for the call that ultimately succeeded. 0 when
435
+ // no payment was needed (free model / cached). Fed into recordUsage
436
+ // and appendAudit below so franklin-stats.json reflects what the
437
+ // wallet actually paid, not a token-catalog estimate.
438
+ let paidUsd = 0;
433
439
  const requestTimeoutMs = effectiveRequestTimeoutMs;
434
440
  // Use fallback chain if enabled
435
441
  if (fallbackEnabled && body && requestPath.includes('messages')) {
@@ -457,6 +463,7 @@ export function createProxy(options) {
457
463
  // Use the body with the correct fallback model for payment
458
464
  body = result.bodyUsed;
459
465
  usedFallback = result.fallbackUsed;
466
+ paidUsd = result.paidUsd;
460
467
  // Skip the success log when the request originated from a test
461
468
  // fixture, even if the fallback ended on a real model. Verified
462
469
  // on a real machine: 5 spurious "↺ Fallback successful: using
@@ -473,7 +480,7 @@ export function createProxy(options) {
473
480
  }
474
481
  }
475
482
  else {
476
- response = await fetchModelAttempt(targetUrl, requestInit, body, requestModel, {
483
+ const attempt = await fetchModelAttempt(targetUrl, requestInit, body, requestModel, {
477
484
  method: req.method || 'POST',
478
485
  headers,
479
486
  chain,
@@ -481,6 +488,8 @@ export function createProxy(options) {
481
488
  solanaWallet,
482
489
  timeoutMs: requestTimeoutMs,
483
490
  });
491
+ response = attempt.response;
492
+ paidUsd = attempt.paidUsd;
484
493
  }
485
494
  const responseHeaders = {};
486
495
  response.headers.forEach((v, k) => {
@@ -569,7 +578,13 @@ export function createProxy(options) {
569
578
  if (outputTokens > 0) {
570
579
  trackOutputTokens(finalModel, outputTokens);
571
580
  const latencyMs = Date.now() - requestStartTime;
572
- const cost = estimateCost(finalModel, inputTokens, outputTokens);
581
+ // Real x402 charge wins over the token-catalog estimate.
582
+ // estimateCost only fills in for the no-payment path
583
+ // (free models / cached) so stats stay non-null there.
584
+ const cost = paidUsd > 0
585
+ ? paidUsd
586
+ : estimateCost(finalModel, inputTokens, outputTokens);
587
+ const costSource = paidUsd > 0 ? 'charged' : 'estimated';
573
588
  recordUsage(finalModel, inputTokens, outputTokens, cost, latencyMs, usedFallback);
574
589
  appendAudit({
575
590
  ts: Date.now(),
@@ -582,7 +597,7 @@ export function createProxy(options) {
582
597
  source: 'proxy',
583
598
  });
584
599
  if (options.debug)
585
- logger.debug(`[franklin] recorded: model=${finalModel} in=${inputTokens} out=${outputTokens} cost=$${cost.toFixed(4)} fallback=${usedFallback}`);
600
+ logger.debug(`[franklin] recorded: model=${finalModel} in=${inputTokens} out=${outputTokens} cost=$${cost.toFixed(4)} (${costSource}) fallback=${usedFallback}`);
586
601
  }
587
602
  }
588
603
  res.end();
@@ -609,7 +624,10 @@ export function createProxy(options) {
609
624
  trackOutputTokens(finalModel, outputTokens);
610
625
  const inputTokens = parsed.usage?.input_tokens || 0;
611
626
  const latencyMs = Date.now() - requestStartTime;
612
- const cost = estimateCost(finalModel, inputTokens, outputTokens);
627
+ const cost = paidUsd > 0
628
+ ? paidUsd
629
+ : estimateCost(finalModel, inputTokens, outputTokens);
630
+ const costSource = paidUsd > 0 ? 'charged' : 'estimated';
613
631
  recordUsage(finalModel, inputTokens, outputTokens, cost, latencyMs, usedFallback);
614
632
  appendAudit({
615
633
  ts: Date.now(),
@@ -622,7 +640,7 @@ export function createProxy(options) {
622
640
  source: 'proxy',
623
641
  });
624
642
  if (options.debug)
625
- logger.debug(`[franklin] recorded: model=${finalModel} in=${inputTokens} out=${outputTokens} cost=$${cost.toFixed(4)} fallback=${usedFallback}`);
643
+ logger.debug(`[franklin] recorded: model=${finalModel} in=${inputTokens} out=${outputTokens} cost=$${cost.toFixed(4)} (${costSource}) fallback=${usedFallback}`);
626
644
  }
627
645
  }
628
646
  catch {
@@ -645,16 +663,17 @@ export function createProxy(options) {
645
663
  return server;
646
664
  }
647
665
  async function fetchModelAttempt(url, init, body, model, payment) {
648
- let response = await fetchWithTimeout(url, { ...init, body: body || undefined }, payment.timeoutMs, `Proxy request for ${model}`);
666
+ const response = await fetchWithTimeout(url, { ...init, body: body || undefined }, payment.timeoutMs, `Proxy request for ${model}`);
667
+ // Non-402 path: free model or cached response — no payment, paidUsd = 0.
649
668
  if (response.status !== 402)
650
- return response;
669
+ return { response, paidUsd: 0 };
651
670
  if (payment.chain === 'solana' && payment.solanaWallet) {
652
671
  return handleSolanaPayment(response, url, payment.method, payment.headers, body, payment.solanaWallet.privateKey, payment.solanaWallet.address, payment.timeoutMs, model);
653
672
  }
654
673
  if (payment.baseWallet) {
655
674
  return handleBasePayment(response, url, payment.method, payment.headers, body, payment.baseWallet.privateKey, payment.baseWallet.address, payment.timeoutMs, model);
656
675
  }
657
- return response;
676
+ return { response, paidUsd: 0 };
658
677
  }
659
678
  /**
660
679
  * Try each fallback model as a full x402 attempt:
@@ -670,7 +689,7 @@ async function fetchWithPaymentFallback(url, init, originalBody, config, payment
670
689
  const body = replaceModelInBody(originalBody, model);
671
690
  try {
672
691
  attempts++;
673
- const response = await fetchModelAttempt(url, init, body, model, payment);
692
+ const { response, paidUsd } = await fetchModelAttempt(url, init, body, model, payment);
674
693
  if (!config.retryOn.includes(response.status)) {
675
694
  return {
676
695
  response,
@@ -679,6 +698,7 @@ async function fetchWithPaymentFallback(url, init, originalBody, config, payment
679
698
  fallbackUsed: i > 0,
680
699
  attemptsCount: attempts,
681
700
  failedModels,
701
+ paidUsd,
682
702
  };
683
703
  }
684
704
  try {
@@ -719,17 +739,24 @@ function sleep(ms) {
719
739
  async function handleBasePayment(response, url, method, headers, body, privateKey, fromAddress, timeoutMs = getProxyRequestTimeoutMs(), model = 'unknown') {
720
740
  const paymentHeader = await extractPaymentHeader(response);
721
741
  if (!paymentHeader) {
722
- throw new Error('402 Payment Required — wallet may need funding. Run: franklin balance');
742
+ throw new Error('402 Payment Required — wallet may need funding. Open http://localhost:3100/#wallet to deposit USDC (or run: franklin balance)');
723
743
  }
724
744
  const paymentRequired = parsePaymentRequired(paymentHeader);
725
745
  const details = extractPaymentDetails(paymentRequired);
746
+ const paidUsd = paymentAmountToUsd(details.amount);
747
+ appendSettlementRow(extractEndpointPath(url), paidUsd, {
748
+ model,
749
+ wallet: fromAddress,
750
+ network: details.network || 'base-mainnet',
751
+ client_kind: 'ProxyClient',
752
+ });
726
753
  const paymentPayload = await createPaymentPayload(privateKey, fromAddress, details.recipient, details.amount, details.network || 'eip155:8453', {
727
754
  resourceUrl: details.resource?.url || url,
728
755
  resourceDescription: details.resource?.description || 'BlockRun AI API call',
729
756
  maxTimeoutSeconds: details.maxTimeoutSeconds || 300,
730
757
  extra: details.extra,
731
758
  });
732
- return fetchWithTimeout(url, {
759
+ const paid = await fetchWithTimeout(url, {
733
760
  method,
734
761
  headers: {
735
762
  ...headers,
@@ -737,6 +764,7 @@ async function handleBasePayment(response, url, method, headers, body, privateKe
737
764
  },
738
765
  body: body || undefined,
739
766
  }, timeoutMs, `Paid proxy request for ${model}`);
767
+ return { response: paid, paidUsd };
740
768
  }
741
769
  // ======================================================================
742
770
  // Solana payment handler
@@ -744,10 +772,17 @@ async function handleBasePayment(response, url, method, headers, body, privateKe
744
772
  async function handleSolanaPayment(response, url, method, headers, body, privateKey, fromAddress, timeoutMs = getProxyRequestTimeoutMs(), model = 'unknown') {
745
773
  const paymentHeader = await extractPaymentHeader(response);
746
774
  if (!paymentHeader) {
747
- throw new Error('402 Payment Required — wallet may need funding. Run: franklin balance');
775
+ throw new Error('402 Payment Required — wallet may need funding. Open http://localhost:3100/#wallet to deposit USDC (or run: franklin balance)');
748
776
  }
749
777
  const paymentRequired = parsePaymentRequired(paymentHeader);
750
778
  const details = extractPaymentDetails(paymentRequired, SOLANA_NETWORK);
779
+ const paidUsd = paymentAmountToUsd(details.amount);
780
+ appendSettlementRow(extractEndpointPath(url), paidUsd, {
781
+ model,
782
+ wallet: fromAddress,
783
+ network: details.network || 'solana-mainnet',
784
+ client_kind: 'ProxyClient',
785
+ });
751
786
  const secretKey = await solanaKeyToBytes(privateKey);
752
787
  const feePayer = details.extra?.feePayer || details.recipient;
753
788
  const paymentPayload = await createSolanaPaymentPayload(secretKey, fromAddress, details.recipient, details.amount, feePayer, {
@@ -756,7 +791,7 @@ async function handleSolanaPayment(response, url, method, headers, body, private
756
791
  maxTimeoutSeconds: details.maxTimeoutSeconds || 300,
757
792
  extra: details.extra,
758
793
  });
759
- return fetchWithTimeout(url, {
794
+ const paid = await fetchWithTimeout(url, {
760
795
  method,
761
796
  headers: {
762
797
  ...headers,
@@ -764,6 +799,35 @@ async function handleSolanaPayment(response, url, method, headers, body, private
764
799
  },
765
800
  body: body || undefined,
766
801
  }, timeoutMs, `Paid proxy request for ${model}`);
802
+ return { response: paid, paidUsd };
803
+ }
804
+ /**
805
+ * Extract just the path portion of a URL — `https://api.blockrun.ai/v1/messages`
806
+ * → `/v1/messages`. Used as the `endpoint` field in `cost_log.jsonl` so
807
+ * proxy entries match the SDK's path-only convention. Falls back to the
808
+ * raw input if URL parsing throws (defensive — better to log a weird
809
+ * string than skip the row).
810
+ */
811
+ function extractEndpointPath(url) {
812
+ try {
813
+ return new URL(url).pathname || url;
814
+ }
815
+ catch {
816
+ return url;
817
+ }
818
+ }
819
+ /**
820
+ * Convert an x402 `details.amount` field (USDC in micro-units, 6 decimals)
821
+ * to a USD float. Mirrors the SDK's `appendCostLog` math so the proxy and
822
+ * `cost_log.jsonl` agree to the cent.
823
+ */
824
+ function paymentAmountToUsd(amount) {
825
+ if (amount === undefined || amount === null)
826
+ return 0;
827
+ const n = typeof amount === 'string' ? parseFloat(amount) : amount;
828
+ if (!Number.isFinite(n))
829
+ return 0;
830
+ return n / 1e6;
767
831
  }
768
832
  export function classifyRequest(body) {
769
833
  try {
@@ -4,44 +4,42 @@
4
4
  * using keyword matching from router weights or built-in defaults.
5
5
  */
6
6
  // Built-in category keywords (used when no learned weights available)
7
+ // Keyword fast-path uses English only by policy (English-only-source rule).
8
+ // Non-English user queries route through the LLM-level classifier above this
9
+ // fast-path, which is multilingual and handles intent correctly without
10
+ // needing a per-language keyword list here.
7
11
  const DEFAULT_CATEGORY_KEYWORDS = {
8
12
  coding: [
9
13
  'function', 'class', 'import', 'def', 'SELECT', 'async', 'await',
10
14
  'const', 'let', 'var', 'return', '```', 'bug', 'error', 'fix',
11
15
  'refactor', 'implement', 'test', 'npm', 'pip', 'git', 'deploy',
12
16
  'API', 'endpoint', 'database', 'query', 'migration', 'lint',
13
- '函数', '类', '导入', '修复', '调试', '部署',
14
17
  ],
15
18
  trading: [
16
19
  'BTC', 'ETH', 'SOL', 'bitcoin', 'ethereum', 'solana', 'crypto',
17
20
  'price', 'market', 'signal', 'trade', 'buy', 'sell', 'RSI',
18
21
  'MACD', 'volume', 'bullish', 'bearish', 'support', 'resistance',
19
22
  'portfolio', 'risk', 'leverage', 'DeFi', 'token', 'swap',
20
- '比特币', '以太坊', '价格', '市场', '交易', '信号',
21
23
  ],
22
24
  reasoning: [
23
25
  'prove', 'theorem', 'derive', 'step by step', 'chain of thought',
24
26
  'formally', 'mathematical', 'proof', 'logically', 'analyze',
25
27
  'compare', 'evaluate', 'trade-off', 'pros and cons', 'why',
26
28
  'explain why', 'reasoning', 'logic', 'deduce', 'infer',
27
- '证明', '定理', '推导', '分析', '比较',
28
29
  ],
29
30
  creative: [
30
31
  'write a story', 'poem', 'creative', 'brainstorm', 'imagine',
31
32
  'generate an image', 'design', 'logo', 'illustration', 'art',
32
33
  'narrative', 'fiction', 'song', 'lyrics', 'slogan', 'tagline',
33
- '写一个故事', '诗', '创意', '设计', '头脑风暴',
34
34
  ],
35
35
  research: [
36
36
  'search', 'find', 'look up', 'what is', 'who is', 'when was',
37
37
  'summarize', 'report', 'overview', 'comparison', 'review',
38
38
  'article', 'paper', 'study', 'data', 'statistics', 'trend',
39
- '搜索', '查找', '什么是', '总结', '报告',
40
39
  ],
41
40
  chat: [
42
41
  'hello', 'hi', 'thanks', 'thank you', 'how are you', 'help',
43
42
  'translate', 'yes', 'no', 'ok', 'sure', 'good',
44
- '你好', '谢谢', '帮我', '翻译',
45
43
  ],
46
44
  };
47
45
  /**
@@ -70,13 +70,18 @@ const AUTO_TIERS = {
70
70
  },
71
71
  };
72
72
  // ─── Keywords for Classification ───
73
+ //
74
+ // Keyword fast-path uses English only by policy (English-only-source rule).
75
+ // Non-English user queries route through the LLM-level classifier above this
76
+ // fast-path, which is multilingual and handles intent correctly without
77
+ // needing per-language keyword lists here.
73
78
  const CODE_KEYWORDS = [
74
79
  'function', 'class', 'import', 'def', 'SELECT', 'async', 'await',
75
- 'const', 'let', 'var', 'return', '```', '函数', '类', '导入',
80
+ 'const', 'let', 'var', 'return', '```',
76
81
  ];
77
82
  const REASONING_KEYWORDS = [
78
83
  'prove', 'theorem', 'derive', 'step by step', 'chain of thought',
79
- 'formally', 'mathematical', 'proof', 'logically', '证明', '定理', '推导',
84
+ 'formally', 'mathematical', 'proof', 'logically',
80
85
  ];
81
86
  const SIMPLE_KEYWORDS = [
82
87
  // True simple intents: greeting, definition lookup, translation. Factual
@@ -84,7 +89,7 @@ const SIMPLE_KEYWORDS = [
84
89
  // because they look easy but require external recall — sending them to
85
90
  // SIMPLE-tier models reliably produces hallucinated subscriber counts,
86
91
  // birth years, etc. that the post-hoc grounding check then has to flag.
87
- 'define', 'translate', 'hello', 'yes or no', '翻译', '你好',
92
+ 'define', 'translate', 'hello', 'yes or no',
88
93
  ];
89
94
  // Research / fact-retrieval intent: questions whose correct answer depends
90
95
  // on data the model can't reliably recall from weights — current statistics,
@@ -98,19 +103,16 @@ const RESEARCH_KEYWORDS = [
98
103
  'best', 'top ', 'most popular', 'compare', 'vs ', ' vs.',
99
104
  'latest', 'current', 'recent', 'today', 'now',
100
105
  'subscribers', 'members', 'followers', 'market cap', 'price of',
101
- '最好的', '最新', '最近', '现在', '当前', '排名', '对比',
102
106
  ];
103
107
  const TECHNICAL_KEYWORDS = [
104
108
  'algorithm', 'optimize', 'architecture', 'distributed', 'kubernetes',
105
- 'microservice', 'database', 'infrastructure', '算法', '架构', '优化',
109
+ 'microservice', 'database', 'infrastructure',
106
110
  ];
107
111
  const AGENTIC_KEYWORDS = [
108
112
  'read file', 'edit', 'modify', 'update', 'create file', 'execute',
109
113
  'deploy', 'install', 'npm', 'pip', 'fix', 'debug', 'verify',
110
114
  'commit', 'push', 'pull', 'merge', 'rename', 'replace', 'delete',
111
115
  'remove', 'add', 'change', 'move', 'refactor', 'migrate',
112
- '编辑', '修改', '部署', '安装', '修复', '调试',
113
- '更新', '替换', '删除', '添加', '提交', '改',
114
116
  ];
115
117
  // URL patterns that signal agentic/coding tasks
116
118
  const AGENTIC_URL_PATTERNS = [
@@ -223,7 +225,7 @@ function classifyRequest(prompt, tokenCount) {
223
225
  // Imperative verbs (build, create, implement, etc.)
224
226
  const imperativeMatches = countMatches(prompt, [
225
227
  'build', 'create', 'implement', 'design', 'develop', 'write', 'make',
226
- 'generate', 'construct', '构建', '创建', '实现', '设计', '开发'
228
+ 'generate', 'construct',
227
229
  ]);
228
230
  if (imperativeMatches >= 1) {
229
231
  score += 0.15;
@@ -51,4 +51,4 @@ export declare function extractArticleBlocks(tree: string): Array<{
51
51
  * This doubles as the "this is a tweet" signal in social-bot — the only link
52
52
  * inside an article block with this label shape is the permalink to the tweet.
53
53
  */
54
- export declare const X_TIME_LINK_PATTERN = "(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\\s+\\d+(?:,?\\s+\\d{4})?|\\d+[smhd]|\\d+\\s+(?:second|minute|hour|day|week|month|year)s?\\s+ago|just now|now|yesterday|\\d{1,2}:\\d{2}\\s*[AaPp][Mm]|\\d{4}\u5E74\\d{1,2}\u6708\\d{1,2}\u65E5";
54
+ export declare const X_TIME_LINK_PATTERN = "(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\\s+\\d+(?:,?\\s+\\d{4})?|\\d+[smhd]|\\d+\\s+(?:second|minute|hour|day|week|month|year)s?\\s+ago|just now|now|yesterday|\\d{1,2}:\\d{2}\\s*[AaPp][Mm]|\\d{4}\\p{Script=Han}\\d{1,2}\\p{Script=Han}\\d{1,2}\\p{Script=Han}";
@@ -23,7 +23,7 @@
23
23
  * @returns Array of ref ids like ["0-0", "1-3"] in document order
24
24
  */
25
25
  export function findRefs(tree, role, label = '.*') {
26
- const re = new RegExp(`\\[(\\d+-\\d+)\\]\\s+${escapeRegex(role)}:\\s*${label}`, 'g');
26
+ const re = new RegExp(`\\[(\\d+-\\d+)\\]\\s+${escapeRegex(role)}:\\s*(?:${label})`, 'gu');
27
27
  const out = [];
28
28
  let m;
29
29
  while ((m = re.exec(tree)) !== null) {
@@ -36,7 +36,7 @@ export function findRefs(tree, role, label = '.*') {
36
36
  * (ref) and the visible text (label) in one pass.
37
37
  */
38
38
  export function findRefsWithLabels(tree, role, label = '.*') {
39
- const re = new RegExp(`\\[(\\d+-\\d+)\\]\\s+${escapeRegex(role)}:\\s*(${label})`, 'g');
39
+ const re = new RegExp(`\\[(\\d+-\\d+)\\]\\s+${escapeRegex(role)}:\\s*(${label})`, 'gu');
40
40
  const out = [];
41
41
  let m;
42
42
  while ((m = re.exec(tree)) !== null) {
@@ -86,8 +86,9 @@ export function extractArticleBlocks(tree) {
86
86
  // Matches all known X time-link formats:
87
87
  // "Mar 16", "Apr 12, 2026", "5h", "5m", "2d", "30s", "just now", "now"
88
88
  // "31 seconds ago", "35 minutes ago", "4 hours ago" (full-word format)
89
- // "Yesterday", "Apr 12", "12:30 AM", "2026年4月12日" (CJK)
90
- export const X_TIME_LINK_PATTERN = '(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\\s+\\d+(?:,?\\s+\\d{4})?|\\d+[smhd]|\\d+\\s+(?:second|minute|hour|day|week|month|year)s?\\s+ago|just now|now|yesterday|\\d{1,2}:\\d{2}\\s*[AaPp][Mm]|\\d{4}年\\d{1,2}月\\d{1,2}日';
89
+ // "Yesterday", "Apr 12", "12:30 AM"
90
+ // Locale-rendered numeric dates separated by Han-script date markers
91
+ export const X_TIME_LINK_PATTERN = '(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\\s+\\d+(?:,?\\s+\\d{4})?|\\d+[smhd]|\\d+\\s+(?:second|minute|hour|day|week|month|year)s?\\s+ago|just now|now|yesterday|\\d{1,2}:\\d{2}\\s*[AaPp][Mm]|\\d{4}\\p{Script=Han}\\d{1,2}\\p{Script=Han}\\d{1,2}\\p{Script=Han}';
91
92
  function escapeRegex(s) {
92
93
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
93
94
  }
@@ -78,6 +78,54 @@ export function serializeAxTree(root) {
78
78
  walk(root, 0);
79
79
  return { tree: lines.join('\n'), refs };
80
80
  }
81
+ function cdpStringValue(v) {
82
+ if (v === undefined || v === null)
83
+ return '';
84
+ if (typeof v === 'string')
85
+ return v;
86
+ return String(v);
87
+ }
88
+ function cdpNodesToAxTree(nodes) {
89
+ if (!nodes || nodes.length === 0)
90
+ return null;
91
+ const byId = new Map();
92
+ const childSet = new Set();
93
+ for (const n of nodes) {
94
+ byId.set(n.nodeId, n);
95
+ if (n.childIds)
96
+ for (const cid of n.childIds)
97
+ childSet.add(cid);
98
+ }
99
+ // The root has no parent (or no entry pointing at it as a child).
100
+ const root = nodes.find((n) => !n.parentId && !childSet.has(n.nodeId)) ??
101
+ nodes.find((n) => !n.parentId) ??
102
+ nodes[0];
103
+ const seen = new Set();
104
+ function build(node) {
105
+ if (seen.has(node.nodeId))
106
+ return null;
107
+ seen.add(node.nodeId);
108
+ const ax = {
109
+ role: cdpStringValue(node.role?.value),
110
+ name: cdpStringValue(node.name?.value),
111
+ value: cdpStringValue(node.value?.value),
112
+ description: cdpStringValue(node.description?.value),
113
+ children: [],
114
+ };
115
+ if (node.childIds) {
116
+ for (const cid of node.childIds) {
117
+ const child = byId.get(cid);
118
+ if (!child)
119
+ continue;
120
+ const built = build(child);
121
+ if (built)
122
+ ax.children.push(built);
123
+ }
124
+ }
125
+ return ax;
126
+ }
127
+ return build(root);
128
+ }
81
129
  /**
82
130
  * Franklin's social browser driver. Lazy-imports playwright-core so the
83
131
  * rest of the CLI stays fast to start.
@@ -144,10 +192,21 @@ export class SocialBrowser {
144
192
  */
145
193
  async snapshot() {
146
194
  this.requirePage();
147
- // Playwright's accessibility snapshot returns a full AX tree
148
- // page.accessibility was removed from Playwright types in v1.46 but still works at runtime
149
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
150
- const axRoot = await this.page.accessibility.snapshot({ interestingOnly: false });
195
+ // page.accessibility was removed from playwright-core (gone by 1.59).
196
+ // Calling it threw `Cannot read properties of undefined (reading 'snapshot')`
197
+ // in production (failures.jsonl entries 1776662596215 / 1776662608060).
198
+ // The supported replacement is the CDP Accessibility domain, which still
199
+ // ships with Chromium-based browsers.
200
+ const cdp = await this.page.context().newCDPSession(this.page);
201
+ let axRoot;
202
+ try {
203
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
204
+ const result = (await cdp.send('Accessibility.getFullAXTree'));
205
+ axRoot = cdpNodesToAxTree(result?.nodes);
206
+ }
207
+ finally {
208
+ await cdp.detach().catch(() => { });
209
+ }
151
210
  if (!axRoot)
152
211
  return '';
153
212
  const { tree, refs } = serializeAxTree(axRoot);
@@ -1,24 +1,26 @@
1
1
  /**
2
- * Reader for `~/.blockrun/cost_log.jsonl` — the SDK-owned ledger of every
3
- * settled x402 payment.
2
+ * Reader (and limited writer) for `~/.blockrun/cost_log.jsonl` — the
3
+ * append-only ledger of every settled x402 payment.
4
4
  *
5
- * Franklin's own `franklin-stats.json` and `franklin-audit.jsonl` only
6
- * capture calls that pass through specific code paths (the main agent
7
- * loop and the proxy). Helper LLM calls (analyzeTurn, prefetchForIntent,
8
- * compaction, evaluator, verification, MoA, subagent, learning extraction,
9
- * etc.) all settle x402 payments through the SDK those payments DO get
10
- * recorded in cost_log.jsonl by `@blockrun/llm` itself, but Franklin's
11
- * stats infra had been ignoring this file entirely.
5
+ * History: this file was originally SDK-only territory. `@blockrun/llm`'s
6
+ * internal `appendCostLog` writes one line per micropayment when callers
7
+ * use SDK helper methods (modal sandbox, prediction market, exa, etc.).
8
+ * But Franklin's main LLM stream both the in-process agent loop
9
+ * (`src/agent/llm.ts`) and the proxy server (`src/proxy/server.ts`)
10
+ * have **their own** x402 signers that bypass the SDK entirely. Verified
11
+ * 2026-05-09 on a real machine: a single paid agent turn dropped the
12
+ * wallet by $0.001 and updated `franklin-stats.json` correctly, but
13
+ * cost_log.jsonl gained zero entries. So cost_log was never the
14
+ * "wallet truth" it advertised — it was an SDK-subset.
12
15
  *
13
- * Verified 2026-05-06 against a real machine: cost_log.jsonl is written
14
- * by the SDK with snake_case keys (`cost_usd`, `ts` in unix seconds with
15
- * subsecond precision Python convention) and Franklin's reads/writes
16
- * use camelCase + ms. This module bridges the format gap so stats /
17
- * insights / `franklin balance` can surface the wallet-truth total
18
- * alongside the recorded total.
16
+ * Fix (2026-05-09): expose `appendSettlementRow` so the agent and proxy
17
+ * signers can write the same shape the SDK does. The format contract
18
+ * (snake_case `cost_usd`, `ts` in unix seconds with subsecond precision,
19
+ * one JSON object per line) is preserved exactly so both writers
20
+ * interleave cleanly. Order in the file follows wall-clock arrival.
19
21
  *
20
- * Responsibility: read-only. We never write or trim cost_log.jsonl —
21
- * the SDK owns it.
22
+ * Responsibility: read + append-only write. We never trim or rotate
23
+ * cost_log.jsonl — that contract still belongs to the SDK / hygiene.
22
24
  */
23
25
  export interface SettlementRow {
24
26
  /** Endpoint path that was paid for, e.g. `/v1/chat/completions`. */
@@ -61,6 +63,39 @@ interface ReadOptions {
61
63
  * is only created on the first paid call.
62
64
  */
63
65
  export declare function loadSdkSettlements(opts?: ReadOptions): SettlementRow[];
66
+ /**
67
+ * Optional metadata fields the SDK writes alongside `endpoint` / `cost_usd`.
68
+ * Adding these to agent + proxy entries keeps cost_log.jsonl uniformly
69
+ * queryable (group by model, filter by wallet, etc.). Verified 2026-05-10
70
+ * against a real cost_log: the SDK writes
71
+ * {endpoint, cost_usd, model, wallet, network, client_kind}
72
+ * Without these on agent rows you can't tell which model burned a $0.001
73
+ * — the row is just `/v1/messages: 0.001`. With them, every line is a
74
+ * complete forensic record.
75
+ */
76
+ export interface SettlementMeta {
77
+ model?: string;
78
+ wallet?: string;
79
+ network?: string;
80
+ client_kind?: string;
81
+ }
82
+ /**
83
+ * Append one settlement row to ~/.blockrun/cost_log.jsonl in the same
84
+ * shape `@blockrun/llm`'s internal `appendCostLog` writes. Best-effort:
85
+ * silently swallows fs errors so a logging failure never breaks the
86
+ * paid call that just succeeded. Costs <= 0 are treated as no-op (no
87
+ * point logging $0 — the file's purpose is "what was actually paid").
88
+ *
89
+ * Honors FRANKLIN_NO_AUDIT=1 the same way `appendAudit` and `recordUsage`
90
+ * do, so test runs (test/e2e.mjs sets this) don't pollute the user's
91
+ * real cost_log. Verified 2026-05-10 on a real machine: two
92
+ * `/v1/messages: $0.000001` rows leaked into the user's cost_log from
93
+ * a paid e2e run because this gate was missing — paid e2e was hitting
94
+ * the real gateway with a real wallet, but the test framework expected
95
+ * NO writes to land. Restoring the gate keeps cost_log a clean ledger
96
+ * of REAL traffic.
97
+ */
98
+ export declare function appendSettlementRow(endpoint: string, costUsd: number, meta?: SettlementMeta): void;
64
99
  /** Aggregate the SDK ledger into a single summary object. */
65
100
  export declare function summarizeSdkSettlements(opts?: ReadOptions): SettlementSummary;
66
101
  export {};