@blockrun/runcode 2.5.7 → 2.5.8

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.
@@ -4,5 +4,8 @@
4
4
  */
5
5
  /**
6
6
  * Build the full system instructions array for a session.
7
+ * Result is memoized per workingDir for the process lifetime.
7
8
  */
8
9
  export declare function assembleInstructions(workingDir: string): string[];
10
+ /** Invalidate cache for a workingDir (call after /clear or session reset). */
11
+ export declare function invalidateInstructionCache(workingDir: string): void;
@@ -53,10 +53,17 @@ The user can type these shortcuts: /commit, /review, /test, /fix, /debug, /expla
53
53
  /log, /branch, /stash, /plan, /ultraplan, /execute, /compact, /retry, /sessions, /resume,
54
54
  /tasks, /context, /doctor, /tokens, /model, /cost, /dump, /ultrathink [query], /clear,
55
55
  /help, /exit.`;
56
+ // Cache assembled instructions per workingDir — avoids re-running git commands
57
+ // when sub-agents are spawned (common in parallel tool use patterns).
58
+ const _instructionCache = new Map();
56
59
  /**
57
60
  * Build the full system instructions array for a session.
61
+ * Result is memoized per workingDir for the process lifetime.
58
62
  */
59
63
  export function assembleInstructions(workingDir) {
64
+ const cached = _instructionCache.get(workingDir);
65
+ if (cached)
66
+ return cached;
60
67
  const parts = [BASE_INSTRUCTIONS];
61
68
  // Read RUNCODE.md or CLAUDE.md from the project
62
69
  const projectConfig = readProjectConfig(workingDir);
@@ -70,8 +77,13 @@ export function assembleInstructions(workingDir) {
70
77
  if (gitInfo) {
71
78
  parts.push(`# Git Context\n\n${gitInfo}`);
72
79
  }
80
+ _instructionCache.set(workingDir, parts);
73
81
  return parts;
74
82
  }
83
+ /** Invalidate cache for a workingDir (call after /clear or session reset). */
84
+ export function invalidateInstructionCache(workingDir) {
85
+ _instructionCache.delete(workingDir);
86
+ }
75
87
  // ─── Project Config ────────────────────────────────────────────────────────
76
88
  /**
77
89
  * Look for RUNCODE.md, then CLAUDE.md in the working directory and parents.
@@ -114,6 +126,8 @@ function buildEnvironmentSection(workingDir) {
114
126
  }
115
127
  // ─── Git Context ───────────────────────────────────────────────────────────
116
128
  const GIT_TIMEOUT_MS = 5_000;
129
+ // Max chars for git log output — long commit messages can bloat the system prompt
130
+ const MAX_GIT_LOG_CHARS = 2_000;
117
131
  function getGitContext(workingDir) {
118
132
  try {
119
133
  const isGit = execSync('git rev-parse --is-inside-work-tree', {
@@ -154,15 +168,18 @@ function getGitContext(workingDir) {
154
168
  }
155
169
  }
156
170
  catch { /* ignore */ }
