@bubblebrain-ai/bubble 0.0.5 → 0.0.6

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.
Files changed (38) hide show
  1. package/dist/agent/task-size.d.ts +9 -0
  2. package/dist/agent/task-size.js +33 -0
  3. package/dist/agent/tool-intent.d.ts +1 -0
  4. package/dist/agent/tool-intent.js +1 -1
  5. package/dist/agent.js +46 -2
  6. package/dist/orchestrator/default-hooks.js +80 -69
  7. package/dist/orchestrator/hooks.d.ts +5 -8
  8. package/dist/prompt/compose.js +3 -0
  9. package/dist/prompt/environment.js +2 -0
  10. package/dist/prompt/provider-prompts/deepseek.js +1 -2
  11. package/dist/prompt/provider-prompts/kimi.js +1 -2
  12. package/dist/prompt/reminders.d.ts +20 -3
  13. package/dist/prompt/reminders.js +43 -17
  14. package/dist/prompt/runtime.js +17 -23
  15. package/dist/provider.d.ts +10 -1
  16. package/dist/provider.js +87 -34
  17. package/dist/tools/bash.d.ts +2 -1
  18. package/dist/tools/bash.js +1 -1
  19. package/dist/tools/edit-apply.js +37 -6
  20. package/dist/tools/edit.d.ts +2 -1
  21. package/dist/tools/edit.js +18 -6
  22. package/dist/tools/file-state.d.ts +25 -0
  23. package/dist/tools/file-state.js +52 -0
  24. package/dist/tools/index.d.ts +2 -0
  25. package/dist/tools/index.js +6 -4
  26. package/dist/tools/read.d.ts +2 -1
  27. package/dist/tools/read.js +5 -1
  28. package/dist/tools/write.d.ts +4 -3
  29. package/dist/tools/write.js +133 -54
  30. package/dist/tui/display-history.d.ts +2 -0
  31. package/dist/tui/run.js +115 -23
  32. package/dist/tui/streaming-tool-args.d.ts +15 -0
  33. package/dist/tui/streaming-tool-args.js +30 -0
  34. package/dist/tui/tool-renderers/write-preview.d.ts +1 -1
  35. package/dist/tui/tool-renderers/write-preview.js +9 -1
  36. package/dist/tui/tool-renderers/write.js +13 -7
  37. package/dist/types.d.ts +15 -0
  38. 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
- // Some providers (notably Fireworks-hosted Kimi) stream tool-call arguments
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
- return "{}";
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
- return s;
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
- return "{}";
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
- return firstBrace;
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
- return firstBrace;
169
+ debugToolArgs({ stage: "normalize", input: raw, output: firstBrace, reason: "snapshot-dedup", rest });
170
+ return { args: firstBrace, corrupt: false };
152
171
  }
153
172
  catch { }
154
- return firstBrace;
173
+ debugToolArgs({ stage: "normalize", input: raw, output: firstBrace, reason: "trailing-junk-dropped", rest });
174
+ return { args: firstBrace, corrupt: false };
155
175
  }
156
- return "{}";
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: normalizeToolArgs(entry.args),
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
- const merged = mergeToolArgumentDelta(entry.args, tc.function.arguments);
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
- // Repeated identical snapshots should not duplicate the TUI preview or final
330
- // JSON arguments.
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
  }
@@ -3,4 +3,5 @@
3
3
  */
4
4
  import type { ApprovalController } from "../approval/types.js";
5
5
  import type { ToolRegistryEntry } from "../types.js";
6
- export declare function createBashTool(cwd: string, approval?: ApprovalController): ToolRegistryEntry;
6
+ import type { FileStateTracker } from "./file-state.js";
7
+ export declare function createBashTool(cwd: string, approval?: ApprovalController, _fileState?: FileStateTracker): ToolRegistryEntry;
@@ -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",
@@ -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 suffix = hint ? `\n${hint}` : "";
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)}"${suffix}`
149
- : `Error: edits[${index}].oldText not found in file: "${summarizeOldText(oldText)}"${suffix}`);
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("Error: No changes made. The replacement produced identical content.");
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),
@@ -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;
@@ -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 { isAbsolute, relative, resolve } from "node:path";
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
- function isWithinWorkspace(cwd, filePath) {
15
- const rel = relative(resolve(cwd), filePath);
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
+ }
@@ -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[];
@@ -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),
@@ -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
- export declare function createReadTool(cwd: string, approval?: ApprovalController, lsp?: LspService): ToolRegistryEntry;
7
+ import type { FileStateTracker } from "./file-state.js";
8
+ export declare function createReadTool(cwd: string, approval?: ApprovalController, lsp?: LspService, fileState?: FileStateTracker): ToolRegistryEntry;
@@ -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,
@@ -1,11 +1,12 @@
1
1
  /**
2
- * Write tool - create or overwrite files.
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, refuse to overwrite existing files */
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;