@blockrun/franklin 3.3.3 → 3.5.1

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.
Files changed (109) hide show
  1. package/README.md +65 -25
  2. package/dist/agent/commands.d.ts +1 -1
  3. package/dist/agent/commands.js +128 -17
  4. package/dist/agent/compact.d.ts +2 -2
  5. package/dist/agent/compact.js +148 -22
  6. package/dist/agent/context.d.ts +8 -3
  7. package/dist/agent/context.js +301 -108
  8. package/dist/agent/error-classifier.d.ts +11 -2
  9. package/dist/agent/error-classifier.js +64 -10
  10. package/dist/agent/llm.d.ts +8 -1
  11. package/dist/agent/llm.js +114 -19
  12. package/dist/agent/loop.d.ts +1 -2
  13. package/dist/agent/loop.js +509 -61
  14. package/dist/agent/optimize.d.ts +2 -2
  15. package/dist/agent/optimize.js +9 -7
  16. package/dist/agent/permissions.d.ts +1 -1
  17. package/dist/agent/permissions.js +1 -1
  18. package/dist/agent/planner.d.ts +42 -0
  19. package/dist/agent/planner.js +110 -0
  20. package/dist/agent/reduce.d.ts +7 -1
  21. package/dist/agent/reduce.js +85 -3
  22. package/dist/agent/streaming-executor.d.ts +6 -1
  23. package/dist/agent/streaming-executor.js +83 -5
  24. package/dist/agent/tokens.d.ts +11 -2
  25. package/dist/agent/tokens.js +38 -5
  26. package/dist/agent/tool-guard.d.ts +27 -0
  27. package/dist/agent/tool-guard.js +324 -0
  28. package/dist/agent/types.d.ts +7 -1
  29. package/dist/agent/types.js +1 -1
  30. package/dist/brain/extract.d.ts +11 -0
  31. package/dist/brain/extract.js +154 -0
  32. package/dist/brain/index.d.ts +3 -0
  33. package/dist/brain/index.js +2 -0
  34. package/dist/brain/store.d.ts +42 -0
  35. package/dist/brain/store.js +225 -0
  36. package/dist/brain/types.d.ts +45 -0
  37. package/dist/brain/types.js +5 -0
  38. package/dist/commands/daemon.js +2 -1
  39. package/dist/commands/start.js +19 -7
  40. package/dist/config.js +1 -1
  41. package/dist/index.js +27 -2
  42. package/dist/learnings/extractor.d.ts +13 -0
  43. package/dist/learnings/extractor.js +69 -8
  44. package/dist/learnings/index.d.ts +1 -1
  45. package/dist/learnings/index.js +1 -1
  46. package/dist/learnings/store.js +42 -13
  47. package/dist/learnings/types.d.ts +1 -1
  48. package/dist/mcp/client.d.ts +1 -1
  49. package/dist/mcp/client.js +5 -5
  50. package/dist/mcp/config.d.ts +1 -1
  51. package/dist/mcp/config.js +1 -1
  52. package/dist/panel/html.d.ts +2 -0
  53. package/dist/panel/html.js +409 -146
  54. package/dist/panel/server.js +19 -0
  55. package/dist/pricing.js +3 -2
  56. package/dist/proxy/fallback.d.ts +3 -1
  57. package/dist/proxy/fallback.js +4 -4
  58. package/dist/proxy/server.js +29 -11
  59. package/dist/proxy/sse-translator.js +1 -1
  60. package/dist/router/categories.d.ts +21 -0
  61. package/dist/router/categories.js +96 -0
  62. package/dist/router/index.d.ts +9 -2
  63. package/dist/router/index.js +106 -27
  64. package/dist/router/local-elo.d.ts +32 -0
  65. package/dist/router/local-elo.js +107 -0
  66. package/dist/router/selector.d.ts +46 -0
  67. package/dist/router/selector.js +106 -0
  68. package/dist/session/storage.d.ts +5 -1
  69. package/dist/session/storage.js +24 -2
  70. package/dist/social/a11y.d.ts +1 -1
  71. package/dist/social/a11y.js +5 -1
  72. package/dist/social/browser.d.ts +5 -0
  73. package/dist/social/browser.js +22 -0
  74. package/dist/social/preflight.d.ts +4 -0
  75. package/dist/social/preflight.js +42 -3
  76. package/dist/stats/failures.d.ts +20 -0
  77. package/dist/stats/failures.js +63 -0
  78. package/dist/stats/format.d.ts +6 -0
  79. package/dist/stats/format.js +23 -0
  80. package/dist/stats/insights.js +1 -21
  81. package/dist/stats/session-tracker.d.ts +21 -0
  82. package/dist/stats/session-tracker.js +28 -0
  83. package/dist/stats/tracker.d.ts +1 -1
  84. package/dist/stats/tracker.js +1 -1
  85. package/dist/tools/bash.d.ts +14 -1
  86. package/dist/tools/bash.js +132 -7
  87. package/dist/tools/edit.js +77 -14
  88. package/dist/tools/glob.js +13 -3
  89. package/dist/tools/grep.js +30 -12
  90. package/dist/tools/imagegen.js +5 -5
  91. package/dist/tools/index.d.ts +1 -1
  92. package/dist/tools/index.js +5 -1
  93. package/dist/tools/read.d.ts +16 -2
  94. package/dist/tools/read.js +36 -8
  95. package/dist/tools/searchx.d.ts +6 -2
  96. package/dist/tools/searchx.js +221 -44
  97. package/dist/tools/subagent.js +37 -3
  98. package/dist/tools/task.js +43 -7
  99. package/dist/tools/validate.d.ts +11 -0
  100. package/dist/tools/validate.js +42 -0
  101. package/dist/tools/webfetch.js +18 -7
  102. package/dist/tools/websearch.js +41 -7
  103. package/dist/tools/write.js +26 -6
  104. package/dist/ui/app.js +31 -6
  105. package/dist/ui/model-picker.d.ts +1 -1
  106. package/dist/ui/model-picker.js +1 -1
  107. package/dist/ui/terminal.d.ts +1 -1
  108. package/dist/ui/terminal.js +1 -1
  109. package/package.json +2 -2
