@blockrun/franklin 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +190 -0
- package/README.md +256 -0
- package/dist/agent/commands.d.ts +27 -0
- package/dist/agent/commands.js +659 -0
- package/dist/agent/compact.d.ts +31 -0
- package/dist/agent/compact.js +366 -0
- package/dist/agent/context.d.ts +11 -0
- package/dist/agent/context.js +184 -0
- package/dist/agent/error-classifier.d.ts +10 -0
- package/dist/agent/error-classifier.js +61 -0
- package/dist/agent/llm.d.ts +63 -0
- package/dist/agent/llm.js +448 -0
- package/dist/agent/loop.d.ts +12 -0
- package/dist/agent/loop.js +346 -0
- package/dist/agent/optimize.d.ts +53 -0
- package/dist/agent/optimize.js +262 -0
- package/dist/agent/permissions.d.ts +39 -0
- package/dist/agent/permissions.js +226 -0
- package/dist/agent/reduce.d.ts +49 -0
- package/dist/agent/reduce.js +317 -0
- package/dist/agent/streaming-executor.d.ts +36 -0
- package/dist/agent/streaming-executor.js +149 -0
- package/dist/agent/tokens.d.ts +53 -0
- package/dist/agent/tokens.js +185 -0
- package/dist/agent/types.d.ts +125 -0
- package/dist/agent/types.js +5 -0
- package/dist/banner.d.ts +1 -0
- package/dist/banner.js +27 -0
- package/dist/commands/balance.d.ts +1 -0
- package/dist/commands/balance.js +40 -0
- package/dist/commands/config.d.ts +14 -0
- package/dist/commands/config.js +107 -0
- package/dist/commands/daemon.d.ts +3 -0
- package/dist/commands/daemon.js +117 -0
- package/dist/commands/history.d.ts +5 -0
- package/dist/commands/history.js +31 -0
- package/dist/commands/init.d.ts +3 -0
- package/dist/commands/init.js +92 -0
- package/dist/commands/logs.d.ts +5 -0
- package/dist/commands/logs.js +89 -0
- package/dist/commands/models.d.ts +1 -0
- package/dist/commands/models.js +56 -0
- package/dist/commands/plugin.d.ts +14 -0
- package/dist/commands/plugin.js +176 -0
- package/dist/commands/proxy.d.ts +13 -0
- package/dist/commands/proxy.js +106 -0
- package/dist/commands/setup.d.ts +1 -0
- package/dist/commands/setup.js +49 -0
- package/dist/commands/start.d.ts +8 -0
- package/dist/commands/start.js +292 -0
- package/dist/commands/stats.d.ts +10 -0
- package/dist/commands/stats.js +94 -0
- package/dist/commands/uninit.d.ts +1 -0
- package/dist/commands/uninit.js +63 -0
- package/dist/config.d.ts +9 -0
- package/dist/config.js +41 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +179 -0
- package/dist/mcp/client.d.ts +44 -0
- package/dist/mcp/client.js +147 -0
- package/dist/mcp/config.d.ts +20 -0
- package/dist/mcp/config.js +138 -0
- package/dist/plugin-sdk/channel.d.ts +100 -0
- package/dist/plugin-sdk/channel.js +10 -0
- package/dist/plugin-sdk/index.d.ts +14 -0
- package/dist/plugin-sdk/index.js +9 -0
- package/dist/plugin-sdk/plugin.d.ts +87 -0
- package/dist/plugin-sdk/plugin.js +7 -0
- package/dist/plugin-sdk/search.d.ts +13 -0
- package/dist/plugin-sdk/search.js +4 -0
- package/dist/plugin-sdk/tracker.d.ts +27 -0
- package/dist/plugin-sdk/tracker.js +5 -0
- package/dist/plugin-sdk/workflow.d.ts +126 -0
- package/dist/plugin-sdk/workflow.js +11 -0
- package/dist/plugins/registry.d.ts +33 -0
- package/dist/plugins/registry.js +155 -0
- package/dist/plugins/runner.d.ts +21 -0
- package/dist/plugins/runner.js +453 -0
- package/dist/plugins-bundled/social/index.d.ts +10 -0
- package/dist/plugins-bundled/social/index.js +363 -0
- package/dist/plugins-bundled/social/plugin.json +14 -0
- package/dist/plugins-bundled/social/prompts.d.ts +19 -0
- package/dist/plugins-bundled/social/prompts.js +67 -0
- package/dist/plugins-bundled/social/types.d.ts +58 -0
- package/dist/plugins-bundled/social/types.js +16 -0
- package/dist/pricing.d.ts +21 -0
- package/dist/pricing.js +91 -0
- package/dist/proxy/fallback.d.ts +38 -0
- package/dist/proxy/fallback.js +144 -0
- package/dist/proxy/server.d.ts +18 -0
- package/dist/proxy/server.js +576 -0
- package/dist/proxy/sse-translator.d.ts +29 -0
- package/dist/proxy/sse-translator.js +270 -0
- package/dist/router/index.d.ts +22 -0
- package/dist/router/index.js +269 -0
- package/dist/session/search.d.ts +33 -0
- package/dist/session/search.js +229 -0
- package/dist/session/storage.d.ts +48 -0
- package/dist/session/storage.js +173 -0
- package/dist/stats/insights.d.ts +55 -0
- package/dist/stats/insights.js +195 -0
- package/dist/stats/tracker.d.ts +54 -0
- package/dist/stats/tracker.js +165 -0
- package/dist/tools/askuser.d.ts +6 -0
- package/dist/tools/askuser.js +76 -0
- package/dist/tools/bash.d.ts +5 -0
- package/dist/tools/bash.js +336 -0
- package/dist/tools/edit.d.ts +5 -0
- package/dist/tools/edit.js +148 -0
- package/dist/tools/glob.d.ts +5 -0
- package/dist/tools/glob.js +158 -0
- package/dist/tools/grep.d.ts +5 -0
- package/dist/tools/grep.js +194 -0
- package/dist/tools/imagegen.d.ts +6 -0
- package/dist/tools/imagegen.js +172 -0
- package/dist/tools/index.d.ts +17 -0
- package/dist/tools/index.js +30 -0
- package/dist/tools/read.d.ts +11 -0
- package/dist/tools/read.js +90 -0
- package/dist/tools/subagent.d.ts +5 -0
- package/dist/tools/subagent.js +116 -0
- package/dist/tools/task.d.ts +5 -0
- package/dist/tools/task.js +91 -0
- package/dist/tools/webfetch.d.ts +5 -0
- package/dist/tools/webfetch.js +166 -0
- package/dist/tools/websearch.d.ts +5 -0
- package/dist/tools/websearch.js +103 -0
- package/dist/tools/write.d.ts +5 -0
- package/dist/tools/write.js +114 -0
- package/dist/ui/app.d.ts +26 -0
- package/dist/ui/app.js +545 -0
- package/dist/ui/model-picker.d.ts +14 -0
- package/dist/ui/model-picker.js +161 -0
- package/dist/ui/terminal.d.ts +35 -0
- package/dist/ui/terminal.js +337 -0
- package/dist/wallet/manager.d.ts +10 -0
- package/dist/wallet/manager.js +23 -0
- package/package.json +79 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Usage tracking for runcode
|
|
3
|
+
* Records all requests with cost, tokens, and latency for stats display
|
|
4
|
+
*/
|
|
5
|
+
export interface UsageRecord {
|
|
6
|
+
timestamp: number;
|
|
7
|
+
model: string;
|
|
8
|
+
inputTokens: number;
|
|
9
|
+
outputTokens: number;
|
|
10
|
+
costUsd: number;
|
|
11
|
+
latencyMs: number;
|
|
12
|
+
fallback?: boolean;
|
|
13
|
+
}
|
|
14
|
+
export interface ModelStats {
|
|
15
|
+
requests: number;
|
|
16
|
+
costUsd: number;
|
|
17
|
+
inputTokens: number;
|
|
18
|
+
outputTokens: number;
|
|
19
|
+
fallbackCount: number;
|
|
20
|
+
avgLatencyMs: number;
|
|
21
|
+
totalLatencyMs: number;
|
|
22
|
+
}
|
|
23
|
+
export interface Stats {
|
|
24
|
+
version: number;
|
|
25
|
+
totalRequests: number;
|
|
26
|
+
totalCostUsd: number;
|
|
27
|
+
totalInputTokens: number;
|
|
28
|
+
totalOutputTokens: number;
|
|
29
|
+
totalFallbacks: number;
|
|
30
|
+
byModel: Record<string, ModelStats>;
|
|
31
|
+
history: UsageRecord[];
|
|
32
|
+
firstRequest?: number;
|
|
33
|
+
lastRequest?: number;
|
|
34
|
+
}
|
|
35
|
+
export declare function loadStats(): Stats;
|
|
36
|
+
export declare function saveStats(stats: Stats): void;
|
|
37
|
+
export declare function clearStats(): void;
|
|
38
|
+
/** Flush stats to disk immediately (call on process exit) */
|
|
39
|
+
export declare function flushStats(): void;
|
|
40
|
+
/**
|
|
41
|
+
* Record a completed request for stats tracking
|
|
42
|
+
*/
|
|
43
|
+
export declare function recordUsage(model: string, inputTokens: number, outputTokens: number, costUsd: number, latencyMs: number, fallback?: boolean): void;
|
|
44
|
+
/**
|
|
45
|
+
* Get stats summary for display
|
|
46
|
+
*/
|
|
47
|
+
export declare function getStatsSummary(): {
|
|
48
|
+
stats: Stats;
|
|
49
|
+
opusCost: number;
|
|
50
|
+
saved: number;
|
|
51
|
+
savedPct: number;
|
|
52
|
+
avgCostPerRequest: number;
|
|
53
|
+
period: string;
|
|
54
|
+
};
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Usage tracking for runcode
|
|
3
|
+
* Records all requests with cost, tokens, and latency for stats display
|
|
4
|
+
*/
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import { OPUS_PRICING } from '../pricing.js';
|
|
9
|
+
const STATS_FILE = path.join(os.homedir(), '.blockrun', 'runcode-stats.json');
|
|
10
|
+
const EMPTY_STATS = {
|
|
11
|
+
version: 1,
|
|
12
|
+
totalRequests: 0,
|
|
13
|
+
totalCostUsd: 0,
|
|
14
|
+
totalInputTokens: 0,
|
|
15
|
+
totalOutputTokens: 0,
|
|
16
|
+
totalFallbacks: 0,
|
|
17
|
+
byModel: {},
|
|
18
|
+
history: [],
|
|
19
|
+
};
|
|
20
|
+
export function loadStats() {
|
|
21
|
+
try {
|
|
22
|
+
if (fs.existsSync(STATS_FILE)) {
|
|
23
|
+
const data = JSON.parse(fs.readFileSync(STATS_FILE, 'utf-8'));
|
|
24
|
+
// Migration: add missing fields
|
|
25
|
+
return {
|
|
26
|
+
...EMPTY_STATS,
|
|
27
|
+
...data,
|
|
28
|
+
version: 1,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
/* ignore parse errors, return empty */
|
|
34
|
+
}
|
|
35
|
+
return { ...EMPTY_STATS };
|
|
36
|
+
}
|
|
37
|
+
export function saveStats(stats) {
|
|
38
|
+
try {
|
|
39
|
+
fs.mkdirSync(path.dirname(STATS_FILE), { recursive: true });
|
|
40
|
+
// Keep only last 1000 history records
|
|
41
|
+
stats.history = stats.history.slice(-1000);
|
|
42
|
+
fs.writeFileSync(STATS_FILE, JSON.stringify(stats, null, 2));
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
/* ignore write errors */
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export function clearStats() {
|
|
49
|
+
cachedStats = null;
|
|
50
|
+
if (flushTimer) {
|
|
51
|
+
clearTimeout(flushTimer);
|
|
52
|
+
flushTimer = null;
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
if (fs.existsSync(STATS_FILE)) {
|
|
56
|
+
fs.unlinkSync(STATS_FILE);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
/* ignore */
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// ─── In-memory stats cache with debounced write ─────────────────────────
|
|
64
|
+
// Prevents concurrent load→modify→save from losing data in proxy mode
|
|
65
|
+
let cachedStats = null;
|
|
66
|
+
let flushTimer = null;
|
|
67
|
+
const FLUSH_DELAY_MS = 2000;
|
|
68
|
+
function getCachedStats() {
|
|
69
|
+
if (!cachedStats) {
|
|
70
|
+
cachedStats = loadStats();
|
|
71
|
+
}
|
|
72
|
+
return cachedStats;
|
|
73
|
+
}
|
|
74
|
+
function scheduleSave() {
|
|
75
|
+
if (flushTimer)
|
|
76
|
+
return; // Already scheduled
|
|
77
|
+
flushTimer = setTimeout(() => {
|
|
78
|
+
flushTimer = null;
|
|
79
|
+
if (cachedStats)
|
|
80
|
+
saveStats(cachedStats);
|
|
81
|
+
}, FLUSH_DELAY_MS);
|
|
82
|
+
}
|
|
83
|
+
/** Flush stats to disk immediately (call on process exit) */
|
|
84
|
+
export function flushStats() {
|
|
85
|
+
if (flushTimer) {
|
|
86
|
+
clearTimeout(flushTimer);
|
|
87
|
+
flushTimer = null;
|
|
88
|
+
}
|
|
89
|
+
if (cachedStats)
|
|
90
|
+
saveStats(cachedStats);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Record a completed request for stats tracking
|
|
94
|
+
*/
|
|
95
|
+
export function recordUsage(model, inputTokens, outputTokens, costUsd, latencyMs, fallback = false) {
|
|
96
|
+
const stats = getCachedStats();
|
|
97
|
+
const now = Date.now();
|
|
98
|
+
// Update totals
|
|
99
|
+
stats.totalRequests++;
|
|
100
|
+
stats.totalCostUsd += costUsd;
|
|
101
|
+
stats.totalInputTokens += inputTokens;
|
|
102
|
+
stats.totalOutputTokens += outputTokens;
|
|
103
|
+
if (fallback)
|
|
104
|
+
stats.totalFallbacks++;
|
|
105
|
+
// Update timestamps
|
|
106
|
+
if (!stats.firstRequest)
|
|
107
|
+
stats.firstRequest = now;
|
|
108
|
+
stats.lastRequest = now;
|
|
109
|
+
// Update per-model stats
|
|
110
|
+
if (!stats.byModel[model]) {
|
|
111
|
+
stats.byModel[model] = {
|
|
112
|
+
requests: 0,
|
|
113
|
+
costUsd: 0,
|
|
114
|
+
inputTokens: 0,
|
|
115
|
+
outputTokens: 0,
|
|
116
|
+
fallbackCount: 0,
|
|
117
|
+
avgLatencyMs: 0,
|
|
118
|
+
totalLatencyMs: 0,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
const modelStats = stats.byModel[model];
|
|
122
|
+
modelStats.requests++;
|
|
123
|
+
modelStats.costUsd += costUsd;
|
|
124
|
+
modelStats.inputTokens += inputTokens;
|
|
125
|
+
modelStats.outputTokens += outputTokens;
|
|
126
|
+
modelStats.totalLatencyMs += latencyMs;
|
|
127
|
+
modelStats.avgLatencyMs = modelStats.totalLatencyMs / modelStats.requests;
|
|
128
|
+
if (fallback)
|
|
129
|
+
modelStats.fallbackCount++;
|
|
130
|
+
// Add to history
|
|
131
|
+
stats.history.push({
|
|
132
|
+
timestamp: now,
|
|
133
|
+
model,
|
|
134
|
+
inputTokens,
|
|
135
|
+
outputTokens,
|
|
136
|
+
costUsd,
|
|
137
|
+
latencyMs,
|
|
138
|
+
fallback,
|
|
139
|
+
});
|
|
140
|
+
scheduleSave();
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Get stats summary for display
|
|
144
|
+
*/
|
|
145
|
+
export function getStatsSummary() {
|
|
146
|
+
const stats = loadStats();
|
|
147
|
+
// Calculate what it would cost with Claude Opus
|
|
148
|
+
const opusCost = (stats.totalInputTokens / 1_000_000) * OPUS_PRICING.input +
|
|
149
|
+
(stats.totalOutputTokens / 1_000_000) * OPUS_PRICING.output;
|
|
150
|
+
const saved = opusCost - stats.totalCostUsd;
|
|
151
|
+
const savedPct = opusCost > 0 ? (saved / opusCost) * 100 : 0;
|
|
152
|
+
const avgCostPerRequest = stats.totalRequests > 0 ? stats.totalCostUsd / stats.totalRequests : 0;
|
|
153
|
+
// Calculate period
|
|
154
|
+
let period = 'No data';
|
|
155
|
+
if (stats.firstRequest && stats.lastRequest) {
|
|
156
|
+
const days = Math.ceil((stats.lastRequest - stats.firstRequest) / (1000 * 60 * 60 * 24));
|
|
157
|
+
if (days === 0)
|
|
158
|
+
period = 'Today';
|
|
159
|
+
else if (days === 1)
|
|
160
|
+
period = '1 day';
|
|
161
|
+
else
|
|
162
|
+
period = `${days} days`;
|
|
163
|
+
}
|
|
164
|
+
return { stats, opusCost, saved, savedPct, avgCostPerRequest, period };
|
|
165
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AskUser capability — let the agent ask the user a clarifying question.
|
|
3
|
+
* The question is displayed and the response is returned as tool output.
|
|
4
|
+
*/
|
|
5
|
+
import type { CapabilityHandler } from '../agent/types.js';
|
|
6
|
+
export declare const askUserCapability: CapabilityHandler;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AskUser capability — let the agent ask the user a clarifying question.
|
|
3
|
+
* The question is displayed and the response is returned as tool output.
|
|
4
|
+
*/
|
|
5
|
+
import readline from 'node:readline';
|
|
6
|
+
import chalk from 'chalk';
|
|
7
|
+
async function execute(input, ctx) {
|
|
8
|
+
const { question, options } = input;
|
|
9
|
+
if (!question) {
|
|
10
|
+
return { output: 'Error: question is required', isError: true };
|
|
11
|
+
}
|
|
12
|
+
// Ink UI path: use the provided callback to avoid raw-mode stdin conflict
|
|
13
|
+
if (ctx.onAskUser) {
|
|
14
|
+
try {
|
|
15
|
+
const answer = await ctx.onAskUser(question, options);
|
|
16
|
+
return { output: answer || '(no response)' };
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return { output: 'User did not respond.', isError: false };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
// Non-ink fallback (CLI piped / scripted mode)
|
|
23
|
+
if (!process.stdin.isTTY) {
|
|
24
|
+
return {
|
|
25
|
+
output: `[Non-interactive mode] Cannot prompt user. Proceed with a reasonable assumption. Question was: ${question}`,
|
|
26
|
+
isError: false,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
// Bare TTY fallback (no ink UI) — use readline
|
|
30
|
+
console.error('');
|
|
31
|
+
console.error(chalk.yellow(' ╭─ Question ────────────────────────────'));
|
|
32
|
+
console.error(chalk.yellow(` │ ${question}`));
|
|
33
|
+
if (options && options.length > 0) {
|
|
34
|
+
for (let i = 0; i < options.length; i++) {
|
|
35
|
+
console.error(chalk.dim(` │ ${i + 1}. ${options[i]}`));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
console.error(chalk.yellow(' ╰───────────────────────────────────────'));
|
|
39
|
+
const rl = readline.createInterface({
|
|
40
|
+
input: process.stdin,
|
|
41
|
+
output: process.stderr,
|
|
42
|
+
terminal: true,
|
|
43
|
+
});
|
|
44
|
+
return new Promise((resolve) => {
|
|
45
|
+
let answered = false;
|
|
46
|
+
rl.question(chalk.bold(' answer> '), (answer) => {
|
|
47
|
+
answered = true;
|
|
48
|
+
rl.close();
|
|
49
|
+
resolve({ output: answer.trim() || '(no response)' });
|
|
50
|
+
});
|
|
51
|
+
rl.on('close', () => {
|
|
52
|
+
if (!answered)
|
|
53
|
+
resolve({ output: 'User closed input without responding.', isError: false });
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
export const askUserCapability = {
|
|
58
|
+
spec: {
|
|
59
|
+
name: 'AskUser',
|
|
60
|
+
description: 'Ask the user a clarifying question. Use when you need more information before proceeding.',
|
|
61
|
+
input_schema: {
|
|
62
|
+
type: 'object',
|
|
63
|
+
properties: {
|
|
64
|
+
question: { type: 'string', description: 'The question to ask the user' },
|
|
65
|
+
options: {
|
|
66
|
+
type: 'array',
|
|
67
|
+
items: { type: 'string' },
|
|
68
|
+
description: 'Optional list of suggested answers to present',
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
required: ['question'],
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
execute,
|
|
75
|
+
concurrent: false,
|
|
76
|
+
};
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bash capability — execute shell commands with timeout and output capture.
|
|
3
|
+
*/
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
// ─── Smart Output Compression ─────────────────────────────────────────────
|
|
6
|
+
// Learned from RTK (Rust Token Killer): strip noise before sending to LLM.
|
|
7
|
+
// Applied after capture, before the 32KB cap — reduces tokens on verbose commands.
|
|
8
|
+
const ANSI_RE = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
|
|
9
|
+
function stripAnsi(s) {
|
|
10
|
+
return s.replace(ANSI_RE, '');
|
|
11
|
+
}
|
|
12
|
+
function collapseBlankLines(s) {
|
|
13
|
+
// Collapse 3+ consecutive blank lines → 1 blank line
|
|
14
|
+
return s.replace(/\n{3,}/g, '\n\n');
|
|
15
|
+
}
|
|
16
|
+
/** Extract the base command word (first non-env token). */
|
|
17
|
+
function baseCmd(command) {
|
|
18
|
+
// Strip leading env var assignments (FOO=bar cmd → cmd)
|
|
19
|
+
const stripped = command.replace(/^(?:[A-Z_][A-Z0-9_]*=\S*\s+)*/, '').trimStart();
|
|
20
|
+
return stripped.split(/\s+/)[0] ?? '';
|
|
21
|
+
}
|
|
22
|
+
function compressOutput(command, output) {
|
|
23
|
+
// 1. Always strip ANSI escape codes
|
|
24
|
+
let out = stripAnsi(output);
|
|
25
|
+
const cmd = baseCmd(command);
|
|
26
|
+
const fullCmd = command.trimStart();
|
|
27
|
+
// 2. Git command-aware compression
|
|
28
|
+
if (cmd === 'git') {
|
|
29
|
+
const sub = fullCmd.split(/\s+/)[1] ?? '';
|
|
30
|
+
out = compressGit(sub, out);
|
|
31
|
+
}
|
|
32
|
+
// 3. Package manager installs — keep only errors + final summary
|
|
33
|
+
else if (/^(npm|pnpm|yarn|bun)\s+(install|i|add|ci)\b/.test(fullCmd)) {
|
|
34
|
+
out = compressInstall(out);
|
|
35
|
+
}
|
|
36
|
+
// 4. Test runners — keep only failures + summary line
|
|
37
|
+
else if (/^(npm|pnpm|bun)\s+test\b|^(jest|vitest|mocha)\b/.test(fullCmd)) {
|
|
38
|
+
out = compressTests(out);
|
|
39
|
+
}
|
|
40
|
+
// 5. Build commands — keep errors/warnings, drop verbose compile lines
|
|
41
|
+
else if (/^(npm|pnpm|bun)\s+(run\s+)?(build|compile)\b|^tsc\b/.test(fullCmd)) {
|
|
42
|
+
out = compressBuild(out);
|
|
43
|
+
}
|
|
44
|
+
// 6. cargo
|
|
45
|
+
else if (cmd === 'cargo') {
|
|
46
|
+
const sub = fullCmd.split(/\s+/)[1] ?? '';
|
|
47
|
+
if (sub === 'test' || sub === 'nextest')
|
|
48
|
+
out = compressTests(out);
|
|
49
|
+
else if (sub === 'build' || sub === 'check' || sub === 'clippy')
|
|
50
|
+
out = compressBuild(out);
|
|
51
|
+
else if (sub === 'install')
|
|
52
|
+
out = compressInstall(out);
|
|
53
|
+
}
|
|
54
|
+
// 7. Always collapse excessive blank lines
|
|
55
|
+
out = collapseBlankLines(out);
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
function compressGit(sub, out) {
|
|
59
|
+
switch (sub) {
|
|
60
|
+
case 'add': {
|
|
61
|
+
// git add is usually silent. Strip any blank output.
|
|
62
|
+
const trimmed = out.trim();
|
|
63
|
+
return trimmed || 'ok';
|
|
64
|
+
}
|
|
65
|
+
case 'commit': {
|
|
66
|
+
// Keep: [branch abc1234] message + stats line. Strip verbose output.
|
|
67
|
+
const lines = out.split('\n');
|
|
68
|
+
const kept = lines.filter(l => /^\[.+\]/.test(l) || // [main abc1234] commit msg
|
|
69
|
+
/\d+ file/.test(l) || // 2 files changed, 10 insertions
|
|
70
|
+
/^\s*(create|delete) mode/.test(l) ||
|
|
71
|
+
l.trim() === '');
|
|
72
|
+
return kept.join('\n').trim() || out.trim();
|
|
73
|
+
}
|
|
74
|
+
case 'push': {
|
|
75
|
+
// Strip verbose remote "enumerating/counting/compressing" lines
|
|
76
|
+
const lines = out.split('\n').filter(l => !/^remote:\s*(Enumerating|Counting|Compressing|Writing|Total|Delta)/.test(l) &&
|
|
77
|
+
!/^Counting objects|^Compressing objects|^Writing objects/.test(l) &&
|
|
78
|
+
l.trim() !== '');
|
|
79
|
+
return lines.join('\n').trim() || 'ok';
|
|
80
|
+
}
|
|
81
|
+
case 'pull': {
|
|
82
|
+
// Strip "remote: Counting..." lines, keep summary
|
|
83
|
+
const lines = out.split('\n').filter(l => !/^remote:\s*(Enumerating|Counting|Compressing|Writing|Total|Delta)/.test(l) &&
|
|
84
|
+
!/^Counting objects|^Compressing objects/.test(l));
|
|
85
|
+
return collapseBlankLines(lines.join('\n')).trim();
|
|
86
|
+
}
|
|
87
|
+
case 'fetch': {
|
|
88
|
+
const lines = out.split('\n').filter(l => !/^remote:\s*(Enumerating|Counting|Compressing|Writing|Total|Delta)/.test(l));
|
|
89
|
+
return lines.join('\n').trim();
|
|
90
|
+
}
|
|
91
|
+
case 'log': {
|
|
92
|
+
// Already terse if user uses --oneline; just collapse blanks
|
|
93
|
+
return out.trim();
|
|
94
|
+
}
|
|
95
|
+
default:
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function compressInstall(out) {
|
|
100
|
+
const lines = out.split('\n');
|
|
101
|
+
const kept = [];
|
|
102
|
+
for (const line of lines) {
|
|
103
|
+
const l = line.trim();
|
|
104
|
+
// Drop pure progress lines
|
|
105
|
+
if (/^(Downloading|Fetching|Resolving|Progress|Preparing|Caching)/.test(l))
|
|
106
|
+
continue;
|
|
107
|
+
if (/^[\s.]*$/.test(l))
|
|
108
|
+
continue;
|
|
109
|
+
// Keep errors, warnings, and summary lines
|
|
110
|
+
kept.push(line);
|
|
111
|
+
}
|
|
112
|
+
// If no lines kept, return original trimmed (don't lose error info)
|
|
113
|
+
const result = kept.join('\n').trim();
|
|
114
|
+
return result || out.trim();
|
|
115
|
+
}
|
|
116
|
+
function compressTests(out) {
|
|
117
|
+
const lines = out.split('\n');
|
|
118
|
+
// Look for failure sections and summary
|
|
119
|
+
const kept = [];
|
|
120
|
+
let inFailure = false;
|
|
121
|
+
for (const line of lines) {
|
|
122
|
+
const l = line.trim();
|
|
123
|
+
// Detect failure/error blocks
|
|
124
|
+
if (/^(FAIL|FAILED|Error:|●|✕|✗|×|error\[)/.test(l)) {
|
|
125
|
+
inFailure = true;
|
|
126
|
+
}
|
|
127
|
+
// Summary lines (always keep)
|
|
128
|
+
if (/^(Tests?|Test Suites?|Suites?|PASS|FAIL|ok\s|error|warning|\d+ (test|spec|example))/.test(l) ||
|
|
129
|
+
/\d+\s*(passed|failed|skipped|pending|todo)/.test(l)) {
|
|
130
|
+
kept.push(line);
|
|
131
|
+
inFailure = false;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (inFailure) {
|
|
135
|
+
kept.push(line);
|
|
136
|
+
// End failure block on blank line after content
|
|
137
|
+
if (l === '' && kept[kept.length - 2]?.trim() !== '')
|
|
138
|
+
inFailure = false;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// If nothing matched (e.g. all passed with no verbose output), return original
|
|
142
|
+
if (kept.length === 0)
|
|
143
|
+
return out.trim();
|
|
144
|
+
return collapseBlankLines(kept.join('\n')).trim();
|
|
145
|
+
}
|
|
146
|
+
function compressBuild(out) {
|
|
147
|
+
const lines = out.split('\n');
|
|
148
|
+
const kept = lines.filter(l => {
|
|
149
|
+
const t = l.trim();
|
|
150
|
+
if (t === '')
|
|
151
|
+
return false;
|
|
152
|
+
// Drop pure progress/info lines from bundlers/compilers
|
|
153
|
+
if (/^(Compiling|Finished|Checking|warning: unused import)/.test(t) &&
|
|
154
|
+
!/^(Compiling.*error|Finished.*error)/.test(t)) {
|
|
155
|
+
// Keep "Finished" summary
|
|
156
|
+
if (/^Finished/.test(t))
|
|
157
|
+
return true;
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
return true;
|
|
161
|
+
});
|
|
162
|
+
return collapseBlankLines(kept.join('\n')).trim() || out.trim();
|
|
163
|
+
}
|
|
164
|
+
const MAX_OUTPUT_BYTES = 512 * 1024; // 512KB capture buffer (prevents OOM)
|
|
165
|
+
const MAX_RETURN_CHARS = 32_000; // 32KB return cap (~8,000 tokens) — prevents context bloat
|
|
166
|
+
const DEFAULT_TIMEOUT_MS = 120_000; // 2 minutes
|
|
167
|
+
async function execute(input, ctx) {
|
|
168
|
+
const { command, timeout } = input;
|
|
169
|
+
if (!command || typeof command !== 'string') {
|
|
170
|
+
return { output: 'Error: command is required', isError: true };
|
|
171
|
+
}
|
|
172
|
+
const timeoutMs = Math.min(timeout ?? DEFAULT_TIMEOUT_MS, 600_000);
|
|
173
|
+
return new Promise((resolve) => {
|
|
174
|
+
const shell = process.env.SHELL || '/bin/bash';
|
|
175
|
+
let child;
|
|
176
|
+
try {
|
|
177
|
+
child = spawn(shell, ['-c', command], {
|
|
178
|
+
cwd: ctx.workingDir,
|
|
179
|
+
env: {
|
|
180
|
+
...process.env,
|
|
181
|
+
RUNCODE: '1', // Let scripts detect they're running inside runcode
|
|
182
|
+
RUNCODE_WORKDIR: ctx.workingDir,
|
|
183
|
+
},
|
|
184
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
catch (spawnErr) {
|
|
188
|
+
resolve({ output: `Error spawning shell: ${spawnErr.message}`, isError: true });
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
let stdout = '';
|
|
192
|
+
let stderr = '';
|
|
193
|
+
let outputBytes = 0;
|
|
194
|
+
let truncated = false;
|
|
195
|
+
let killed = false;
|
|
196
|
+
const timer = setTimeout(() => {
|
|
197
|
+
killed = true;
|
|
198
|
+
child.kill('SIGTERM');
|
|
199
|
+
setTimeout(() => {
|
|
200
|
+
try {
|
|
201
|
+
child.kill('SIGKILL');
|
|
202
|
+
}
|
|
203
|
+
catch { /* already dead */ }
|
|
204
|
+
}, 5000); // Give 5s for graceful shutdown before SIGKILL
|
|
205
|
+
}, timeoutMs);
|
|
206
|
+
// Handle abort signal
|
|
207
|
+
const onAbort = () => {
|
|
208
|
+
killed = true;
|
|
209
|
+
child.kill('SIGTERM');
|
|
210
|
+
};
|
|
211
|
+
ctx.abortSignal.addEventListener('abort', onAbort, { once: true });
|
|
212
|
+
// Emit last non-empty line to UI progress (throttled to avoid flooding)
|
|
213
|
+
let lastProgressEmit = 0;
|
|
214
|
+
const emitProgress = (text) => {
|
|
215
|
+
if (!ctx.onProgress)
|
|
216
|
+
return;
|
|
217
|
+
const now = Date.now();
|
|
218
|
+
if (now - lastProgressEmit < 500)
|
|
219
|
+
return; // max 2 updates/sec
|
|
220
|
+
lastProgressEmit = now;
|
|
221
|
+
const lastLine = text.split('\n').map(l => l.trim()).filter(Boolean).pop();
|
|
222
|
+
if (lastLine)
|
|
223
|
+
ctx.onProgress(lastLine.slice(0, 120));
|
|
224
|
+
};
|
|
225
|
+
child.stdout?.on('data', (chunk) => {
|
|
226
|
+
if (truncated)
|
|
227
|
+
return;
|
|
228
|
+
const remaining = MAX_OUTPUT_BYTES - outputBytes;
|
|
229
|
+
if (remaining <= 0) {
|
|
230
|
+
truncated = true;
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const text = chunk.toString('utf-8');
|
|
234
|
+
if (chunk.length <= remaining) {
|
|
235
|
+
stdout += text;
|
|
236
|
+
outputBytes += chunk.length;
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
stdout += text.slice(0, remaining);
|
|
240
|
+
outputBytes = MAX_OUTPUT_BYTES;
|
|
241
|
+
truncated = true;
|
|
242
|
+
}
|
|
243
|
+
emitProgress(text);
|
|
244
|
+
});
|
|
245
|
+
child.stderr?.on('data', (chunk) => {
|
|
246
|
+
if (truncated)
|
|
247
|
+
return;
|
|
248
|
+
const remaining = MAX_OUTPUT_BYTES - outputBytes;
|
|
249
|
+
if (remaining <= 0) {
|
|
250
|
+
truncated = true;
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const text = chunk.toString('utf-8');
|
|
254
|
+
if (chunk.length <= remaining) {
|
|
255
|
+
stderr += text;
|
|
256
|
+
outputBytes += chunk.length;
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
stderr += text.slice(0, remaining);
|
|
260
|
+
outputBytes = MAX_OUTPUT_BYTES;
|
|
261
|
+
truncated = true;
|
|
262
|
+
}
|
|
263
|
+
emitProgress(text);
|
|
264
|
+
});
|
|
265
|
+
child.on('close', (code) => {
|
|
266
|
+
clearTimeout(timer);
|
|
267
|
+
ctx.abortSignal.removeEventListener('abort', onAbort);
|
|
268
|
+
let result = '';
|
|
269
|
+
if (stdout)
|
|
270
|
+
result += stdout;
|
|
271
|
+
if (stderr) {
|
|
272
|
+
if (result)
|
|
273
|
+
result += '\n';
|
|
274
|
+
result += stderr;
|
|
275
|
+
}
|
|
276
|
+
if (truncated) {
|
|
277
|
+
result += '\n\n... (output truncated — command produced >512KB)';
|
|
278
|
+
}
|
|
279
|
+
// Smart compression: strip ANSI, collapse blank lines, command-aware filters
|
|
280
|
+
result = compressOutput(command, result);
|
|
281
|
+
// Cap returned output to prevent context bloat.
|
|
282
|
+
// Keep the LAST part (most relevant for errors/test failures/build output).
|
|
283
|
+
if (result.length > MAX_RETURN_CHARS) {
|
|
284
|
+
const lines = result.split('\n');
|
|
285
|
+
let trimmed = '';
|
|
286
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
287
|
+
const candidate = lines[i] + '\n' + trimmed;
|
|
288
|
+
if (candidate.length > MAX_RETURN_CHARS)
|
|
289
|
+
break;
|
|
290
|
+
trimmed = candidate;
|
|
291
|
+
}
|
|
292
|
+
const omitted = result.length - trimmed.length;
|
|
293
|
+
result = `... (${omitted.toLocaleString()} chars omitted from start)\n${trimmed}`;
|
|
294
|
+
}
|
|
295
|
+
if (killed) {
|
|
296
|
+
resolve({
|
|
297
|
+
output: result + `\n\n(command killed — timeout after ${timeoutMs / 1000}s. Set timeout param up to 600000ms for longer.)`,
|
|
298
|
+
isError: true,
|
|
299
|
+
});
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
if (code !== 0 && code !== null) {
|
|
303
|
+
resolve({
|
|
304
|
+
output: result || `Command exited with code ${code}`,
|
|
305
|
+
isError: true,
|
|
306
|
+
});
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
resolve({ output: result || '(no output)' });
|
|
310
|
+
});
|
|
311
|
+
child.on('error', (err) => {
|
|
312
|
+
clearTimeout(timer);
|
|
313
|
+
ctx.abortSignal.removeEventListener('abort', onAbort);
|
|
314
|
+
resolve({
|
|
315
|
+
output: `Error spawning command: ${err.message}`,
|
|
316
|
+
isError: true,
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
export const bashCapability = {
|
|
322
|
+
spec: {
|
|
323
|
+
name: 'Bash',
|
|
324
|
+
description: 'Execute a shell command and return stdout+stderr. Runs in working directory with user env. Output capped at 512KB. Default timeout: 2min, max: 10min (set via timeout param in ms).',
|
|
325
|
+
input_schema: {
|
|
326
|
+
type: 'object',
|
|
327
|
+
properties: {
|
|
328
|
+
command: { type: 'string', description: 'The shell command to execute' },
|
|
329
|
+
timeout: { type: 'number', description: 'Timeout in milliseconds (default: 120000, max: 600000)' },
|
|
330
|
+
},
|
|
331
|
+
required: ['command'],
|
|
332
|
+
},
|
|
333
|
+
},
|
|
334
|
+
execute,
|
|
335
|
+
concurrent: false,
|
|
336
|
+
};
|