@bubblebrain-ai/bubble 0.0.5 → 0.0.7
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/execution-governor.d.ts +5 -13
- package/dist/agent/execution-governor.js +33 -142
- package/dist/agent/task-size.d.ts +9 -0
- package/dist/agent/task-size.js +33 -0
- package/dist/agent/tool-intent.d.ts +1 -0
- package/dist/agent/tool-intent.js +1 -1
- package/dist/agent.js +46 -2
- package/dist/main.js +57 -42
- package/dist/orchestrator/default-hooks.js +83 -84
- package/dist/orchestrator/hooks.d.ts +5 -8
- package/dist/prompt/compose.js +3 -0
- package/dist/prompt/environment.js +2 -0
- package/dist/prompt/provider-prompts/deepseek.js +1 -2
- package/dist/prompt/provider-prompts/kimi.js +1 -2
- package/dist/prompt/reminders.d.ts +21 -3
- package/dist/prompt/reminders.js +44 -17
- package/dist/prompt/runtime.js +17 -23
- package/dist/provider.d.ts +10 -1
- package/dist/provider.js +87 -34
- package/dist/slash-commands/commands.js +0 -17
- package/dist/tools/bash.d.ts +2 -1
- package/dist/tools/bash.js +1 -1
- package/dist/tools/edit-apply.js +37 -6
- package/dist/tools/edit.d.ts +2 -1
- package/dist/tools/edit.js +18 -6
- package/dist/tools/file-state.d.ts +25 -0
- package/dist/tools/file-state.js +52 -0
- package/dist/tools/index.d.ts +2 -0
- package/dist/tools/index.js +6 -4
- package/dist/tools/read.d.ts +2 -1
- package/dist/tools/read.js +5 -1
- package/dist/tools/write.d.ts +4 -3
- package/dist/tools/write.js +133 -54
- package/dist/tui/display-history.d.ts +2 -0
- package/dist/tui/run.js +115 -23
- package/dist/tui/streaming-tool-args.d.ts +15 -0
- package/dist/tui/streaming-tool-args.js +30 -0
- package/dist/tui/tool-renderers/write-preview.d.ts +1 -1
- package/dist/tui/tool-renderers/write-preview.js +9 -1
- package/dist/tui/tool-renderers/write.js +13 -7
- package/dist/types.d.ts +15 -0
- package/package.json +1 -1
package/dist/provider.js
CHANGED
|
@@ -4,9 +4,25 @@
|
|
|
4
4
|
* Works with OpenRouter, OpenAI, DeepSeek, Google, Groq, Together, and local OpenAI-compatible endpoints.
|
|
5
5
|
*/
|
|
6
6
|
import OpenAI from "openai";
|
|
7
|
+
import { appendFileSync } from "node:fs";
|
|
7
8
|
import { createOpenAICodexProvider, isOpenAICodexBaseUrl } from "./provider-openai-codex.js";
|
|
8
9
|
import { createProviderProtocolArtifactFilter } from "./provider-artifacts.js";
|
|
9
10
|
import { resolveProviderRequestConfig } from "./provider-transform.js";
|
|
11
|
+
// Diagnostic logger for tool-args byte-loss investigation. Activate with
|
|
12
|
+
// BUBBLE_DEBUG_TOOL_ARGS=/path/to/log.jsonl (any writable path)
|
|
13
|
+
// Each line is a JSON record describing a transition. When debugging is off,
|
|
14
|
+
// the function is a no-op and free.
|
|
15
|
+
const TOOL_ARGS_DEBUG_PATH = process.env.BUBBLE_DEBUG_TOOL_ARGS?.trim();
|
|
16
|
+
function debugToolArgs(event) {
|
|
17
|
+
if (!TOOL_ARGS_DEBUG_PATH)
|
|
18
|
+
return;
|
|
19
|
+
try {
|
|
20
|
+
appendFileSync(TOOL_ARGS_DEBUG_PATH, JSON.stringify({ t: Date.now(), ...event }) + "\n", "utf-8");
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
// Diagnostic failures must not affect the model session.
|
|
24
|
+
}
|
|
25
|
+
}
|
|
10
26
|
export function toChatCompletionsMessage(message, options = {}) {
|
|
11
27
|
const reasoningContentEcho = options.reasoningContentEcho ?? "tool_calls";
|
|
12
28
|
if (message.role === "assistant") {
|
|
@@ -91,7 +107,9 @@ export function createProviderInstance(options) {
|
|
|
91
107
|
const stream = (await client.chat.completions.create(body, {
|
|
92
108
|
signal: chatOptions.abortSignal,
|
|
93
109
|
}));
|
|
94
|
-
yield* translateOpenAIStream(stream
|
|
110
|
+
yield* translateOpenAIStream(stream, {
|
|
111
|
+
toolArgsMergeMode: resolveToolArgsMergeMode(options.providerId || "", options.baseURL),
|
|
112
|
+
});
|
|
95
113
|
yield { type: "done" };
|
|
96
114
|
}
|
|
97
115
|
async function complete(messages, chatOptions) {
|
|
@@ -118,19 +136,16 @@ export function createProviderInstance(options) {
|
|
|
118
136
|
}
|
|
119
137
|
return { streamChat, complete };
|
|
120
138
|
}
|
|
121
|
-
|
|
122
|
-
// as repeated full snapshots in each delta instead of incremental chunks, so
|
|
123
|
-
// a naive `+=` produces `{"x":1}{"x":1}` — not valid JSON. Parse the raw
|
|
124
|
-
// stream; if it doesn't parse but contains a balanced `{…}` prefix or suffix
|
|
125
|
-
// that does, use that. Empty or unsalvageable input becomes `"{}"` so the
|
|
126
|
-
// downstream echo to the model is always valid JSON.
|
|
127
|
-
export function normalizeToolArgs(raw) {
|
|
139
|
+
export function normalizeToolArgsDetailed(raw) {
|
|
128
140
|
const s = (raw ?? "").trim();
|
|
129
|
-
if (!s)
|
|
130
|
-
|
|
141
|
+
if (!s) {
|
|
142
|
+
debugToolArgs({ stage: "normalize", input: raw, output: "{}", reason: "empty" });
|
|
143
|
+
return { args: "{}", corrupt: false };
|
|
144
|
+
}
|
|
131
145
|
try {
|
|
132
146
|
JSON.parse(s);
|
|
133
|
-
|
|
147
|
+
debugToolArgs({ stage: "normalize", input: raw, output: s, reason: "passthrough" });
|
|
148
|
+
return { args: s, corrupt: false };
|
|
134
149
|
}
|
|
135
150
|
catch { }
|
|
136
151
|
const firstBrace = extractBalancedJson(s, 0);
|
|
@@ -139,21 +154,39 @@ export function normalizeToolArgs(raw) {
|
|
|
139
154
|
JSON.parse(firstBrace);
|
|
140
155
|
}
|
|
141
156
|
catch {
|
|
142
|
-
|
|
157
|
+
debugToolArgs({ stage: "normalize", input: raw, output: "{}", reason: "first-brace-unparseable", firstBrace });
|
|
158
|
+
return { args: "{}", corrupt: true };
|
|
143
159
|
}
|
|
144
160
|
// If the content after the first balanced object is another valid object
|
|
145
161
|
// with the same parse, we've got a snapshot duplication — keep one copy.
|
|
146
162
|
const rest = s.slice(firstBrace.length).trim();
|
|
147
|
-
if (!rest)
|
|
148
|
-
|
|
163
|
+
if (!rest) {
|
|
164
|
+
debugToolArgs({ stage: "normalize", input: raw, output: firstBrace, reason: "single-brace" });
|
|
165
|
+
return { args: firstBrace, corrupt: false };
|
|
166
|
+
}
|
|
149
167
|
try {
|
|
150
168
|
JSON.parse(rest);
|
|
151
|
-
|
|
169
|
+
debugToolArgs({ stage: "normalize", input: raw, output: firstBrace, reason: "snapshot-dedup", rest });
|
|
170
|
+
return { args: firstBrace, corrupt: false };
|
|
152
171
|
}
|
|
153
172
|
catch { }
|
|
154
|
-
|
|
173
|
+
debugToolArgs({ stage: "normalize", input: raw, output: firstBrace, reason: "trailing-junk-dropped", rest });
|
|
174
|
+
return { args: firstBrace, corrupt: false };
|
|
155
175
|
}
|
|
156
|
-
|
|
176
|
+
debugToolArgs({ stage: "normalize", input: raw, output: "{}", reason: "no-balanced-json" });
|
|
177
|
+
return { args: "{}", corrupt: true };
|
|
178
|
+
}
|
|
179
|
+
export function normalizeToolArgs(raw) {
|
|
180
|
+
return normalizeToolArgsDetailed(raw).args;
|
|
181
|
+
}
|
|
182
|
+
function resolveToolArgsMergeMode(providerId, baseURL) {
|
|
183
|
+
const id = providerId.toLowerCase();
|
|
184
|
+
const url = baseURL.toLowerCase();
|
|
185
|
+
// Fireworks-hosted Kimi has been observed to stream cumulative snapshots
|
|
186
|
+
// rather than OpenAI-style argument deltas.
|
|
187
|
+
if (id === "fireworks" || url.includes("fireworks.ai"))
|
|
188
|
+
return "snapshot";
|
|
189
|
+
return "delta";
|
|
157
190
|
}
|
|
158
191
|
function extractBalancedJson(s, start) {
|
|
159
192
|
if (s[start] !== "{")
|
|
@@ -195,9 +228,16 @@ function extractBalancedJson(s, start) {
|
|
|
195
228
|
* partial write previews before the tool executes. End events are still flushed
|
|
196
229
|
* in index order to keep multi-call turns deterministic.
|
|
197
230
|
*/
|
|
198
|
-
export async function* translateOpenAIStream(stream) {
|
|
231
|
+
export async function* translateOpenAIStream(stream, options = {}) {
|
|
199
232
|
const toolCalls = new Map();
|
|
200
233
|
const textFilter = createProviderProtocolArtifactFilter();
|
|
234
|
+
const toolArgsMergeMode = options.toolArgsMergeMode ?? "delta";
|
|
235
|
+
// DeepSeek (and some inference re-hosts) sometimes deliver reasoning twice:
|
|
236
|
+
// once via a dedicated `reasoning_content` / `thinking` field, and again
|
|
237
|
+
// embedded as `<think>...</think>` inside `delta.content`. Track whether we
|
|
238
|
+
// have seen the dedicated channel; if yes, strip <think> blocks from text
|
|
239
|
+
// silently instead of yielding a second reasoning_delta.
|
|
240
|
+
let hasDedicatedReasoningChannel = false;
|
|
201
241
|
function* flushToolCalls() {
|
|
202
242
|
if (toolCalls.size === 0)
|
|
203
243
|
return;
|
|
@@ -212,12 +252,15 @@ export async function* translateOpenAIStream(stream) {
|
|
|
212
252
|
yield { type: "tool_call", id: entry.id, name: entry.name, arguments: entry.args, isStart: false, isEnd: false };
|
|
213
253
|
}
|
|
214
254
|
}
|
|
255
|
+
const normalized = normalizeToolArgsDetailed(entry.args);
|
|
256
|
+
debugToolArgs({ stage: "flush-end", id: entry.id, name: entry.name, entryArgs: entry.args, finalArgs: normalized.args, corrupt: normalized.corrupt });
|
|
215
257
|
yield {
|
|
216
258
|
type: "tool_call",
|
|
217
259
|
id: entry.id,
|
|
218
260
|
name: entry.name,
|
|
219
261
|
arguments: "",
|
|
220
|
-
argumentsFull:
|
|
262
|
+
argumentsFull: normalized.args,
|
|
263
|
+
argumentsCorrupt: normalized.corrupt || undefined,
|
|
221
264
|
isStart: false,
|
|
222
265
|
isEnd: true,
|
|
223
266
|
};
|
|
@@ -253,12 +296,13 @@ export async function* translateOpenAIStream(stream) {
|
|
|
253
296
|
}
|
|
254
297
|
const reasoning = delta?.reasoning ?? delta?.thinking ?? delta?.reasoning_content;
|
|
255
298
|
if (reasoning) {
|
|
299
|
+
hasDedicatedReasoningChannel = true;
|
|
256
300
|
yield { type: "reasoning_delta", content: reasoning };
|
|
257
301
|
}
|
|
258
302
|
if (delta?.content) {
|
|
259
303
|
const thinkMatch = delta.content.match(/<think>([\s\S]*?)<\/think>/);
|
|
260
304
|
if (thinkMatch) {
|
|
261
|
-
if (thinkMatch[1]) {
|
|
305
|
+
if (thinkMatch[1] && !hasDedicatedReasoningChannel) {
|
|
262
306
|
yield { type: "reasoning_delta", content: thinkMatch[1] };
|
|
263
307
|
}
|
|
264
308
|
const remaining = delta.content.replace(/<think>[\s\S]*?<\/think>/, "");
|
|
@@ -288,7 +332,8 @@ export async function* translateOpenAIStream(stream) {
|
|
|
288
332
|
entry.name = tc.function.name;
|
|
289
333
|
yield* startToolCallIfReady(entry);
|
|
290
334
|
if (typeof tc.function?.arguments === "string" && tc.function.arguments) {
|
|
291
|
-
|
|
335
|
+
debugToolArgs({ stage: "raw-chunk", id: entry.id, name: entry.name, idx, raw: tc.function.arguments });
|
|
336
|
+
const merged = mergeToolArgumentDelta(entry.args, tc.function.arguments, toolArgsMergeMode);
|
|
292
337
|
entry.args = merged.args;
|
|
293
338
|
if (entry.started && merged.delta) {
|
|
294
339
|
yield {
|
|
@@ -314,22 +359,30 @@ export async function* translateOpenAIStream(stream) {
|
|
|
314
359
|
}
|
|
315
360
|
yield* flushToolCalls();
|
|
316
361
|
}
|
|
317
|
-
function mergeToolArgumentDelta(current, incoming) {
|
|
318
|
-
if (!current)
|
|
362
|
+
function mergeToolArgumentDelta(current, incoming, mode) {
|
|
363
|
+
if (!current) {
|
|
364
|
+
debugToolArgs({ stage: "merge", branch: "empty-current", current, incoming, args: incoming, delta: incoming });
|
|
319
365
|
return { args: incoming, delta: incoming };
|
|
320
|
-
if (!incoming)
|
|
321
|
-
return { args: current, delta: "" };
|
|
322
|
-
// Standard OpenAI-compatible streams send incremental argument deltas. Some
|
|
323
|
-
// providers send cumulative snapshots instead. If the incoming chunk already
|
|
324
|
-
// contains what we have, emit only the new suffix so downstream state remains
|
|
325
|
-
// append-only.
|
|
326
|
-
if (incoming.startsWith(current)) {
|
|
327
|
-
return { args: incoming, delta: incoming.slice(current.length) };
|
|
328
366
|
}
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
if (incoming === current || current.endsWith(incoming)) {
|
|
367
|
+
if (!incoming) {
|
|
368
|
+
debugToolArgs({ stage: "merge", branch: "empty-incoming", current, incoming, args: current, delta: "" });
|
|
332
369
|
return { args: current, delta: "" };
|
|
333
370
|
}
|
|
371
|
+
if (mode === "snapshot") {
|
|
372
|
+
// Snapshot streams repeat the current full argument buffer. Only treat a
|
|
373
|
+
// chunk as duplicate when it is exactly equal, or as growth when it carries
|
|
374
|
+
// the current buffer as a prefix. A suffix match is not enough: the next
|
|
375
|
+
// legitimate delta can be a single trailing character like "0".
|
|
376
|
+
if (incoming === current) {
|
|
377
|
+
debugToolArgs({ stage: "merge", branch: "snapshot-dup", current, incoming, args: current, delta: "" });
|
|
378
|
+
return { args: current, delta: "" };
|
|
379
|
+
}
|
|
380
|
+
if (incoming.startsWith(current)) {
|
|
381
|
+
const delta = incoming.slice(current.length);
|
|
382
|
+
debugToolArgs({ stage: "merge", branch: "snapshot-grow", current, incoming, args: incoming, delta });
|
|
383
|
+
return { args: incoming, delta };
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
debugToolArgs({ stage: "merge", branch: mode === "delta" ? "delta-append" : "snapshot-fallback-concat", current, incoming, args: current + incoming, delta: incoming });
|
|
334
387
|
return { args: current + incoming, delta: incoming };
|
|
335
388
|
}
|
|
@@ -274,24 +274,7 @@ const builtinSlashCommandEntries = [
|
|
|
274
274
|
name: "quit",
|
|
275
275
|
description: "Exit the application",
|
|
276
276
|
async handler(args, ctx) {
|
|
277
|
-
// Shut MCP stdio children down first; their stdout/stderr listeners
|
|
278
|
-
// otherwise hold the Node event loop open even after ink unmounts.
|
|
279
|
-
try {
|
|
280
|
-
await ctx.mcpManager?.shutdown();
|
|
281
|
-
}
|
|
282
|
-
catch {
|
|
283
|
-
// ignore — we're quitting anyway
|
|
284
|
-
}
|
|
285
|
-
try {
|
|
286
|
-
await ctx.flushMemory?.();
|
|
287
|
-
}
|
|
288
|
-
catch {
|
|
289
|
-
// memory shutdown hooks are best-effort during exit
|
|
290
|
-
}
|
|
291
277
|
ctx.exit();
|
|
292
|
-
// Belt-and-braces: if anything else (raw-mode tty handle, pending
|
|
293
|
-
// timer, etc.) still holds the loop, force-exit shortly after.
|
|
294
|
-
setTimeout(() => process.exit(0), 100).unref();
|
|
295
278
|
},
|
|
296
279
|
},
|
|
297
280
|
{
|
package/dist/tools/bash.d.ts
CHANGED
|
@@ -3,4 +3,5 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import type { ApprovalController } from "../approval/types.js";
|
|
5
5
|
import type { ToolRegistryEntry } from "../types.js";
|
|
6
|
-
|
|
6
|
+
import type { FileStateTracker } from "./file-state.js";
|
|
7
|
+
export declare function createBashTool(cwd: string, approval?: ApprovalController, _fileState?: FileStateTracker): ToolRegistryEntry;
|
package/dist/tools/bash.js
CHANGED
|
@@ -8,7 +8,7 @@ import { gateToolAction } from "../approval/tool-helper.js";
|
|
|
8
8
|
import { parseReadBashCommand, parseSearchBashCommand } from "../agent/tool-intent.js";
|
|
9
9
|
import { referencesSensitivePath } from "./sensitive-paths.js";
|
|
10
10
|
const MAX_OUTPUT = 50 * 1024;
|
|
11
|
-
export function createBashTool(cwd, approval) {
|
|
11
|
+
export function createBashTool(cwd, approval, _fileState) {
|
|
12
12
|
return {
|
|
13
13
|
name: "bash",
|
|
14
14
|
effect: "unknown",
|
package/dist/tools/edit-apply.js
CHANGED
|
@@ -118,15 +118,32 @@ function matchEdit(content, edit, index, total) {
|
|
|
118
118
|
if (edit.oldText.length === 0) {
|
|
119
119
|
throw new EditApplyError(total === 1 ? "Error: oldText must not be empty." : `Error: edits[${index}].oldText must not be empty.`);
|
|
120
120
|
}
|
|
121
|
+
if (edit.oldText === edit.newText) {
|
|
122
|
+
const header = total === 1
|
|
123
|
+
? "Error: This edit is a no-op because oldText and newText are byte-identical."
|
|
124
|
+
: `Error: edits[${index}] is a no-op because oldText and newText are byte-identical.`;
|
|
125
|
+
throw new EditApplyError([
|
|
126
|
+
header,
|
|
127
|
+
"",
|
|
128
|
+
"Common causes and how to escape:",
|
|
129
|
+
"- Your tokenizer may be folding repeated characters into a single token (hex colors like '#ec489' vs '#ec4899', repeated digits, etc.). The two strings feel different in your head but serialize to identical bytes.",
|
|
130
|
+
"- Use the write tool with overwrite=true and the full new content for full-file replacements that hinge on a single repeated character or trailing digit.",
|
|
131
|
+
"- Or re-read the file with the read tool, then copy the exact bytes you want to replace before retrying.",
|
|
132
|
+
].join("\n"));
|
|
133
|
+
}
|
|
121
134
|
const oldText = normalizeToLF(edit.oldText);
|
|
122
135
|
const exact = findAllOccurrences(content, oldText);
|
|
123
136
|
if (exact.length === 1) {
|
|
124
137
|
return { editIndex: index, mode: "exact", start: exact[0], end: exact[0] + oldText.length };
|
|
125
138
|
}
|
|
126
139
|
if (exact.length > 1) {
|
|
140
|
+
const recovery = [
|
|
141
|
+
"",
|
|
142
|
+
"Extend oldText with more surrounding context (the lines immediately before/after) until it uniquely identifies the intended span.",
|
|
143
|
+
].join("\n");
|
|
127
144
|
throw new EditApplyError(total === 1
|
|
128
|
-
? `Error: oldText appears ${exact.length} times in file. Must be unique: "${summarizeOldText(oldText)}"`
|
|
129
|
-
: `Error: edits[${index}].oldText appears ${exact.length} times in file. Must be unique: "${summarizeOldText(oldText)}"`);
|
|
145
|
+
? `Error: oldText appears ${exact.length} times in file. Must be unique: "${summarizeOldText(oldText)}"${recovery}`
|
|
146
|
+
: `Error: edits[${index}].oldText appears ${exact.length} times in file. Must be unique: "${summarizeOldText(oldText)}"${recovery}`);
|
|
130
147
|
}
|
|
131
148
|
const normalizedLineMatches = findNormalizedLineMatches(content, oldText);
|
|
132
149
|
if (normalizedLineMatches.length === 1) {
|
|
@@ -143,10 +160,17 @@ function matchEdit(content, edit, index, total) {
|
|
|
143
160
|
: `Error: edits[${index}].oldText matched ${normalizedLineMatches.length} normalized line blocks in file. Provide more surrounding context.`);
|
|
144
161
|
}
|
|
145
162
|
const hint = findBestLineHint(content, oldText);
|
|
146
|
-
const
|
|
163
|
+
const hintSuffix = hint ? `\n${hint}` : "";
|
|
164
|
+
const recovery = [
|
|
165
|
+
"",
|
|
166
|
+
"How to recover:",
|
|
167
|
+
"- Re-read the file with the read tool to see its current bytes; the file may have been changed by a prior edit this turn.",
|
|
168
|
+
"- Shorten oldText to a smaller unique anchor and try again. Long multi-line anchors are fragile to whitespace and indentation.",
|
|
169
|
+
"- If many lines need to change, use the write tool with overwrite=true and the full new content instead of stacking edits.",
|
|
170
|
+
].join("\n");
|
|
147
171
|
throw new EditApplyError(total === 1
|
|
148
|
-
? `Error: oldText not found in file: "${summarizeOldText(oldText)}"${
|
|
149
|
-
: `Error: edits[${index}].oldText not found in file: "${summarizeOldText(oldText)}"${
|
|
172
|
+
? `Error: oldText not found in file: "${summarizeOldText(oldText)}"${hintSuffix}${recovery}`
|
|
173
|
+
: `Error: edits[${index}].oldText not found in file: "${summarizeOldText(oldText)}"${hintSuffix}${recovery}`);
|
|
150
174
|
}
|
|
151
175
|
function assertNoOverlaps(matches) {
|
|
152
176
|
const sorted = [...matches].sort((a, b) => a.start - b.start);
|
|
@@ -178,7 +202,14 @@ export function applyEditsToContent(rawContent, edits) {
|
|
|
178
202
|
normalizedNext = normalizedNext.slice(0, match.start) + edit.newText + normalizedNext.slice(match.end);
|
|
179
203
|
}
|
|
180
204
|
if (normalizedNext === normalizedOriginal) {
|
|
181
|
-
throw new EditApplyError(
|
|
205
|
+
throw new EditApplyError([
|
|
206
|
+
"Error: No changes made. The replacement produced identical content.",
|
|
207
|
+
"",
|
|
208
|
+
"Common causes and how to escape:",
|
|
209
|
+
"- oldText and newText are byte-identical. Verify newText actually contains the intended change (a missing trailing char like turning '#ec489' into '#ec4899' is a frequent culprit).",
|
|
210
|
+
"- The file already contains newText. Re-read the file to confirm the current state before editing again.",
|
|
211
|
+
"- For wholesale rewrites, use the write tool with overwrite=true and the full new content instead.",
|
|
212
|
+
].join("\n"));
|
|
182
213
|
}
|
|
183
214
|
return {
|
|
184
215
|
content: bom + restoreLineEndings(normalizedNext, lineEnding),
|
package/dist/tools/edit.d.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import type { ApprovalController } from "../approval/types.js";
|
|
7
7
|
import type { ToolRegistryEntry } from "../types.js";
|
|
8
8
|
import { type LspService } from "../lsp/index.js";
|
|
9
|
+
import { type FileStateTracker } from "./file-state.js";
|
|
9
10
|
export interface EditArgs {
|
|
10
11
|
path: string;
|
|
11
12
|
edits: Array<{
|
|
@@ -13,4 +14,4 @@ export interface EditArgs {
|
|
|
13
14
|
newText: string;
|
|
14
15
|
}>;
|
|
15
16
|
}
|
|
16
|
-
export declare function createEditTool(cwd: string, approval?: ApprovalController, lsp?: LspService): ToolRegistryEntry;
|
|
17
|
+
export declare function createEditTool(cwd: string, approval?: ApprovalController, lsp?: LspService, fileState?: FileStateTracker): ToolRegistryEntry;
|
package/dist/tools/edit.js
CHANGED
|
@@ -5,17 +5,14 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { constants } from "node:fs";
|
|
7
7
|
import { access, readFile, writeFile } from "node:fs/promises";
|
|
8
|
-
import {
|
|
8
|
+
import { resolve } from "node:path";
|
|
9
9
|
import { createTwoFilesPatch } from "diff";
|
|
10
10
|
import { gateToolAction } from "../approval/tool-helper.js";
|
|
11
11
|
import { formatDiagnosticBlocks } from "../lsp/index.js";
|
|
12
12
|
import { applyEditsToContent, EditApplyError, formatEditMatchNotes } from "./edit-apply.js";
|
|
13
13
|
import { withFileMutationQueue } from "./file-mutation-queue.js";
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
17
|
-
}
|
|
18
|
-
export function createEditTool(cwd, approval, lsp) {
|
|
14
|
+
import { isWithinWorkspace } from "./file-state.js";
|
|
15
|
+
export function createEditTool(cwd, approval, lsp, fileState) {
|
|
19
16
|
return {
|
|
20
17
|
name: "edit",
|
|
21
18
|
effect: "write_direct",
|
|
@@ -82,7 +79,22 @@ export function createEditTool(cwd, approval, lsp) {
|
|
|
82
79
|
});
|
|
83
80
|
if (!gate.approved)
|
|
84
81
|
return gate.result;
|
|
82
|
+
const latest = await readFile(filePath, "utf-8");
|
|
83
|
+
if (latest !== original) {
|
|
84
|
+
return {
|
|
85
|
+
content: `Error: Cannot safely edit ${filePath} because it changed while approval was pending.\n\n`
|
|
86
|
+
+ "Re-read the file and retry the edit against the latest content.",
|
|
87
|
+
isError: true,
|
|
88
|
+
status: "blocked",
|
|
89
|
+
metadata: {
|
|
90
|
+
kind: "security",
|
|
91
|
+
path: filePath,
|
|
92
|
+
reason: "changed",
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
85
96
|
await writeFile(filePath, applied.content, "utf-8");
|
|
97
|
+
await fileState?.observe(filePath, "edit", applied.content).catch(() => undefined);
|
|
86
98
|
let output = `Edited ${filePath}${formatEditMatchNotes(applied.matches)}\n\nDiff:\n${diff}`;
|
|
87
99
|
if (lsp) {
|
|
88
100
|
try {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type FileObservationSource = "read" | "write" | "edit";
|
|
2
|
+
export interface FileVersion {
|
|
3
|
+
hash: string;
|
|
4
|
+
mtimeMs: number;
|
|
5
|
+
size: number;
|
|
6
|
+
}
|
|
7
|
+
export type FileFreshnessResult = {
|
|
8
|
+
ok: true;
|
|
9
|
+
version: FileVersion;
|
|
10
|
+
} | {
|
|
11
|
+
ok: false;
|
|
12
|
+
reason: "unobserved" | "missing" | "changed";
|
|
13
|
+
observed?: FileVersion;
|
|
14
|
+
current?: FileVersion;
|
|
15
|
+
};
|
|
16
|
+
export declare class FileStateTracker {
|
|
17
|
+
private readonly cwd;
|
|
18
|
+
private readonly observed;
|
|
19
|
+
constructor(cwd: string);
|
|
20
|
+
observe(filePath: string, source: FileObservationSource, content?: string): Promise<FileVersion>;
|
|
21
|
+
checkFresh(filePath: string): Promise<FileFreshnessResult>;
|
|
22
|
+
private resolvePath;
|
|
23
|
+
private computeVersion;
|
|
24
|
+
}
|
|
25
|
+
export declare function isWithinWorkspace(cwd: string, filePath: string): boolean;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { stat, readFile } from "node:fs/promises";
|
|
3
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
4
|
+
export class FileStateTracker {
|
|
5
|
+
cwd;
|
|
6
|
+
observed = new Map();
|
|
7
|
+
constructor(cwd) {
|
|
8
|
+
this.cwd = cwd;
|
|
9
|
+
}
|
|
10
|
+
async observe(filePath, source, content) {
|
|
11
|
+
const absolute = this.resolvePath(filePath);
|
|
12
|
+
const version = await this.computeVersion(absolute, content);
|
|
13
|
+
this.observed.set(absolute, { ...version, source, observedAt: Date.now() });
|
|
14
|
+
return version;
|
|
15
|
+
}
|
|
16
|
+
async checkFresh(filePath) {
|
|
17
|
+
const absolute = this.resolvePath(filePath);
|
|
18
|
+
const observed = this.observed.get(absolute);
|
|
19
|
+
if (!observed) {
|
|
20
|
+
return { ok: false, reason: "unobserved" };
|
|
21
|
+
}
|
|
22
|
+
let current;
|
|
23
|
+
try {
|
|
24
|
+
current = await this.computeVersion(absolute);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return { ok: false, reason: "missing", observed };
|
|
28
|
+
}
|
|
29
|
+
if (current.hash === observed.hash && current.size === observed.size) {
|
|
30
|
+
return { ok: true, version: current };
|
|
31
|
+
}
|
|
32
|
+
return { ok: false, reason: "changed", observed, current };
|
|
33
|
+
}
|
|
34
|
+
resolvePath(filePath) {
|
|
35
|
+
return resolve(this.cwd, filePath);
|
|
36
|
+
}
|
|
37
|
+
async computeVersion(filePath, content) {
|
|
38
|
+
const [stats, bytes] = await Promise.all([
|
|
39
|
+
stat(filePath),
|
|
40
|
+
content === undefined ? readFile(filePath) : Promise.resolve(Buffer.from(content, "utf-8")),
|
|
41
|
+
]);
|
|
42
|
+
return {
|
|
43
|
+
hash: createHash("sha256").update(bytes).digest("hex"),
|
|
44
|
+
mtimeMs: stats.mtimeMs,
|
|
45
|
+
size: stats.size,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export function isWithinWorkspace(cwd, filePath) {
|
|
50
|
+
const rel = relative(resolve(cwd), filePath);
|
|
51
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
52
|
+
}
|
package/dist/tools/index.d.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { type LspService } from "../lsp/index.js";
|
|
|
25
25
|
import { type TodoStore } from "./todo.js";
|
|
26
26
|
import { type ToolSearchController } from "./tool-search.js";
|
|
27
27
|
import type { QuestionController } from "../question/index.js";
|
|
28
|
+
import { FileStateTracker } from "./file-state.js";
|
|
28
29
|
export interface CreateAllToolsOptions {
|
|
29
30
|
todoStore?: TodoStore;
|
|
30
31
|
planController?: PlanController;
|
|
@@ -32,5 +33,6 @@ export interface CreateAllToolsOptions {
|
|
|
32
33
|
questionController?: QuestionController;
|
|
33
34
|
toolSearchController?: ToolSearchController;
|
|
34
35
|
lspService?: LspService;
|
|
36
|
+
fileStateTracker?: FileStateTracker;
|
|
35
37
|
}
|
|
36
38
|
export declare function createAllTools(cwd: string, skillRegistry?: SkillRegistry, options?: CreateAllToolsOptions): ToolRegistryEntry[];
|
package/dist/tools/index.js
CHANGED
|
@@ -34,14 +34,16 @@ import { createWebSearchTool } from "./web-search.js";
|
|
|
34
34
|
import { createWriteTool } from "./write.js";
|
|
35
35
|
import { createQuestionTool } from "./question.js";
|
|
36
36
|
import { createMemoryReadSummaryTool, createMemorySearchTool } from "./memory.js";
|
|
37
|
+
import { FileStateTracker } from "./file-state.js";
|
|
37
38
|
export function createAllTools(cwd, skillRegistry, options = {}) {
|
|
38
39
|
const approval = options.approvalController;
|
|
39
40
|
const lsp = options.lspService ?? getLspService(cwd);
|
|
41
|
+
const fileState = options.fileStateTracker ?? new FileStateTracker(cwd);
|
|
40
42
|
return [
|
|
41
|
-
createReadTool(cwd, approval, lsp),
|
|
42
|
-
createBashTool(cwd, approval),
|
|
43
|
-
createWriteTool(cwd, { refuseOverwrite: true }, approval, lsp),
|
|
44
|
-
createEditTool(cwd, approval, lsp),
|
|
43
|
+
createReadTool(cwd, approval, lsp, fileState),
|
|
44
|
+
createBashTool(cwd, approval, fileState),
|
|
45
|
+
createWriteTool(cwd, { refuseOverwrite: true }, approval, lsp, fileState),
|
|
46
|
+
createEditTool(cwd, approval, lsp, fileState),
|
|
45
47
|
createGlobTool(cwd),
|
|
46
48
|
createGrepTool(cwd),
|
|
47
49
|
createLspTool(cwd, lsp, approval),
|
package/dist/tools/read.d.ts
CHANGED
|
@@ -4,4 +4,5 @@
|
|
|
4
4
|
import type { ApprovalController } from "../approval/types.js";
|
|
5
5
|
import type { ToolRegistryEntry } from "../types.js";
|
|
6
6
|
import type { LspService } from "../lsp/index.js";
|
|
7
|
-
|
|
7
|
+
import type { FileStateTracker } from "./file-state.js";
|
|
8
|
+
export declare function createReadTool(cwd: string, approval?: ApprovalController, lsp?: LspService, fileState?: FileStateTracker): ToolRegistryEntry;
|
package/dist/tools/read.js
CHANGED
|
@@ -7,7 +7,7 @@ import { resolve } from "node:path";
|
|
|
7
7
|
import { isSensitivePath } from "./sensitive-paths.js";
|
|
8
8
|
const MAX_LINES = 250;
|
|
9
9
|
const MAX_BYTES = 100 * 1024;
|
|
10
|
-
export function createReadTool(cwd, approval, lsp) {
|
|
10
|
+
export function createReadTool(cwd, approval, lsp, fileState) {
|
|
11
11
|
return {
|
|
12
12
|
name: "read",
|
|
13
13
|
readOnly: true,
|
|
@@ -70,6 +70,10 @@ export function createReadTool(cwd, approval, lsp) {
|
|
|
70
70
|
if (truncated) {
|
|
71
71
|
result += `\n[Output truncated: exceeded ${MAX_LINES} lines or ${MAX_BYTES / 1024}KB limit]`;
|
|
72
72
|
}
|
|
73
|
+
const isFullRead = offset === 0 && !truncated && offset + limit >= lines.length;
|
|
74
|
+
if (isFullRead) {
|
|
75
|
+
await fileState?.observe(filePath, "read", content).catch(() => undefined);
|
|
76
|
+
}
|
|
73
77
|
void lsp?.touchFile(filePath).catch(() => undefined);
|
|
74
78
|
return {
|
|
75
79
|
content: result,
|
package/dist/tools/write.d.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Write tool - create or
|
|
2
|
+
* Write tool - create files or safely replace full file contents.
|
|
3
3
|
*/
|
|
4
4
|
import type { ApprovalController } from "../approval/types.js";
|
|
5
5
|
import type { ToolRegistryEntry } from "../types.js";
|
|
6
6
|
import { type LspService } from "../lsp/index.js";
|
|
7
|
+
import { type FileStateTracker } from "./file-state.js";
|
|
7
8
|
export interface WriteToolOptions {
|
|
8
|
-
/** If true,
|
|
9
|
+
/** If true, existing files require overwrite=true plus a fresh agent-observed version. */
|
|
9
10
|
refuseOverwrite?: boolean;
|
|
10
11
|
}
|
|
11
|
-
export declare function createWriteTool(cwd: string, options?: WriteToolOptions, approval?: ApprovalController, lsp?: LspService): ToolRegistryEntry;
|
|
12
|
+
export declare function createWriteTool(cwd: string, options?: WriteToolOptions, approval?: ApprovalController, lsp?: LspService, fileState?: FileStateTracker): ToolRegistryEntry;
|