157
- // Recent commits (last 5)
171
+ // Recent commits (last 5) — capped to prevent huge messages bloating context
158
172
  try {
159
- const log = execSync('git log --oneline -5', {
173
+ let log = execSync('git log --oneline -5', {
160
174
  cwd: workingDir,
161
175
  encoding: 'utf-8',
162
176
  stdio: ['pipe', 'pipe', 'pipe'],
163
177
  timeout: GIT_TIMEOUT_MS,
164
178
  }).trim();
165
179
  if (log) {
180
+ if (log.length > MAX_GIT_LOG_CHARS) {
181
+ log = log.slice(0, MAX_GIT_LOG_CHARS) + '\n... (truncated)';
182
+ }
166
183
  lines.push(`\nRecent commits:\n${log}`);
167
184
  }
168
185
  }
@@ -3,6 +3,16 @@
3
3
  */
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
+ import { partiallyReadFiles } from './read.js';
7
+ /**
8
+ * Normalize curly/smart quotes to straight quotes.
9
+ * Claude Code does this to handle API-sanitized strings and editor paste artifacts.
10
+ */
11
+ function normalizeQuotes(str) {
12
+ return str
13
+ .replace(/[\u201C\u201D]/g, '"') // " " → "
14
+ .replace(/[\u2018\u2019]/g, "'"); // ' ' → '
15
+ }
6
16
  async function execute(input, ctx) {
7
17
  const { file_path: filePath, old_string: oldStr, new_string: newStr, replace_all: replaceAll } = input;
8
18
  if (!filePath) {
@@ -18,12 +28,25 @@ async function execute(input, ctx) {
18
28
  return { output: 'Error: old_string and new_string are identical', isError: true };
19
29
  }
20
30
  const resolved = path.isAbsolute(filePath) ? filePath : path.resolve(ctx.workingDir, filePath);
31
+ // Warn if the file was only partially read — editing without full context risks mistakes
32
+ const isPartial = partiallyReadFiles.has(resolved);
21
33
  try {
22
34
  if (!fs.existsSync(resolved)) {
23
35
  return { output: `Error: file not found: ${resolved}`, isError: true };
24
36
  }
25
37
  const content = fs.readFileSync(resolved, 'utf-8');
38
+ // Try exact match first, then quote-normalized fallback
39
+ let effectiveOldStr = oldStr;
26
40
  if (!content.includes(oldStr)) {
41
+ const normalized = normalizeQuotes(oldStr);
42
+ const contentNormalized = normalizeQuotes(content);
43
+ if (normalized !== oldStr && contentNormalized.includes(normalized)) {
44
+ // Find the original text in content that corresponds to the normalized match
45
+ const idx = contentNormalized.indexOf(normalized);
46
+ effectiveOldStr = content.slice(idx, idx + normalized.length);
47
+ }
48
+ }
49
+ if (!content.includes(effectiveOldStr)) {
27
50
  // Find lines containing fragments of old_string for helpful context
28
51
  const lines = content.split('\n');
29
52
  const searchTerms = oldStr.split('\n').map(l => l.trim()).filter(l => l.length > 3);
@@ -52,20 +75,17 @@ async function execute(input, ctx) {
52
75
  let updated;
53
76
  let matchCount;
54
77
  if (replaceAll) {
55
- // Count occurrences
56
- matchCount = content.split(oldStr).length - 1;
57
- updated = content.split(oldStr).join(newStr);
78
+ matchCount = content.split(effectiveOldStr).length - 1;
79
+ updated = content.split(effectiveOldStr).join(newStr);
58
80
  }
59
81
  else {
60
- // Ensure uniqueness for single replacement
61
- const firstIdx = content.indexOf(oldStr);
62
- const secondIdx = content.indexOf(oldStr, firstIdx + 1);
82
+ const firstIdx = content.indexOf(effectiveOldStr);
83
+ const secondIdx = content.indexOf(effectiveOldStr, firstIdx + 1);
63
84
  if (secondIdx !== -1) {
64
- // Multiple matches — show where they are
65
85
  const positions = [];
66
86
  let searchFrom = 0;
67
87
  while (true) {
68
- const idx = content.indexOf(oldStr, searchFrom);
88
+ const idx = content.indexOf(effectiveOldStr, searchFrom);
69
89
  if (idx === -1)
70
90
  break;
71
91
  const lineNum = content.slice(0, idx).split('\n').length;
@@ -79,11 +99,13 @@ async function execute(input, ctx) {
79
99
  };
80
100
  }
81
101
  matchCount = 1;
82
- updated = content.slice(0, firstIdx) + newStr + content.slice(firstIdx + oldStr.length);
102
+ updated = content.slice(0, firstIdx) + newStr + content.slice(firstIdx + effectiveOldStr.length);
83
103
  }
84
104
  fs.writeFileSync(resolved, updated, 'utf-8');
105
+ // File has been modified — remove from partial-read tracking so next read is fresh
106
+ partiallyReadFiles.delete(resolved);
85
107
  // Build a concise diff preview
86
- const oldLines = oldStr.split('\n');
108
+ const oldLines = effectiveOldStr.split('\n');
87
109
  const newLines = newStr.split('\n');
88
110
  let diffPreview = '';
89
111
  if (oldLines.length <= 5 && newLines.length <= 5) {
@@ -94,8 +116,11 @@ async function execute(input, ctx) {
94
116
  else {
95
117
  diffPreview = ` (${oldLines.length} lines → ${newLines.length} lines)`;
96
118
  }
119
+ const partialWarning = isPartial
120
+ ? '\nNote: file was only partially read before this edit.'
121
+ : '';
97
122
  return {
98
- output: `Updated ${resolved} — ${matchCount} replacement${matchCount > 1 ? 's' : ''} made.${diffPreview}`,
123
+ output: `Updated ${resolved} — ${matchCount} replacement${matchCount > 1 ? 's' : ''} made.${diffPreview}${partialWarning}`,
99
124
  };
100
125
  }
101
126
  catch (err) {
@@ -2,4 +2,10 @@
2
2
  * Read capability — reads files with line numbers.
3
3
  */
4
4
  import type { CapabilityHandler } from '../agent/types.js';
5
+ /**
6
+ * Tracks files that were only partially read (offset or limit applied).
7
+ * Edit tool uses this to warn when editing without full context.
8
+ * Exported so edit.ts can check and clear entries.
9
+ */
10
+ export declare const partiallyReadFiles: Set<string>;
5
11
  export declare const readCapability: CapabilityHandler;
@@ -3,6 +3,12 @@
3
3
  */
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
+ /**
7
+ * Tracks files that were only partially read (offset or limit applied).
8
+ * Edit tool uses this to warn when editing without full context.
9
+ * Exported so edit.ts can check and clear entries.
10
+ */
11
+ export const partiallyReadFiles = new Set();
6
12
  async function execute(input, ctx) {
7
13
  const { file_path: filePath, offset, limit } = input;
8
14
  if (!filePath) {
@@ -37,6 +43,15 @@ async function execute(input, ctx) {
37
43
  const maxLines = limit ?? 2000;
38
44
  const endLine = Math.min(allLines.length, startLine + maxLines);
39
45
  const slice = allLines.slice(startLine, endLine);
46
+ // Track partial reads — file was not read from the beginning or was truncated
47
+ const isPartial = startLine > 0 || endLine < allLines.length;
48
+ if (isPartial) {
49
+ partiallyReadFiles.add(resolved);
50
+ }
51
+ else {
52
+ // Full read — clear any stale partial flag
53
+ partiallyReadFiles.delete(resolved);
54
+ }
40
55
  // Format with line numbers (cat -n style)
41
56
  const numbered = slice.map((line, i) => `${startLine + i + 1}\t${line}`);
42
57
  let result = numbered.join('\n');
@@ -3,6 +3,32 @@
3
3
  */
4
4
  import { VERSION } from '../config.js';
5
5
  const MAX_BODY_BYTES = 256 * 1024; // 256KB
6
+ // ─── Session cache ──────────────────────────────────────────────────────────
7
+ // Avoids re-fetching the same URL within a session (common in research tasks).
8
+ // 15-min TTL, max 50 entries.
9
+ const CACHE_TTL_MS = 15 * 60 * 1000;
10
+ const MAX_CACHE_ENTRIES = 50;
11
+ const fetchCache = new Map();
12
+ function getCached(url) {
13
+ const entry = fetchCache.get(url);
14
+ if (!entry)
15
+ return null;
16
+ if (Date.now() > entry.expiresAt) {
17
+ fetchCache.delete(url);
18
+ return null;
19
+ }
20
+ return entry.output;
21
+ }
22
+ function setCached(url, output) {
23
+ // Evict oldest entry if at capacity
24
+ if (fetchCache.size >= MAX_CACHE_ENTRIES) {
25
+ const firstKey = fetchCache.keys().next().value;
26
+ if (firstKey)
27
+ fetchCache.delete(firstKey);
28
+ }
29
+ fetchCache.set(url, { output, expiresAt: Date.now() + CACHE_TTL_MS });
30
+ }
31
+ // ─── Execute ────────────────────────────────────────────────────────────────
6
32
  async function execute(input, _ctx) {
7
33
  const { url, max_length } = input;
8
34
  if (!url) {
@@ -19,6 +45,11 @@ async function execute(input, _ctx) {
19
45
  if (!['http:', 'https:'].includes(parsed.protocol)) {
20
46
  return { output: `Error: only http/https URLs are supported`, isError: true };
21
47
  }
48
+ // Check cache first
49
+ const cached = getCached(url);
50
+ if (cached) {
51
+ return { output: cached + '\n\n(cached)' };
52
+ }
22
53
  const controller = new AbortController();
23
54
  const timeout = setTimeout(() => controller.abort(), 30_000);
24
55
  try {
@@ -62,8 +93,8 @@ async function execute(input, _ctx) {
62
93
  // Format response based on content type
63
94
  if (contentType.includes('json')) {
64
95
  try {
65
- const parsed = JSON.parse(body);
66
- body = JSON.stringify(parsed, null, 2).slice(0, maxLen);
96
+ const parsedJson = JSON.parse(body);
97
+ body = JSON.stringify(parsedJson, null, 2).slice(0, maxLen);
67
98
  }
68
99
  catch { /* leave as-is if not valid JSON */ }
69
100
  }
@@ -74,6 +105,8 @@ async function execute(input, _ctx) {
74
105
  if (totalBytes >= maxLen) {
75
106
  output += '\n\n... (content truncated)';
76
107
  }
108
+ // Cache successful responses
109
+ setCached(url, output);
77
110
  return { output };
78
111
  }
79
112
  catch (err) {
@@ -118,7 +151,7 @@ function stripHtml(html) {
118
151
  export const webFetchCapability = {
119
152
  spec: {
120
153
  name: 'WebFetch',
121
- description: 'Fetch a web page and return its content. HTML tags are stripped for readability.',
154
+ description: 'Fetch a web page and return its content. HTML tags are stripped for readability. Results are cached for 15 minutes.',
122
155
  input_schema: {
123
156
  type: 'object',
124
157
  properties: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockrun/runcode",
3
- "version": "2.5.7",
3
+ "version": "2.5.8",
4
4
  "description": "RunCode — AI coding agent powered by 41+ models. Pay per use with USDC.",
5
5
  "type": "module",
6
6
  "bin": {