@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.
@@ -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
- return `Execute: ${cmd.length > 100 ? cmd.slice(0, 100) + '...' : cmd}`;
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)
@@ -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': 128_000,
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
  };
@@ -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;
@@ -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'];
@@ -51,7 +51,26 @@ function compressOutput(command, output) {
51
51
  else if (sub === 'install')
52
52
  out = compressInstall(out);
53
53
  }
54
- // 7. Always collapse excessive blank lines
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). */
@@ -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 — remove from partial-read tracking so next read is fresh
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) {
@@ -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;
@@ -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');
@@ -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() });