@blockrun/franklin 3.5.1 → 3.6.2
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/dist/agent/bash-guard.d.ts +17 -0
- package/dist/agent/bash-guard.js +158 -0
- package/dist/agent/permissions.js +41 -2
- package/dist/agent/streaming-executor.js +32 -0
- package/dist/agent/tokens.js +1 -1
- package/dist/agent/types.d.ts +9 -0
- package/dist/mcp/client.js +36 -0
- package/dist/pricing.js +1 -1
- package/dist/tools/bash.js +56 -1
- package/dist/tools/edit.js +4 -2
- package/dist/tools/read.d.ts +2 -0
- package/dist/tools/read.js +28 -0
- package/dist/tools/write.js +2 -1
- package/dist/ui/app.js +167 -32
- package/dist/ui/markdown.d.ts +6 -0
- package/dist/ui/markdown.js +73 -6
- package/dist/ui/model-picker.js +2 -2
- package/dist/ui/mouse.d.ts +29 -0
- package/dist/ui/mouse.js +89 -0
- package/dist/ui/terminal.js +45 -28
- package/dist/ui/vim-input.d.ts +19 -0
- package/dist/ui/vim-input.js +439 -0
- package/package.json +1 -1
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bash Risk Classifier — lightweight Guardian for Franklin.
|
|
3
|
+
*
|
|
4
|
+
* Classifies bash commands into three risk levels:
|
|
5
|
+
* safe — read-only or standard dev commands → auto-approve
|
|
6
|
+
* normal — typical mutations (file writes, installs) → default ask behavior
|
|
7
|
+
* dangerous — destructive/irreversible operations → always ask, with warning
|
|
8
|
+
*
|
|
9
|
+
* Inspired by OpenAI Codex's Guardian system, but deterministic pattern matching
|
|
10
|
+
* instead of an LLM call. Fast, predictable, zero-cost.
|
|
11
|
+
*/
|
|
12
|
+
export type BashRiskLevel = 'safe' | 'normal' | 'dangerous';
|
|
13
|
+
export interface BashRiskResult {
|
|
14
|
+
level: BashRiskLevel;
|
|
15
|
+
reason?: string;
|
|
16
|
+
}
|
|
17
|
+
export declare function classifyBashRisk(command: string): BashRiskResult;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bash Risk Classifier — lightweight Guardian for Franklin.
|
|
3
|
+
*
|
|
4
|
+
* Classifies bash commands into three risk levels:
|
|
5
|
+
* safe — read-only or standard dev commands → auto-approve
|
|
6
|
+
* normal — typical mutations (file writes, installs) → default ask behavior
|
|
7
|
+
* dangerous — destructive/irreversible operations → always ask, with warning
|
|
8
|
+
*
|
|
9
|
+
* Inspired by OpenAI Codex's Guardian system, but deterministic pattern matching
|
|
10
|
+
* instead of an LLM call. Fast, predictable, zero-cost.
|
|
11
|
+
*/
|
|
12
|
+
// ─── Dangerous Patterns ──────────────────────────────────────────────────
|
|
13
|
+
// Checked first. If ANY pattern matches, the command is dangerous.
|
|
14
|
+
const DANGEROUS_PATTERNS = [
|
|
15
|
+
// Destructive file operations
|
|
16
|
+
[/\brm\s+-[a-zA-Z]*[rR][a-zA-Z]*\s+[/~]/, 'recursive delete on root/home'],
|
|
17
|
+
[/\brm\s+-[a-zA-Z]*[rR][a-zA-Z]*f/, 'forced recursive delete'],
|
|
18
|
+
[/\brm\s+-[a-zA-Z]*f[a-zA-Z]*[rR]/, 'forced recursive delete'],
|
|
19
|
+
[/\bmkfs\b/, 'format filesystem'],
|
|
20
|
+
[/\bdd\s+.*of=/, 'raw disk write'],
|
|
21
|
+
// Git irreversible operations
|
|
22
|
+
[/\bgit\s+push\s+.*--force\b/, 'force push'],
|
|
23
|
+
[/\bgit\s+push\s+-f\b/, 'force push'],
|
|
24
|
+
[/\bgit\s+reset\s+--hard\b/, 'hard reset — discards uncommitted changes'],
|
|
25
|
+
[/\bgit\s+clean\s+-[a-zA-Z]*f/, 'git clean — deletes untracked files'],
|
|
26
|
+
[/\bgit\s+checkout\s+--\s+\./, 'discard all working changes'],
|
|
27
|
+
[/\bgit\s+branch\s+-D\b/, 'force delete branch'],
|
|
28
|
+
// Database destructive
|
|
29
|
+
[/\bDROP\s+(TABLE|DATABASE|SCHEMA)\b/i, 'drop database objects'],
|
|
30
|
+
[/\bTRUNCATE\s+TABLE\b/i, 'truncate table'],
|
|
31
|
+
// System-level danger
|
|
32
|
+
[/\bchmod\s+(-R\s+)?777\b/, 'world-writable permissions'],
|
|
33
|
+
[/\bcurl\s+.*\|\s*(sudo\s+)?(ba)?sh\b/, 'pipe URL to shell'],
|
|
34
|
+
[/\bwget\s+.*\|\s*(sudo\s+)?(ba)?sh\b/, 'pipe URL to shell'],
|
|
35
|
+
[/\bsudo\s+rm\b/, 'sudo delete'],
|
|
36
|
+
// Kill/shutdown
|
|
37
|
+
[/\bkill\s+-9\s+-1\b/, 'kill all processes'],
|
|
38
|
+
[/\bshutdown\b/, 'system shutdown'],
|
|
39
|
+
[/\breboot\b/, 'system reboot'],
|
|
40
|
+
];
|
|
41
|
+
// ─── Safe Commands ────────────────────────────────────────────────────────
|
|
42
|
+
// If ALL segments use these commands, auto-approve.
|
|
43
|
+
const SAFE_COMMANDS = new Set([
|
|
44
|
+
// Filesystem read-only
|
|
45
|
+
'ls', 'cat', 'head', 'tail', 'wc', 'du', 'df', 'file', 'stat', 'tree',
|
|
46
|
+
'find', 'grep', 'rg', 'ag', 'ack', 'which', 'whereis', 'type',
|
|
47
|
+
'echo', 'printf', 'date', 'whoami', 'hostname', 'uname', 'printenv',
|
|
48
|
+
'pwd', 'realpath', 'dirname', 'basename',
|
|
49
|
+
// Text processing (read-only when not redirecting)
|
|
50
|
+
'jq', 'yq', 'sort', 'uniq', 'cut', 'tr', 'diff', 'comm', 'less', 'more',
|
|
51
|
+
'wc', 'tee', 'xargs',
|
|
52
|
+
]);
|
|
53
|
+
const SAFE_GIT_SUBCOMMANDS = new Set([
|
|
54
|
+
'status', 'log', 'diff', 'show', 'branch', 'tag', 'remote',
|
|
55
|
+
'blame', 'shortlog', 'describe', 'rev-parse', 'rev-list',
|
|
56
|
+
'ls-files', 'ls-tree', 'ls-remote', 'config', 'reflog',
|
|
57
|
+
]);
|
|
58
|
+
const SAFE_PKG_SUBCOMMANDS = new Set([
|
|
59
|
+
'test', 'run', 'list', 'ls', 'info', 'view', 'show',
|
|
60
|
+
'outdated', 'audit', 'start', 'dev', 'serve', 'lint', 'check',
|
|
61
|
+
'why', 'explain', 'doctor',
|
|
62
|
+
]);
|
|
63
|
+
const SAFE_CARGO_SUBCOMMANDS = new Set([
|
|
64
|
+
'test', 'check', 'clippy', 'build', 'run', 'bench', 'doc',
|
|
65
|
+
'fmt', 'tree', 'metadata', 'verify-project',
|
|
66
|
+
]);
|
|
67
|
+
// ─── Classifier ──────────────────────────────────────────────────────────
|
|
68
|
+
export function classifyBashRisk(command) {
|
|
69
|
+
// 1. Check dangerous patterns first (highest priority)
|
|
70
|
+
for (const [pattern, reason] of DANGEROUS_PATTERNS) {
|
|
71
|
+
if (pattern.test(command)) {
|
|
72
|
+
return { level: 'dangerous', reason };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// 2. Check if every segment is a known-safe command
|
|
76
|
+
const segments = command.split(/\s*(?:&&|\|\||[;|])\s*/);
|
|
77
|
+
let allSafe = true;
|
|
78
|
+
for (const segment of segments) {
|
|
79
|
+
const trimmed = segment.trim();
|
|
80
|
+
if (!trimmed)
|
|
81
|
+
continue;
|
|
82
|
+
if (!isSegmentSafe(trimmed)) {
|
|
83
|
+
allSafe = false;
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (allSafe && segments.some(s => s.trim().length > 0)) {
|
|
88
|
+
return { level: 'safe' };
|
|
89
|
+
}
|
|
90
|
+
return { level: 'normal' };
|
|
91
|
+
}
|
|
92
|
+
function isSegmentSafe(segment) {
|
|
93
|
+
// Parse: strip env vars, extract command and args
|
|
94
|
+
const words = segment.split(/\s+/).filter(w => !w.includes('='));
|
|
95
|
+
let idx = 0;
|
|
96
|
+
let cmd = words[idx] || '';
|
|
97
|
+
// Strip harmless prefixes
|
|
98
|
+
while (['time', 'nice'].includes(cmd) && idx < words.length - 1) {
|
|
99
|
+
cmd = words[++idx] || '';
|
|
100
|
+
}
|
|
101
|
+
// sudo → not safe (even if the underlying command is safe)
|
|
102
|
+
if (cmd === 'sudo')
|
|
103
|
+
return false;
|
|
104
|
+
const baseName = cmd.split('/').pop() || cmd;
|
|
105
|
+
const argIdx = idx + 1;
|
|
106
|
+
const subCmd = words[argIdx] || '';
|
|
107
|
+
// git
|
|
108
|
+
if (baseName === 'git') {
|
|
109
|
+
return SAFE_GIT_SUBCOMMANDS.has(subCmd);
|
|
110
|
+
}
|
|
111
|
+
// npm / yarn / pnpm / bun / npx
|
|
112
|
+
if (['npm', 'npx', 'yarn', 'pnpm', 'bun'].includes(baseName)) {
|
|
113
|
+
// "npm run <script>" — safe (dev servers, linters, etc.)
|
|
114
|
+
if (subCmd === 'run')
|
|
115
|
+
return true;
|
|
116
|
+
return SAFE_PKG_SUBCOMMANDS.has(subCmd);
|
|
117
|
+
}
|
|
118
|
+
// cargo
|
|
119
|
+
if (baseName === 'cargo') {
|
|
120
|
+
return SAFE_CARGO_SUBCOMMANDS.has(subCmd);
|
|
121
|
+
}
|
|
122
|
+
// rtk (RTK wrapper — safe, it's a proxy)
|
|
123
|
+
if (baseName === 'rtk')
|
|
124
|
+
return true;
|
|
125
|
+
// Known safe base command
|
|
126
|
+
if (SAFE_COMMANDS.has(baseName)) {
|
|
127
|
+
// sed -i is not read-only
|
|
128
|
+
if (baseName === 'sed' && segment.includes(' -i'))
|
|
129
|
+
return false;
|
|
130
|
+
// Output redirection means writing — not safe
|
|
131
|
+
if (/>\s*[^&|]/.test(segment))
|
|
132
|
+
return false;
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
// Version/help checks are always safe
|
|
136
|
+
if (/\s+(-v|--version|-V)\s*$/.test(segment))
|
|
137
|
+
return true;
|
|
138
|
+
if (/\s+(-h|--help)\s*$/.test(segment))
|
|
139
|
+
return true;
|
|
140
|
+
// gh (GitHub CLI) read-only commands
|
|
141
|
+
if (baseName === 'gh') {
|
|
142
|
+
const ghAction = words.slice(argIdx, argIdx + 2).join(' ');
|
|
143
|
+
if (/^(pr|issue|repo|release|run)\s+(view|list|status|diff|checks|comments)/.test(ghAction))
|
|
144
|
+
return true;
|
|
145
|
+
if (subCmd === 'api')
|
|
146
|
+
return true; // gh api is read-only (GET)
|
|
147
|
+
if (subCmd === 'auth' && words[argIdx + 1] === 'status')
|
|
148
|
+
return true;
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
// docker/podman read-only
|
|
152
|
+
if (baseName === 'docker' || baseName === 'podman') {
|
|
153
|
+
if (['ps', 'images', 'inspect', 'logs', 'stats', 'top', 'port', 'version', 'info'].includes(subCmd))
|
|
154
|
+
return true;
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
@@ -7,6 +7,31 @@ import path from 'node:path';
|
|
|
7
7
|
import readline from 'node:readline';
|
|
8
8
|
import chalk from 'chalk';
|
|
9
9
|
import { BLOCKRUN_DIR } from '../config.js';
|
|
10
|
+
import { classifyBashRisk } from './bash-guard.js';
|
|
11
|
+
// ─── Common dev command patterns (auto-allow without prompting) ──────────
|
|
12
|
+
// These are "normal" risk commands that are too common to interrupt the user.
|
|
13
|
+
// Only applied when --trust flag is set (user explicitly opted into auto-mode).
|
|
14
|
+
const COMMON_DEV_PATTERNS = [
|
|
15
|
+
/^npm\s+(install|i|ci|run|exec|test|start|build|lint|format|outdated|ls|list|info|view|pack)\b/,
|
|
16
|
+
/^(pnpm|yarn|bun)\s+(install|add|run|test|build|lint|exec)\b/,
|
|
17
|
+
/^pip3?\s+install\b/,
|
|
18
|
+
/^python3?\s+/,
|
|
19
|
+
/^node\s+/,
|
|
20
|
+
/^(pytest|jest|vitest|mocha)\b/,
|
|
21
|
+
/^(tsc|eslint|prettier|biome)\b/,
|
|
22
|
+
/^git\s+(add|commit|push|pull|fetch|status|diff|log|branch|checkout|switch|merge|rebase|stash|tag|remote|show)\b/,
|
|
23
|
+
/^(cat|head|tail|wc|sort|uniq|diff|file|which|whoami|hostname|uname|date|echo)\b/,
|
|
24
|
+
/^(ls|pwd|cd|mkdir|touch)\b/,
|
|
25
|
+
/^(docker|docker-compose)\s+(ps|logs|images|inspect|stats|exec|build|run|pull)\b/,
|
|
26
|
+
/^(curl|wget)\s+/,
|
|
27
|
+
/^make\b/,
|
|
28
|
+
/^cargo\s+(build|test|check|clippy|run|bench|doc|fmt)\b/,
|
|
29
|
+
/^go\s+(build|test|run|vet|fmt|mod)\b/,
|
|
30
|
+
];
|
|
31
|
+
function isCommonDevCommand(cmd) {
|
|
32
|
+
const trimmed = cmd.trim();
|
|
33
|
+
return COMMON_DEV_PATTERNS.some(p => p.test(trimmed));
|
|
34
|
+
}
|
|
10
35
|
// ─── Default Rules ─────────────────────────────────────────────────────────
|
|
11
36
|
const READ_ONLY_TOOLS = new Set(['Read', 'Glob', 'Grep', 'WebSearch', 'Task', 'AskUser', 'ImageGen', 'TradingSignal', 'TradingMarket', 'SearchX']);
|
|
12
37
|
const DESTRUCTIVE_TOOLS = new Set(['Write', 'Edit', 'Bash']);
|
|
@@ -61,8 +86,17 @@ export class PermissionManager {
|
|
|
61
86
|
if (this.matchesRule(toolName, input, this.rules.allow)) {
|
|
62
87
|
return { behavior: 'allow', reason: 'allowed by rule' };
|
|
63
88
|
}
|
|
64
|
-
// Check explicit ask rules
|
|
89
|
+
// Check explicit ask rules — with Bash risk classification
|
|
65
90
|
if (this.matchesRule(toolName, input, this.rules.ask)) {
|
|
91
|
+
// Bash Guardian: classify risk before blindly asking
|
|
92
|
+
if (toolName === 'Bash') {
|
|
93
|
+
const cmd = input.command || '';
|
|
94
|
+
const risk = classifyBashRisk(cmd);
|
|
95
|
+
if (risk.level === 'safe') {
|
|
96
|
+
return { behavior: 'allow', reason: 'safe command' };
|
|
97
|
+
}
|
|
98
|
+
// dangerous and normal both ask, but dangerous gets a warning in describeAction
|
|
99
|
+
}
|
|
66
100
|
return { behavior: 'ask' };
|
|
67
101
|
}
|
|
68
102
|
// Default: read-only tools are auto-allowed, others ask
|
|
@@ -179,7 +213,12 @@ export class PermissionManager {
|
|
|
179
213
|
switch (toolName) {
|
|
180
214
|
case 'Bash': {
|
|
181
215
|
const cmd = input.command || '';
|
|
182
|
-
|
|
216
|
+
const preview = cmd.length > 100 ? cmd.slice(0, 100) + '...' : cmd;
|
|
217
|
+
const risk = classifyBashRisk(cmd);
|
|
218
|
+
if (risk.level === 'dangerous') {
|
|
219
|
+
return `\x1b[31m⚠ DANGEROUS: ${risk.reason}\x1b[0m\n │ Execute: ${preview}`;
|
|
220
|
+
}
|
|
221
|
+
return `Execute: ${preview}`;
|
|
183
222
|
}
|
|
184
223
|
case 'Write': {
|
|
185
224
|
const fp = input.file_path || '';
|
|
@@ -174,6 +174,38 @@ export class StreamingExecutor {
|
|
|
174
174
|
}
|
|
175
175
|
: this.scope;
|
|
176
176
|
try {
|
|
177
|
+
// Runtime input validation: check required fields and types
|
|
178
|
+
const schema = handler.spec.input_schema;
|
|
179
|
+
if (schema?.required) {
|
|
180
|
+
for (const field of schema.required) {
|
|
181
|
+
if (invocation.input[field] === undefined || invocation.input[field] === null) {
|
|
182
|
+
const desc = schema.properties?.[field]?.description || '';
|
|
183
|
+
return {
|
|
184
|
+
output: `Error: missing required parameter "${field}" for ${handler.spec.name}. ${desc}`,
|
|
185
|
+
isError: true,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
// Type coercion for common model mistakes (string↔number, string↔boolean)
|
|
191
|
+
if (schema?.properties) {
|
|
192
|
+
for (const [key, value] of Object.entries(invocation.input)) {
|
|
193
|
+
if (value == null)
|
|
194
|
+
continue;
|
|
195
|
+
const prop = schema.properties[key];
|
|
196
|
+
if (!prop?.type)
|
|
197
|
+
continue;
|
|
198
|
+
if (prop.type === 'number' && typeof value === 'string' && !isNaN(Number(value))) {
|
|
199
|
+
invocation.input[key] = Number(value);
|
|
200
|
+
}
|
|
201
|
+
else if (prop.type === 'boolean' && typeof value === 'string') {
|
|
202
|
+
if (value === 'true')
|
|
203
|
+
invocation.input[key] = true;
|
|
204
|
+
else if (value === 'false')
|
|
205
|
+
invocation.input[key] = false;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
177
209
|
let result = await handler.execute(invocation.input, progressScope);
|
|
178
210
|
this.guard?.afterExecute(invocation, result);
|
|
179
211
|
// Persist large results to disk with preview (inspired by Claude Code toolResultStorage)
|
package/dist/agent/tokens.js
CHANGED
|
@@ -180,7 +180,7 @@ const MODEL_CONTEXT_WINDOWS = {
|
|
|
180
180
|
'xai/grok-4-0709': 131_072,
|
|
181
181
|
'xai/grok-4-1-fast-reasoning': 131_072,
|
|
182
182
|
// Others
|
|
183
|
-
'zai/glm-5.1':
|
|
183
|
+
'zai/glm-5.1': 200_000,
|
|
184
184
|
'moonshot/kimi-k2.5': 128_000,
|
|
185
185
|
'minimax/minimax-m2.7': 128_000,
|
|
186
186
|
};
|
package/dist/agent/types.d.ts
CHANGED
|
@@ -51,6 +51,15 @@ export interface CapabilityHandler {
|
|
|
51
51
|
export interface CapabilityResult {
|
|
52
52
|
output: string;
|
|
53
53
|
isError?: boolean;
|
|
54
|
+
/** Structured diff for Edit tool — enables colored diff display in UI. */
|
|
55
|
+
diff?: {
|
|
56
|
+
file: string;
|
|
57
|
+
oldLines: string[];
|
|
58
|
+
newLines: string[];
|
|
59
|
+
count: number;
|
|
60
|
+
};
|
|
61
|
+
/** Full tool output for expandable display — separate from truncated preview. */
|
|
62
|
+
fullOutput?: string;
|
|
54
63
|
}
|
|
55
64
|
export interface ExecutionScope {
|
|
56
65
|
workingDir: string;
|
package/dist/mcp/client.js
CHANGED
|
@@ -79,6 +79,42 @@ async function connectStdio(name, config) {
|
|
|
79
79
|
concurrent: true, // MCP tools are safe to run concurrently
|
|
80
80
|
});
|
|
81
81
|
}
|
|
82
|
+
// Discover resources (optional — not all servers expose resources)
|
|
83
|
+
try {
|
|
84
|
+
const { resources: mcpResources } = await client.listResources();
|
|
85
|
+
for (const resource of mcpResources) {
|
|
86
|
+
const resourceToolName = `mcp__${name}__read_${resource.name.replace(/[^a-zA-Z0-9_]/g, '_')}`;
|
|
87
|
+
const resourceDesc = resource.description
|
|
88
|
+
? `Read resource: ${resource.description}`.slice(0, 2048)
|
|
89
|
+
: `Read MCP resource "${resource.name}" from ${name}`;
|
|
90
|
+
capabilities.push({
|
|
91
|
+
spec: {
|
|
92
|
+
name: resourceToolName,
|
|
93
|
+
description: resourceDesc,
|
|
94
|
+
input_schema: { type: 'object', properties: {}, required: [] },
|
|
95
|
+
},
|
|
96
|
+
execute: async () => {
|
|
97
|
+
try {
|
|
98
|
+
const result = await client.readResource({ uri: resource.uri });
|
|
99
|
+
const output = result.contents
|
|
100
|
+
?.map(c => c.text ?? `[resource: ${c.uri}]`)
|
|
101
|
+
?.join('\n') || JSON.stringify(result.contents);
|
|
102
|
+
return { output, isError: false };
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
return {
|
|
106
|
+
output: `MCP resource error (${name}/${resource.name}): ${err.message}`,
|
|
107
|
+
isError: true,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
concurrent: true,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// Server doesn't support resources — that's fine, tools-only mode
|
|
117
|
+
}
|
|
82
118
|
const connected = { name, client, transport, tools: capabilities };
|
|
83
119
|
connections.set(name, connected);
|
|
84
120
|
return connected;
|
package/dist/pricing.js
CHANGED
|
@@ -73,7 +73,7 @@ export const MODEL_PRICING = {
|
|
|
73
73
|
'zai/glm-5': { input: 0, output: 0, perCall: 0.001 },
|
|
74
74
|
'zai/glm-5.1': { input: 0, output: 0, perCall: 0.001 },
|
|
75
75
|
'zai/glm-5-turbo': { input: 0, output: 0, perCall: 0.001 },
|
|
76
|
-
'zai/glm-5.1-turbo': { input: 0, output: 0, perCall: 0.001 },
|
|
76
|
+
'zai/glm-5.1-turbo': { input: 0, output: 0, perCall: 0.001 }, // client alias for zai/glm-5-turbo
|
|
77
77
|
};
|
|
78
78
|
/** Opus pricing for savings calculations */
|
|
79
79
|
export const OPUS_PRICING = MODEL_PRICING['anthropic/claude-opus-4.6'];
|
package/dist/tools/bash.js
CHANGED
|
@@ -51,7 +51,26 @@ function compressOutput(command, output) {
|
|
|
51
51
|
else if (sub === 'install')
|
|
52
52
|
out = compressInstall(out);
|
|
53
53
|
}
|
|
54
|
-
// 7.
|
|
54
|
+
// 7. Python — pip install, pytest, python scripts
|
|
55
|
+
else if (/^(pip|pip3)\s+install\b/.test(fullCmd)) {
|
|
56
|
+
out = compressInstall(out);
|
|
57
|
+
}
|
|
58
|
+
else if (/^(pytest|python.*-m\s+pytest)\b/.test(fullCmd)) {
|
|
59
|
+
out = compressTests(out);
|
|
60
|
+
}
|
|
61
|
+
// 8. Docker — strip layer hashes, progress bars, keep errors + summary
|
|
62
|
+
else if (/^docker\s+(build|run|pull|push|compose)\b/.test(fullCmd)) {
|
|
63
|
+
out = compressDocker(out);
|
|
64
|
+
}
|
|
65
|
+
// 9. curl/wget — strip progress bars, keep response
|
|
66
|
+
else if (/^(curl|wget)\b/.test(fullCmd)) {
|
|
67
|
+
out = compressDownload(out);
|
|
68
|
+
}
|
|
69
|
+
// 10. Make — keep errors/warnings, drop recipe lines
|
|
70
|
+
else if (cmd === 'make') {
|
|
71
|
+
out = compressBuild(out);
|
|
72
|
+
}
|
|
73
|
+
// 11. Always collapse excessive blank lines
|
|
55
74
|
out = collapseBlankLines(out);
|
|
56
75
|
return out;
|
|
57
76
|
}
|
|
@@ -161,6 +180,42 @@ function compressBuild(out) {
|
|
|
161
180
|
});
|
|
162
181
|
return collapseBlankLines(kept.join('\n')).trim() || out.trim();
|
|
163
182
|
}
|
|
183
|
+
function compressDocker(out) {
|
|
184
|
+
const lines = out.split('\n');
|
|
185
|
+
const kept = lines.filter(l => {
|
|
186
|
+
const t = l.trim();
|
|
187
|
+
// Drop layer progress: "sha256:abc123: Pulling fs layer" / "Downloading [==> ]"
|
|
188
|
+
if (/^[a-f0-9]{12}:\s*(Pull|Wait|Download|Extract|Verif|Already)/.test(t))
|
|
189
|
+
return false;
|
|
190
|
+
// Drop download/upload progress bars
|
|
191
|
+
if (/^\[[\s=>#]+\]/.test(t) || /\d+(\.\d+)?%/.test(t) && t.length < 80)
|
|
192
|
+
return false;
|
|
193
|
+
// Drop "Sending build context" progress
|
|
194
|
+
if (/^Sending build context/.test(t))
|
|
195
|
+
return false;
|
|
196
|
+
return true;
|
|
197
|
+
});
|
|
198
|
+
return collapseBlankLines(kept.join('\n')).trim() || out.trim();
|
|
199
|
+
}
|
|
200
|
+
function compressDownload(out) {
|
|
201
|
+
const lines = out.split('\n');
|
|
202
|
+
const kept = lines.filter(l => {
|
|
203
|
+
const t = l.trim();
|
|
204
|
+
// Drop curl progress bars: " % Total % Received..."
|
|
205
|
+
if (/^\s*%\s+Total/.test(t))
|
|
206
|
+
return false;
|
|
207
|
+
if (/^\s*\d+\s+\d+[kMG]?\s+\d+\s+\d+[kMG]?/.test(t) && t.length < 100)
|
|
208
|
+
return false;
|
|
209
|
+
// Drop wget progress: "2024-01-01 12:00:00 (1.23 MB/s) - saved"
|
|
210
|
+
if (/^\d{4}-\d{2}-\d{2}.*saved/.test(t))
|
|
211
|
+
return false;
|
|
212
|
+
// Drop download percentage lines
|
|
213
|
+
if (/^\s*\d+%\s/.test(t))
|
|
214
|
+
return false;
|
|
215
|
+
return true;
|
|
216
|
+
});
|
|
217
|
+
return collapseBlankLines(kept.join('\n')).trim() || out.trim();
|
|
218
|
+
}
|
|
164
219
|
const backgroundTasks = new Map();
|
|
165
220
|
let bgTaskCounter = 0;
|
|
166
221
|
/** Get a background task's result (called by the agent to check status). */
|
package/dist/tools/edit.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
-
import { partiallyReadFiles, fileReadTracker } from './read.js';
|
|
6
|
+
import { partiallyReadFiles, fileReadTracker, invalidateFileCache } from './read.js';
|
|
7
7
|
/**
|
|
8
8
|
* Normalize curly/smart quotes to straight quotes.
|
|
9
9
|
* Claude Code does this to handle API-sanitized strings and editor paste artifacts.
|
|
@@ -143,8 +143,9 @@ async function execute(input, ctx) {
|
|
|
143
143
|
updated = content.slice(0, firstIdx) + newStr + content.slice(firstIdx + effectiveOldStr.length);
|
|
144
144
|
}
|
|
145
145
|
fs.writeFileSync(resolved, updated, 'utf-8');
|
|
146
|
-
// File has been modified —
|
|
146
|
+
// File has been modified — invalidate caches so next read is fresh
|
|
147
147
|
partiallyReadFiles.delete(resolved);
|
|
148
|
+
invalidateFileCache(resolved);
|
|
148
149
|
// Update read tracker mtime so subsequent edits don't trigger stale-write detection
|
|
149
150
|
const newStat = fs.statSync(resolved);
|
|
150
151
|
fileReadTracker.set(resolved, { mtimeMs: newStat.mtimeMs, readAt: Date.now() });
|
|
@@ -172,6 +173,7 @@ async function execute(input, ctx) {
|
|
|
172
173
|
}
|
|
173
174
|
return {
|
|
174
175
|
output: `Updated ${resolved} — ${matchCount} replacement${matchCount > 1 ? 's' : ''} made.${diffPreview}${partialWarning}`,
|
|
176
|
+
diff: { file: resolved, oldLines, newLines, count: matchCount },
|
|
175
177
|
};
|
|
176
178
|
}
|
|
177
179
|
catch (err) {
|
package/dist/tools/read.d.ts
CHANGED
|
@@ -22,4 +22,6 @@ export declare const fileReadTracker: Map<string, {
|
|
|
22
22
|
mtimeMs: number;
|
|
23
23
|
readAt: number;
|
|
24
24
|
}>;
|
|
25
|
+
/** Invalidate the content cache for a file (call after Edit/Write modifies it). */
|
|
26
|
+
export declare function invalidateFileCache(resolvedPath: string): void;
|
|
25
27
|
export declare const readCapability: CapabilityHandler;
|
package/dist/tools/read.js
CHANGED
|
@@ -16,6 +16,24 @@ export const partiallyReadFiles = new Map();
|
|
|
16
16
|
* Exported so edit.ts and write.ts can check.
|
|
17
17
|
*/
|
|
18
18
|
export const fileReadTracker = new Map();
|
|
19
|
+
/**
|
|
20
|
+
* File state cache — avoids re-reading unchanged files across turns.
|
|
21
|
+
* Stores mtime + line count for each file. If the model requests a Read
|
|
22
|
+
* and the file hasn't changed (same mtime), return a short stub instead
|
|
23
|
+
* of the full content. This saves thousands of tokens on repeated reads.
|
|
24
|
+
*
|
|
25
|
+
* Cache is invalidated when:
|
|
26
|
+
* - File mtime changes (edited externally or by Edit/Write tool)
|
|
27
|
+
* - Different offset/limit is requested (user wants a different section)
|
|
28
|
+
*/
|
|
29
|
+
const fileContentCache = new Map();
|
|
30
|
+
function cacheKey(resolved, offset, limit) {
|
|
31
|
+
return `${offset ?? 0}:${limit ?? 2000}`;
|
|
32
|
+
}
|
|
33
|
+
/** Invalidate the content cache for a file (call after Edit/Write modifies it). */
|
|
34
|
+
export function invalidateFileCache(resolvedPath) {
|
|
35
|
+
fileContentCache.delete(resolvedPath);
|
|
36
|
+
}
|
|
19
37
|
async function execute(input, ctx) {
|
|
20
38
|
const { file_path: filePath, offset, limit } = input;
|
|
21
39
|
if (!filePath) {
|
|
@@ -24,6 +42,14 @@ async function execute(input, ctx) {
|
|
|
24
42
|
const resolved = path.isAbsolute(filePath) ? filePath : path.resolve(ctx.workingDir, filePath);
|
|
25
43
|
try {
|
|
26
44
|
const stat = fs.statSync(resolved);
|
|
45
|
+
// File state cache: if file hasn't changed and same range requested, return stub
|
|
46
|
+
const range = cacheKey(resolved, offset, limit);
|
|
47
|
+
const cached = fileContentCache.get(resolved);
|
|
48
|
+
if (cached && cached.mtimeMs === stat.mtimeMs && cached.readRange === range) {
|
|
49
|
+
return {
|
|
50
|
+
output: `File unchanged since last read (${cached.lineCount} lines). Content is already in your context — do not re-read it.`,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
27
53
|
if (stat.isDirectory()) {
|
|
28
54
|
// Helpfully list directory contents instead of just erroring
|
|
29
55
|
const entries = fs.readdirSync(resolved, { withFileTypes: true });
|
|
@@ -65,6 +91,8 @@ async function execute(input, ctx) {
|
|
|
65
91
|
}
|
|
66
92
|
// Record this read for read-before-edit/write enforcement
|
|
67
93
|
fileReadTracker.set(resolved, { mtimeMs: stat.mtimeMs, readAt: Date.now() });
|
|
94
|
+
// Update file state cache (for cross-turn dedup)
|
|
95
|
+
fileContentCache.set(resolved, { mtimeMs: stat.mtimeMs, lineCount: allLines.length, readRange: range });
|
|
68
96
|
// Format with line numbers (cat -n style)
|
|
69
97
|
const numbered = slice.map((line, i) => `${startLine + i + 1}\t${line}`);
|
|
70
98
|
let result = numbered.join('\n');
|
package/dist/tools/write.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import os from 'node:os';
|
|
7
|
-
import { partiallyReadFiles, fileReadTracker } from './read.js';
|
|
7
|
+
import { partiallyReadFiles, fileReadTracker, invalidateFileCache } from './read.js';
|
|
8
8
|
function withTrailingSep(value) {
|
|
9
9
|
return value.endsWith(path.sep) ? value : value + path.sep;
|
|
10
10
|
}
|
|
@@ -93,6 +93,7 @@ async function execute(input, ctx) {
|
|
|
93
93
|
fs.mkdirSync(parentDir, { recursive: true });
|
|
94
94
|
fs.writeFileSync(resolved, content, 'utf-8');
|
|
95
95
|
partiallyReadFiles.delete(resolved);
|
|
96
|
+
invalidateFileCache(resolved);
|
|
96
97
|
// Update read tracker so subsequent edits don't trigger stale detection
|
|
97
98
|
const newStat = fs.statSync(resolved);
|
|
98
99
|
fileReadTracker.set(resolved, { mtimeMs: newStat.mtimeMs, readAt: Date.now() });
|