@blockrun/franklin 3.3.3 → 3.5.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.
Files changed (109) hide show
  1. package/README.md +55 -4
  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 +16 -3
  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 +3 -3
  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
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Model selector for the learned router.
3
+ *
4
+ * Scoring formula (4 factors):
5
+ * score = w_quality * norm_quality
6
+ * + w_cost * (1 - norm_cost)
7
+ * + w_latency * (1 - norm_latency)
8
+ * + w_efficiency * norm_efficiency
9
+ *
10
+ * Efficiency = how few tool calls a model needs to complete a task.
11
+ * A model that does it in 5 calls is better than one that loops 85 times.
12
+ * Measured as 1/avg_tool_calls_per_turn (higher = more efficient).
13
+ *
14
+ * Profile weights:
15
+ * auto — balanced: quality 0.2, cost 0.3, latency 0.25, efficiency 0.25
16
+ * eco — cost-first: quality 0.1, cost 0.6, latency 0.15, efficiency 0.15
17
+ * premium — quality-first: quality 0.4, cost 0.1, latency 0.25, efficiency 0.25
18
+ * free — best efficiency among free models
19
+ */
20
+ import type { Category } from './categories.js';
21
+ import type { RoutingProfile } from './index.js';
22
+ export interface ModelScore {
23
+ model: string;
24
+ elo: number;
25
+ avg_cost_per_1k?: number;
26
+ avg_latency_ms?: number;
27
+ avg_tool_calls_per_turn?: number;
28
+ requests?: number;
29
+ unique_users?: number;
30
+ }
31
+ export interface LearnedWeights {
32
+ version: number;
33
+ trained_on: number;
34
+ trained_at: string;
35
+ categories: string[];
36
+ category_keywords?: Record<string, string[]>;
37
+ model_scores: Record<string, ModelScore[]>;
38
+ }
39
+ export interface SelectionResult {
40
+ model: string;
41
+ score: number;
42
+ expectedCost: number;
43
+ expectedLatency: number;
44
+ category: Category;
45
+ }
46
+ export declare function selectModel(category: Category, profile: RoutingProfile, weights: LearnedWeights): SelectionResult | null;
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Model selector for the learned router.
3
+ *
4
+ * Scoring formula (4 factors):
5
+ * score = w_quality * norm_quality
6
+ * + w_cost * (1 - norm_cost)
7
+ * + w_latency * (1 - norm_latency)
8
+ * + w_efficiency * norm_efficiency
9
+ *
10
+ * Efficiency = how few tool calls a model needs to complete a task.
11
+ * A model that does it in 5 calls is better than one that loops 85 times.
12
+ * Measured as 1/avg_tool_calls_per_turn (higher = more efficient).
13
+ *
14
+ * Profile weights:
15
+ * auto — balanced: quality 0.2, cost 0.3, latency 0.25, efficiency 0.25
16
+ * eco — cost-first: quality 0.1, cost 0.6, latency 0.15, efficiency 0.15
17
+ * premium — quality-first: quality 0.4, cost 0.1, latency 0.25, efficiency 0.25
18
+ * free — best efficiency among free models
19
+ */
20
+ import { MODEL_PRICING } from '../pricing.js';
21
+ const PROFILE_WEIGHTS = {
22
+ auto: { quality: 0.20, cost: 0.30, latency: 0.25, efficiency: 0.25 },
23
+ eco: { quality: 0.10, cost: 0.60, latency: 0.15, efficiency: 0.15 },
24
+ premium: { quality: 0.40, cost: 0.10, latency: 0.25, efficiency: 0.25 },
25
+ };
26
+ export function selectModel(category, profile, weights) {
27
+ const candidates = weights.model_scores[category];
28
+ if (!candidates || candidates.length === 0)
29
+ return null;
30
+ // Enrich with pricing data and defaults
31
+ const enriched = candidates.map(c => {
32
+ const pricing = MODEL_PRICING[c.model];
33
+ const costPer1K = pricing
34
+ ? (pricing.input + pricing.output) / 2 / 1000
35
+ : c.avg_cost_per_1k ?? 0.005;
36
+ const latencyMs = c.avg_latency_ms ?? 2000;
37
+ // Efficiency: 1/avg_tool_calls (higher = better). Default 10 calls/turn if unknown.
38
+ const toolCallsPerTurn = c.avg_tool_calls_per_turn ?? 10;
39
+ const efficiency = 1 / Math.max(1, toolCallsPerTurn);
40
+ return { ...c, costPer1K, latencyMs, efficiency };
41
+ });
42
+ // Filter to models we can actually route to
43
+ const available = enriched.filter(c => MODEL_PRICING[c.model]);
44
+ if (available.length === 0)
45
+ return null;
46
+ // ── Free profile: best efficiency + latency among free models ──
47
+ if (profile === 'free') {
48
+ const free = available.filter(c => c.costPer1K === 0);
49
+ if (free.length === 0)
50
+ return null;
51
+ // Score free models by efficiency (60%) + latency (40%)
52
+ const maxLat = Math.max(...free.map(c => c.latencyMs)) || 1;
53
+ const maxEff = Math.max(...free.map(c => c.efficiency)) || 1;
54
+ const selected = free.reduce((best, c) => {
55
+ const s = 0.6 * (c.efficiency / maxEff) + 0.4 * (1 - c.latencyMs / maxLat);
56
+ const bestS = 0.6 * (best.efficiency / maxEff) + 0.4 * (1 - best.latencyMs / maxLat);
57
+ return s > bestS ? c : best;
58
+ });
59
+ return {
60
+ model: selected.model,
61
+ score: selected.efficiency,
62
+ expectedCost: 0,
63
+ expectedLatency: selected.latencyMs,
64
+ category,
65
+ };
66
+ }
67
+ // ── Scored profiles: auto / eco / premium ──
68
+ const w = PROFILE_WEIGHTS[profile] ?? PROFILE_WEIGHTS.auto;
69
+ // Compute normalization bounds
70
+ const elos = available.map(c => c.elo);
71
+ const costs = available.map(c => c.costPer1K);
72
+ const latencies = available.map(c => c.latencyMs);
73
+ const efficiencies = available.map(c => c.efficiency);
74
+ const minElo = Math.min(...elos);
75
+ const maxElo = Math.max(...elos);
76
+ const maxCost = Math.max(...costs);
77
+ const maxLatency = Math.max(...latencies);
78
+ const maxEfficiency = Math.max(...efficiencies);
79
+ const eloRange = maxElo - minElo || 1;
80
+ const costRange = maxCost || 1;
81
+ const latencyRange = maxLatency || 1;
82
+ const efficiencyRange = maxEfficiency || 1;
83
+ let bestScore = -Infinity;
84
+ let selected = available[0];
85
+ for (const c of available) {
86
+ const normQuality = (c.elo - minElo) / eloRange;
87
+ const normCost = c.costPer1K / costRange;
88
+ const normLatency = c.latencyMs / latencyRange;
89
+ const normEfficiency = c.efficiency / efficiencyRange;
90
+ const score = w.quality * normQuality +
91
+ w.cost * (1 - normCost) +
92
+ w.latency * (1 - normLatency) +
93
+ w.efficiency * normEfficiency;
94
+ if (score > bestScore) {
95
+ bestScore = score;
96
+ selected = c;
97
+ }
98
+ }
99
+ return {
100
+ model: selected.model,
101
+ score: bestScore,
102
+ expectedCost: selected.costPer1K,
103
+ expectedLatency: selected.latencyMs,
104
+ category,
105
+ };
106
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Session persistence for runcode.
2
+ * Session persistence for Franklin.
3
3
  * Saves conversation history as JSONL for resume capability.
4
4
  */
5
5
  import type { Dialogue } from '../agent/types.js';
@@ -11,6 +11,10 @@ export interface SessionMeta {
11
11
  updatedAt: number;
12
12
  turnCount: number;
13
13
  messageCount: number;
14
+ inputTokens?: number;
15
+ outputTokens?: number;
16
+ costUsd?: number;
17
+ savedVsOpusUsd?: number;
14
18
  }
15
19
  /** Get the absolute path to a session's JSONL file (for external readers like search). */
16
20
  export declare function getSessionFilePath(id: string): string;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Session persistence for runcode.
2
+ * Session persistence for Franklin.
3
3
  * Saves conversation history as JSONL for resume capability.
4
4
  */
5
5
  import fs from 'node:fs';
@@ -88,6 +88,10 @@ export function updateSessionMeta(sessionId, meta) {
88
88
  updatedAt: Date.now(),
89
89
  turnCount: meta.turnCount ?? existing?.turnCount ?? 0,
90
90
  messageCount: meta.messageCount ?? existing?.messageCount ?? 0,
91
+ inputTokens: meta.inputTokens ?? existing?.inputTokens ?? 0,
92
+ outputTokens: meta.outputTokens ?? existing?.outputTokens ?? 0,
93
+ costUsd: meta.costUsd ?? existing?.costUsd ?? 0,
94
+ savedVsOpusUsd: meta.savedVsOpusUsd ?? existing?.savedVsOpusUsd ?? 0,
91
95
  };
92
96
  fs.writeFileSync(metaPath(sessionId), JSON.stringify(updated, null, 2));
93
97
  });
