@bubblebrain-ai/bubble 0.0.58 → 0.0.60
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/README.md +56 -6
- package/dist/agent/child-runner.js +5 -1
- package/dist/agent/internal-reminder-sanitizer.d.ts +1 -1
- package/dist/agent/internal-reminder-sanitizer.js +7 -1
- package/dist/agent/profiles.js +4 -4
- package/dist/agent/routing-catalog.d.ts +19 -1
- package/dist/agent/routing-catalog.js +32 -13
- package/dist/agent/subagent/runtime.d.ts +28 -0
- package/dist/agent/subagent/runtime.js +34 -30
- package/dist/agent/subagent-control.d.ts +7 -0
- package/dist/agent/subagent-control.js +2 -0
- package/dist/agent/subagent-lifecycle-reminder.js +2 -1
- package/dist/agent/subagent-wait-guidance.d.ts +2 -0
- package/dist/agent/subagent-wait-guidance.js +8 -0
- package/dist/agent/subtask-policy.js +4 -4
- package/dist/agent.d.ts +20 -11
- package/dist/agent.js +200 -117
- package/dist/anthropic-thinking.d.ts +4 -0
- package/dist/anthropic-thinking.js +11 -0
- package/dist/approval/controller.d.ts +20 -6
- package/dist/approval/controller.js +67 -8
- package/dist/approval/protected-paths.d.ts +1 -0
- package/dist/approval/protected-paths.js +35 -0
- package/dist/approval/session-cache.d.ts +12 -26
- package/dist/approval/session-cache.js +20 -50
- package/dist/approval/types.d.ts +27 -1
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +25 -2
- package/dist/config.d.ts +3 -0
- package/dist/config.js +7 -0
- package/dist/context/checkpoint.d.ts +18 -0
- package/dist/context/checkpoint.js +117 -0
- package/dist/context/compact-llm.js +7 -0
- package/dist/context/compact.d.ts +8 -0
- package/dist/context/compact.js +40 -7
- package/dist/context/llm-compactor.d.ts +36 -2
- package/dist/context/llm-compactor.js +227 -44
- package/dist/context/session-write-lock.d.ts +20 -0
- package/dist/context/session-write-lock.js +162 -0
- package/dist/context/tool-images.d.ts +3 -0
- package/dist/context/tool-images.js +35 -0
- package/dist/context/usage.js +22 -5
- package/dist/feishu/agent-host/run-driver.js +56 -9
- package/dist/lsp/service.d.ts +13 -1
- package/dist/lsp/service.js +163 -43
- package/dist/main.js +82 -16
- package/dist/mcp/config.d.ts +4 -0
- package/dist/mcp/config.js +17 -0
- package/dist/mcp/manager.d.ts +13 -1
- package/dist/mcp/manager.js +32 -1
- package/dist/mcp/transports.js +11 -0
- package/dist/memory/db.d.ts +1 -0
- package/dist/memory/db.js +20 -4
- package/dist/memory/phase1.js +122 -16
- package/dist/memory/prompts.js +3 -0
- package/dist/model-catalog.js +19 -3
- package/dist/model-config.d.ts +1 -0
- package/dist/model-config.js +5 -5
- package/dist/model-pricing.js +26 -9
- package/dist/network/provider-transport-log.d.ts +12 -0
- package/dist/network/provider-transport-log.js +32 -0
- package/dist/network/provider-transport.js +6 -1
- package/dist/network/retry.d.ts +1 -1
- package/dist/network/retry.js +1 -1
- package/dist/oauth/grok.d.ts +1 -0
- package/dist/oauth/grok.js +20 -16
- package/dist/oauth/openai-codex.d.ts +1 -0
- package/dist/oauth/openai-codex.js +29 -25
- package/dist/oauth/refresh-control.d.ts +12 -0
- package/dist/oauth/refresh-control.js +42 -0
- package/dist/oauth/storage.d.ts +55 -1
- package/dist/oauth/storage.js +281 -16
- package/dist/permissions/rule.d.ts +6 -3
- package/dist/permissions/rule.js +71 -12
- package/dist/permissions/settings.d.ts +28 -1
- package/dist/permissions/settings.js +93 -3
- package/dist/permissions/shell-command.d.ts +36 -0
- package/dist/permissions/shell-command.js +226 -0
- package/dist/permissions/trust-prompt.d.ts +10 -0
- package/dist/permissions/trust-prompt.js +34 -0
- package/dist/permissions/trust.d.ts +26 -0
- package/dist/permissions/trust.js +113 -0
- package/dist/prompt/compose.d.ts +3 -0
- package/dist/prompt/compose.js +24 -7
- package/dist/prompt/environment.js +2 -2
- package/dist/prompt/reminders.js +1 -1
- package/dist/prompt/runtime.js +1 -1
- package/dist/provider-anthropic.d.ts +6 -2
- package/dist/provider-anthropic.js +98 -18
- package/dist/provider-error-record.d.ts +9 -0
- package/dist/provider-error-record.js +42 -14
- package/dist/provider-grok.js +5 -1
- package/dist/provider-openai-codex.d.ts +8 -0
- package/dist/provider-openai-codex.js +107 -24
- package/dist/provider-registry.d.ts +45 -2
- package/dist/provider-registry.js +300 -88
- package/dist/provider-transform.js +8 -0
- package/dist/provider-zen-errors.d.ts +9 -0
- package/dist/provider-zen-errors.js +36 -0
- package/dist/provider.js +36 -5
- package/dist/sdk/index.d.ts +69 -8
- package/dist/sdk/index.js +252 -39
- package/dist/sdk/replay-event-log.d.ts +7 -0
- package/dist/sdk/replay-event-log.js +66 -16
- package/dist/session-context-fence.d.ts +16 -0
- package/dist/session-context-fence.js +26 -0
- package/dist/session-log.d.ts +11 -2
- package/dist/session-log.js +187 -39
- package/dist/session-types.d.ts +5 -1
- package/dist/session.d.ts +39 -7
- package/dist/session.js +341 -93
- package/dist/skills/format.d.ts +31 -1
- package/dist/skills/format.js +45 -24
- package/dist/slash-commands/commands.d.ts +3 -0
- package/dist/slash-commands/commands.js +29 -14
- package/dist/system-prompt.d.ts +3 -1
- package/dist/tasks/manager.d.ts +7 -0
- package/dist/tasks/manager.js +97 -18
- package/dist/tools/agent-lifecycle.js +11 -7
- package/dist/tools/child-tools.d.ts +1 -1
- package/dist/tools/child-tools.js +4 -4
- package/dist/tools/edit-apply.d.ts +0 -3
- package/dist/tools/edit-apply.js +45 -249
- package/dist/tools/edit.js +3 -3
- package/dist/tools/exit-plan-mode.d.ts +5 -0
- package/dist/tools/exit-plan-mode.js +3 -2
- package/dist/tools/file-state.d.ts +4 -0
- package/dist/tools/file-state.js +12 -3
- package/dist/tools/grep.js +20 -1
- package/dist/tools/index.d.ts +1 -1
- package/dist/tools/index.js +3 -3
- package/dist/tools/ls.d.ts +3 -0
- package/dist/tools/ls.js +85 -0
- package/dist/tools/path-utils.d.ts +6 -0
- package/dist/tools/path-utils.js +38 -1
- package/dist/tools/sensitive-paths.d.ts +8 -0
- package/dist/tools/sensitive-paths.js +85 -3
- package/dist/tools/skill.d.ts +2 -0
- package/dist/tools/skill.js +34 -2
- package/dist/tools/task-tools.js +11 -7
- package/dist/tui/app.js +22 -12
- package/dist/tui/components/approval-dialog.d.ts +5 -5
- package/dist/tui/components/approval-dialog.js +16 -51
- package/dist/tui/components/skills-panel.d.ts +4 -0
- package/dist/tui/components/skills-panel.js +15 -1
- package/dist/tui/components/streaming-message.js +7 -2
- package/dist/tui/components/tasks-pane.d.ts +38 -0
- package/dist/tui/components/tasks-pane.js +182 -32
- package/dist/tui/components/transcript.d.ts +11 -4
- package/dist/tui/components/transcript.js +158 -71
- package/dist/tui/composer-autocomplete.d.ts +17 -2
- package/dist/tui/composer-autocomplete.js +79 -2
- package/dist/tui/controller/controller.d.ts +9 -4
- package/dist/tui/controller/controller.js +87 -95
- package/dist/tui/controller/goal-runtime-controller.js +1 -1
- package/dist/tui/controller/session-transition.js +1 -1
- package/dist/tui/model/subagent-view.d.ts +11 -19
- package/dist/tui/model/subagent-view.js +56 -85
- package/dist/tui/model/task-lifecycle.d.ts +57 -0
- package/dist/tui/model/task-lifecycle.js +124 -0
- package/dist/tui/model/trace-groups.js +10 -9
- package/dist/tui/trace-groups.js +10 -9
- package/dist/types.d.ts +20 -0
- package/dist/variant/variant-resolver.js +2 -0
- package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui-alt-screen.d.ts +1 -0
- package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui-alt-screen.d.ts.map +1 -1
- package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui-alt-screen.js +6 -0
- package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui-alt-screen.js.map +1 -1
- package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui.d.ts +2 -0
- package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui.d.ts.map +1 -1
- package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui.js +8 -4
- package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui.js.map +1 -1
- package/package.json +11 -3
- package/dist/prompt/skills.d.ts +0 -2
- package/dist/prompt/skills.js +0 -4
- package/dist/tools/glob.d.ts +0 -5
- package/dist/tools/glob.js +0 -199
package/dist/tools/edit-apply.js
CHANGED
|
@@ -21,38 +21,36 @@ function stripBom(content) {
|
|
|
21
21
|
function normalizeToLF(text) {
|
|
22
22
|
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
23
23
|
}
|
|
24
|
-
function
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
.
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
function restoreLineEndings(text, lineEnding) {
|
|
55
|
-
return lineEnding === "\r\n" ? text.replace(/\n/g, "\r\n") : text;
|
|
24
|
+
function buildOriginalOffsets(text) {
|
|
25
|
+
const offsets = [];
|
|
26
|
+
for (let i = 0; i < text.length; i++) {
|
|
27
|
+
offsets.push(i);
|
|
28
|
+
if (text[i] === "\r" && text[i + 1] === "\n")
|
|
29
|
+
i++;
|
|
30
|
+
}
|
|
31
|
+
offsets.push(text.length);
|
|
32
|
+
return offsets;
|
|
33
|
+
}
|
|
34
|
+
function lineEndingAt(text, offset, fallback) {
|
|
35
|
+
const after = text.slice(offset).match(/\r\n|\r|\n/);
|
|
36
|
+
if (after)
|
|
37
|
+
return after[0];
|
|
38
|
+
const before = text.slice(0, offset).match(/(\r\n|\r|\n)[^\r\n]*$/);
|
|
39
|
+
return before ? before[1] : fallback;
|
|
40
|
+
}
|
|
41
|
+
// Splices replacements into the original text so untouched lines keep their own
|
|
42
|
+
// terminators; inserted text uses the terminator of the line being edited.
|
|
43
|
+
function spliceOriginal(text, matches, edits, fallbackEnding) {
|
|
44
|
+
const offsets = buildOriginalOffsets(text);
|
|
45
|
+
let out = "";
|
|
46
|
+
let cursor = 0;
|
|
47
|
+
for (const match of [...matches].sort((a, b) => a.start - b.start)) {
|
|
48
|
+
const start = offsets[match.start];
|
|
49
|
+
const ending = lineEndingAt(text, start, fallbackEnding);
|
|
50
|
+
out += text.slice(cursor, start) + edits[match.editIndex].newText.replace(/\n/g, ending);
|
|
51
|
+
cursor = offsets[match.end];
|
|
52
|
+
}
|
|
53
|
+
return out + text.slice(cursor);
|
|
56
54
|
}
|
|
57
55
|
function normalizeLineForMatch(line) {
|
|
58
56
|
return line
|
|
@@ -63,9 +61,6 @@ function normalizeLineForMatch(line) {
|
|
|
63
61
|
.replace(/[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]/g, "-")
|
|
64
62
|
.replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " ");
|
|
65
63
|
}
|
|
66
|
-
function normalizeLeadingWhitespaceForMatch(line) {
|
|
67
|
-
return normalizeLineForMatch(line).replace(/^\s+/, " ");
|
|
68
|
-
}
|
|
69
64
|
function findAllOccurrences(content, needle) {
|
|
70
65
|
const indexes = [];
|
|
71
66
|
if (needle.length === 0)
|
|
@@ -104,130 +99,6 @@ function normalizedOldNonBlankLines(oldText) {
|
|
|
104
99
|
.map((line) => normalizeLineForMatch(line.text))
|
|
105
100
|
.filter((line) => line.trim().length > 0);
|
|
106
101
|
}
|
|
107
|
-
function singleNonBlankOldLine(oldText) {
|
|
108
|
-
const lines = normalizedOldNonBlankLines(oldText);
|
|
109
|
-
return lines.length === 1 ? lines[0] : undefined;
|
|
110
|
-
}
|
|
111
|
-
function findNormalizedLineMatches(content, oldText) {
|
|
112
|
-
const contentLines = splitLines(content);
|
|
113
|
-
const searchable = nonBlankLines(contentLines);
|
|
114
|
-
const oldLines = normalizedOldNonBlankLines(oldText);
|
|
115
|
-
if (oldLines.length === 0)
|
|
116
|
-
return [];
|
|
117
|
-
const matches = [];
|
|
118
|
-
for (let i = 0; i <= searchable.length - oldLines.length; i++) {
|
|
119
|
-
let matched = true;
|
|
120
|
-
for (let j = 0; j < oldLines.length; j++) {
|
|
121
|
-
if (searchable[i + j].normalized !== oldLines[j]) {
|
|
122
|
-
matched = false;
|
|
123
|
-
break;
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
if (matched) {
|
|
127
|
-
const first = contentLines[searchable[i].lineIndex];
|
|
128
|
-
const last = contentLines[searchable[i + oldLines.length - 1].lineIndex];
|
|
129
|
-
matches.push({ start: first.start, end: last.endNoNewline });
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
return matches;
|
|
133
|
-
}
|
|
134
|
-
function findSmartLineMatches(content, oldText) {
|
|
135
|
-
const contentLines = splitLines(content);
|
|
136
|
-
const oldLines = splitLines(oldText).map((line) => normalizeLeadingWhitespaceForMatch(line.text));
|
|
137
|
-
if (oldLines.length === 0 || oldLines.every((line) => line.length === 0))
|
|
138
|
-
return [];
|
|
139
|
-
const matches = [];
|
|
140
|
-
for (let i = 0; i <= contentLines.length - oldLines.length; i++) {
|
|
141
|
-
let matched = true;
|
|
142
|
-
for (let j = 0; j < oldLines.length; j++) {
|
|
143
|
-
const actual = normalizeLeadingWhitespaceForMatch(contentLines[i + j].text);
|
|
144
|
-
if (actual !== oldLines[j]) {
|
|
145
|
-
matched = false;
|
|
146
|
-
break;
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
if (matched) {
|
|
150
|
-
const first = contentLines[i];
|
|
151
|
-
const last = contentLines[i + oldLines.length - 1];
|
|
152
|
-
matches.push({ start: first.start, end: last.endNoNewline });
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
return matches;
|
|
156
|
-
}
|
|
157
|
-
function splitMarkdownTableCells(line) {
|
|
158
|
-
const normalized = normalizeLineForMatch(line).trim();
|
|
159
|
-
if (!normalized.startsWith("|") || !normalized.endsWith("|"))
|
|
160
|
-
return undefined;
|
|
161
|
-
const parts = [];
|
|
162
|
-
let current = "";
|
|
163
|
-
let escaped = false;
|
|
164
|
-
for (const char of normalized) {
|
|
165
|
-
if (escaped) {
|
|
166
|
-
current += char;
|
|
167
|
-
escaped = false;
|
|
168
|
-
continue;
|
|
169
|
-
}
|
|
170
|
-
if (char === "\\") {
|
|
171
|
-
current += char;
|
|
172
|
-
escaped = true;
|
|
173
|
-
continue;
|
|
174
|
-
}
|
|
175
|
-
if (char === "|") {
|
|
176
|
-
parts.push(current);
|
|
177
|
-
current = "";
|
|
178
|
-
continue;
|
|
179
|
-
}
|
|
180
|
-
current += char;
|
|
181
|
-
}
|
|
182
|
-
parts.push(current);
|
|
183
|
-
if (parts.length < 4 || parts[0] !== "" || parts[parts.length - 1] !== "")
|
|
184
|
-
return undefined;
|
|
185
|
-
const cells = parts.slice(1, -1).map((cell) => cell.trim());
|
|
186
|
-
return cells.length >= 2 ? cells : undefined;
|
|
187
|
-
}
|
|
188
|
-
function sameCells(a, b) {
|
|
189
|
-
return a.length === b.length && a.every((cell, index) => cell === b[index]);
|
|
190
|
-
}
|
|
191
|
-
function findMarkdownTableMatches(content, oldText) {
|
|
192
|
-
const oldLine = singleNonBlankOldLine(oldText);
|
|
193
|
-
if (!oldLine)
|
|
194
|
-
return [];
|
|
195
|
-
const oldCells = splitMarkdownTableCells(oldLine);
|
|
196
|
-
if (!oldCells)
|
|
197
|
-
return [];
|
|
198
|
-
const matches = [];
|
|
199
|
-
for (const line of splitLines(content)) {
|
|
200
|
-
const cells = splitMarkdownTableCells(line.text);
|
|
201
|
-
if (cells && sameCells(cells, oldCells)) {
|
|
202
|
-
matches.push({ start: line.start, end: line.endNoNewline });
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
return matches;
|
|
206
|
-
}
|
|
207
|
-
function collapseInlineWhitespace(text) {
|
|
208
|
-
return normalizeLineForMatch(text).trim().replace(/[ \t]+/g, " ");
|
|
209
|
-
}
|
|
210
|
-
function isDocumentLikePath(path) {
|
|
211
|
-
return !!path && /\.(?:md|mdx|markdown|txt|rst|adoc)$/i.test(path);
|
|
212
|
-
}
|
|
213
|
-
function findSingleLineWhitespaceMatches(content, oldText, options) {
|
|
214
|
-
if (!isDocumentLikePath(options?.path))
|
|
215
|
-
return [];
|
|
216
|
-
const oldLine = singleNonBlankOldLine(oldText);
|
|
217
|
-
if (!oldLine)
|
|
218
|
-
return [];
|
|
219
|
-
const normalizedOld = collapseInlineWhitespace(oldLine);
|
|
220
|
-
if (normalizedOld.length === 0)
|
|
221
|
-
return [];
|
|
222
|
-
const matches = [];
|
|
223
|
-
for (const line of splitLines(content)) {
|
|
224
|
-
const collapsed = collapseInlineWhitespace(line.text);
|
|
225
|
-
if (collapsed === normalizedOld && line.text !== oldLine) {
|
|
226
|
-
matches.push({ start: line.start, end: line.endNoNewline });
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
return matches;
|
|
230
|
-
}
|
|
231
102
|
function summarizeOldText(oldText) {
|
|
232
103
|
const firstLine = normalizeToLF(oldText).split("\n").find((line) => line.trim().length > 0) ?? oldText;
|
|
233
104
|
return firstLine.length > 80 ? `${firstLine.slice(0, 80)}...` : firstLine;
|
|
@@ -326,88 +197,18 @@ function matchEdit(content, edit, index, total, options) {
|
|
|
326
197
|
].join("\n"));
|
|
327
198
|
}
|
|
328
199
|
const oldText = normalizeToLF(edit.oldText);
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
if (exact.length === 1) {
|
|
333
|
-
return { editIndex: index, mode: candidate.mode, start: exact[0], end: exact[0] + candidate.text.length };
|
|
334
|
-
}
|
|
335
|
-
if (exact.length > 1) {
|
|
336
|
-
const recovery = [
|
|
337
|
-
"",
|
|
338
|
-
"Extend oldText with more surrounding context (the lines immediately before/after) until it uniquely identifies the intended span.",
|
|
339
|
-
].join("\n");
|
|
340
|
-
const duplicateReason = candidate.mode === "exact"
|
|
341
|
-
? `appears ${exact.length} times in file`
|
|
342
|
-
: `matched ${exact.length} times after ${candidate.mode} matching`;
|
|
343
|
-
throw new EditApplyError(total === 1
|
|
344
|
-
? `Error: oldText ${duplicateReason}. Must be unique: "${summarizeOldText(oldText)}"${recovery}`
|
|
345
|
-
: `Error: edits[${index}].oldText ${duplicateReason}. Must be unique: "${summarizeOldText(oldText)}"${recovery}`);
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
for (const candidate of candidates) {
|
|
349
|
-
const normalizedLineMatches = findNormalizedLineMatches(content, candidate.text);
|
|
350
|
-
if (normalizedLineMatches.length === 1) {
|
|
351
|
-
return {
|
|
352
|
-
editIndex: index,
|
|
353
|
-
mode: candidate.mode === "exact" ? "normalized-line" : candidate.mode,
|
|
354
|
-
start: normalizedLineMatches[0].start,
|
|
355
|
-
end: normalizedLineMatches[0].end,
|
|
356
|
-
};
|
|
357
|
-
}
|
|
358
|
-
if (normalizedLineMatches.length > 1) {
|
|
359
|
-
throw new EditApplyError(total === 1
|
|
360
|
-
? `Error: oldText matched ${normalizedLineMatches.length} normalized line blocks in file. Provide more surrounding context.`
|
|
361
|
-
: `Error: edits[${index}].oldText matched ${normalizedLineMatches.length} normalized line blocks in file. Provide more surrounding context.`);
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
for (const candidate of candidates) {
|
|
365
|
-
const markdownTableMatches = findMarkdownTableMatches(content, candidate.text);
|
|
366
|
-
if (markdownTableMatches.length === 1) {
|
|
367
|
-
return {
|
|
368
|
-
editIndex: index,
|
|
369
|
-
mode: "markdown-table",
|
|
370
|
-
start: markdownTableMatches[0].start,
|
|
371
|
-
end: markdownTableMatches[0].end,
|
|
372
|
-
};
|
|
373
|
-
}
|
|
374
|
-
if (markdownTableMatches.length > 1) {
|
|
375
|
-
throw new EditApplyError(total === 1
|
|
376
|
-
? `Error: oldText matched ${markdownTableMatches.length} markdown table rows in file. Provide more surrounding context.`
|
|
377
|
-
: `Error: edits[${index}].oldText matched ${markdownTableMatches.length} markdown table rows in file. Provide more surrounding context.`);
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
for (const candidate of candidates) {
|
|
381
|
-
const whitespaceMatches = findSingleLineWhitespaceMatches(content, candidate.text, options);
|
|
382
|
-
if (whitespaceMatches.length === 1) {
|
|
383
|
-
return {
|
|
384
|
-
editIndex: index,
|
|
385
|
-
mode: "single-line-whitespace",
|
|
386
|
-
start: whitespaceMatches[0].start,
|
|
387
|
-
end: whitespaceMatches[0].end,
|
|
388
|
-
};
|
|
389
|
-
}
|
|
390
|
-
if (whitespaceMatches.length > 1) {
|
|
391
|
-
throw new EditApplyError(total === 1
|
|
392
|
-
? `Error: oldText matched ${whitespaceMatches.length} whitespace-normalized lines in file. Provide more surrounding context.`
|
|
393
|
-
: `Error: edits[${index}].oldText matched ${whitespaceMatches.length} whitespace-normalized lines in file. Provide more surrounding context.`);
|
|
394
|
-
}
|
|
200
|
+
const occurrences = findAllOccurrences(content, oldText);
|
|
201
|
+
if (occurrences.length === 1) {
|
|
202
|
+
return { editIndex: index, start: occurrences[0], end: occurrences[0] + oldText.length };
|
|
395
203
|
}
|
|
396
|
-
|
|
397
|
-
const
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
};
|
|
405
|
-
}
|
|
406
|
-
if (smartLineMatches.length > 1) {
|
|
407
|
-
throw new EditApplyError(total === 1
|
|
408
|
-
? `Error: oldText matched ${smartLineMatches.length} indentation-normalized line blocks in file. Provide more surrounding context.`
|
|
409
|
-
: `Error: edits[${index}].oldText matched ${smartLineMatches.length} indentation-normalized line blocks in file. Provide more surrounding context.`);
|
|
410
|
-
}
|
|
204
|
+
if (occurrences.length > 1) {
|
|
205
|
+
const recovery = [
|
|
206
|
+
"",
|
|
207
|
+
"Extend oldText with more surrounding context (the lines immediately before/after) until it uniquely identifies the intended span.",
|
|
208
|
+
].join("\n");
|
|
209
|
+
throw new EditApplyError(total === 1
|
|
210
|
+
? `Error: oldText appears ${occurrences.length} times in file. Must be unique: "${summarizeOldText(oldText)}"${recovery}`
|
|
211
|
+
: `Error: edits[${index}].oldText appears ${occurrences.length} times in file. Must be unique: "${summarizeOldText(oldText)}"${recovery}`);
|
|
411
212
|
}
|
|
412
213
|
const hint = findBestLineHint(content, oldText);
|
|
413
214
|
const hintSuffix = hint ? `\n${formatBestLineHint(content, hint, options)}` : "";
|
|
@@ -439,9 +240,10 @@ export function applyEditsToContent(rawContent, edits, options) {
|
|
|
439
240
|
const { bom, text } = stripBom(rawContent);
|
|
440
241
|
const lineEnding = detectLineEnding(text);
|
|
441
242
|
const normalizedOriginal = normalizeToLF(text);
|
|
243
|
+
const dropCopiedBom = (value) => (bom ? stripBom(value).text : value);
|
|
442
244
|
const normalizedEdits = edits.map((edit) => ({
|
|
443
|
-
oldText: normalizeToLF(edit.oldText),
|
|
444
|
-
newText: normalizeToLF(edit.newText),
|
|
245
|
+
oldText: normalizeToLF(dropCopiedBom(edit.oldText)),
|
|
246
|
+
newText: normalizeToLF(dropCopiedBom(edit.newText)),
|
|
445
247
|
}));
|
|
446
248
|
const matches = normalizedEdits.map((edit, index) => matchEdit(normalizedOriginal, edit, index, normalizedEdits.length, options));
|
|
447
249
|
assertNoOverlaps(matches);
|
|
@@ -462,7 +264,7 @@ export function applyEditsToContent(rawContent, edits, options) {
|
|
|
462
264
|
].join("\n"));
|
|
463
265
|
}
|
|
464
266
|
return {
|
|
465
|
-
content: bom +
|
|
267
|
+
content: bom + spliceOriginal(text, matches, normalizedEdits, lineEnding),
|
|
466
268
|
normalizedOriginal,
|
|
467
269
|
normalizedNext,
|
|
468
270
|
bom,
|
|
@@ -470,9 +272,3 @@ export function applyEditsToContent(rawContent, edits, options) {
|
|
|
470
272
|
matches,
|
|
471
273
|
};
|
|
472
274
|
}
|
|
473
|
-
export function formatEditMatchNotes(matches) {
|
|
474
|
-
const normalizedCount = matches.filter((match) => match.mode !== "exact").length;
|
|
475
|
-
if (normalizedCount === 0)
|
|
476
|
-
return "";
|
|
477
|
-
return `\n\nNote: ${normalizedCount} edit${normalizedCount === 1 ? "" : "s"} applied using normalized line matching for whitespace/formatting differences.`;
|
|
478
|
-
}
|
package/dist/tools/edit.js
CHANGED
|
@@ -9,7 +9,7 @@ import { createTwoFilesPatch } from "diff";
|
|
|
9
9
|
import { gateToolAction } from "../approval/tool-helper.js";
|
|
10
10
|
import { countUnifiedDiffChanges } from "../diff-stats.js";
|
|
11
11
|
import { formatDiagnosticBlocks } from "../lsp/index.js";
|
|
12
|
-
import { applyEditsToContent, EditApplyError
|
|
12
|
+
import { applyEditsToContent, EditApplyError } from "./edit-apply.js";
|
|
13
13
|
import { withFileMutationQueue } from "./file-mutation-queue.js";
|
|
14
14
|
import { isWithinWorkspace } from "./file-state.js";
|
|
15
15
|
import { resolveToolPath } from "./path-utils.js";
|
|
@@ -65,7 +65,7 @@ export function createEditTool(cwd, approval, lsp, fileState, checkpoints) {
|
|
|
65
65
|
name: "edit",
|
|
66
66
|
effect: "write_direct",
|
|
67
67
|
requiresApproval: true,
|
|
68
|
-
description: "Edit a single file using targeted text replacements. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes overlap or one replacement is nested inside another, merge them into one edit. Do not include large unchanged regions just to connect distant changes.",
|
|
68
|
+
description: "Edit a single file using targeted text replacements. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes overlap or one replacement is nested inside another, merge them into one edit. Do not include large unchanged regions just to connect distant changes. Matching is exact; a UTF-8 BOM and CRLF line endings are handled automatically and preserved, so write oldText/newText with plain \\n line breaks.",
|
|
69
69
|
promptSnippet: "Make precise file edits with exact text replacement, including multiple disjoint edits in one call",
|
|
70
70
|
promptGuidelines: [
|
|
71
71
|
"Use edit for precise changes; each edits[].oldText must be copied verbatim from a fresh read of the current exact target block and must identify a unique target. Do not reconstruct oldText from memory, stale reads, or similar code elsewhere.",
|
|
@@ -173,7 +173,7 @@ export function createEditTool(cwd, approval, lsp, fileState, checkpoints) {
|
|
|
173
173
|
await checkpoints?.()?.captureBefore(filePath, original);
|
|
174
174
|
await writeFile(filePath, applied.content, "utf-8");
|
|
175
175
|
await fileState?.observe(filePath, "edit", applied.content).catch(() => undefined);
|
|
176
|
-
let output = `Edited ${filePath}
|
|
176
|
+
let output = `Edited ${filePath}\n\nDiff:\n${diff}`;
|
|
177
177
|
if (lsp) {
|
|
178
178
|
try {
|
|
179
179
|
await lsp.touchFile(filePath, "document");
|
|
@@ -13,5 +13,10 @@ export interface PlanController {
|
|
|
13
13
|
requestApproval(plan: string): Promise<PlanDecision>;
|
|
14
14
|
/** Switch the agent's mode. Called after an approval so the next turn runs unconstrained. */
|
|
15
15
|
setMode(mode: PermissionMode): void;
|
|
16
|
+
/**
|
|
17
|
+
* Mode to switch to once the plan is approved — the mode the user was in
|
|
18
|
+
* before entering plan mode. Defaults to "default" when omitted.
|
|
19
|
+
*/
|
|
20
|
+
getExitMode?: () => Exclude<PermissionMode, "plan">;
|
|
16
21
|
}
|
|
17
22
|
export declare function createExitPlanModeTool(controller: PlanController): ToolRegistryEntry;
|
|
@@ -59,12 +59,13 @@ export function createExitPlanModeTool(controller) {
|
|
|
59
59
|
};
|
|
60
60
|
}
|
|
61
61
|
if (decision.action === "approve") {
|
|
62
|
-
controller.
|
|
62
|
+
const exitMode = controller.getExitMode?.() ?? "default";
|
|
63
|
+
controller.setMode(exitMode);
|
|
63
64
|
const finalPlan = decision.plan.trim() || plan;
|
|
64
65
|
const edited = finalPlan !== plan;
|
|
65
66
|
return {
|
|
66
67
|
content: `User approved the plan${edited ? " (with edits)" : ""}. ` +
|
|
67
|
-
`Agent mode has been switched to
|
|
68
|
+
`Agent mode has been switched to ${exitMode} — you may now execute the plan using any tools. ` +
|
|
68
69
|
`Approved plan:\n\n${finalPlan}`,
|
|
69
70
|
};
|
|
70
71
|
}
|
|
@@ -41,4 +41,8 @@ export declare class FileStateTracker {
|
|
|
41
41
|
private resolvePath;
|
|
42
42
|
private computeVersion;
|
|
43
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* Inside the workspace both as written and where symlinks actually lead, so
|
|
46
|
+
* a link inside the repository cannot carry an auto-approved write outside.
|
|
47
|
+
*/
|
|
44
48
|
export declare function isWithinWorkspace(cwd: string, filePath: string): boolean;
|
package/dist/tools/file-state.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { stat, readFile } from "node:fs/promises";
|
|
3
|
-
import { isAbsolute, relative, resolve } from "node:path";
|
|
3
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { resolveThroughLinks } from "./path-utils.js";
|
|
4
5
|
export class FileStateTracker {
|
|
5
6
|
cwd;
|
|
6
7
|
observed = new Map();
|
|
@@ -61,7 +62,15 @@ export class FileStateTracker {
|
|
|
61
62
|
};
|
|
62
63
|
}
|
|
63
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* Inside the workspace both as written and where symlinks actually lead, so
|
|
67
|
+
* a link inside the repository cannot carry an auto-approved write outside.
|
|
68
|
+
*/
|
|
64
69
|
export function isWithinWorkspace(cwd, filePath) {
|
|
65
|
-
|
|
66
|
-
|
|
70
|
+
return isInside(resolve(cwd), resolve(filePath))
|
|
71
|
+
&& isInside(resolveThroughLinks(cwd), resolveThroughLinks(filePath));
|
|
72
|
+
}
|
|
73
|
+
function isInside(root, path) {
|
|
74
|
+
const rel = relative(root, path);
|
|
75
|
+
return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
67
76
|
}
|
package/dist/tools/grep.js
CHANGED
|
@@ -113,6 +113,18 @@ function executeBoundedGrep(options) {
|
|
|
113
113
|
});
|
|
114
114
|
const decoder = new StringDecoder("utf8");
|
|
115
115
|
const matches = [];
|
|
116
|
+
// The root check alone is not enough: ripgrep searches inside an explicit
|
|
117
|
+
// hidden root, so `path: ~/.aws` would reach ~/.aws/credentials. Every
|
|
118
|
+
// matched file gets the same check the read tool applies (cached per file).
|
|
119
|
+
const sensitiveByPath = new Map();
|
|
120
|
+
const isSensitiveMatch = (absolutePath) => {
|
|
121
|
+
let sensitive = sensitiveByPath.get(absolutePath);
|
|
122
|
+
if (sensitive === undefined) {
|
|
123
|
+
sensitive = isSensitivePath(absolutePath);
|
|
124
|
+
sensitiveByPath.set(absolutePath, sensitive);
|
|
125
|
+
}
|
|
126
|
+
return sensitive;
|
|
127
|
+
};
|
|
116
128
|
let stdoutBuffer = "";
|
|
117
129
|
let stdoutBytes = 0;
|
|
118
130
|
let stderrBytes = 0;
|
|
@@ -142,6 +154,13 @@ function executeBoundedGrep(options) {
|
|
|
142
154
|
if (event.type !== "match")
|
|
143
155
|
return;
|
|
144
156
|
const rawPath = decodeRgField(event.data?.path);
|
|
157
|
+
// A path that is not valid UTF-8 cannot be checked (or read back by
|
|
158
|
+
// the model), so its matches are dropped rather than shown unchecked.
|
|
159
|
+
if (event.data?.path && !rawPath)
|
|
160
|
+
return;
|
|
161
|
+
const absolutePath = rawPath ? resolvePath(options.cwd, rawPath) : undefined;
|
|
162
|
+
if (absolutePath && isSensitiveMatch(absolutePath))
|
|
163
|
+
return;
|
|
145
164
|
const lineNumber = event.data?.line_number;
|
|
146
165
|
const rawText = decodeRgField(event.data?.lines, "[non-UTF-8 match bytes omitted]") ?? "";
|
|
147
166
|
const displayPath = rawPath || "<non-UTF-8 path>";
|
|
@@ -149,7 +168,7 @@ function executeBoundedGrep(options) {
|
|
|
149
168
|
const rendered = truncateCodePoints(`${displayPath}:${typeof lineNumber === "number" ? lineNumber : "?"}: ${normalizedText}`, MAX_RENDERED_LINE_CODE_POINTS);
|
|
150
169
|
matches.push({
|
|
151
170
|
rendered,
|
|
152
|
-
...(
|
|
171
|
+
...(absolutePath ? { absolutePath } : {}),
|
|
153
172
|
});
|
|
154
173
|
// We stop at N rather than pretending we proved there are N+1 matches.
|
|
155
174
|
// The notice says "limit reached / may be incomplete", which is honest
|
package/dist/tools/index.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ export { createManagedServerTools } from "./server.js";
|
|
|
7
7
|
export { createWriteTool } from "./write.js";
|
|
8
8
|
export { createEditTool } from "./edit.js";
|
|
9
9
|
export { buildToolPromptOptions } from "./prompt-metadata.js";
|
|
10
|
-
export {
|
|
10
|
+
export { createLsTool } from "./ls.js";
|
|
11
11
|
export { createGrepTool } from "./grep.js";
|
|
12
12
|
export { createLspTool } from "./lsp.js";
|
|
13
13
|
export { createWebFetchTool } from "./web-fetch.js";
|
package/dist/tools/index.js
CHANGED
|
@@ -7,7 +7,7 @@ export { createManagedServerTools } from "./server.js";
|
|
|
7
7
|
export { createWriteTool } from "./write.js";
|
|
8
8
|
export { createEditTool } from "./edit.js";
|
|
9
9
|
export { buildToolPromptOptions } from "./prompt-metadata.js";
|
|
10
|
-
export {
|
|
10
|
+
export { createLsTool } from "./ls.js";
|
|
11
11
|
export { createGrepTool } from "./grep.js";
|
|
12
12
|
export { createLspTool } from "./lsp.js";
|
|
13
13
|
export { createWebFetchTool } from "./web-fetch.js";
|
|
@@ -23,7 +23,7 @@ import { createBashTool } from "./bash.js";
|
|
|
23
23
|
import { createManagedServerTools } from "./server.js";
|
|
24
24
|
import { createEditTool } from "./edit.js";
|
|
25
25
|
import { createExitPlanModeTool } from "./exit-plan-mode.js";
|
|
26
|
-
import {
|
|
26
|
+
import { createLsTool } from "./ls.js";
|
|
27
27
|
import { createGrepTool } from "./grep.js";
|
|
28
28
|
import { getLspService } from "../lsp/index.js";
|
|
29
29
|
import { createLspTool } from "./lsp.js";
|
|
@@ -57,7 +57,7 @@ export function createAllTools(cwd, skillRegistry, options = {}) {
|
|
|
57
57
|
...createManagedServerTools(cwd, approval),
|
|
58
58
|
createWriteTool(cwd, {}, approval, lsp, fileState, options.checkpoints),
|
|
59
59
|
createEditTool(cwd, approval, lsp, fileState, options.checkpoints),
|
|
60
|
-
|
|
60
|
+
createLsTool(cwd),
|
|
61
61
|
createGrepTool(cwd),
|
|
62
62
|
createLspTool(cwd, lsp, approval),
|
|
63
63
|
createWebSearchTool(),
|
package/dist/tools/ls.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { resolveToolPath } from "./path-utils.js";
|
|
4
|
+
import { isSensitivePath } from "./sensitive-paths.js";
|
|
5
|
+
const DEFAULT_LIMIT = 500;
|
|
6
|
+
const MAX_BYTES = 50 * 1024;
|
|
7
|
+
/** A shallow directory listing: nested files never crowd out sibling directories. */
|
|
8
|
+
export function createLsTool(cwd) {
|
|
9
|
+
return {
|
|
10
|
+
name: "ls",
|
|
11
|
+
readOnly: true,
|
|
12
|
+
effect: "read",
|
|
13
|
+
promptSnippet: "List directory contents, including hidden files and subdirectories",
|
|
14
|
+
description: `List one directory, sorted alphabetically, including hidden entries. Directories end in '/', symbolic links in '@' (not followed). Defaults to ${DEFAULT_LIMIT} entries; increase limit for more. Output is capped at 50 KiB. Does not recurse.`,
|
|
15
|
+
parameters: {
|
|
16
|
+
type: "object",
|
|
17
|
+
properties: {
|
|
18
|
+
path: { type: "string", description: "Directory to list (default: cwd)" },
|
|
19
|
+
limit: { type: "integer", description: "Positive maximum entries to return (default: 500)" },
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
async execute(args, ctx) {
|
|
23
|
+
const cancelled = () => ({ content: "Directory listing cancelled.", isError: true, status: "cancelled" });
|
|
24
|
+
if (ctx.abortSignal?.aborted)
|
|
25
|
+
return cancelled();
|
|
26
|
+
const limit = args.limit ?? DEFAULT_LIMIT;
|
|
27
|
+
if (!Number.isSafeInteger(limit) || limit < 1) {
|
|
28
|
+
return { content: "Error: limit must be a positive integer", isError: true, status: "command_error" };
|
|
29
|
+
}
|
|
30
|
+
if (args.path !== undefined && typeof args.path !== "string") {
|
|
31
|
+
return { content: "Error: path must be a string", isError: true, status: "command_error" };
|
|
32
|
+
}
|
|
33
|
+
const root = resolveToolPath(cwd, args.path?.trim() ? args.path : ".");
|
|
34
|
+
if (isSensitivePath(root)) {
|
|
35
|
+
return { content: `Error: Listing blocked for sensitive credential storage: ${root}`, isError: true, status: "blocked" };
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
39
|
+
if (ctx.abortSignal?.aborted)
|
|
40
|
+
return cancelled();
|
|
41
|
+
entries.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()) || a.name.localeCompare(b.name));
|
|
42
|
+
const lines = [];
|
|
43
|
+
const paths = [];
|
|
44
|
+
let bytes = 0;
|
|
45
|
+
let byteLimitReached = false;
|
|
46
|
+
for (const entry of entries) {
|
|
47
|
+
if (ctx.abortSignal?.aborted)
|
|
48
|
+
return cancelled();
|
|
49
|
+
if (lines.length >= limit)
|
|
50
|
+
break;
|
|
51
|
+
// Quote control characters so one entry always occupies one output line.
|
|
52
|
+
const name = /[\u0000-\u001f\u007f]/.test(entry.name) ? JSON.stringify(entry.name) : entry.name;
|
|
53
|
+
const suffix = entry.isDirectory() ? "/" : entry.isSymbolicLink() ? "@" : "";
|
|
54
|
+
const line = name + suffix;
|
|
55
|
+
const size = Buffer.byteLength(line, "utf8") + (lines.length ? 1 : 0);
|
|
56
|
+
if (bytes + size > MAX_BYTES) {
|
|
57
|
+
byteLimitReached = true;
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
lines.push(line);
|
|
61
|
+
paths.push(resolve(root, entry.name) + (entry.isDirectory() ? "/" : ""));
|
|
62
|
+
bytes += size;
|
|
63
|
+
}
|
|
64
|
+
const truncated = lines.length < entries.length;
|
|
65
|
+
const notice = !truncated ? "" : byteLimitReached
|
|
66
|
+
? `\n\n[Output truncated at 50 KiB: showing ${lines.length} of ${entries.length} entries. Use bash with ls/find to filter or page the listing.]`
|
|
67
|
+
: `\n\n[Output truncated: showing ${lines.length} of ${entries.length} entries. Increase limit to see more.]`;
|
|
68
|
+
return {
|
|
69
|
+
content: (entries.length === 0 ? "(empty directory)" : lines.join("\n")) + notice,
|
|
70
|
+
status: truncated ? "partial" : "success",
|
|
71
|
+
metadata: { kind: "search", path: root, matches: lines.length, totalEntries: entries.length, truncated, paths },
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
if (ctx.abortSignal?.aborted)
|
|
76
|
+
return cancelled();
|
|
77
|
+
return {
|
|
78
|
+
content: `Error: Cannot list directory: ${root} (${error instanceof Error ? error.message : String(error)})`,
|
|
79
|
+
isError: true,
|
|
80
|
+
status: "command_error",
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -1,2 +1,8 @@
|
|
|
1
1
|
export declare function expandHomePath(value: unknown): string;
|
|
2
2
|
export declare function resolveToolPath(cwd: string, value: unknown, fallback?: string): string;
|
|
3
|
+
/**
|
|
4
|
+
* Where a path really leads once symlinks are followed — including links in
|
|
5
|
+
* its parent directories and dangling links (writing through a dangling link
|
|
6
|
+
* creates its target). Parts that do not exist yet are kept as written.
|
|
7
|
+
*/
|
|
8
|
+
export declare function resolveThroughLinks(path: string): string;
|