@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,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permission system for runcode.
|
|
3
|
+
* Controls which tools can execute automatically vs. require user approval.
|
|
4
|
+
*/
|
|
5
|
+
export type PermissionBehavior = 'allow' | 'deny' | 'ask';
|
|
6
|
+
export interface PermissionRules {
|
|
7
|
+
allow: string[];
|
|
8
|
+
deny: string[];
|
|
9
|
+
ask: string[];
|
|
10
|
+
}
|
|
11
|
+
export type PermissionMode = 'default' | 'trust' | 'deny-all' | 'plan';
|
|
12
|
+
export interface PermissionDecision {
|
|
13
|
+
behavior: PermissionBehavior;
|
|
14
|
+
reason?: string;
|
|
15
|
+
}
|
|
16
|
+
export declare class PermissionManager {
|
|
17
|
+
private rules;
|
|
18
|
+
private mode;
|
|
19
|
+
private sessionAllowed;
|
|
20
|
+
private promptFn?;
|
|
21
|
+
constructor(mode?: PermissionMode, promptFn?: (toolName: string, description: string) => Promise<'yes' | 'no' | 'always'>);
|
|
22
|
+
/**
|
|
23
|
+
* Check if a tool can be used. Returns the decision.
|
|
24
|
+
*/
|
|
25
|
+
check(toolName: string, input: Record<string, unknown>): Promise<PermissionDecision>;
|
|
26
|
+
/**
|
|
27
|
+
* Prompt the user interactively for permission.
|
|
28
|
+
* Uses injected promptFn (Ink UI) when available, falls back to readline.
|
|
29
|
+
* pendingCount: how many more operations of this type are waiting (including this one).
|
|
30
|
+
* Returns true if allowed, false if denied.
|
|
31
|
+
*/
|
|
32
|
+
promptUser(toolName: string, input: Record<string, unknown>, pendingCount?: number): Promise<boolean>;
|
|
33
|
+
private loadRules;
|
|
34
|
+
private matchesRule;
|
|
35
|
+
private getPrimaryInputValue;
|
|
36
|
+
private globMatch;
|
|
37
|
+
private sessionKey;
|
|
38
|
+
private describeAction;
|
|
39
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permission system for runcode.
|
|
3
|
+
* Controls which tools can execute automatically vs. require user approval.
|
|
4
|
+
*/
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import readline from 'node:readline';
|
|
8
|
+
import chalk from 'chalk';
|
|
9
|
+
import { BLOCKRUN_DIR } from '../config.js';
|
|
10
|
+
// ─── Default Rules ─────────────────────────────────────────────────────────
|
|
11
|
+
const READ_ONLY_TOOLS = new Set(['Read', 'Glob', 'Grep', 'WebSearch', 'Task', 'AskUser', 'ImageGen']);
|
|
12
|
+
const DESTRUCTIVE_TOOLS = new Set(['Write', 'Edit', 'Bash']);
|
|
13
|
+
const DEFAULT_RULES = {
|
|
14
|
+
allow: ['Read', 'Glob', 'Grep', 'WebSearch', 'WebFetch', 'Task', 'AskUser', 'ImageGen'],
|
|
15
|
+
deny: [],
|
|
16
|
+
ask: ['Write', 'Edit', 'Bash', 'Agent'],
|
|
17
|
+
};
|
|
18
|
+
// ─── Permission Manager ────────────────────────────────────────────────────
|
|
19
|
+
export class PermissionManager {
|
|
20
|
+
rules;
|
|
21
|
+
mode;
|
|
22
|
+
sessionAllowed = new Set(); // "always allow" for this session
|
|
23
|
+
promptFn;
|
|
24
|
+
constructor(mode = 'default', promptFn) {
|
|
25
|
+
this.mode = mode;
|
|
26
|
+
this.rules = this.loadRules();
|
|
27
|
+
this.promptFn = promptFn;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Check if a tool can be used. Returns the decision.
|
|
31
|
+
*/
|
|
32
|
+
async check(toolName, input) {
|
|
33
|
+
// Trust mode: allow everything
|
|
34
|
+
if (this.mode === 'trust') {
|
|
35
|
+
return { behavior: 'allow', reason: 'trust mode' };
|
|
36
|
+
}
|
|
37
|
+
// Plan mode: only allow read-only tools
|
|
38
|
+
if (this.mode === 'plan') {
|
|
39
|
+
if (READ_ONLY_TOOLS.has(toolName)) {
|
|
40
|
+
return { behavior: 'allow', reason: 'plan mode — read-only' };
|
|
41
|
+
}
|
|
42
|
+
return { behavior: 'deny', reason: 'plan mode — use /execute to enable writes' };
|
|
43
|
+
}
|
|
44
|
+
// Deny-all mode: deny everything that isn't read-only
|
|
45
|
+
if (this.mode === 'deny-all') {
|
|
46
|
+
if (READ_ONLY_TOOLS.has(toolName)) {
|
|
47
|
+
return { behavior: 'allow', reason: 'read-only tool' };
|
|
48
|
+
}
|
|
49
|
+
return { behavior: 'deny', reason: 'deny-all mode' };
|
|
50
|
+
}
|
|
51
|
+
// Check session-level always-allow
|
|
52
|
+
const sessionKey = this.sessionKey(toolName, input);
|
|
53
|
+
if (this.sessionAllowed.has(toolName) || this.sessionAllowed.has(sessionKey)) {
|
|
54
|
+
return { behavior: 'allow', reason: 'session allow' };
|
|
55
|
+
}
|
|
56
|
+
// Check explicit deny rules
|
|
57
|
+
if (this.matchesRule(toolName, input, this.rules.deny)) {
|
|
58
|
+
return { behavior: 'deny', reason: 'denied by rule' };
|
|
59
|
+
}
|
|
60
|
+
// Check explicit allow rules
|
|
61
|
+
if (this.matchesRule(toolName, input, this.rules.allow)) {
|
|
62
|
+
return { behavior: 'allow', reason: 'allowed by rule' };
|
|
63
|
+
}
|
|
64
|
+
// Check explicit ask rules
|
|
65
|
+
if (this.matchesRule(toolName, input, this.rules.ask)) {
|
|
66
|
+
return { behavior: 'ask' };
|
|
67
|
+
}
|
|
68
|
+
// Default: read-only tools are auto-allowed, others ask
|
|
69
|
+
if (READ_ONLY_TOOLS.has(toolName)) {
|
|
70
|
+
return { behavior: 'allow', reason: 'read-only default' };
|
|
71
|
+
}
|
|
72
|
+
return { behavior: 'ask' };
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Prompt the user interactively for permission.
|
|
76
|
+
* Uses injected promptFn (Ink UI) when available, falls back to readline.
|
|
77
|
+
* pendingCount: how many more operations of this type are waiting (including this one).
|
|
78
|
+
* Returns true if allowed, false if denied.
|
|
79
|
+
*/
|
|
80
|
+
async promptUser(toolName, input, pendingCount = 1) {
|
|
81
|
+
const description = this.describeAction(toolName, input);
|
|
82
|
+
// Append pending-count hint so user knows to press [a] to skip all
|
|
83
|
+
const hint = pendingCount > 1
|
|
84
|
+
? `${description}\n │ \x1b[33m${pendingCount} pending — press [a] to allow all\x1b[0m`
|
|
85
|
+
: description;
|
|
86
|
+
// Ink UI path: use injected prompt function to avoid stdin conflict.
|
|
87
|
+
// Ink owns stdin in raw mode; a second readline would get EOF immediately.
|
|
88
|
+
if (this.promptFn) {
|
|
89
|
+
const result = await this.promptFn(toolName, hint);
|
|
90
|
+
if (result === 'always') {
|
|
91
|
+
this.sessionAllowed.add(toolName);
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
return result === 'yes';
|
|
95
|
+
}
|
|
96
|
+
// Readline fallback (basic terminal / piped mode)
|
|
97
|
+
console.error('');
|
|
98
|
+
console.error(chalk.yellow(' ╭─ Permission required ─────────────────'));
|
|
99
|
+
console.error(chalk.yellow(` │ ${toolName}`));
|
|
100
|
+
console.error(chalk.dim(` │ ${description}`));
|
|
101
|
+
if (pendingCount > 1) {
|
|
102
|
+
console.error(chalk.yellow(` │ ${pendingCount} pending — press [a] to allow all`));
|
|
103
|
+
}
|
|
104
|
+
console.error(chalk.yellow(' ╰─────────────────────────────────────'));
|
|
105
|
+
const answer = await askQuestion(chalk.bold(' Allow? ') + chalk.dim('[Y/a/n] '));
|
|
106
|
+
const normalized = answer.trim().toLowerCase();
|
|
107
|
+
if (normalized === 'a' || normalized === 'always') {
|
|
108
|
+
this.sessionAllowed.add(toolName);
|
|
109
|
+
console.error(chalk.green(` ✓ ${toolName} allowed for this session`));
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
if (normalized === 'y' || normalized === 'yes' || normalized === '') {
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
console.error(chalk.red(` ✗ ${toolName} denied`));
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
// ─── Internal ──────────────────────────────────────────────────────────
|
|
119
|
+
loadRules() {
|
|
120
|
+
const configPath = path.join(BLOCKRUN_DIR, 'runcode-permissions.json');
|
|
121
|
+
try {
|
|
122
|
+
if (fs.existsSync(configPath)) {
|
|
123
|
+
const raw = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
124
|
+
return {
|
|
125
|
+
allow: [...DEFAULT_RULES.allow, ...(raw.allow || [])],
|
|
126
|
+
deny: [...(raw.deny || [])],
|
|
127
|
+
ask: [...DEFAULT_RULES.ask, ...(raw.ask || [])],
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
catch { /* use defaults */ }
|
|
132
|
+
return { ...DEFAULT_RULES };
|
|
133
|
+
}
|
|
134
|
+
matchesRule(toolName, input, rules) {
|
|
135
|
+
for (const rule of rules) {
|
|
136
|
+
// Exact tool name match
|
|
137
|
+
if (rule === toolName)
|
|
138
|
+
return true;
|
|
139
|
+
// Pattern match: "Bash(git *)" matches Bash with command starting with "git "
|
|
140
|
+
const patternMatch = rule.match(/^(\w+)\((.+)\)$/);
|
|
141
|
+
if (patternMatch) {
|
|
142
|
+
const [, ruleTool, pattern] = patternMatch;
|
|
143
|
+
if (ruleTool !== toolName)
|
|
144
|
+
continue;
|
|
145
|
+
// Match against the primary input field
|
|
146
|
+
const primaryValue = this.getPrimaryInputValue(toolName, input);
|
|
147
|
+
if (primaryValue && this.globMatch(pattern, primaryValue)) {
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
getPrimaryInputValue(toolName, input) {
|
|
155
|
+
switch (toolName) {
|
|
156
|
+
case 'Bash': return input.command || null;
|
|
157
|
+
case 'Read': return input.file_path || null;
|
|
158
|
+
case 'Write': return input.file_path || null;
|
|
159
|
+
case 'Edit': return input.file_path || null;
|
|
160
|
+
default: return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
globMatch(pattern, text) {
|
|
164
|
+
// Glob matching: * matches non-space chars, ** matches anything
|
|
165
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
166
|
+
const regex = new RegExp('^' +
|
|
167
|
+
escaped
|
|
168
|
+
.replace(/\*\*/g, '{{GLOB_STAR}}')
|
|
169
|
+
.replace(/\*/g, '[^ ]*')
|
|
170
|
+
.replace(/\{\{GLOB_STAR\}\}/g, '.*')
|
|
171
|
+
+ '$');
|
|
172
|
+
return regex.test(text);
|
|
173
|
+
}
|
|
174
|
+
sessionKey(toolName, input) {
|
|
175
|
+
const primary = this.getPrimaryInputValue(toolName, input);
|
|
176
|
+
return primary ? `${toolName}:${primary}` : toolName;
|
|
177
|
+
}
|
|
178
|
+
describeAction(toolName, input) {
|
|
179
|
+
switch (toolName) {
|
|
180
|
+
case 'Bash': {
|
|
181
|
+
const cmd = input.command || '';
|
|
182
|
+
return `Execute: ${cmd.length > 100 ? cmd.slice(0, 100) + '...' : cmd}`;
|
|
183
|
+
}
|
|
184
|
+
case 'Write': {
|
|
185
|
+
const fp = input.file_path || '';
|
|
186
|
+
return `Write file: ${fp}`;
|
|
187
|
+
}
|
|
188
|
+
case 'Edit': {
|
|
189
|
+
const fp = input.file_path || '';
|
|
190
|
+
const old = input.old_string || '';
|
|
191
|
+
return `Edit ${fp}: replace "${old.slice(0, 60)}${old.length > 60 ? '...' : ''}"`;
|
|
192
|
+
}
|
|
193
|
+
case 'Agent':
|
|
194
|
+
return `Launch sub-agent: ${input.description || input.prompt?.slice(0, 80) || 'task'}`;
|
|
195
|
+
default:
|
|
196
|
+
return JSON.stringify(input).slice(0, 120);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
201
|
+
function askQuestion(prompt) {
|
|
202
|
+
// Non-TTY (piped/scripted) input: cannot ask interactively — auto-allow.
|
|
203
|
+
// The caller (permissionMode logic in start.ts) already routes piped sessions
|
|
204
|
+
// to trust mode, so this path is rarely hit. Guard here for safety.
|
|
205
|
+
if (!process.stdin.isTTY) {
|
|
206
|
+
process.stderr.write(prompt + 'y (auto-approved: non-interactive mode)\n');
|
|
207
|
+
return Promise.resolve('y');
|
|
208
|
+
}
|
|
209
|
+
const rl = readline.createInterface({
|
|
210
|
+
input: process.stdin,
|
|
211
|
+
output: process.stderr,
|
|
212
|
+
terminal: true,
|
|
213
|
+
});
|
|
214
|
+
return new Promise((resolve) => {
|
|
215
|
+
let answered = false;
|
|
216
|
+
rl.question(prompt, (answer) => {
|
|
217
|
+
answered = true;
|
|
218
|
+
rl.close();
|
|
219
|
+
resolve(answer);
|
|
220
|
+
});
|
|
221
|
+
rl.on('close', () => {
|
|
222
|
+
if (!answered)
|
|
223
|
+
resolve('n'); // Default deny on EOF for safety
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token Reduction for runcode.
|
|
3
|
+
* Original implementation — reduces context size through intelligent pruning.
|
|
4
|
+
*
|
|
5
|
+
* Strategy: instead of compression/encoding, we PRUNE redundant content.
|
|
6
|
+
* The model doesn't need verbose tool outputs from 20 turns ago.
|
|
7
|
+
*
|
|
8
|
+
* Three reduction passes:
|
|
9
|
+
* 1. Tool result aging — progressively shorten old tool results
|
|
10
|
+
* 2. Whitespace normalization — remove excessive blank lines and indentation
|
|
11
|
+
* 3. Stale context removal — drop system info that's been superseded
|
|
12
|
+
*/
|
|
13
|
+
import type { Dialogue } from './types.js';
|
|
14
|
+
/**
|
|
15
|
+
* Progressively shorten tool results based on age.
|
|
16
|
+
* Recent results: keep full. Older results: keep summary. Very old: keep one line.
|
|
17
|
+
*
|
|
18
|
+
* This is the biggest token saver — a 10KB bash output from 20 turns ago
|
|
19
|
+
* can be reduced to "✓ Bash: ran npm test (exit 0)" saving ~2500 tokens.
|
|
20
|
+
*/
|
|
21
|
+
export declare function ageToolResults(history: Dialogue[]): Dialogue[];
|
|
22
|
+
/**
|
|
23
|
+
* Normalize whitespace in text messages.
|
|
24
|
+
* - Collapse 3+ blank lines to 2
|
|
25
|
+
* - Remove trailing spaces
|
|
26
|
+
* - Reduce indentation beyond 8 spaces to 8
|
|
27
|
+
*/
|
|
28
|
+
export declare function normalizeWhitespace(history: Dialogue[]): Dialogue[];
|
|
29
|
+
/**
|
|
30
|
+
* Trim very long assistant text messages from old turns.
|
|
31
|
+
* Recent messages: keep full. Old long messages: keep first 1000 chars.
|
|
32
|
+
*/
|
|
33
|
+
export declare function trimOldAssistantMessages(history: Dialogue[]): Dialogue[];
|
|
34
|
+
/**
|
|
35
|
+
* Remove consecutive duplicate messages (same role + same content).
|
|
36
|
+
*/
|
|
37
|
+
export declare function deduplicateMessages(history: Dialogue[]): Dialogue[];
|
|
38
|
+
/**
|
|
39
|
+
* Collapse repeated consecutive lines within tool results.
|
|
40
|
+
* "Fetching...\nFetching...\nFetching...\n" → "Fetching... ×3"
|
|
41
|
+
* Also strips any residual ANSI escape codes from older tool results.
|
|
42
|
+
* RTK-inspired: dedup_lines + strip_ansi pipeline stages.
|
|
43
|
+
*/
|
|
44
|
+
export declare function deduplicateToolResultLines(history: Dialogue[]): Dialogue[];
|
|
45
|
+
/**
|
|
46
|
+
* Run all token reduction passes on conversation history.
|
|
47
|
+
* Returns same reference if nothing changed (cheap identity check).
|
|
48
|
+
*/
|
|
49
|
+
export declare function reduceTokens(history: Dialogue[], debug?: boolean): Dialogue[];
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token Reduction for runcode.
|
|
3
|
+
* Original implementation — reduces context size through intelligent pruning.
|
|
4
|
+
*
|
|
5
|
+
* Strategy: instead of compression/encoding, we PRUNE redundant content.
|
|
6
|
+
* The model doesn't need verbose tool outputs from 20 turns ago.
|
|
7
|
+
*
|
|
8
|
+
* Three reduction passes:
|
|
9
|
+
* 1. Tool result aging — progressively shorten old tool results
|
|
10
|
+
* 2. Whitespace normalization — remove excessive blank lines and indentation
|
|
11
|
+
* 3. Stale context removal — drop system info that's been superseded
|
|
12
|
+
*/
|
|
13
|
+
// ─── 1. Tool Result Aging ─────────────────────────────────────────────────
|
|
14
|
+
/**
|
|
15
|
+
* Progressively shorten tool results based on age.
|
|
16
|
+
* Recent results: keep full. Older results: keep summary. Very old: keep one line.
|
|
17
|
+
*
|
|
18
|
+
* This is the biggest token saver — a 10KB bash output from 20 turns ago
|
|
19
|
+
* can be reduced to "✓ Bash: ran npm test (exit 0)" saving ~2500 tokens.
|
|
20
|
+
*/
|
|
21
|
+
export function ageToolResults(history) {
|
|
22
|
+
// Find all tool_result positions
|
|
23
|
+
const toolPositions = [];
|
|
24
|
+
for (let i = 0; i < history.length; i++) {
|
|
25
|
+
const msg = history[i];
|
|
26
|
+
if (msg.role === 'user' &&
|
|
27
|
+
Array.isArray(msg.content) &&
|
|
28
|
+
msg.content.some(p => p.type === 'tool_result')) {
|
|
29
|
+
toolPositions.push(i);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (toolPositions.length <= 3)
|
|
33
|
+
return history; // Nothing to age
|
|
34
|
+
const result = [...history];
|
|
35
|
+
const totalResults = toolPositions.length;
|
|
36
|
+
for (let idx = 0; idx < toolPositions.length; idx++) {
|
|
37
|
+
const pos = toolPositions[idx];
|
|
38
|
+
const age = totalResults - idx; // Higher = older
|
|
39
|
+
const msg = result[pos];
|
|
40
|
+
if (!Array.isArray(msg.content))
|
|
41
|
+
continue;
|
|
42
|
+
const parts = msg.content;
|
|
43
|
+
let modified = false;
|
|
44
|
+
const aged = parts.map(part => {
|
|
45
|
+
if (part.type !== 'tool_result')
|
|
46
|
+
return part;
|
|
47
|
+
const content = typeof part.content === 'string'
|
|
48
|
+
? part.content
|
|
49
|
+
: JSON.stringify(part.content);
|
|
50
|
+
const charLen = content.length;
|
|
51
|
+
// Recent 3 results: keep full
|
|
52
|
+
if (age <= 3)
|
|
53
|
+
return part;
|
|
54
|
+
// Age 4-8: keep first 500 chars
|
|
55
|
+
if (age <= 8 && charLen > 500) {
|
|
56
|
+
modified = true;
|
|
57
|
+
const truncated = content.slice(0, 500);
|
|
58
|
+
const lastNl = truncated.lastIndexOf('\n');
|
|
59
|
+
const clean = lastNl > 250 ? truncated.slice(0, lastNl) : truncated;
|
|
60
|
+
return {
|
|
61
|
+
...part,
|
|
62
|
+
content: `${clean}\n... (${charLen - clean.length} chars omitted, ${age} turns ago)`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
// Age 9-15: keep first 200 chars
|
|
66
|
+
if (age <= 15 && charLen > 200) {
|
|
67
|
+
modified = true;
|
|
68
|
+
const firstLine = content.split('\n')[0].slice(0, 150);
|
|
69
|
+
return {
|
|
70
|
+
...part,
|
|
71
|
+
content: `${firstLine}\n... (${charLen} chars, ${age} turns ago)`,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
// Age 16+: one line summary
|
|
75
|
+
if (age > 15 && charLen > 80) {
|
|
76
|
+
modified = true;
|
|
77
|
+
const summary = content.split('\n')[0].slice(0, 60);
|
|
78
|
+
return {
|
|
79
|
+
...part,
|
|
80
|
+
content: part.is_error
|
|
81
|
+
? `[Error: ${summary}...]`
|
|
82
|
+
: `[Result: ${summary}...]`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
return part;
|
|
86
|
+
});
|
|
87
|
+
if (modified) {
|
|
88
|
+
result[pos] = { role: 'user', content: aged };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
93
|
+
// ─── 2. Whitespace Normalization ──────────────────────────────────────────
|
|
94
|
+
/**
|
|
95
|
+
* Normalize whitespace in text messages.
|
|
96
|
+
* - Collapse 3+ blank lines to 2
|
|
97
|
+
* - Remove trailing spaces
|
|
98
|
+
* - Reduce indentation beyond 8 spaces to 8
|
|
99
|
+
*/
|
|
100
|
+
export function normalizeWhitespace(history) {
|
|
101
|
+
let modified = false;
|
|
102
|
+
const result = history.map(msg => {
|
|
103
|
+
if (typeof msg.content !== 'string')
|
|
104
|
+
return msg;
|
|
105
|
+
const original = msg.content;
|
|
106
|
+
const cleaned = original
|
|
107
|
+
.replace(/[ \t]+$/gm, '') // Trailing spaces
|
|
108
|
+
.replace(/\n{4,}/g, '\n\n\n') // Max 3 consecutive newlines
|
|
109
|
+
.replace(/^( {9,})/gm, ' '); // Cap indentation at 8 spaces
|
|
110
|
+
if (cleaned !== original) {
|
|
111
|
+
modified = true;
|
|
112
|
+
return { ...msg, content: cleaned };
|
|
113
|
+
}
|
|
114
|
+
return msg;
|
|
115
|
+
});
|
|
116
|
+
return modified ? result : history;
|
|
117
|
+
}
|
|
118
|
+
// ─── 3. Verbose Assistant Message Trimming ────────────────────────────────
|
|
119
|
+
/**
|
|
120
|
+
* Trim very long assistant text messages from old turns.
|
|
121
|
+
* Recent messages: keep full. Old long messages: keep first 1000 chars.
|
|
122
|
+
*/
|
|
123
|
+
export function trimOldAssistantMessages(history) {
|
|
124
|
+
const MAX_OLD_ASSISTANT_CHARS = 1500;
|
|
125
|
+
const KEEP_RECENT = 4; // Keep last 4 assistant messages full
|
|
126
|
+
let assistantCount = 0;
|
|
127
|
+
for (const msg of history) {
|
|
128
|
+
if (msg.role === 'assistant')
|
|
129
|
+
assistantCount++;
|
|
130
|
+
}
|
|
131
|
+
if (assistantCount <= KEEP_RECENT)
|
|
132
|
+
return history;
|
|
133
|
+
let seenAssistant = 0;
|
|
134
|
+
let modified = false;
|
|
135
|
+
const result = history.map(msg => {
|
|
136
|
+
if (msg.role !== 'assistant')
|
|
137
|
+
return msg;
|
|
138
|
+
seenAssistant++;
|
|
139
|
+
// Keep recent messages full
|
|
140
|
+
if (assistantCount - seenAssistant < KEEP_RECENT)
|
|
141
|
+
return msg;
|
|
142
|
+
if (typeof msg.content === 'string' && msg.content.length > MAX_OLD_ASSISTANT_CHARS) {
|
|
143
|
+
modified = true;
|
|
144
|
+
const truncated = msg.content.slice(0, MAX_OLD_ASSISTANT_CHARS);
|
|
145
|
+
const lastNl = truncated.lastIndexOf('\n');
|
|
146
|
+
const clean = lastNl > MAX_OLD_ASSISTANT_CHARS / 2 ? truncated.slice(0, lastNl) : truncated;
|
|
147
|
+
return { ...msg, content: clean + '\n... (response truncated)' };
|
|
148
|
+
}
|
|
149
|
+
// Also handle content array with text parts
|
|
150
|
+
if (Array.isArray(msg.content)) {
|
|
151
|
+
const parts = msg.content;
|
|
152
|
+
let totalChars = 0;
|
|
153
|
+
for (const p of parts) {
|
|
154
|
+
if (p.type === 'text')
|
|
155
|
+
totalChars += p.text.length;
|
|
156
|
+
}
|
|
157
|
+
if (totalChars > MAX_OLD_ASSISTANT_CHARS) {
|
|
158
|
+
modified = true;
|
|
159
|
+
const trimmedParts = parts.map(p => {
|
|
160
|
+
if (p.type !== 'text' || p.text.length <= 500)
|
|
161
|
+
return p;
|
|
162
|
+
return { ...p, text: p.text.slice(0, 500) + '\n... (trimmed)' };
|
|
163
|
+
});
|
|
164
|
+
return { ...msg, content: trimmedParts };
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return msg;
|
|
168
|
+
});
|
|
169
|
+
return modified ? result : history;
|
|
170
|
+
}
|
|
171
|
+
// ─── 4. Deduplication ─────────────────────────────────────────────────────
|
|
172
|
+
/**
|
|
173
|
+
* Remove consecutive duplicate messages (same role + same content).
|
|
174
|
+
*/
|
|
175
|
+
export function deduplicateMessages(history) {
|
|
176
|
+
if (history.length < 3)
|
|
177
|
+
return history;
|
|
178
|
+
const result = [history[0]];
|
|
179
|
+
let modified = false;
|
|
180
|
+
for (let i = 1; i < history.length; i++) {
|
|
181
|
+
const prev = history[i - 1];
|
|
182
|
+
const curr = history[i];
|
|
183
|
+
if (curr.role === prev.role && typeof curr.content === 'string' && curr.content === prev.content) {
|
|
184
|
+
modified = true;
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
result.push(curr);
|
|
188
|
+
}
|
|
189
|
+
return modified ? result : history;
|
|
190
|
+
}
|
|
191
|
+
// ─── 5. Line-level deduplication in tool results ──────────────────────────
|
|
192
|
+
const ANSI_RE_REDUCE = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
|
|
193
|
+
/**
|
|
194
|
+
* Collapse repeated consecutive lines within tool results.
|
|
195
|
+
* "Fetching...\nFetching...\nFetching...\n" → "Fetching... ×3"
|
|
196
|
+
* Also strips any residual ANSI escape codes from older tool results.
|
|
197
|
+
* RTK-inspired: dedup_lines + strip_ansi pipeline stages.
|
|
198
|
+
*/
|
|
199
|
+
export function deduplicateToolResultLines(history) {
|
|
200
|
+
let modified = false;
|
|
201
|
+
const result = history.map(msg => {
|
|
202
|
+
if (msg.role !== 'user' || !Array.isArray(msg.content))
|
|
203
|
+
return msg;
|
|
204
|
+
const parts = msg.content;
|
|
205
|
+
let partModified = false;
|
|
206
|
+
const newParts = parts.map(part => {
|
|
207
|
+
if (part.type !== 'tool_result')
|
|
208
|
+
return part;
|
|
209
|
+
const raw = typeof part.content === 'string' ? part.content : JSON.stringify(part.content);
|
|
210
|
+
// Strip ANSI codes
|
|
211
|
+
const stripped = raw.replace(ANSI_RE_REDUCE, '');
|
|
212
|
+
// Collapse repeated consecutive lines
|
|
213
|
+
const lines = stripped.split('\n');
|
|
214
|
+
const deduped = [];
|
|
215
|
+
let i = 0;
|
|
216
|
+
while (i < lines.length) {
|
|
217
|
+
const line = lines[i];
|
|
218
|
+
let count = 1;
|
|
219
|
+
while (i + count < lines.length && lines[i + count] === line)
|
|
220
|
+
count++;
|
|
221
|
+
if (count > 2 && line.trim() !== '') {
|
|
222
|
+
deduped.push(`${line} ×${count}`);
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
for (let k = 0; k < count; k++)
|
|
226
|
+
deduped.push(line);
|
|
227
|
+
}
|
|
228
|
+
i += count;
|
|
229
|
+
}
|
|
230
|
+
const result = deduped.join('\n');
|
|
231
|
+
if (result === raw)
|
|
232
|
+
return part;
|
|
233
|
+
partModified = true;
|
|
234
|
+
return { ...part, content: result };
|
|
235
|
+
});
|
|
236
|
+
if (!partModified)
|
|
237
|
+
return msg;
|
|
238
|
+
modified = true;
|
|
239
|
+
return { ...msg, content: newParts };
|
|
240
|
+
});
|
|
241
|
+
return modified ? result : history;
|
|
242
|
+
}
|
|
243
|
+
// ─── Pipeline ───────���───────────────────���─────────────────────────────────
|
|
244
|
+
/**
|
|
245
|
+
* Run all token reduction passes on conversation history.
|
|
246
|
+
* Returns same reference if nothing changed (cheap identity check).
|
|
247
|
+
*/
|
|
248
|
+
export function reduceTokens(history, debug) {
|
|
249
|
+
if (history.length < 8)
|
|
250
|
+
return history; // Skip for short conversations
|
|
251
|
+
let current = history;
|
|
252
|
+
let totalSaved = 0;
|
|
253
|
+
// Pass 1: Age old tool results
|
|
254
|
+
const aged = ageToolResults(current);
|
|
255
|
+
if (aged !== current) {
|
|
256
|
+
const before = estimateChars(current);
|
|
257
|
+
current = aged;
|
|
258
|
+
const saved = before - estimateChars(current);
|
|
259
|
+
totalSaved += saved;
|
|
260
|
+
}
|
|
261
|
+
// Pass 2: Normalize whitespace
|
|
262
|
+
const normalized = normalizeWhitespace(current);
|
|
263
|
+
if (normalized !== current) {
|
|
264
|
+
const before = estimateChars(current);
|
|
265
|
+
current = normalized;
|
|
266
|
+
totalSaved += before - estimateChars(current);
|
|
267
|
+
}
|
|
268
|
+
// Pass 3: Trim old verbose assistant messages
|
|
269
|
+
const trimmed = trimOldAssistantMessages(current);
|
|
270
|
+
if (trimmed !== current) {
|
|
271
|
+
const before = estimateChars(current);
|
|
272
|
+
current = trimmed;
|
|
273
|
+
totalSaved += before - estimateChars(current);
|
|
274
|
+
}
|
|
275
|
+
// Pass 4: Remove consecutive duplicate messages
|
|
276
|
+
const deduped = deduplicateMessages(current);
|
|
277
|
+
if (deduped !== current) {
|
|
278
|
+
const before = estimateChars(current);
|
|
279
|
+
current = deduped;
|
|
280
|
+
totalSaved += before - estimateChars(current);
|
|
281
|
+
}
|
|
282
|
+
// Pass 5: Strip ANSI + collapse repeated lines in tool results
|
|
283
|
+
const lineDeduped = deduplicateToolResultLines(current);
|
|
284
|
+
if (lineDeduped !== current) {
|
|
285
|
+
const before = estimateChars(current);
|
|
286
|
+
current = lineDeduped;
|
|
287
|
+
totalSaved += before - estimateChars(current);
|
|
288
|
+
}
|
|
289
|
+
if (debug && totalSaved > 500) {
|
|
290
|
+
const tokensSaved = Math.round(totalSaved / 4);
|
|
291
|
+
console.error(`[runcode] Token reduction: ~${tokensSaved} tokens saved`);
|
|
292
|
+
}
|
|
293
|
+
return current;
|
|
294
|
+
}
|
|
295
|
+
function estimateChars(history) {
|
|
296
|
+
let total = 0;
|
|
297
|
+
for (const msg of history) {
|
|
298
|
+
if (typeof msg.content === 'string') {
|
|
299
|
+
total += msg.content.length;
|
|
300
|
+
}
|
|
301
|
+
else if (Array.isArray(msg.content)) {
|
|
302
|
+
for (const p of msg.content) {
|
|
303
|
+
if ('type' in p) {
|
|
304
|
+
if (p.type === 'text')
|
|
305
|
+
total += p.text.length;
|
|
306
|
+
else if (p.type === 'tool_result') {
|
|
307
|
+
total += typeof p.content === 'string' ? p.content.length : JSON.stringify(p.content).length;
|
|
308
|
+
}
|
|
309
|
+
else if (p.type === 'tool_use') {
|
|
310
|
+
total += JSON.stringify(p.input).length;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return total;
|
|
317
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaming Tool Executor for runcode.
|
|
3
|
+
* Starts executing concurrent-safe tools while the model is still streaming.
|
|
4
|
+
* Non-concurrent tools wait until the full response is received.
|
|
5
|
+
*/
|
|
6
|
+
import type { CapabilityHandler, CapabilityInvocation, CapabilityResult, ExecutionScope } from './types.js';
|
|
7
|
+
import type { PermissionManager } from './permissions.js';
|
|
8
|
+
export declare class StreamingExecutor {
|
|
9
|
+
private handlers;
|
|
10
|
+
private scope;
|
|
11
|
+
private permissions?;
|
|
12
|
+
private onStart;
|
|
13
|
+
private onProgress?;
|
|
14
|
+
private pending;
|
|
15
|
+
constructor(opts: {
|
|
16
|
+
handlers: Map<string, CapabilityHandler>;
|
|
17
|
+
scope: ExecutionScope;
|
|
18
|
+
permissions?: PermissionManager;
|
|
19
|
+
onStart: (id: string, name: string, preview?: string) => void;
|
|
20
|
+
onProgress?: (id: string, text: string) => void;
|
|
21
|
+
});
|
|
22
|
+
/**
|
|
23
|
+
* Called when a tool_use block is fully received from the stream.
|
|
24
|
+
* If the tool is concurrent-safe, start executing immediately.
|
|
25
|
+
* Otherwise, queue it for later.
|
|
26
|
+
*/
|
|
27
|
+
onToolReceived(invocation: CapabilityInvocation): void;
|
|
28
|
+
/**
|
|
29
|
+
* After the model finishes streaming, execute any non-concurrent tools
|
|
30
|
+
* and collect all results (including concurrent ones that may already be done).
|
|
31
|
+
*/
|
|
32
|
+
collectResults(allInvocations: CapabilityInvocation[]): Promise<[CapabilityInvocation, CapabilityResult][]>;
|
|
33
|
+
private executeWithPermissions;
|
|
34
|
+
/** Extract a short preview string from a tool invocation's input. */
|
|
35
|
+
private inputPreview;
|
|
36
|
+
}
|