@@ -142,7 +146,9 @@ export function listSessions() {
142
146
  }
143
147
  catch { /* skip corrupted */ }
144
148
  }
145
- return metas.sort((a, b) => b.updatedAt - a.updatedAt);
149
+ // Filter out ghost sessions (0 messages)
150
+ const filtered = metas.filter(m => m.messageCount > 0);
151
+ return filtered.sort((a, b) => b.updatedAt - a.updatedAt);
146
152
  }
147
153
  catch {
148
154
  return [];
@@ -172,4 +178,20 @@ export function pruneOldSessions(activeSessionId) {
172
178
  }
173
179
  catch { /* ok */ }
174
180
  }
181
+ // Also clean up ghost sessions (0 messages, older than 5 minutes)
182
+ const fiveMinAgo = Date.now() - 5 * 60 * 1000;
183
+ for (const s of sessions) {
184
+ if (s.id === activeSessionId)
185
+ continue;
186
+ if (s.messageCount === 0 && s.createdAt < fiveMinAgo) {
187
+ try {
188
+ fs.unlinkSync(sessionPath(s.id));
189
+ }
190
+ catch { /* ok */ }
191
+ try {
192
+ fs.unlinkSync(metaPath(s.id));
193
+ }
194
+ catch { /* ok */ }
195
+ }
196
+ }
175
197
  }