@@ -44,6 +44,25 @@ export function createPanelServer(port) {
44
44
  res.end(html);
45
45
  return;
46
46
  }
47
+ // ─── Static assets ──
48
+ if (p.startsWith('/assets/') && p.endsWith('.jpg')) {
49
+ const filename = path.basename(p);
50
+ const assetsDir = path.join(path.dirname(path.dirname(new URL(import.meta.url).pathname)), '..', 'assets');
51
+ const imgPath = path.join(assetsDir, filename);
52
+ try {
53
+ const img = fs.readFileSync(imgPath);
54
+ res.writeHead(200, {
55
+ 'Content-Type': 'image/jpeg',
56
+ 'Cache-Control': 'public, max-age=86400',
57
+ });
58
+ res.end(img);
59
+ }
60
+ catch {
61
+ res.writeHead(404);
62
+ res.end('Not found');
63
+ }
64
+ return;
65
+ }
47
66
  // ─── SSE ──
48
67
  if (p === '/api/events') {
49
68
  res.writeHead(200, {
package/dist/pricing.js CHANGED
@@ -69,9 +69,10 @@ export const MODEL_PRICING = {
69
69
  // Others
70
70
  'moonshot/kimi-k2.5': { input: 0.6, output: 3.0 },
71
71
  'nvidia/kimi-k2.5': { input: 0.55, output: 2.5 },
72
- // PROMOTION (active ~2026-04): flat $0.001/call. Normal pricing: input:1.00, output:3.20
72
+ // PROMOTION (active ~2026-04): flat $0.001/call for all GLM models
73
+ 'zai/glm-5': { input: 0, output: 0, perCall: 0.001 },
73
74
  'zai/glm-5.1': { input: 0, output: 0, perCall: 0.001 },
74
- // PROMOTION (active ~2026-04): flat $0.001/call. Normal pricing: input:1.20, output:4.00
75
+ 'zai/glm-5-turbo': { input: 0, output: 0, perCall: 0.001 },
75
76
  'zai/glm-5.1-turbo': { input: 0, output: 0, perCall: 0.001 },
76
77
  };
77
78
  /** Opus pricing for savings calculations */
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Fallback chain for runcode
2
+ * Fallback chain for Franklin
3
3
  * Automatically switches to backup models when primary fails (429, 5xx, etc.)
4
4
  */
5
5
  export interface FallbackConfig {
@@ -30,6 +30,8 @@ export declare function fetchWithFallback(url: string, init: RequestInit, origin
30
30
  * Get the current model from fallback chain based on parsed request
31
31
  */
32
32
  export declare function getCurrentModelFromChain(requestedModel: string | undefined, config?: FallbackConfig): string;
33
+ /** Routing profiles that must never be sent to the backend directly */
34
+ export declare const ROUTING_PROFILES: Set<string>;
33
35
  /**
34
36
  * Build fallback chain starting from a specific model.
35
37
  * Filters out routing profiles (blockrun/auto etc.) since the backend
@@ -1,11 +1,11 @@
1
1
  /**
2
- * Fallback chain for runcode
2
+ * Fallback chain for Franklin
3
3
  * Automatically switches to backup models when primary fails (429, 5xx, etc.)
4
4
  */
5
5
  import fs from 'node:fs';
6
6
  import os from 'node:os';
7
7
  import path from 'node:path';
8
- const LOG_FILE = path.join(os.homedir(), '.blockrun', 'runcode-debug.log');
8
+ const LOG_FILE = path.join(os.homedir(), '.blockrun', 'franklin-debug.log');
9
9
  // eslint-disable-next-line no-control-regex
10
10
  const ANSI_RE = /\x1B\[[0-9;]*[A-Za-z]|\x1B\][^\x07]*\x07|\x1B[()][A-B]|\r/g;
11
11
  function appendLog(msg) {
@@ -93,7 +93,7 @@ export async function fetchWithFallback(url, init, originalBody, config = DEFAUL
93
93
  if (nextModel && onFallback) {
94
94
  const errMsg = err instanceof Error ? err.message : 'Network error';
95
95
  onFallback(model, 0, nextModel);
96
- appendLog(`[runcode] [fallback] ${model} network error: ${errMsg}`);
96
+ appendLog(`[franklin] [fallback] ${model} network error: ${errMsg}`);
97
97
  }
98
98
  if (i < config.chain.length - 1) {
99
99
  await sleep(config.retryDelayMs);
@@ -120,7 +120,7 @@ export function getCurrentModelFromChain(requestedModel, config = DEFAULT_FALLBA
120
120
  return config.chain[0];
121
121
  }
122
122
  /** Routing profiles that must never be sent to the backend directly */
123
- const ROUTING_PROFILES = new Set([
123
+ export const ROUTING_PROFILES = new Set([
124
124
  'blockrun/auto', 'blockrun/eco', 'blockrun/premium', 'blockrun/free',
125
125
  ]);
126
126
  /**
@@ -4,14 +4,14 @@ 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 { fetchWithFallback, buildFallbackChain, DEFAULT_FALLBACK_CONFIG, } from './fallback.js';
7
+ import { fetchWithFallback, buildFallbackChain, DEFAULT_FALLBACK_CONFIG, ROUTING_PROFILES, } from './fallback.js';
8
8
  import { routeRequest, parseRoutingProfile, } from '../router/index.js';
9
9
  import { estimateCost } from '../pricing.js';
10
10
  import { VERSION } from '../config.js';
11
11
  // User-Agent for backend requests
12
- const USER_AGENT = `runcode/${VERSION}`;
13
- const X_RUNCODE_VERSION = VERSION;
14
- const LOG_FILE = path.join(os.homedir(), '.blockrun', 'runcode-debug.log');
12
+ const USER_AGENT = `franklin/${VERSION}`;
13
+ const X_FRANKLIN_VERSION = VERSION;
14
+ const LOG_FILE = path.join(os.homedir(), '.blockrun', 'franklin-debug.log');
15
15
  // Strip ANSI escape codes so log file doesn't distort terminal on replay
16
16
  function stripAnsi(str) {
17
17
  // eslint-disable-next-line no-control-regex
@@ -30,9 +30,9 @@ function debug(options, ...args) {
30
30
  }
31
31
  }
32
32
  function log(...args) {
33
- const msg = `[runcode] ${args.map(String).join(' ')}`;
34
- // Do NOT print to stdout — Claude Code owns the terminal (stdio: inherit).
35
- // Use `runcode logs` to read runtime messages.
33
+ const msg = `[franklin] ${args.map(String).join(' ')}`;
34
+ // Do NOT print to stdout — the terminal is owned by the parent process (stdio: inherit).
35
+ // Use `franklin logs` to read runtime messages.
36
36
  try {
37
37
  fs.mkdirSync(path.dirname(LOG_FILE), { recursive: true });
38
38
  fs.appendFileSync(LOG_FILE, `[${new Date().toISOString()}] ${stripAnsi(msg)}\n`);
@@ -193,7 +193,7 @@ export function createProxy(options) {
193
193
  currentModel = switchCmd;
194
194
  debug(options, `model switched to: ${currentModel}`);
195
195
  const fakeResponse = {
196
- id: `msg_runcode_${Date.now()}`,
196
+ id: `msg_franklin_${Date.now()}`,
197
197
  type: 'message',
198
198
  role: 'assistant',
199
199
  model: currentModel,
@@ -277,7 +277,7 @@ export function createProxy(options) {
277
277
  const headers = {
278
278
  'Content-Type': 'application/json',
279
279
  'User-Agent': USER_AGENT,
280
- 'X-runcode-Version': X_RUNCODE_VERSION,
280
+ 'X-Franklin-Version': X_FRANKLIN_VERSION,
281
281
  };
282
282
  for (const [key, value] of Object.entries(req.headers)) {
283
283
  if (key.toLowerCase() !== 'host' &&
@@ -287,6 +287,24 @@ export function createProxy(options) {
287
287
  headers[key] = Array.isArray(value) ? value[0] : value;
288
288
  }
289
289
  }
290
+ // Safety net: if requestModel is still a routing profile (blockrun/auto etc.)
291
+ // after all resolution attempts, force-route it to a concrete model.
292
+ // This prevents 404s from the backend which doesn't recognize virtual model names.
293
+ if (ROUTING_PROFILES.has(requestModel) && body) {
294
+ const virtualName = requestModel;
295
+ const profile = parseRoutingProfile(requestModel);
296
+ if (profile) {
297
+ const fallbackRouting = routeRequest('', profile);
298
+ requestModel = fallbackRouting.model;
299
+ try {
300
+ const parsed = JSON.parse(body);
301
+ parsed.model = requestModel;
302
+ body = JSON.stringify(parsed);
303
+ }
304
+ catch { /* body not JSON, skip */ }
305
+ log(`⚠️ Safety net: resolved unrouted ${virtualName} → ${requestModel}`);
306
+ }
307
+ }
290
308
  // Build request init
291
309
  const requestInit = {
292
310
  method: req.method || 'POST',
@@ -475,7 +493,7 @@ export function createProxy(options) {
475
493
  async function handleBasePayment(response, url, method, headers, body, privateKey, fromAddress) {
476
494
  const paymentHeader = await extractPaymentHeader(response);
477
495
  if (!paymentHeader) {
478
- throw new Error('402 Payment Required — wallet may need funding. Run: runcode balance');
496
+ throw new Error('402 Payment Required — wallet may need funding. Run: franklin balance');
479
497
  }
480
498
  const paymentRequired = parsePaymentRequired(paymentHeader);
481
499
  const details = extractPaymentDetails(paymentRequired);
@@ -500,7 +518,7 @@ async function handleBasePayment(response, url, method, headers, body, privateKe
500
518
  async function handleSolanaPayment(response, url, method, headers, body, privateKey, fromAddress) {
501
519
  const paymentHeader = await extractPaymentHeader(response);
502
520
  if (!paymentHeader) {
503
- throw new Error('402 Payment Required — wallet may need funding. Run: runcode balance');
521
+ throw new Error('402 Payment Required — wallet may need funding. Run: franklin balance');
504
522
  }
505
523
  const paymentRequired = parsePaymentRequired(paymentHeader);
506
524
  const details = extractPaymentDetails(paymentRequired, SOLANA_NETWORK);
@@ -12,7 +12,7 @@ export class SSETranslator {
12
12
  buffer = '';
13
13
  constructor(model = 'unknown') {
14
14
  this.state = {
15
- messageId: `msg_runcode_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
15
+ messageId: `msg_franklin_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
16
16
  model,
17
17
  blockIndex: 0,
18
18
  activeToolCalls: new Map(),
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Request category detection for the learned router.
3
+ * Classifies requests into categories (coding, trading, reasoning, etc.)
4
+ * using keyword matching from router weights or built-in defaults.
5
+ */
6
+ export type Category = 'coding' | 'trading' | 'reasoning' | 'chat' | 'creative' | 'research';
7
+ interface CategoryResult {
8
+ category: Category;
9
+ confidence: number;
10
+ scores: Partial<Record<Category, number>>;
11
+ }
12
+ /**
13
+ * Detect the primary category of a request.
14
+ * Uses provided keywords (from learned weights) or built-in defaults.
15
+ */
16
+ export declare function detectCategory(prompt: string, categoryKeywords?: Record<string, string[]>): CategoryResult;
17
+ /**
18
+ * Map a learned category to the legacy tier system (backward compat).
19
+ */
20
+ export declare function mapCategoryToTier(category: Category): 'SIMPLE' | 'MEDIUM' | 'COMPLEX' | 'REASONING';
21
+ export {};
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Request category detection for the learned router.
3
+ * Classifies requests into categories (coding, trading, reasoning, etc.)
4
+ * using keyword matching from router weights or built-in defaults.
5
+ */
6
+ // Built-in category keywords (used when no learned weights available)
7
+ const DEFAULT_CATEGORY_KEYWORDS = {
8
+ coding: [
9
+ 'function', 'class', 'import', 'def', 'SELECT', 'async', 'await',
10
+ 'const', 'let', 'var', 'return', '```', 'bug', 'error', 'fix',
11
+ 'refactor', 'implement', 'test', 'npm', 'pip', 'git', 'deploy',
12
+ 'API', 'endpoint', 'database', 'query', 'migration', 'lint',
13
+ '函数', '类', '导入', '修复', '调试', '部署',
14
+ ],
15
+ trading: [
16
+ 'BTC', 'ETH', 'SOL', 'bitcoin', 'ethereum', 'solana', 'crypto',
17
+ 'price', 'market', 'signal', 'trade', 'buy', 'sell', 'RSI',
18
+ 'MACD', 'volume', 'bullish', 'bearish', 'support', 'resistance',
19
+ 'portfolio', 'risk', 'leverage', 'DeFi', 'token', 'swap',
20
+ '比特币', '以太坊', '价格', '市场', '交易', '信号',
21
+ ],
22
+ reasoning: [
23
+ 'prove', 'theorem', 'derive', 'step by step', 'chain of thought',
24
+ 'formally', 'mathematical', 'proof', 'logically', 'analyze',
25
+ 'compare', 'evaluate', 'trade-off', 'pros and cons', 'why',
26
+ 'explain why', 'reasoning', 'logic', 'deduce', 'infer',
27
+ '证明', '定理', '推导', '分析', '比较',
28
+ ],
29
+ creative: [
30
+ 'write a story', 'poem', 'creative', 'brainstorm', 'imagine',
31
+ 'generate an image', 'design', 'logo', 'illustration', 'art',
32
+ 'narrative', 'fiction', 'song', 'lyrics', 'slogan', 'tagline',
33
+ '写一个故事', '诗', '创意', '设计', '头脑风暴',
34
+ ],
35
+ research: [
36
+ 'search', 'find', 'look up', 'what is', 'who is', 'when was',
37
+ 'summarize', 'report', 'overview', 'comparison', 'review',
38
+ 'article', 'paper', 'study', 'data', 'statistics', 'trend',
39
+ '搜索', '查找', '什么是', '总结', '报告',
40
+ ],
41
+ chat: [
42
+ 'hello', 'hi', 'thanks', 'thank you', 'how are you', 'help',
43
+ 'translate', 'yes', 'no', 'ok', 'sure', 'good',
44
+ '你好', '谢谢', '帮我', '翻译',
45
+ ],
46
+ };
47
+ /**
48
+ * Detect the primary category of a request.
49
+ * Uses provided keywords (from learned weights) or built-in defaults.
50
+ */
51
+ export function detectCategory(prompt, categoryKeywords) {
52
+ const keywords = (categoryKeywords ?? DEFAULT_CATEGORY_KEYWORDS);
53
+ const lower = prompt.toLowerCase();
54
+ const scores = {};
55
+ let maxScore = 0;
56
+ let maxCategory = 'chat'; // default fallback
57
+ for (const [cat, kws] of Object.entries(keywords)) {
58
+ let score = 0;
59
+ for (const kw of kws) {
60
+ if (lower.includes(kw.toLowerCase()))
61
+ score++;
62
+ }
63
+ // Bonus for code blocks (strong coding signal)
64
+ if (cat === 'coding') {
65
+ const codeBlocks = (prompt.match(/```/g) || []).length / 2;
66
+ score += codeBlocks * 3;
67
+ }
68
+ if (score > 0)
69
+ scores[cat] = score;
70
+ if (score > maxScore) {
71
+ maxScore = score;
72
+ maxCategory = cat;
73
+ }
74
+ }
75
+ // Confidence: how much the winner leads the runner-up
76
+ const sortedScores = Object.values(scores).sort((a, b) => b - a);
77
+ const gap = sortedScores.length >= 2
78
+ ? (sortedScores[0] - sortedScores[1]) / Math.max(sortedScores[0], 1)
79
+ : sortedScores.length === 1 ? 0.8 : 0;
80
+ const confidence = Math.min(0.95, 0.5 + gap * 0.5);
81
+ return { category: maxCategory, confidence, scores };
82
+ }
83
+ /**
84
+ * Map a learned category to the legacy tier system (backward compat).
85
+ */
86
+ export function mapCategoryToTier(category) {
87
+ switch (category) {
88
+ case 'chat': return 'SIMPLE';
89
+ case 'research': return 'MEDIUM';
90
+ case 'creative': return 'MEDIUM';
91
+ case 'coding': return 'COMPLEX';
92
+ case 'trading': return 'COMPLEX';
93
+ case 'reasoning': return 'REASONING';
94
+ default: return 'MEDIUM';
95
+ }
96
+ }
@@ -1,6 +1,13 @@
1
1
  /**
2
- * Smart Router for runcode
3
- * Smart Router - 15-dimension weighted scoring for tier classification
2
+ * Smart Router for Franklin
3
+ *
4
+ * Two routing modes:
5
+ * 1. Learned — uses Elo scores from 2M+ gateway requests (router-weights.json)
6
+ * 2. Classic — 15-dimension keyword scoring (fallback when no weights)
7
+ *
8
+ * The learned router detects request category (coding, trading, reasoning, etc.)
9
+ * and picks the model with the best quality-to-cost ratio for that category.
10
+ * Local Elo adjustments personalize routing per user over time.
4
11
  */
5
12
  export type Tier = 'SIMPLE' | 'MEDIUM' | 'COMPLEX' | 'REASONING';
6
13
  export type RoutingProfile = 'auto' | 'eco' | 'premium' | 'free';
@@ -1,8 +1,37 @@
1
1
  /**
2
- * Smart Router for runcode
3
- * Smart Router - 15-dimension weighted scoring for tier classification
2
+ * Smart Router for Franklin
3
+ *
4
+ * Two routing modes:
5
+ * 1. Learned — uses Elo scores from 2M+ gateway requests (router-weights.json)
6
+ * 2. Classic — 15-dimension keyword scoring (fallback when no weights)
7
+ *
8
+ * The learned router detects request category (coding, trading, reasoning, etc.)
9
+ * and picks the model with the best quality-to-cost ratio for that category.
10
+ * Local Elo adjustments personalize routing per user over time.
4
11
  */
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
5
14
  import { MODEL_PRICING, OPUS_PRICING } from '../pricing.js';
15
+ import { BLOCKRUN_DIR } from '../config.js';
16
+ import { detectCategory, mapCategoryToTier } from './categories.js';
17
+ import { selectModel } from './selector.js';
18
+ import { computeLocalElo, blendElo } from './local-elo.js';
19
+ // ─── Learned Weights Loading ───
20
+ const WEIGHTS_FILE = path.join(BLOCKRUN_DIR, 'router-weights.json');
21
+ let cachedWeights; // undefined = not loaded yet
22
+ function loadLearnedWeights() {
23
+ if (cachedWeights !== undefined)
24
+ return cachedWeights;
25
+ try {
26
+ if (fs.existsSync(WEIGHTS_FILE)) {
27
+ cachedWeights = JSON.parse(fs.readFileSync(WEIGHTS_FILE, 'utf-8'));
28
+ return cachedWeights;
29
+ }
30
+ }
31
+ catch { /* fall through */ }
32
+ cachedWeights = null;
33
+ return null;
34
+ }
6
35
  // ─── Tier Model Configs ───
7
36
  const AUTO_TIERS = {
8
37
  SIMPLE: {
@@ -78,7 +107,16 @@ const TECHNICAL_KEYWORDS = [
78
107
  const AGENTIC_KEYWORDS = [
79
108
  'read file', 'edit', 'modify', 'update', 'create file', 'execute',
80
109
  'deploy', 'install', 'npm', 'pip', 'fix', 'debug', 'verify',
110
+ 'commit', 'push', 'pull', 'merge', 'rename', 'replace', 'delete',
111
+ 'remove', 'add', 'change', 'move', 'refactor', 'migrate',
81
112
  '编辑', '修改', '部署', '安装', '修复', '调试',
113
+ '更新', '替换', '删除', '添加', '提交', '改',
114
+ ];
115
+ // URL patterns that signal agentic/coding tasks
116
+ const AGENTIC_URL_PATTERNS = [
117
+ /github\.com/i, /gitlab\.com/i, /bitbucket\.org/i,
118
+ /npmjs\.com/i, /pypi\.org/i, /crates\.io/i,
119
+ /stackoverflow\.com/i, /docs\.\w+/i,
82
120
  ];
83
121
  function countMatches(text, keywords) {
84
122
  const lower = text.toLowerCase();
@@ -139,16 +177,22 @@ function classifyRequest(prompt, tokenCount) {
139
177
  score += 0.2;
140
178
  signals.push('technical-light');
141
179
  }
142
- // Agentic detection (weight: 0.10) - increased
180
+ // Agentic detection — lowered thresholds (real tasks often have just 1-2 action words)
143
181
  const agenticMatches = countMatches(prompt, AGENTIC_KEYWORDS);
144
- if (agenticMatches >= 3) {
182
+ const hasAgenticUrl = AGENTIC_URL_PATTERNS.some(p => p.test(prompt));
183
+ const agenticScore = agenticMatches + (hasAgenticUrl ? 1 : 0);
184
+ if (agenticScore >= 3) {
145
185
  score += 0.35;
146
186
  signals.push('agentic');
147
187
  }
148
- else if (agenticMatches >= 2) {
149
- score += 0.2;
188
+ else if (agenticScore >= 2) {
189
+ score += 0.25;
150
190
  signals.push('agentic-light');
151
191
  }
192
+ else if (agenticScore >= 1) {
193
+ score += 0.15;
194
+ signals.push('agentic-hint');
195
+ }
152
196
  // Multi-step patterns
153
197
  if (/first.*then|step \d|\d\.\s/i.test(prompt)) {
154
198
  score += 0.2;
@@ -187,18 +231,8 @@ function classifyRequest(prompt, tokenCount) {
187
231
  const confidence = Math.min(0.95, 0.7 + Math.abs(score) * 0.3);
188
232
  return { tier, confidence, signals };
189
233
  }
190
- // ─── Main Router ───
191
- export function routeRequest(prompt, profile = 'auto') {
192
- // Free profile - always use free model
193
- if (profile === 'free') {
194
- return {
195
- model: 'nvidia/nemotron-ultra-253b',
196
- tier: 'SIMPLE',
197
- confidence: 1.0,
198
- signals: ['free-profile'],
199
- savings: 1.0,
200
- };
201
- }
234
+ // ─── Classic Router (keyword-based fallback) ───
235
+ function classicRouteRequest(prompt, profile) {
202
236
  // Estimate token count (use byte length / 4 for better accuracy with non-ASCII)
203
237
  const byteLen = Buffer.byteLength(prompt, 'utf-8');
204
238
  const tokenCount = Math.ceil(byteLen / 4);
@@ -217,20 +251,65 @@ export function routeRequest(prompt, profile = 'auto') {
217
251
  tierConfigs = AUTO_TIERS;
218
252
  }
219
253
  const model = tierConfigs[tier].primary;
220
- // Calculate savings estimate vs Claude Opus
254
+ const savings = computeSavings(model);
255
+ return { model, tier, confidence, signals, savings };
256
+ }
257
+ // ─── Main Router ───
258
+ export function routeRequest(prompt, profile = 'auto') {
259
+ // Free profile — always use free model
260
+ if (profile === 'free') {
261
+ return {
262
+ model: 'nvidia/nemotron-ultra-253b',
263
+ tier: 'SIMPLE',
264
+ confidence: 1.0,
265
+ signals: ['free-profile'],
266
+ savings: 1.0,
267
+ };
268
+ }
269
+ // ── Learned routing (if weights available) ──
270
+ const weights = loadLearnedWeights();
271
+ if (weights) {
272
+ const { category, confidence } = detectCategory(prompt, weights.category_keywords);
273
+ // Apply local Elo adjustments
274
+ const localElo = computeLocalElo();
275
+ const localCatMap = localElo.get(category);
276
+ // Create adjusted weights with blended Elo scores
277
+ const adjustedWeights = localCatMap
278
+ ? {
279
+ ...weights,
280
+ model_scores: {
281
+ ...weights.model_scores,
282
+ [category]: (weights.model_scores[category] || []).map(s => ({
283
+ ...s,
284
+ elo: blendElo(s.elo, localCatMap.get(s.model) ?? 0),
285
+ })),
286
+ },
287
+ }
288
+ : weights;
289
+ const selected = selectModel(category, profile, adjustedWeights);
290
+ if (selected) {
291
+ const tier = mapCategoryToTier(category);
292
+ const savings = computeSavings(selected.model);
293
+ return {
294
+ model: selected.model,
295
+ tier,
296
+ confidence,
297
+ signals: [category],
298
+ savings,
299
+ };
300
+ }
301
+ // Fall through to classic if selectModel returns null (no candidates for category)
302
+ }
303
+ // ── Classic routing (keyword-based fallback) ──
304
+ return classicRouteRequest(prompt, profile);
305
+ }
306
+ function computeSavings(model) {
221
307
  const opusCostPer1K = (OPUS_PRICING.input + OPUS_PRICING.output) / 2 / 1000;
222
308
  const modelPricing = MODEL_PRICING[model];
223
309
  const modelCostPer1K = modelPricing
224
310
  ? (modelPricing.input + modelPricing.output) / 2 / 1000
225
311
  : 0.005;
226
- const savings = Math.max(0, (opusCostPer1K - modelCostPer1K) / opusCostPer1K);
227
- return {
228
- model,
229
- tier,
230
- confidence,
231
- signals,
232
- savings,
233
- };
312
+ return Math.max(0, (opusCostPer1K - modelCostPer1K) / opusCostPer1K);
234
313
  }
235
314
  /**
236
315
  * Get fallback models for a tier
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Local Elo learning — adapts routing to the user's own usage patterns.
3
+ * Tracks model outcomes per category and adjusts Elo ratings locally.
4
+ *
5
+ * Storage: ~/.blockrun/router-history.jsonl (append-only, capped 2000 records)
6
+ * Never uploaded — purely local personalization.
7
+ */
8
+ export type Outcome = 'continued' | 'switched' | 'retried' | 'error' | 'max_turns' | 'payment';
9
+ /**
10
+ * Record a model outcome for local learning.
11
+ */
12
+ export declare function recordOutcome(category: string, model: string, outcome: Outcome, toolCalls?: number): void;
13
+ /**
14
+ * Compute local Elo adjustments from history.
15
+ * Returns a map of (category → model → elo_delta).
16
+ *
17
+ * Outcomes map to win/loss:
18
+ * continued → win (+K * 0.6)
19
+ * switched → loss (-K * 1.0)
20
+ * retried → loss (-K * 0.8)
21
+ * error → loss (-K * 0.5)
22
+ * payment → loss (-K * 1.5) — heavy penalty, guaranteed to repeat until funded
23
+ * max_turns → loss (-K * 0.3)
24
+ */
25
+ export declare function computeLocalElo(): Map<string, Map<string, number>>;
26
+ /**
27
+ * Get the effective Elo for a model in a category,
28
+ * blending global (server-trained) and local (user-specific) scores.
29
+ *
30
+ * effective = 0.7 * global + 0.3 * (1200 + local_delta)
31
+ */
32
+ export declare function blendElo(globalElo: number, localDelta: number): number;
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Local Elo learning — adapts routing to the user's own usage patterns.
3
+ * Tracks model outcomes per category and adjusts Elo ratings locally.
4
+ *
5
+ * Storage: ~/.blockrun/router-history.jsonl (append-only, capped 2000 records)
6
+ * Never uploaded — purely local personalization.
7
+ */
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { BLOCKRUN_DIR } from '../config.js';
11
+ const HISTORY_FILE = path.join(BLOCKRUN_DIR, 'router-history.jsonl');
12
+ const MAX_RECORDS = 2000;
13
+ const K_FACTOR = 32; // Elo K-factor — how much each outcome shifts the rating
14
+ /**
15
+ * Record a model outcome for local learning.
16
+ */
17
+ export function recordOutcome(category, model, outcome, toolCalls) {
18
+ try {
19
+ fs.mkdirSync(path.dirname(HISTORY_FILE), { recursive: true });
20
+ const record = { ts: Date.now(), category, model, outcome, toolCalls };
21
+ fs.appendFileSync(HISTORY_FILE, JSON.stringify(record) + '\n');
22
+ // Trim periodically (10% chance)
23
+ if (Math.random() < 0.1) {
24
+ trimHistory();
25
+ }
26
+ }
27
+ catch {
28
+ // Fire-and-forget
29
+ }
30
+ }
31
+ function trimHistory() {
32
+ try {
33
+ if (!fs.existsSync(HISTORY_FILE))
34
+ return;
35
+ const lines = fs.readFileSync(HISTORY_FILE, 'utf-8').trim().split('\n');
36
+ if (lines.length > MAX_RECORDS) {
37
+ fs.writeFileSync(HISTORY_FILE, lines.slice(-MAX_RECORDS).join('\n') + '\n');
38
+ }
39
+ }
40
+ catch { /* ignore */ }
41
+ }
42
+ /**
43
+ * Compute local Elo adjustments from history.
44
+ * Returns a map of (category → model → elo_delta).
45
+ *
46
+ * Outcomes map to win/loss:
47
+ * continued → win (+K * 0.6)
48
+ * switched → loss (-K * 1.0)
49
+ * retried → loss (-K * 0.8)
50
+ * error → loss (-K * 0.5)
51
+ * payment → loss (-K * 1.5) — heavy penalty, guaranteed to repeat until funded
52
+ * max_turns → loss (-K * 0.3)
53
+ */
54
+ export function computeLocalElo() {
55
+ const adjustments = new Map();
56
+ try {
57
+ if (!fs.existsSync(HISTORY_FILE))
58
+ return adjustments;
59
+ const lines = fs.readFileSync(HISTORY_FILE, 'utf-8').trim().split('\n').filter(Boolean);
60
+ for (const line of lines) {
61
+ try {
62
+ const record = JSON.parse(line);
63
+ if (!adjustments.has(record.category)) {
64
+ adjustments.set(record.category, new Map());
65
+ }
66
+ const catMap = adjustments.get(record.category);
67
+ const current = catMap.get(record.model) ?? 0;
68
+ let delta;
69
+ switch (record.outcome) {
70
+ case 'continued':
71
+ delta = K_FACTOR * 0.6;
72
+ break;
73
+ case 'switched':
74
+ delta = -K_FACTOR * 1.0;
75
+ break;
76
+ case 'retried':
77
+ delta = -K_FACTOR * 0.8;
78
+ break;
79
+ case 'error':
80
+ delta = -K_FACTOR * 0.5;
81
+ break;
82
+ case 'payment':
83
+ delta = -K_FACTOR * 1.5;
84
+ break;
85
+ case 'max_turns':
86
+ delta = -K_FACTOR * 0.3;
87
+ break;
88
+ default: delta = 0;
89
+ }
90
+ catMap.set(record.model, current + delta);
91
+ }
92
+ catch { /* skip malformed lines */ }
93
+ }
94
+ }
95
+ catch { /* ignore read errors */ }
96
+ return adjustments;
97
+ }
98
+ /**
99
+ * Get the effective Elo for a model in a category,
100
+ * blending global (server-trained) and local (user-specific) scores.
101
+ *
102
+ * effective = 0.7 * global + 0.3 * (1200 + local_delta)
103
+ */
104
+ export function blendElo(globalElo, localDelta) {
105
+ const localElo = 1200 + localDelta;
106
+ return 0.7 * globalElo + 0.3 * localElo;
107
+ }