@bubblebrain-ai/bubble 0.0.7 → 0.0.9

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 (119) hide show
  1. package/dist/agent/categories.d.ts +34 -0
  2. package/dist/agent/categories.js +98 -0
  3. package/dist/agent/profiles.d.ts +4 -0
  4. package/dist/agent/profiles.js +2 -3
  5. package/dist/agent/subagent-control.d.ts +5 -0
  6. package/dist/agent/subagent-control.js +4 -0
  7. package/dist/agent/subagent-lifecycle-reminder.d.ts +3 -0
  8. package/dist/agent/subagent-lifecycle-reminder.js +102 -0
  9. package/dist/agent/subagent-route-format.d.ts +8 -0
  10. package/dist/agent/subagent-route-format.js +18 -0
  11. package/dist/agent/subtask-policy.d.ts +0 -1
  12. package/dist/agent/subtask-policy.js +0 -4
  13. package/dist/agent.d.ts +18 -0
  14. package/dist/agent.js +188 -16
  15. package/dist/config.d.ts +23 -3
  16. package/dist/config.js +59 -6
  17. package/dist/context/budget.d.ts +3 -2
  18. package/dist/context/budget.js +29 -15
  19. package/dist/context/compact.d.ts +23 -0
  20. package/dist/context/compact.js +129 -0
  21. package/dist/context/llm-compactor.d.ts +19 -0
  22. package/dist/context/llm-compactor.js +200 -0
  23. package/dist/context/projector.js +28 -12
  24. package/dist/context/token-estimator.d.ts +14 -0
  25. package/dist/context/token-estimator.js +106 -0
  26. package/dist/context/tool-output-truncate.d.ts +8 -0
  27. package/dist/context/tool-output-truncate.js +59 -0
  28. package/dist/context/usage.d.ts +34 -0
  29. package/dist/context/usage.js +213 -0
  30. package/dist/diff-stats.d.ts +5 -0
  31. package/dist/diff-stats.js +21 -0
  32. package/dist/main.js +68 -7
  33. package/dist/mcp/transports.d.ts +1 -0
  34. package/dist/mcp/transports.js +8 -0
  35. package/dist/model-catalog.d.ts +9 -0
  36. package/dist/model-catalog.js +17 -1
  37. package/dist/orchestrator/default-hooks.js +24 -18
  38. package/dist/prompt/compose.js +2 -1
  39. package/dist/prompt/provider-prompts/kimi.js +3 -1
  40. package/dist/provider-openai-codex.d.ts +13 -2
  41. package/dist/provider-openai-codex.js +81 -32
  42. package/dist/provider-registry.js +22 -6
  43. package/dist/provider-transform.d.ts +3 -1
  44. package/dist/provider-transform.js +15 -0
  45. package/dist/provider.d.ts +4 -1
  46. package/dist/provider.js +89 -4
  47. package/dist/reasoning-debug.d.ts +7 -0
  48. package/dist/reasoning-debug.js +30 -0
  49. package/dist/session-log.js +13 -2
  50. package/dist/session-types.d.ts +1 -1
  51. package/dist/slash-commands/commands.js +60 -2
  52. package/dist/slash-commands/types.d.ts +7 -0
  53. package/dist/tools/agent-lifecycle.js +22 -4
  54. package/dist/tools/edit.js +7 -2
  55. package/dist/tools/file-state.d.ts +19 -0
  56. package/dist/tools/file-state.js +15 -0
  57. package/dist/tools/glob.js +2 -1
  58. package/dist/tools/grep.js +2 -2
  59. package/dist/tools/lsp.js +2 -2
  60. package/dist/tools/path-utils.d.ts +2 -0
  61. package/dist/tools/path-utils.js +16 -0
  62. package/dist/tools/read.d.ts +1 -1
  63. package/dist/tools/read.js +207 -14
  64. package/dist/tools/write.js +3 -2
  65. package/dist/tui/escape-confirmation.d.ts +15 -0
  66. package/dist/tui/escape-confirmation.js +30 -0
  67. package/dist/tui/run.js +93 -23
  68. package/dist/tui-ink/app.d.ts +52 -0
  69. package/dist/tui-ink/app.js +1129 -0
  70. package/dist/tui-ink/approval/approval-dialog.d.ts +13 -0
  71. package/dist/tui-ink/approval/approval-dialog.js +132 -0
  72. package/dist/tui-ink/approval/diff-view.d.ts +7 -0
  73. package/dist/tui-ink/approval/diff-view.js +44 -0
  74. package/dist/tui-ink/approval/select.d.ts +35 -0
  75. package/dist/tui-ink/approval/select.js +88 -0
  76. package/dist/tui-ink/code-highlight.d.ts +8 -0
  77. package/dist/tui-ink/code-highlight.js +122 -0
  78. package/dist/tui-ink/detect-theme.d.ts +19 -0
  79. package/dist/tui-ink/detect-theme.js +123 -0
  80. package/dist/tui-ink/display-history.d.ts +38 -0
  81. package/dist/tui-ink/display-history.js +130 -0
  82. package/dist/tui-ink/edit-diff.d.ts +11 -0
  83. package/dist/tui-ink/edit-diff.js +52 -0
  84. package/dist/tui-ink/file-mentions.d.ts +29 -0
  85. package/dist/tui-ink/file-mentions.js +174 -0
  86. package/dist/tui-ink/footer.d.ts +19 -0
  87. package/dist/tui-ink/footer.js +45 -0
  88. package/dist/tui-ink/image-paste.d.ts +54 -0
  89. package/dist/tui-ink/image-paste.js +288 -0
  90. package/dist/tui-ink/input-box.d.ts +41 -0
  91. package/dist/tui-ink/input-box.js +694 -0
  92. package/dist/tui-ink/input-history.d.ts +16 -0
  93. package/dist/tui-ink/input-history.js +81 -0
  94. package/dist/tui-ink/markdown.d.ts +38 -0
  95. package/dist/tui-ink/markdown.js +394 -0
  96. package/dist/tui-ink/message-list.d.ts +33 -0
  97. package/dist/tui-ink/message-list.js +667 -0
  98. package/dist/tui-ink/model-picker.d.ts +43 -0
  99. package/dist/tui-ink/model-picker.js +331 -0
  100. package/dist/tui-ink/plan-confirm.d.ts +7 -0
  101. package/dist/tui-ink/plan-confirm.js +105 -0
  102. package/dist/tui-ink/question-dialog.d.ts +8 -0
  103. package/dist/tui-ink/question-dialog.js +99 -0
  104. package/dist/tui-ink/recent-activity.d.ts +8 -0
  105. package/dist/tui-ink/recent-activity.js +71 -0
  106. package/dist/tui-ink/run.d.ts +37 -0
  107. package/dist/tui-ink/run.js +53 -0
  108. package/dist/tui-ink/theme.d.ts +66 -0
  109. package/dist/tui-ink/theme.js +115 -0
  110. package/dist/tui-ink/todos.d.ts +7 -0
  111. package/dist/tui-ink/todos.js +46 -0
  112. package/dist/tui-ink/trace-groups.d.ts +27 -0
  113. package/dist/tui-ink/trace-groups.js +389 -0
  114. package/dist/tui-ink/use-terminal-size.d.ts +4 -0
  115. package/dist/tui-ink/use-terminal-size.js +21 -0
  116. package/dist/tui-ink/welcome.d.ts +18 -0
  117. package/dist/tui-ink/welcome.js +138 -0
  118. package/dist/types.d.ts +10 -0
  119. package/package.json +7 -1