@@ -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+|\\d+[smhd]|just now|now";
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";
@@ -83,7 +83,11 @@ export function extractArticleBlocks(tree) {
83
83
  * This doubles as the "this is a tweet" signal in social-bot — the only link
84
84
  * inside an article block with this label shape is the permalink to the tweet.
85
85
  */
86
- export const X_TIME_LINK_PATTERN = '(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\\s+\\d+|\\d+[smhd]|just now|now';
86
+ // Matches all known X time-link formats:
87
+ // "Mar 16", "Apr 12, 2026", "5h", "5m", "2d", "30s", "just now", "now"
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}日';
87
91
  function escapeRegex(s) {
88
92
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
89
93
  }
@@ -87,6 +87,11 @@ export declare class SocialBrowser {
87
87
  getUrl(): Promise<string>;
88
88
  getTitle(): Promise<string>;
89
89
  waitForTimeout(ms: number): Promise<void>;
90
+ /**
91
+ * Resolve a ref from the last snapshot to its href attribute.
92
+ * Returns the href string, or null if the ref isn't a link or has no href.
93
+ */
94
+ getHref(ref: string): Promise<string | null>;
90
95
  /**
91
96
  * Block until the user closes the browser tab (used by the login flow).
92
97
  * Resolves when the context is closed.
@@ -201,6 +201,28 @@ export class SocialBrowser {
201
201
  this.requirePage();
202
202
  await this.page.waitForTimeout(ms);
203
203
  }
204
+ /**
205
+ * Resolve a ref from the last snapshot to its href attribute.
206
+ * Returns the href string, or null if the ref isn't a link or has no href.
207
+ */
208
+ async getHref(ref) {
209
+ this.requirePage();
210
+ const axRef = this.lastRefs.get(ref);
211
+ if (!axRef)
212
+ return null;
213
+ try {
214
+ const el = this.page.locator(axRef.selector).first();
215
+ // Try the element itself, then walk up to find the nearest <a>
216
+ const href = await el.evaluate((node) => {
217
+ const anchor = node.closest('a') || (node.tagName === 'A' ? node : null);
218
+ return anchor ? anchor.href : null;
219
+ });
220
+ return href;
221
+ }
222
+ catch {
223
+ return null;
224
+ }
225
+ }
204
226
  /**
205
227
  * Block until the user closes the browser tab (used by the login flow).
206
228
  * Resolves when the context is closed.
@@ -6,6 +6,10 @@ import type { SocialBrowser } from './browser.js';
6
6
  /**
7
7
  * Verify that social config is ready and the user is logged in to X.
8
8
  * Returns the browser instance on success so callers can reuse it.
9
+ *
10
+ * Login detection order:
11
+ * 1. Check Cookies DB for auth_token (fast, no browser needed)
12
+ * 2. Fallback: open x.com/home and check AX tree for login_detection string
9
13
  */
10
14
  export declare function checkSocialReady(): Promise<{
11
15
  ready: boolean;
@@ -2,11 +2,38 @@
2
2
  * Pre-flight checks before social tools can run.
3
3
  * Validates config readiness and browser login state.
4
4
  */
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
5
7
  import { loadConfig, isConfigReady } from './config.js';
6
8
  import { browserPool } from './browser-pool.js';
9
+ import { SOCIAL_PROFILE_DIR } from './browser.js';
10
+ /**
11
+ * Quick cookie check — verify auth_token exists in the profile's Cookies DB.
12
+ * Much faster and more reliable than loading X in a browser and inspecting
13
+ * the accessibility tree for a username string.
14
+ */
15
+ function hasSavedAuthCookie() {
16
+ const cookiesPath = path.join(SOCIAL_PROFILE_DIR, 'Default', 'Cookies');
17
+ if (!fs.existsSync(cookiesPath))
18
+ return false;
19
+ try {
20
+ // Read the SQLite file as binary and look for the auth_token cookie.
21
+ // This avoids requiring sqlite3 as a dependency — the cookie name is
22
+ // stored as plain text in the DB file.
23
+ const raw = fs.readFileSync(cookiesPath);
24
+ return raw.includes('auth_token');
25
+ }
26
+ catch {
27
+ return false;
28
+ }
29
+ }
7
30
  /**
8
31
  * Verify that social config is ready and the user is logged in to X.
9
32
  * Returns the browser instance on success so callers can reuse it.
33
+ *
34
+ * Login detection order:
35
+ * 1. Check Cookies DB for auth_token (fast, no browser needed)
36
+ * 2. Fallback: open x.com/home and check AX tree for login_detection string
10
37
  */
11
38
  export async function checkSocialReady() {
12
39
  const cfg = loadConfig();
@@ -14,13 +41,25 @@ export async function checkSocialReady() {
14
41
  if (!configStatus.ready) {
15
42
  return { ready: false, reason: configStatus.reason };
16
43
  }
44
+ // Fast path: check saved cookies first
45
+ if (!hasSavedAuthCookie()) {
46
+ return { ready: false, reason: 'Not logged in to X (no auth_token cookie). Run: franklin social login x' };
47
+ }
48
+ // Cookies exist — browser login should work. Open browser for caller to use.
17
49
  const browser = await browserPool.getBrowser();
18
50
  await browser.open('https://x.com/home');
19
51
  await browser.waitForTimeout(2500);
20
52
  const tree = await browser.snapshot();
21
- if (!tree.includes(cfg.x.login_detection)) {
22
- browserPool.releaseBrowser();
23
- return { ready: false, reason: 'Not logged in to X. Run: franklin social login x' };
53
+ // If login_detection is set, verify it as a secondary check.
54
+ // But don't fail if cookies exist — X may just not show the handle in AX tree.
55
+ if (cfg.x.login_detection && !tree.includes(cfg.x.login_detection)) {
56
+ // Check if we're actually on the home feed (not redirected to login page)
57
+ const isLoginPage = tree.includes('Sign in') && tree.includes('Create account');
58
+ if (isLoginPage) {
59
+ browserPool.releaseBrowser();
60
+ return { ready: false, reason: 'Cookie expired. Run: franklin social login x' };
61
+ }
62
+ // Cookies valid, just handle not found in AX tree — proceed anyway
24
63
  }
25
64
  return { ready: true, browser };
26
65
  }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Structured failure logging for self-evolution analysis.
3
+ * Append-only JSONL at ~/.blockrun/failures.jsonl (capped 500 records).
4
+ */
5
+ export interface FailureRecord {
6
+ timestamp: number;
7
+ model: string;
8
+ failureType: 'tool_error' | 'model_error' | 'permission_denied' | 'agent_loop';
9
+ toolName?: string;
10
+ errorMessage: string;
11
+ recoveryAction?: string;
12
+ }
13
+ export declare function recordFailure(record: FailureRecord): void;
14
+ export declare function loadFailures(limit?: number): FailureRecord[];
15
+ export declare function getFailureStats(): {
16
+ byTool: Map<string, number>;
17
+ byType: Map<string, number>;
18
+ total: number;
19
+ recentFailures: FailureRecord[];
20
+ };
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Structured failure logging for self-evolution analysis.
3
+ * Append-only JSONL at ~/.blockrun/failures.jsonl (capped 500 records).
4
+ */
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { BLOCKRUN_DIR } from '../config.js';
8
+ const FAILURES_FILE = path.join(BLOCKRUN_DIR, 'failures.jsonl');
9
+ const MAX_RECORDS = 500;
10
+ export function recordFailure(record) {
11
+ try {
12
+ fs.mkdirSync(path.dirname(FAILURES_FILE), { recursive: true });
13
+ fs.appendFileSync(FAILURES_FILE, JSON.stringify(record) + '\n');
14
+ // Trim to MAX_RECORDS (only check periodically to avoid constant reads)
15
+ if (Math.random() < 0.1) {
16
+ trimFailures();
17
+ }
18
+ }
19
+ catch {
20
+ // Fire-and-forget — never block the critical path
21
+ }
22
+ }
23
+ function trimFailures() {
24
+ try {
25
+ if (!fs.existsSync(FAILURES_FILE))
26
+ return;
27
+ const lines = fs.readFileSync(FAILURES_FILE, 'utf-8').trim().split('\n');
28
+ if (lines.length > MAX_RECORDS) {
29
+ const trimmed = lines.slice(-MAX_RECORDS).join('\n') + '\n';
30
+ fs.writeFileSync(FAILURES_FILE, trimmed);
31
+ }
32
+ }
33
+ catch {
34
+ // ignore
35
+ }
36
+ }
37
+ export function loadFailures(limit = 100) {
38
+ try {
39
+ if (!fs.existsSync(FAILURES_FILE))
40
+ return [];
41
+ const lines = fs.readFileSync(FAILURES_FILE, 'utf-8').trim().split('\n').filter(Boolean);
42
+ return lines.slice(-limit).map(l => JSON.parse(l));
43
+ }
44
+ catch {
45
+ return [];
46
+ }
47
+ }
48
+ export function getFailureStats() {
49
+ const records = loadFailures(500);
50
+ const byTool = new Map();
51
+ const byType = new Map();
52
+ for (const r of records) {
53
+ if (r.toolName)
54
+ byTool.set(r.toolName, (byTool.get(r.toolName) ?? 0) + 1);
55
+ byType.set(r.failureType, (byType.get(r.failureType) ?? 0) + 1);
56
+ }
57
+ return {
58
+ byTool,
59
+ byType,
60
+ total: records.length,
61
+ recentFailures: records.slice(-10),
62
+ };
63
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Shared formatting utilities for token counts, costs, and model names.
3
+ */
4
+ export declare function formatTokens(n: number): string;
5
+ export declare function formatUsd(n: number): string;
6
+ export declare function shortModelName(model: string): string;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Shared formatting utilities for token counts, costs, and model names.
3
+ */
4
+ export function formatTokens(n) {
5
+ if (n < 1000)
6
+ return String(n);
7
+ if (n < 1_000_000)
8
+ return `${(n / 1000).toFixed(1)}K`;
9
+ return `${(n / 1_000_000).toFixed(2)}M`;
10
+ }
11
+ export function formatUsd(n) {
12
+ if (n === 0)
13
+ return '$0';
14
+ if (n < 0.01)
15
+ return `$${n.toFixed(4)}`;
16
+ if (n < 1)
17
+ return `$${n.toFixed(3)}`;
18
+ return `$${n.toFixed(2)}`;
19
+ }
20
+ export function shortModelName(model) {
21
+ const idx = model.indexOf('/');
22
+ return idx > -1 ? model.slice(idx + 1) : model;
23
+ }
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import { loadStats } from './tracker.js';
15
15
  import { OPUS_PRICING, MODEL_PRICING } from '../pricing.js';
16
+ import { formatTokens, formatUsd, shortModelName } from './format.js';
16
17
  // ─── Generate Report ──────────────────────────────────────────────────────
17
18
  export function generateInsights(days = 30) {
18
19
  const stats = loadStats();
@@ -113,27 +114,6 @@ function sparkline(values) {
113
114
  const chars = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
114
115
  return values.map(v => chars[Math.min(7, Math.floor((v / max) * 8))]).join('');
115
116
  }
116
- function formatUsd(n) {
117
- if (n === 0)
118
- return '$0';
119
- if (n < 0.01)
120
- return `$${n.toFixed(4)}`;
121
- if (n < 1)
122
- return `$${n.toFixed(3)}`;
123
- return `$${n.toFixed(2)}`;
124
- }
125
- function formatTokens(n) {
126
- if (n < 1000)
127
- return String(n);
128
- if (n < 1_000_000)
129
- return `${(n / 1000).toFixed(1)}K`;
130
- return `${(n / 1_000_000).toFixed(2)}M`;
131
- }
132
- function shortModelName(model) {
133
- // Strip provider prefix for display
134
- const idx = model.indexOf('/');
135
- return idx > -1 ? model.slice(idx + 1) : model;
136
- }
137
117
  export function formatInsights(report, days) {
138
118
  const sep = '─'.repeat(60);
139
119
  const lines = [];
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Session-scoped per-model usage tracking.
3
+ * In-memory only — resets on new session. Used by /cost and UI footer.
4
+ */
5
+ export interface SessionModelUsage {
6
+ requests: number;
7
+ inputTokens: number;
8
+ outputTokens: number;
9
+ costUsd: number;
10
+ lastTier?: string;
11
+ }
12
+ export declare function recordSessionUsage(model: string, inputTokens: number, outputTokens: number, costUsd: number, tier?: string): void;
13
+ export declare function getSessionModelBreakdown(): Array<{
14
+ model: string;
15
+ requests: number;
16
+ inputTokens: number;
17
+ outputTokens: number;
18
+ costUsd: number;
19
+ lastTier?: string;
20
+ }>;
21
+ export declare function resetSession(): void;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Session-scoped per-model usage tracking.
3
+ * In-memory only — resets on new session. Used by /cost and UI footer.
4
+ */
5
+ const sessionModels = new Map();
6
+ export function recordSessionUsage(model, inputTokens, outputTokens, costUsd, tier) {
7
+ const existing = sessionModels.get(model) ?? {
8
+ requests: 0,
9
+ inputTokens: 0,
10
+ outputTokens: 0,
11
+ costUsd: 0,
12
+ };
13
+ existing.requests++;
14
+ existing.inputTokens += inputTokens;
15
+ existing.outputTokens += outputTokens;
16
+ existing.costUsd += costUsd;
17
+ if (tier)
18
+ existing.lastTier = tier;
19
+ sessionModels.set(model, existing);
20
+ }
21
+ export function getSessionModelBreakdown() {
22
+ return Array.from(sessionModels.entries())
23
+ .map(([model, usage]) => ({ model, ...usage }))
24
+ .sort((a, b) => b.costUsd - a.costUsd);
25
+ }
26
+ export function resetSession() {
27
+ sessionModels.clear();
28
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Usage tracking for runcode
2
+ * Usage tracking for Franklin
3
3
  * Records all requests with cost, tokens, and latency for stats display
4
4
  */
5
5
  export declare function getStatsFilePath(): string;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Usage tracking for runcode
2
+ * Usage tracking for Franklin
3
3
  * Records all requests with cost, tokens, and latency for stats display
4
4
  */
5
5
  import fs from 'node:fs';
@@ -1,5 +1,18 @@
1
1
  /**
2
2
  * Bash capability — execute shell commands with timeout and output capture.
3
3
  */
4
- import type { CapabilityHandler } from '../agent/types.js';
4
+ import type { CapabilityHandler, CapabilityResult } from '../agent/types.js';
5
+ interface BackgroundTask {
6
+ id: string;
7
+ command: string;
8
+ description: string;
9
+ startedAt: number;
10
+ status: 'running' | 'completed' | 'failed';
11
+ result?: CapabilityResult;
12
+ }
13
+ /** Get a background task's result (called by the agent to check status). */
14
+ export declare function getBackgroundTask(id: string): BackgroundTask | undefined;
15
+ /** List all background tasks. */
16
+ export declare function listBackgroundTasks(): BackgroundTask[];
5
17
  export declare const bashCapability: CapabilityHandler;
18
+ export {};