@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.
- package/dist/agent/categories.d.ts +34 -0
- package/dist/agent/categories.js +98 -0
- package/dist/agent/profiles.d.ts +4 -0
- package/dist/agent/profiles.js +2 -3
- package/dist/agent/subagent-control.d.ts +5 -0
- package/dist/agent/subagent-control.js +4 -0
- package/dist/agent/subagent-lifecycle-reminder.d.ts +3 -0
- package/dist/agent/subagent-lifecycle-reminder.js +102 -0
- package/dist/agent/subagent-route-format.d.ts +8 -0
- package/dist/agent/subagent-route-format.js +18 -0
- package/dist/agent/subtask-policy.d.ts +0 -1
- package/dist/agent/subtask-policy.js +0 -4
- package/dist/agent.d.ts +18 -0
- package/dist/agent.js +188 -16
- package/dist/config.d.ts +23 -3
- package/dist/config.js +59 -6
- package/dist/context/budget.d.ts +3 -2
- package/dist/context/budget.js +29 -15
- package/dist/context/compact.d.ts +23 -0
- package/dist/context/compact.js +129 -0
- package/dist/context/llm-compactor.d.ts +19 -0
- package/dist/context/llm-compactor.js +200 -0
- package/dist/context/projector.js +28 -12
- package/dist/context/token-estimator.d.ts +14 -0
- package/dist/context/token-estimator.js +106 -0
- package/dist/context/tool-output-truncate.d.ts +8 -0
- package/dist/context/tool-output-truncate.js +59 -0
- package/dist/context/usage.d.ts +34 -0
- package/dist/context/usage.js +213 -0
- package/dist/diff-stats.d.ts +5 -0
- package/dist/diff-stats.js +21 -0
- package/dist/main.js +68 -7
- package/dist/mcp/transports.d.ts +1 -0
- package/dist/mcp/transports.js +8 -0
- package/dist/model-catalog.d.ts +9 -0
- package/dist/model-catalog.js +17 -1
- package/dist/orchestrator/default-hooks.js +24 -18
- package/dist/prompt/compose.js +2 -1
- package/dist/prompt/provider-prompts/kimi.js +3 -1
- package/dist/provider-openai-codex.d.ts +13 -2
- package/dist/provider-openai-codex.js +81 -32
- package/dist/provider-registry.js +22 -6
- package/dist/provider-transform.d.ts +3 -1
- package/dist/provider-transform.js +15 -0
- package/dist/provider.d.ts +4 -1
- package/dist/provider.js +89 -4
- package/dist/reasoning-debug.d.ts +7 -0
- package/dist/reasoning-debug.js +30 -0
- package/dist/session-log.js +13 -2
- package/dist/session-types.d.ts +1 -1
- package/dist/slash-commands/commands.js +60 -2
- package/dist/slash-commands/types.d.ts +7 -0
- package/dist/tools/agent-lifecycle.js +22 -4
- package/dist/tools/edit.js +7 -2
- package/dist/tools/file-state.d.ts +19 -0
- package/dist/tools/file-state.js +15 -0
- package/dist/tools/glob.js +2 -1
- package/dist/tools/grep.js +2 -2
- package/dist/tools/lsp.js +2 -2
- package/dist/tools/path-utils.d.ts +2 -0
- package/dist/tools/path-utils.js +16 -0
- package/dist/tools/read.d.ts +1 -1
- package/dist/tools/read.js +207 -14
- package/dist/tools/write.js +3 -2
- package/dist/tui/escape-confirmation.d.ts +15 -0
- package/dist/tui/escape-confirmation.js +30 -0
- package/dist/tui/run.js +93 -23
- package/dist/tui-ink/app.d.ts +52 -0
- package/dist/tui-ink/app.js +1129 -0
- package/dist/tui-ink/approval/approval-dialog.d.ts +13 -0
- package/dist/tui-ink/approval/approval-dialog.js +132 -0
- package/dist/tui-ink/approval/diff-view.d.ts +7 -0
- package/dist/tui-ink/approval/diff-view.js +44 -0
- package/dist/tui-ink/approval/select.d.ts +35 -0
- package/dist/tui-ink/approval/select.js +88 -0
- package/dist/tui-ink/code-highlight.d.ts +8 -0
- package/dist/tui-ink/code-highlight.js +122 -0
- package/dist/tui-ink/detect-theme.d.ts +19 -0
- package/dist/tui-ink/detect-theme.js +123 -0
- package/dist/tui-ink/display-history.d.ts +38 -0
- package/dist/tui-ink/display-history.js +130 -0
- package/dist/tui-ink/edit-diff.d.ts +11 -0
- package/dist/tui-ink/edit-diff.js +52 -0
- package/dist/tui-ink/file-mentions.d.ts +29 -0
- package/dist/tui-ink/file-mentions.js +174 -0
- package/dist/tui-ink/footer.d.ts +19 -0
- package/dist/tui-ink/footer.js +45 -0
- package/dist/tui-ink/image-paste.d.ts +54 -0
- package/dist/tui-ink/image-paste.js +288 -0
- package/dist/tui-ink/input-box.d.ts +41 -0
- package/dist/tui-ink/input-box.js +694 -0
- package/dist/tui-ink/input-history.d.ts +16 -0
- package/dist/tui-ink/input-history.js +81 -0
- package/dist/tui-ink/markdown.d.ts +38 -0
- package/dist/tui-ink/markdown.js +394 -0
- package/dist/tui-ink/message-list.d.ts +33 -0
- package/dist/tui-ink/message-list.js +667 -0
- package/dist/tui-ink/model-picker.d.ts +43 -0
- package/dist/tui-ink/model-picker.js +331 -0
- package/dist/tui-ink/plan-confirm.d.ts +7 -0
- package/dist/tui-ink/plan-confirm.js +105 -0
- package/dist/tui-ink/question-dialog.d.ts +8 -0
- package/dist/tui-ink/question-dialog.js +99 -0
- package/dist/tui-ink/recent-activity.d.ts +8 -0
- package/dist/tui-ink/recent-activity.js +71 -0
- package/dist/tui-ink/run.d.ts +37 -0
- package/dist/tui-ink/run.js +53 -0
- package/dist/tui-ink/theme.d.ts +66 -0
- package/dist/tui-ink/theme.js +115 -0
- package/dist/tui-ink/todos.d.ts +7 -0
- package/dist/tui-ink/todos.js +46 -0
- package/dist/tui-ink/trace-groups.d.ts +27 -0
- package/dist/tui-ink/trace-groups.js +389 -0
- package/dist/tui-ink/use-terminal-size.d.ts +4 -0
- package/dist/tui-ink/use-terminal-size.js +21 -0
- package/dist/tui-ink/welcome.d.ts +18 -0
- package/dist/tui-ink/welcome.js +138 -0
- package/dist/types.d.ts +10 -0
- 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
|
|
66
|
+
`Spawned ${snapshot.nickname} (${formatSnapshotRole(snapshot)})`,
|
|
64
67
|
`agent_id: ${snapshot.agentId}`,
|
|
65
68
|
`status: ${snapshot.status}`,
|
|
66
|
-
|
|
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
|
|
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,
|
package/dist/tools/edit.js
CHANGED
|
@@ -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 =
|
|
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;
|
package/dist/tools/file-state.js
CHANGED
|
@@ -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);
|
package/dist/tools/glob.js
CHANGED
|
@@ -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 =
|
|
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" };
|
package/dist/tools/grep.js
CHANGED
|
@@ -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 ?
|
|
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 =
|
|
40
|
+
const file = resolveToolPath(cwd, args.filePath);
|
|
41
41
|
try {
|
|
42
42
|
await access(file, constants.R_OK);
|
|
43
43
|
}
|
|
@@ -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
|
+
}
|
package/dist/tools/read.d.ts
CHANGED
package/dist/tools/read.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
9
|
-
const
|
|
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).
|
|
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 =
|
|
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
|
-
|
|
75
|
+
currentMtimeMs = undefined;
|
|
53
76
|
}
|
|
54
|
-
|
|
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
|
|
57
|
-
const
|
|
58
|
-
let sliced = lines.slice(
|
|
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
|
-
|
|
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 =
|
|
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
|
+
}
|
package/dist/tools/write.js
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
+
}
|