@@ -1,4 +1,5 @@
1
1
  import { discoverAgentProfiles, findAgentProfile } from "../agent/profiles.js";
2
+ import { formatSubagentRoute } from "../agent/subagent-route-format.js";
2
3
  export function createSpawnAgentTool() {
3
4
  return {
4
5
  name: "spawn_agent",
@@ -16,6 +17,7 @@ export function createSpawnAgentTool() {
16
17
  properties: {
17
18
  agent_type: { type: "string", description: "Subagent profile or role name. Defaults to default." },
18
19
  agent: { type: "string", description: "Alias for agent_type." },
20
+ category: { type: "string", description: "Optional semantic category for model/thinking routing, such as quick, deep, explore, review, frontend, or writing." },
19
21
  message: { type: "string", description: "Initial task for the subagent." },
20
22
  task: { type: "string", description: "Alias for message." },
21
23
  fork_context: { type: "boolean", description: "When true, copy recent parent conversation into the child thread." },
@@ -55,15 +57,18 @@ export function createSpawnAgentTool() {
55
57
  const snapshot = await ctx.agent.spawnSubAgent(message, ctx.cwd, {
56
58
  profile: resolved.profile,
57
59
  parentToolCallId: ctx.toolCall?.id ?? snapshotFallbackId(),
60
+ category: stringArg(args.category),
58
61
  approval: parseApproval(args.approval),
59
62
  abortSignal: ctx.abortSignal,
60
63
  forkContext: args.fork_context === true,
61
64
  });
62
65
  return formatLifecycleResult("spawn_agent", [snapshot], [
63
- `Spawned ${snapshot.nickname} (${snapshot.agentName})`,
66
+ `Spawned ${snapshot.nickname} (${formatSnapshotRole(snapshot)})`,
64
67
  `agent_id: ${snapshot.agentId}`,
65
68
  `status: ${snapshot.status}`,
66
- `next: call wait_agent for ${snapshot.agentId} to collect the delegated result`,
69
+ ...formatRouteLines(snapshot),
70
+ `next: call wait_agent for ${snapshot.agentId} before reporting this subagent's current status or final result`,
71
+ "counting: this spawn result creates one unique subagent; later wait_agent results for the same agent_id are updates, not additional subagents",
67
72
  ]);
68
73
  }
69
74
  catch (error) {
@@ -278,13 +283,17 @@ function isFinalSnapshotStatus(status) {
278
283
  || status === "closed";
279
284
  }
280
285
  function formatSnapshot(snapshot) {
281
- const label = `${snapshot.nickname} (${snapshot.agentName})`;
286
+ const label = `${snapshot.nickname} (${formatSnapshotRole(snapshot)})`;
282
287
  const lines = [
283
288
  `## ${label}`,
284
289
  `agent_id: ${snapshot.agentId}`,
285
290
  `status: ${snapshot.status}`,
286
- `task: ${snapshot.task}`,
287
291
  ];
292
+ if (snapshot.category) {
293
+ lines.push(`category: ${snapshot.category}`);
294
+ }
295
+ lines.push(...formatRouteLines(snapshot));
296
+ lines.push(`task: ${snapshot.task}`);
288
297
  if (snapshot.summary) {
289
298
  lines.push("", "Summary:", snapshot.summary);
290
299
  }
@@ -299,6 +308,13 @@ function formatSnapshot(snapshot) {
299
308
  }
300
309
  return lines;
301
310
  }
311
+ function formatSnapshotRole(snapshot) {
312
+ return [snapshot.agentName, snapshot.category ? `/${snapshot.category}` : ""].join("") || "default";
313
+ }
314
+ function formatRouteLines(snapshot) {
315
+ const route = formatSubagentRoute(snapshot.route, { includeThinking: true });
316
+ return route ? [`route: ${route}`] : [];
317
+ }
302
318
  function snapshotToMetadata(snapshot) {
303
319
  return {
304
320
  subAgentId: snapshot.agentId,
@@ -306,6 +322,8 @@ function snapshotToMetadata(snapshot) {
306
322
  nickname: snapshot.nickname,
307
323
  status: snapshot.status === "closed" ? "cancelled" : snapshot.status,
308
324
  profileSource: snapshot.profileSource,
325
+ category: snapshot.category,
326
+ route: snapshot.route,
309
327
  task: snapshot.task,
310
328
  summary: snapshot.summary,
311
329
  toolNotes: snapshot.toolNotes,
@@ -5,13 +5,14 @@
5
5
  */
6
6
  import { constants } from "node:fs";
7
7
  import { access, readFile, writeFile } from "node:fs/promises";
8
- import { resolve } from "node:path";
9
8
  import { createTwoFilesPatch } from "diff";
10
9
  import { gateToolAction } from "../approval/tool-helper.js";
10
+ import { countUnifiedDiffChanges } from "../diff-stats.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
14
  import { isWithinWorkspace } from "./file-state.js";
15
+ import { resolveToolPath } from "./path-utils.js";
15
16
  export function createEditTool(cwd, approval, lsp, fileState) {
16
17
  return {
17
18
  name: "edit",
@@ -38,7 +39,7 @@ export function createEditTool(cwd, approval, lsp, fileState) {
38
39
  required: ["path", "edits"],
39
40
  },
40
41
  async execute(args) {
41
- const filePath = resolve(cwd, args.path);
42
+ const filePath = resolveToolPath(cwd, args.path);
42
43
  if (!isWithinWorkspace(cwd, filePath)) {
43
44
  return {
44
45
  content: `Error: Edit path is outside the workspace: ${filePath}`,
@@ -70,6 +71,7 @@ export function createEditTool(cwd, approval, lsp, fileState) {
70
71
  throw err;
71
72
  }
72
73
  const diff = createTwoFilesPatch(filePath, filePath, original, applied.content, "original", "modified", { context: 3 });
74
+ const diffStats = countUnifiedDiffChanges(diff);
73
75
  // Gate on the approval controller BEFORE persisting the change.
74
76
  const gate = await gateToolAction(approval, {
75
77
  type: "edit",
@@ -111,6 +113,9 @@ export function createEditTool(cwd, approval, lsp, fileState) {
111
113
  metadata: {
112
114
  kind: "edit",
113
115
  path: filePath,
116
+ diff,
117
+ addedLines: diffStats.added,
118
+ removedLines: diffStats.removed,
114
119
  },
115
120
  };
116
121
  });
@@ -13,10 +13,29 @@ export type FileFreshnessResult = {
13
13
  observed?: FileVersion;
14
14
  current?: FileVersion;
15
15
  };
16
+ export interface ReadHistoryEntry {
17
+ argOffset: number | undefined;
18
+ argLimit: number | undefined;
19
+ effectiveOffset: number;
20
+ effectiveLimit: number;
21
+ returnedLines: number;
22
+ totalLines: number;
23
+ mtimeMs: number;
24
+ truncated: boolean;
25
+ }
16
26
  export declare class FileStateTracker {
17
27
  private readonly cwd;
18
28
  private readonly observed;
29
+ private readonly readHistory;
19
30
  constructor(cwd: string);
31
+ getReadHistory(filePath: string): ReadHistoryEntry | undefined;
32
+ setReadHistory(filePath: string, entry: ReadHistoryEntry): void;
33
+ /**
34
+ * Drops all read-dedup state. Call this whenever conversation history is
35
+ * compacted or pruned, because the dedup stub points the model back at
36
+ * earlier tool_result content that may no longer be resident.
37
+ */
38
+ invalidateReadHistory(): void;
20
39
  observe(filePath: string, source: FileObservationSource, content?: string): Promise<FileVersion>;
21
40
  checkFresh(filePath: string): Promise<FileFreshnessResult>;
22
41
  private resolvePath;
@@ -4,9 +4,24 @@ import { isAbsolute, relative, resolve } from "node:path";
4
4
  export class FileStateTracker {
5
5
  cwd;
6
6
  observed = new Map();
7
+ readHistory = new Map();
7
8
  constructor(cwd) {
8
9
  this.cwd = cwd;
9
10
  }
11
+ getReadHistory(filePath) {
12
+ return this.readHistory.get(this.resolvePath(filePath));
13
+ }
14
+ setReadHistory(filePath, entry) {
15
+ this.readHistory.set(this.resolvePath(filePath), entry);
16
+ }
17
+ /**
18
+ * Drops all read-dedup state. Call this whenever conversation history is
19
+ * compacted or pruned, because the dedup stub points the model back at
20
+ * earlier tool_result content that may no longer be resident.
21
+ */
22
+ invalidateReadHistory() {
23
+ this.readHistory.clear();
24
+ }
10
25
  async observe(filePath, source, content) {
11
26
  const absolute = this.resolvePath(filePath);
12
27
  const version = await this.computeVersion(absolute, content);
@@ -5,6 +5,7 @@ import { readdir, stat } from "node:fs/promises";
5
5
  import { relative, resolve } from "node:path";
6
6
  import picomatch from "picomatch";
7
7
  import { isSensitivePath } from "./sensitive-paths.js";
8
+ import { resolveToolPath } from "./path-utils.js";
8
9
  const MAX_RESULTS = 100;
9
10
  const DEFAULT_IGNORES = new Set([
10
11
  ".git",
@@ -31,7 +32,7 @@ export function createGlobTool(cwd) {
31
32
  required: ["pattern"],
32
33
  },
33
34
  async execute(args, ctx) {
34
- const root = resolve(cwd, typeof args.path === "string" && args.path.trim() ? args.path : ".");
35
+ const root = resolveToolPath(cwd, typeof args.path === "string" && args.path.trim() ? args.path : ".");
35
36
  const pattern = String(args.pattern || "").trim();
36
37
  if (!pattern) {
37
38
  return { content: "Error: glob pattern is required", isError: true, status: "command_error" };
@@ -2,9 +2,9 @@
2
2
  * Grep tool - search file contents using ripgrep.
3
3
  */
4
4
  import { execFile } from "node:child_process";
5
- import { resolve } from "node:path";
6
5
  import { isSensitivePath } from "./sensitive-paths.js";
7
6
  import { analyzeToolIntent } from "../agent/tool-intent.js";
7
+ import { resolveToolPath } from "./path-utils.js";
8
8
  const MAX_MATCHES = 100;
9
9
  export function createGrepTool(cwd) {
10
10
  return {
@@ -22,7 +22,7 @@ export function createGrepTool(cwd) {
22
22
  required: ["pattern"],
23
23
  },
24
24
  async execute(args) {
25
- const searchPath = args.path ? resolve(cwd, args.path) : cwd;
25
+ const searchPath = args.path ? resolveToolPath(cwd, args.path) : cwd;
26
26
  const pattern = String(args.pattern);
27
27
  const intent = analyzeToolIntent({
28
28
  name: "grep",
package/dist/tools/lsp.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { access } from "node:fs/promises";
2
2
  import { constants } from "node:fs";
3
- import { resolve } from "node:path";
4
3
  import { gateToolAction } from "../approval/tool-helper.js";
5
4
  import { getLspService } from "../lsp/index.js";
5
+ import { resolveToolPath } from "./path-utils.js";
6
6
  const OPERATIONS = [
7
7
  "goToDefinition",
8
8
  "findReferences",
@@ -37,7 +37,7 @@ export function createLspTool(cwd, lsp = getLspService(cwd), approval) {
37
37
  if (!OPERATIONS.includes(operation)) {
38
38
  return { content: `Error: Unsupported LSP operation: ${args.operation}`, isError: true };
39
39
  }
40
- const file = resolve(cwd, String(args.filePath));
40
+ const file = resolveToolPath(cwd, args.filePath);
41
41
  try {
42
42
  await access(file, constants.R_OK);
43
43
  }
@@ -0,0 +1,2 @@
1
+ export declare function expandHomePath(value: unknown): string;
2
+ export declare function resolveToolPath(cwd: string, value: unknown, fallback?: string): string;
@@ -0,0 +1,16 @@
1
+ import { homedir } from "node:os";
2
+ import { join, resolve } from "node:path";
3
+ export function expandHomePath(value) {
4
+ const text = String(value ?? "");
5
+ if (text === "~")
6
+ return homedir();
7
+ if (text.startsWith("~/") || text.startsWith("~\\")) {
8
+ return join(homedir(), text.slice(2));
9
+ }
10
+ return text;
11
+ }
12
+ export function resolveToolPath(cwd, value, fallback = ".") {
13
+ const text = String(value ?? "");
14
+ const path = text === "" ? fallback : text;
15
+ return resolve(cwd, expandHomePath(path));
16
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Read tool - read file contents with truncation.
2
+ * Read tool - read file contents with truncation, dedup, and auto-pagination.
3
3
  */
4
4
  import type { ApprovalController } from "../approval/types.js";
5
5
  import type { ToolRegistryEntry } from "../types.js";
@@ -1,18 +1,29 @@
1
1
  /**
2
- * Read tool - read file contents with truncation.
2
+ * Read tool - read file contents with truncation, dedup, and auto-pagination.
3
3
  */
4
4
  import { constants } from "node:fs";
5
- import { access, readFile } from "node:fs/promises";
6
- import { resolve } from "node:path";
5
+ import { access, readFile, readdir, stat } from "node:fs/promises";
6
+ import { basename, dirname, extname, join, relative } from "node:path";
7
7
  import { isSensitivePath } from "./sensitive-paths.js";
8
- const MAX_LINES = 250;
9
- const MAX_BYTES = 100 * 1024;
8
+ import { resolveToolPath } from "./path-utils.js";
9
+ const MAX_LINES = 2500;
10
+ const MAX_BYTES = 256 * 1024;
11
+ const FILE_UNCHANGED_STUB = "File unchanged since last read. The earlier read tool_result in this conversation is still current — refer to that instead of re-reading. If you need a different range, call read again with explicit offset/limit; if the file has actually changed, edit or write will refresh this cache automatically.";
12
+ const END_OF_FILE_STUB = (totalLines) => `End of file reached. All ${totalLines} lines of this file have already been returned by previous read tool_results in this conversation. Refer to those results, or pass an explicit offset to re-read a specific range.`;
10
13
  export function createReadTool(cwd, approval, lsp, fileState) {
14
+ const localHistory = new Map();
15
+ const getHistory = (path) => fileState?.getReadHistory(path) ?? localHistory.get(path);
16
+ const setHistory = (path, entry) => {
17
+ if (fileState)
18
+ fileState.setReadHistory(path, entry);
19
+ else
20
+ localHistory.set(path, entry);
21
+ };
11
22
  return {
12
23
  name: "read",
13
24
  readOnly: true,
14
25
  effect: "read",
15
- description: `Read the contents of a file. Output is truncated to ${MAX_LINES} lines or ${MAX_BYTES / 1024}KB (whichever is hit first). Use offset/limit for large files.`,
26
+ description: `Read the contents of a file. Output is truncated to ${MAX_LINES} lines or ${MAX_BYTES / 1024}KB (whichever is hit first). For large files: either pass explicit offset/limit to target a range, or simply call read again — the tool auto-advances to the next page when the previous read was truncated and the file is unchanged.`,
16
27
  parameters: {
17
28
  type: "object",
18
29
  properties: {
@@ -23,7 +34,7 @@ export function createReadTool(cwd, approval, lsp, fileState) {
23
34
  required: ["path"],
24
35
  },
25
36
  async execute(args) {
26
- const filePath = resolve(cwd, args.path);
37
+ const filePath = resolveToolPath(cwd, args.path);
27
38
  if (isSensitivePath(filePath)) {
28
39
  return {
29
40
  content: `Error: Access to sensitive credential storage is blocked: ${filePath}`,
@@ -48,14 +59,68 @@ export function createReadTool(cwd, approval, lsp, fileState) {
48
59
  try {
49
60
  await access(filePath, constants.R_OK);
50
61
  }
62
+ catch (error) {
63
+ return {
64
+ content: await readFileNotFoundMessage(filePath, cwd, error),
65
+ isError: true,
66
+ };
67
+ }
68
+ const argOffset = typeof args.offset === "number" ? args.offset : undefined;
69
+ const argLimit = typeof args.limit === "number" ? args.limit : undefined;
70
+ let currentMtimeMs;
71
+ try {
72
+ currentMtimeMs = (await stat(filePath)).mtimeMs;
73
+ }
51
74
  catch {
52
- return { content: `Error: Cannot read file: ${filePath}`, isError: true };
75
+ currentMtimeMs = undefined;
53
76
  }
54
- let content = await readFile(filePath, "utf-8");
77
+ const prior = getHistory(filePath);
78
+ const sameArgs = prior !== undefined
79
+ && prior.argOffset === argOffset
80
+ && prior.argLimit === argLimit;
81
+ const mtimeUnchanged = prior !== undefined
82
+ && currentMtimeMs !== undefined
83
+ && Math.floor(prior.mtimeMs) === Math.floor(currentMtimeMs);
84
+ let effectiveOffset = argOffset !== undefined ? Math.max(0, argOffset - 1) : 0;
85
+ let autoAdvanceNote;
86
+ if (prior && sameArgs && mtimeUnchanged) {
87
+ if (prior.truncated && argOffset === undefined) {
88
+ const nextStart = prior.effectiveOffset + prior.returnedLines;
89
+ if (nextStart >= prior.totalLines) {
90
+ return {
91
+ content: END_OF_FILE_STUB(prior.totalLines),
92
+ status: "success",
93
+ metadata: { kind: "read", path: filePath, dedup: "end_of_file" },
94
+ };
95
+ }
96
+ effectiveOffset = nextStart;
97
+ autoAdvanceNote =
98
+ `[Auto-advanced from previous truncated read of ${filePath}. ` +
99
+ `Showing lines ${effectiveOffset + 1}+ (file has ${prior.totalLines} lines). ` +
100
+ `Pass an explicit offset/limit to override this auto-paging.]`;
101
+ }
102
+ else if (argOffset === undefined
103
+ && prior.effectiveOffset > 0
104
+ && !prior.truncated) {
105
+ return {
106
+ content: END_OF_FILE_STUB(prior.totalLines),
107
+ status: "success",
108
+ metadata: { kind: "read", path: filePath, dedup: "end_of_file" },
109
+ };
110
+ }
111
+ else {
112
+ return {
113
+ content: FILE_UNCHANGED_STUB,
114
+ status: "success",
115
+ metadata: { kind: "read", path: filePath, dedup: "unchanged" },
116
+ };
117
+ }
118
+ }
119
+ const content = await readFile(filePath, "utf-8");
55
120
  const lines = content.split("\n");
56
- const offset = typeof args.offset === "number" ? Math.max(0, args.offset - 1) : 0;
57
- const limit = typeof args.limit === "number" ? args.limit : lines.length;
58
- let sliced = lines.slice(offset, offset + limit);
121
+ const totalLines = lines.length;
122
+ const effectiveLimit = argLimit !== undefined ? argLimit : totalLines;
123
+ let sliced = lines.slice(effectiveOffset, effectiveOffset + effectiveLimit);
59
124
  let truncated = false;
60
125
  if (sliced.length > MAX_LINES) {
61
126
  sliced = sliced.slice(0, MAX_LINES);
@@ -67,10 +132,28 @@ export function createReadTool(cwd, approval, lsp, fileState) {
67
132
  result = Buffer.from(result, "utf-8").subarray(0, MAX_BYTES).toString("utf-8");
68
133
  truncated = true;
69
134
  }
135
+ if (autoAdvanceNote) {
136
+ result = `${autoAdvanceNote}\n${result}`;
137
+ }
70
138
  if (truncated) {
71
- result += `\n[Output truncated: exceeded ${MAX_LINES} lines or ${MAX_BYTES / 1024}KB limit]`;
139
+ const lastLine = effectiveOffset + sliced.length;
140
+ result += `\n[Output truncated at line ${lastLine} of ${totalLines}. Call read again on the same path to auto-advance to the next page, or pass explicit offset/limit.]`;
141
+ }
142
+ if (currentMtimeMs !== undefined) {
143
+ setHistory(filePath, {
144
+ argOffset,
145
+ argLimit,
146
+ effectiveOffset,
147
+ effectiveLimit,
148
+ returnedLines: sliced.length,
149
+ totalLines,
150
+ mtimeMs: currentMtimeMs,
151
+ truncated,
152
+ });
72
153
  }
73
- const isFullRead = offset === 0 && !truncated && offset + limit >= lines.length;
154
+ const isFullRead = effectiveOffset === 0
155
+ && !truncated
156
+ && effectiveOffset + effectiveLimit >= totalLines;
74
157
  if (isFullRead) {
75
158
  await fileState?.observe(filePath, "read", content).catch(() => undefined);
76
159
  }
@@ -81,8 +164,118 @@ export function createReadTool(cwd, approval, lsp, fileState) {
81
164
  metadata: {
82
165
  kind: "read",
83
166
  path: filePath,
167
+ ...(autoAdvanceNote ? { autoAdvanced: true } : {}),
168
+ ...(truncated ? { truncated: true } : {}),
84
169
  },
85
170
  };
86
171
  },
87
172
  };
88
173
  }
174
+ async function readFileNotFoundMessage(filePath, cwd, error) {
175
+ const message = [`Error: Cannot read file: ${filePath}`];
176
+ const code = typeof error?.code === "string" ? error.code : undefined;
177
+ if (code && code !== "ENOENT" && code !== "ENOTDIR")
178
+ return message[0];
179
+ const suggestions = await suggestReadPaths(filePath, cwd);
180
+ if (suggestions.length === 1) {
181
+ message.push(`Did you mean ${suggestions[0]}?`);
182
+ }
183
+ else if (suggestions.length > 1) {
184
+ message.push("Did you mean one of these?");
185
+ message.push(...suggestions.map((suggestion) => `- ${suggestion}`));
186
+ }
187
+ return message.join("\n");
188
+ }
189
+ async function suggestReadPaths(filePath, cwd) {
190
+ const suggestions = new Set();
191
+ const underCwd = await suggestPathUnderCwd(filePath, cwd);
192
+ if (underCwd)
193
+ suggestions.add(underCwd);
194
+ for (const suggestion of await suggestSimilarFiles(filePath)) {
195
+ suggestions.add(suggestion);
196
+ }
197
+ return [...suggestions].slice(0, 5);
198
+ }
199
+ async function suggestPathUnderCwd(filePath, cwd) {
200
+ const parent = dirname(cwd);
201
+ const parentPrefix = parent.endsWith("/") ? parent : `${parent}/`;
202
+ if (!filePath.startsWith(parentPrefix) || filePath === cwd || filePath.startsWith(`${cwd}/`)) {
203
+ return undefined;
204
+ }
205
+ const candidate = join(cwd, relative(parent, filePath));
206
+ try {
207
+ const stats = await stat(candidate);
208
+ return stats.isFile() ? candidate : undefined;
209
+ }
210
+ catch {
211
+ return undefined;
212
+ }
213
+ }
214
+ async function suggestSimilarFiles(filePath) {
215
+ const dir = dirname(filePath);
216
+ const target = basename(filePath);
217
+ let entries;
218
+ try {
219
+ entries = await readdir(dir, { withFileTypes: true });
220
+ }
221
+ catch {
222
+ return [];
223
+ }
224
+ return entries
225
+ .filter((entry) => entry.isFile() || entry.isSymbolicLink())
226
+ .map((entry) => {
227
+ const score = similarFileScore(target, entry.name);
228
+ return score === undefined ? undefined : { path: join(dir, entry.name), score };
229
+ })
230
+ .filter((entry) => entry !== undefined)
231
+ .sort((a, b) => a.score - b.score || a.path.length - b.path.length || a.path.localeCompare(b.path))
232
+ .map((entry) => entry.path)
233
+ .slice(0, 5);
234
+ }
235
+ function similarFileScore(target, candidate) {
236
+ if (candidate === target)
237
+ return undefined;
238
+ const targetExt = extname(target).toLowerCase();
239
+ const candidateExt = extname(candidate).toLowerCase();
240
+ const targetStem = basename(target, targetExt).toLowerCase();
241
+ const candidateStem = basename(candidate, candidateExt).toLowerCase();
242
+ if (!targetStem || !candidateStem)
243
+ return undefined;
244
+ if (candidateExt === targetExt &&
245
+ (candidateStem.startsWith(`${targetStem}_`) || candidateStem.startsWith(`${targetStem}-`))) {
246
+ return 0;
247
+ }
248
+ if (candidateExt === targetExt && (candidateStem.startsWith(targetStem) || targetStem.startsWith(candidateStem))) {
249
+ return 5;
250
+ }
251
+ if (candidateStem === targetStem) {
252
+ return 10;
253
+ }
254
+ if (candidateStem.includes(targetStem) || targetStem.includes(candidateStem)) {
255
+ return candidateExt === targetExt ? 15 : 20;
256
+ }
257
+ const distance = levenshteinDistance(targetStem, candidateStem, 3);
258
+ if (distance <= 2) {
259
+ return (candidateExt === targetExt ? 30 : 35) + distance;
260
+ }
261
+ return undefined;
262
+ }
263
+ function levenshteinDistance(a, b, maxDistance) {
264
+ if (Math.abs(a.length - b.length) > maxDistance)
265
+ return maxDistance + 1;
266
+ let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
267
+ for (let i = 1; i <= a.length; i++) {
268
+ const current = [i];
269
+ let rowMin = current[0];
270
+ for (let j = 1; j <= b.length; j++) {
271
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
272
+ const value = Math.min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + cost);
273
+ current[j] = value;
274
+ rowMin = Math.min(rowMin, value);
275
+ }
276
+ if (rowMin > maxDistance)
277
+ return maxDistance + 1;
278
+ previous = current;
279
+ }
280
+ return previous[b.length];
281
+ }
@@ -2,12 +2,13 @@
2
2
  * Write tool - create files or safely replace full file contents.
3
3
  */
4
4
  import { mkdir, readFile, writeFile } from "node:fs/promises";
5
- import { dirname, resolve } from "node:path";
5
+ import { dirname } from "node:path";
6
6
  import { createTwoFilesPatch } from "diff";
7
7
  import { gateToolAction } from "../approval/tool-helper.js";
8
8
  import { formatDiagnosticBlocks } from "../lsp/index.js";
9
9
  import { isWithinWorkspace } from "./file-state.js";
10
10
  import { withFileMutationQueue } from "./file-mutation-queue.js";
11
+ import { resolveToolPath } from "./path-utils.js";
11
12
  export function createWriteTool(cwd, options = {}, approval, lsp, fileState) {
12
13
  return {
13
14
  name: "write",
@@ -27,7 +28,7 @@ export function createWriteTool(cwd, options = {}, approval, lsp, fileState) {
27
28
  required: ["path", "content"],
28
29
  },
29
30
  async execute(args) {
30
- const filePath = resolve(cwd, args.path);
31
+ const filePath = resolveToolPath(cwd, args.path);
31
32
  const overwrite = args.overwrite === true;
32
33
  if (!isWithinWorkspace(cwd, filePath)) {
33
34
  return {
@@ -0,0 +1,15 @@
1
+ export type EscapeConfirmationDecision = {
2
+ action: "arm";
3
+ expiresAt: number;
4
+ } | {
5
+ action: "confirm";
6
+ };
7
+ export declare class EscapeConfirmationGate {
8
+ private readonly windowMs;
9
+ private armedRunId;
10
+ private deadline;
11
+ constructor(windowMs: number);
12
+ press(runId: number, now?: number): EscapeConfirmationDecision;
13
+ isArmed(runId: number, now?: number): boolean;
14
+ clear(): void;
15
+ }
@@ -0,0 +1,30 @@
1
+ export class EscapeConfirmationGate {
2
+ windowMs;
3
+ armedRunId;
4
+ deadline = 0;
5
+ constructor(windowMs) {
6
+ this.windowMs = windowMs;
7
+ }
8
+ press(runId, now = Date.now()) {
9
+ if (this.armedRunId === runId && now <= this.deadline) {
10
+ this.clear();
11
+ return { action: "confirm" };
12
+ }
13
+ this.armedRunId = runId;
14
+ this.deadline = now + this.windowMs;
15
+ return { action: "arm", expiresAt: this.deadline };
16
+ }
17
+ isArmed(runId, now = Date.now()) {
18
+ if (this.armedRunId !== runId)
19
+ return false;
20
+ if (now > this.deadline) {
21
+ this.clear();
22
+ return false;
23
+ }
24
+ return true;
25
+ }
26
+ clear() {
27
+ this.armedRunId = undefined;
28
+ this.deadline = 0;
29
+ }
30
+ }