@bubblebrain-ai/bubble 0.0.19 → 0.0.21
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/internal-reminder-sanitizer.d.ts +1 -0
- package/dist/agent/internal-reminder-sanitizer.js +46 -0
- package/dist/agent.d.ts +10 -0
- package/dist/agent.js +310 -18
- package/dist/approval/controller.d.ts +6 -0
- package/dist/approval/controller.js +104 -11
- package/dist/checkpoints.d.ts +57 -0
- package/dist/checkpoints.js +0 -0
- package/dist/debug-trace.js +4 -0
- package/dist/feishu/agent-host/run-driver.js +29 -0
- package/dist/hooks/config.d.ts +9 -0
- package/dist/hooks/config.js +278 -0
- package/dist/hooks/controller.d.ts +24 -0
- package/dist/hooks/controller.js +254 -0
- package/dist/hooks/index.d.ts +6 -0
- package/dist/hooks/index.js +4 -0
- package/dist/hooks/log.d.ts +14 -0
- package/dist/hooks/log.js +54 -0
- package/dist/hooks/runner.d.ts +5 -0
- package/dist/hooks/runner.js +225 -0
- package/dist/hooks/trust.d.ts +37 -0
- package/dist/hooks/trust.js +143 -0
- package/dist/hooks/types.d.ts +173 -0
- package/dist/hooks/types.js +46 -0
- package/dist/main.js +86 -13
- package/dist/memory/prompts.js +3 -1
- package/dist/model-catalog.js +2 -0
- package/dist/model-pricing.js +8 -0
- package/dist/network/chatgpt-transport.d.ts +0 -1
- package/dist/network/chatgpt-transport.js +40 -121
- package/dist/network/provider-transport.d.ts +32 -0
- package/dist/network/provider-transport.js +265 -0
- package/dist/network/retry.d.ts +29 -0
- package/dist/network/retry.js +88 -0
- package/dist/network/system-proxy.d.ts +18 -0
- package/dist/network/system-proxy.js +175 -0
- package/dist/provider-anthropic.d.ts +1 -0
- package/dist/provider-anthropic.js +127 -52
- package/dist/provider-openai-codex.js +19 -29
- package/dist/session-log.js +3 -3
- package/dist/session.d.ts +31 -0
- package/dist/session.js +69 -0
- package/dist/slash-commands/commands.js +164 -0
- package/dist/slash-commands/types.d.ts +6 -0
- package/dist/tools/bash.js +4 -0
- package/dist/tools/edit-apply.js +63 -3
- package/dist/tools/edit.d.ts +2 -1
- package/dist/tools/edit.js +6 -5
- package/dist/tools/index.d.ts +7 -0
- package/dist/tools/index.js +2 -2
- package/dist/tools/write.d.ts +2 -1
- package/dist/tools/write.js +2 -1
- package/dist/tui/display-history.d.ts +4 -3
- package/dist/tui/display-history.js +34 -57
- package/dist/tui/display-sanitizer.d.ts +3 -0
- package/dist/tui/display-sanitizer.js +38 -0
- package/dist/tui/image-paste.d.ts +18 -0
- package/dist/tui/image-paste.js +60 -0
- package/dist/tui/paste-placeholder.d.ts +1 -0
- package/dist/tui/paste-placeholder.js +7 -0
- package/dist/tui/run.d.ts +2 -0
- package/dist/tui/run.js +568 -223
- package/dist/tui/trace-groups.d.ts +16 -0
- package/dist/tui/trace-groups.js +82 -5
- package/dist/tui/transcript-scroll.d.ts +25 -0
- package/dist/tui/transcript-scroll.js +20 -0
- package/dist/tui/wordmark.d.ts +1 -0
- package/dist/tui/wordmark.js +56 -54
- package/dist/tui-ink/app.d.ts +4 -1
- package/dist/tui-ink/app.js +303 -248
- package/dist/tui-ink/display-history.d.ts +16 -1
- package/dist/tui-ink/display-history.js +50 -21
- package/dist/tui-ink/footer.d.ts +6 -12
- package/dist/tui-ink/footer.js +10 -29
- package/dist/tui-ink/image-paste.d.ts +59 -0
- package/dist/tui-ink/image-paste.js +277 -0
- package/dist/tui-ink/input-box.d.ts +26 -1
- package/dist/tui-ink/input-box.js +171 -41
- package/dist/tui-ink/message-list.d.ts +1 -1
- package/dist/tui-ink/message-list.js +46 -29
- package/dist/tui-ink/run.d.ts +7 -2
- package/dist/tui-ink/run.js +73 -23
- package/dist/tui-ink/terminal-mouse.d.ts +1 -0
- package/dist/tui-ink/terminal-mouse.js +4 -0
- package/dist/tui-ink/trace-groups.d.ts +16 -0
- package/dist/tui-ink/trace-groups.js +90 -6
- package/dist/tui-ink/transcript-viewport-math.d.ts +11 -0
- package/dist/tui-ink/transcript-viewport-math.js +17 -0
- package/dist/tui-ink/transcript-viewport.d.ts +24 -0
- package/dist/tui-ink/transcript-viewport.js +83 -0
- package/dist/tui-ink/welcome.d.ts +9 -7
- package/dist/tui-ink/welcome.js +7 -33
- package/dist/tui-opentui/app.js +2 -1
- package/dist/tui-opentui/trace-groups.js +40 -4
- package/dist/types.d.ts +27 -0
- package/package.json +1 -1
package/dist/session.js
CHANGED
|
@@ -5,6 +5,7 @@ import { randomUUID } from "node:crypto";
|
|
|
5
5
|
import { mkdirSync, appendFileSync, existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { basename, dirname, join } from "node:path";
|
|
7
7
|
import { getBubbleHome } from "./bubble-home.js";
|
|
8
|
+
import { CheckpointStore } from "./checkpoints.js";
|
|
8
9
|
import { compactSessionEntries } from "./context/compact.js";
|
|
9
10
|
import { SessionLog } from "./session-log.js";
|
|
10
11
|
import { normalizeSingleLine, truncateVisual } from "./text-display.js";
|
|
@@ -14,6 +15,7 @@ const AUTO_COMPACT_KEEP_RECENT_TURNS = 3;
|
|
|
14
15
|
export class SessionManager {
|
|
15
16
|
sessionFile;
|
|
16
17
|
log = new SessionLog();
|
|
18
|
+
checkpoints;
|
|
17
19
|
constructor(sessionFile) {
|
|
18
20
|
this.sessionFile = sessionFile;
|
|
19
21
|
if (existsSync(sessionFile)) {
|
|
@@ -163,6 +165,73 @@ export class SessionManager {
|
|
|
163
165
|
getMessages() {
|
|
164
166
|
return this.log.toMessages();
|
|
165
167
|
}
|
|
168
|
+
/**
|
|
169
|
+
* Pre-edit file snapshot store for this session, used by /rewind.
|
|
170
|
+
* Lives next to the session JSONL as `<session>.checkpoints/`.
|
|
171
|
+
*/
|
|
172
|
+
getCheckpoints() {
|
|
173
|
+
if (!this.checkpoints) {
|
|
174
|
+
this.checkpoints = new CheckpointStore(this.sessionFile.replace(/\.jsonl$/, "") + ".checkpoints", () => this.lastUserEntryId());
|
|
175
|
+
}
|
|
176
|
+
return this.checkpoints;
|
|
177
|
+
}
|
|
178
|
+
/** Entry id of the most recent user message, or "0" before the first one. */
|
|
179
|
+
lastUserEntryId() {
|
|
180
|
+
const entries = this.log.list();
|
|
181
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
182
|
+
const entry = entries[i];
|
|
183
|
+
if (entry.type === "user_message")
|
|
184
|
+
return entry.id;
|
|
185
|
+
}
|
|
186
|
+
return "0";
|
|
187
|
+
}
|
|
188
|
+
/** User messages after the latest /clear, oldest first — the valid rewind anchors. */
|
|
189
|
+
listUserTurns() {
|
|
190
|
+
const entries = this.log.list();
|
|
191
|
+
let start = 0;
|
|
192
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
193
|
+
const entry = entries[i];
|
|
194
|
+
if (entry.type === "marker" && entry.kind === "conversation_clear") {
|
|
195
|
+
start = i + 1;
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const turns = [];
|
|
200
|
+
for (let i = start; i < entries.length; i++) {
|
|
201
|
+
const entry = entries[i];
|
|
202
|
+
if (entry.type !== "user_message")
|
|
203
|
+
continue;
|
|
204
|
+
const text = messageText(entry.message);
|
|
205
|
+
turns.push({
|
|
206
|
+
id: entry.id,
|
|
207
|
+
text,
|
|
208
|
+
preview: truncateVisual(normalizeSingleLine(text), 80) || "(empty message)",
|
|
209
|
+
timestamp: entry.timestamp,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
return turns;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Truncate the session to just before the user message with the given
|
|
216
|
+
* entry id. Returns undefined when the id does not name a user message.
|
|
217
|
+
*/
|
|
218
|
+
rewindToEntry(entryId) {
|
|
219
|
+
const entries = this.log.list();
|
|
220
|
+
const index = entries.findIndex((entry) => entry.id === entryId && entry.type === "user_message");
|
|
221
|
+
if (index < 0)
|
|
222
|
+
return undefined;
|
|
223
|
+
const target = entries[index];
|
|
224
|
+
const removed = entries.slice(index);
|
|
225
|
+
this.rewrite(entries.slice(0, index));
|
|
226
|
+
const metadata = this.log.getMetadata();
|
|
227
|
+
if (metadata.titleUserMessageId && removed.some((entry) => entry.id === metadata.titleUserMessageId)) {
|
|
228
|
+
this.clearTitleMetadata();
|
|
229
|
+
}
|
|
230
|
+
return {
|
|
231
|
+
removedEntries: removed.length,
|
|
232
|
+
targetText: target.type === "user_message" ? messageText(target.message) : "",
|
|
233
|
+
};
|
|
234
|
+
}
|
|
166
235
|
getEntries() {
|
|
167
236
|
return this.log.list();
|
|
168
237
|
}
|
|
@@ -6,6 +6,7 @@ import { parseRule } from "../permissions/rule.js";
|
|
|
6
6
|
import { encodeModel, decodeModel, displayModel, BUILTIN_PROVIDERS, isUserVisibleProvider } from "../provider-registry.js";
|
|
7
7
|
import { getAvailableThinkingLevels, getDefaultThinkingLevel, normalizeThinkingLevel } from "../provider-transform.js";
|
|
8
8
|
import { buildSystemPrompt } from "../system-prompt.js";
|
|
9
|
+
import { HOOK_EVENT_NAMES, isHookEventName } from "../hooks/index.js";
|
|
9
10
|
import { isThinkingLevel } from "../variant/thinking-level.js";
|
|
10
11
|
import { collectUsageStatsBundle, formatStatsText } from "../stats/usage.js";
|
|
11
12
|
import { buildMemoryPrompt, getMemoryStatus, isMemoryDisabled, resetMemory, searchMemory, } from "../memory/index.js";
|
|
@@ -49,6 +50,45 @@ function handlePermissionsMutation(sub, tokens, ctx) {
|
|
|
49
50
|
return `Rule not found in ${scope} ${list}: ${rule}`;
|
|
50
51
|
return `Removed from ${scope} ${list}: ${rule}`;
|
|
51
52
|
}
|
|
53
|
+
async function handleHooksCommand(args, ctx) {
|
|
54
|
+
const hooks = ctx.hookController;
|
|
55
|
+
if (!hooks)
|
|
56
|
+
return "Hooks controller is not attached to this session.";
|
|
57
|
+
const tokens = args.trim().split(/\s+/).filter(Boolean);
|
|
58
|
+
const sub = tokens[0] ?? "status";
|
|
59
|
+
if (sub === "status" || sub === "list" || sub === "") {
|
|
60
|
+
return hooks.status();
|
|
61
|
+
}
|
|
62
|
+
if (sub === "reload") {
|
|
63
|
+
hooks.reload();
|
|
64
|
+
return `Reloaded hooks.\n\n${hooks.status()}`;
|
|
65
|
+
}
|
|
66
|
+
if (sub === "trust" && tokens[1] === "project") {
|
|
67
|
+
return hooks.trustProject();
|
|
68
|
+
}
|
|
69
|
+
if (sub === "untrust" && tokens[1] === "project") {
|
|
70
|
+
return hooks.untrustProject();
|
|
71
|
+
}
|
|
72
|
+
if (sub === "test") {
|
|
73
|
+
const event = tokens[1];
|
|
74
|
+
if (!isHookEventName(event)) {
|
|
75
|
+
return `Usage: /hooks test <event> [target]\nEvents: ${HOOK_EVENT_NAMES.join(", ")}`;
|
|
76
|
+
}
|
|
77
|
+
return hooks.test(event, tokens.slice(2).join(" ") || undefined);
|
|
78
|
+
}
|
|
79
|
+
if (sub === "explain") {
|
|
80
|
+
const event = tokens[1];
|
|
81
|
+
if (!isHookEventName(event)) {
|
|
82
|
+
return `Usage: /hooks explain <event>\nEvents: ${HOOK_EVENT_NAMES.join(", ")}`;
|
|
83
|
+
}
|
|
84
|
+
return hooks.explain(event);
|
|
85
|
+
}
|
|
86
|
+
if (sub === "logs") {
|
|
87
|
+
const limit = Number(tokens[1] ?? 20);
|
|
88
|
+
return hooks.logs(Number.isFinite(limit) ? limit : 20);
|
|
89
|
+
}
|
|
90
|
+
return "Usage: /hooks [status|reload|trust project|untrust project|test <event> [target]|explain <event>|logs [limit]]";
|
|
91
|
+
}
|
|
52
92
|
function persistSelectedModel(model, ctx) {
|
|
53
93
|
const userConfig = new UserConfig();
|
|
54
94
|
userConfig.setDefaultModel(model);
|
|
@@ -359,6 +399,86 @@ const builtinSlashCommandEntries = [
|
|
|
359
399
|
ctx.clearMessages();
|
|
360
400
|
},
|
|
361
401
|
},
|
|
402
|
+
{
|
|
403
|
+
name: "rewind",
|
|
404
|
+
description: "Rewind conversation and/or file edits to before an earlier message. Usage: /rewind [n] [--code|--chat]",
|
|
405
|
+
async handler(args, ctx) {
|
|
406
|
+
const session = ctx.sessionManager;
|
|
407
|
+
if (!session) {
|
|
408
|
+
return "Rewind requires an active session.";
|
|
409
|
+
}
|
|
410
|
+
const turns = session.listUserTurns();
|
|
411
|
+
if (turns.length === 0) {
|
|
412
|
+
return "Nothing to rewind: no user messages in this session.";
|
|
413
|
+
}
|
|
414
|
+
const tokens = args.trim().split(/\s+/).filter(Boolean);
|
|
415
|
+
const flags = tokens.filter((token) => token.startsWith("--"));
|
|
416
|
+
const positional = tokens.filter((token) => !token.startsWith("--"));
|
|
417
|
+
const checkpoints = session.getCheckpoints();
|
|
418
|
+
if (positional.length === 0) {
|
|
419
|
+
if (ctx.openRewindPicker) {
|
|
420
|
+
ctx.openRewindPicker();
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
const lines = ["Rewind points (oldest first):", ""];
|
|
424
|
+
turns.forEach((turn, index) => {
|
|
425
|
+
const time = new Date(turn.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
426
|
+
const files = checkpoints.filesTouchedAt(turn.id).length;
|
|
427
|
+
const fileNote = files > 0 ? ` [${files} file${files === 1 ? "" : "s"} changed]` : "";
|
|
428
|
+
lines.push(` ${index + 1}. ${time} ${turn.preview}${fileNote}`);
|
|
429
|
+
});
|
|
430
|
+
lines.push("", "Usage:", " /rewind <n> restore conversation AND files to just before message n", " /rewind <n> --chat conversation only", " /rewind <n> --code files only", "", "Note: only edits made by the edit/write tools are tracked; changes from", "bash commands are not. Checkpoints complement git, they don't replace it.");
|
|
431
|
+
return lines.join("\n");
|
|
432
|
+
}
|
|
433
|
+
const n = Number(positional[0]);
|
|
434
|
+
if (!Number.isInteger(n) || n < 1 || n > turns.length) {
|
|
435
|
+
return `Invalid rewind point "${positional[0]}". Run /rewind to list points (1-${turns.length}).`;
|
|
436
|
+
}
|
|
437
|
+
const target = turns[n - 1];
|
|
438
|
+
const codeOnly = flags.includes("--code");
|
|
439
|
+
const chatOnly = flags.includes("--chat") || flags.includes("--conversation");
|
|
440
|
+
if (codeOnly && chatOnly) {
|
|
441
|
+
return "Pick at most one of --code / --chat.";
|
|
442
|
+
}
|
|
443
|
+
// The "⏪" prefix is recognized by the TUIs: they rebuild the visible
|
|
444
|
+
// transcript from the rewound agent.messages before showing this text.
|
|
445
|
+
const lines = [
|
|
446
|
+
codeOnly
|
|
447
|
+
? `Files restored to just before: ${target.preview}`
|
|
448
|
+
: `⏪ Rewound to before: ${target.preview}`,
|
|
449
|
+
];
|
|
450
|
+
if (!chatOnly) {
|
|
451
|
+
const restore = await checkpoints.restoreTo(target.id);
|
|
452
|
+
const touched = restore.restored.length + restore.deleted.length;
|
|
453
|
+
if (touched === 0 && restore.failed.length === 0) {
|
|
454
|
+
lines.push("Files: no tracked edits to undo.");
|
|
455
|
+
}
|
|
456
|
+
else {
|
|
457
|
+
for (const file of restore.restored)
|
|
458
|
+
lines.push(`Restored ${file}`);
|
|
459
|
+
for (const file of restore.deleted)
|
|
460
|
+
lines.push(`Deleted ${file} (created after this point)`);
|
|
461
|
+
for (const file of restore.failed)
|
|
462
|
+
lines.push(`FAILED to restore ${file}`);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
if (!codeOnly) {
|
|
466
|
+
session.rewindToEntry(target.id);
|
|
467
|
+
const head = ctx.agent.messages.filter((m) => m.role === "system" || m.role === "meta");
|
|
468
|
+
ctx.agent.messages = [...head, ...session.getMessages()];
|
|
469
|
+
ctx.agent.setTodos(session.getTodos());
|
|
470
|
+
ctx.agent.resetContextUsageAnchor();
|
|
471
|
+
if (ctx.fillComposer) {
|
|
472
|
+
// Put the rewound message back into the input box for re-editing.
|
|
473
|
+
ctx.fillComposer(target.text);
|
|
474
|
+
}
|
|
475
|
+
else {
|
|
476
|
+
lines.push("", "Rewound message (copy to re-edit):", target.text);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return lines.join("\n");
|
|
480
|
+
},
|
|
481
|
+
},
|
|
362
482
|
{
|
|
363
483
|
name: "session",
|
|
364
484
|
description: "Show current session information",
|
|
@@ -667,6 +787,13 @@ const builtinSlashCommandEntries = [
|
|
|
667
787
|
return lines.join("\n");
|
|
668
788
|
},
|
|
669
789
|
},
|
|
790
|
+
{
|
|
791
|
+
name: "hooks",
|
|
792
|
+
description: "Inspect and manage lifecycle hooks. Usage: /hooks [status|trust project|test <event>]",
|
|
793
|
+
async handler(args, ctx) {
|
|
794
|
+
return handleHooksCommand(args, ctx);
|
|
795
|
+
},
|
|
796
|
+
},
|
|
670
797
|
{
|
|
671
798
|
name: "lsp",
|
|
672
799
|
description: "Inspect or restart language servers. Usage: /lsp [status|diagnostics|restart]",
|
|
@@ -788,8 +915,33 @@ const builtinSlashCommandEntries = [
|
|
|
788
915
|
if (!ctx.sessionManager) {
|
|
789
916
|
return "Compaction requires session persistence. Start an interactive session first.";
|
|
790
917
|
}
|
|
918
|
+
const preHook = await ctx.hookController?.runEvent({
|
|
919
|
+
eventName: "PreCompact",
|
|
920
|
+
cwd: ctx.cwd,
|
|
921
|
+
sessionId: ctx.sessionManager.getSessionFile(),
|
|
922
|
+
agentRole: "driver",
|
|
923
|
+
target: "manual",
|
|
924
|
+
payload: {
|
|
925
|
+
kind: "manual",
|
|
926
|
+
messageCount: ctx.agent.messages.length,
|
|
927
|
+
},
|
|
928
|
+
});
|
|
929
|
+
if (preHook?.decision === "deny") {
|
|
930
|
+
return preHook.reason ?? `Compaction blocked by hook ${preHook.sourceHookId ?? "<unknown>"}.`;
|
|
931
|
+
}
|
|
791
932
|
const result = ctx.sessionManager.compact();
|
|
792
933
|
if (!result.compacted) {
|
|
934
|
+
await ctx.hookController?.runEvent({
|
|
935
|
+
eventName: "PostCompact",
|
|
936
|
+
cwd: ctx.cwd,
|
|
937
|
+
sessionId: ctx.sessionManager.getSessionFile(),
|
|
938
|
+
agentRole: "driver",
|
|
939
|
+
target: "manual",
|
|
940
|
+
payload: {
|
|
941
|
+
kind: "manual",
|
|
942
|
+
compacted: false,
|
|
943
|
+
},
|
|
944
|
+
});
|
|
793
945
|
return "Session is already compact enough.";
|
|
794
946
|
}
|
|
795
947
|
const systemMessage = ctx.agent.messages.find((message) => message.role === "system");
|
|
@@ -799,6 +951,18 @@ const builtinSlashCommandEntries = [
|
|
|
799
951
|
];
|
|
800
952
|
ctx.agent.resetContextUsageAnchor();
|
|
801
953
|
const dropped = result.droppedEntries ?? 0;
|
|
954
|
+
await ctx.hookController?.runEvent({
|
|
955
|
+
eventName: "PostCompact",
|
|
956
|
+
cwd: ctx.cwd,
|
|
957
|
+
sessionId: ctx.sessionManager.getSessionFile(),
|
|
958
|
+
agentRole: "driver",
|
|
959
|
+
target: "manual",
|
|
960
|
+
payload: {
|
|
961
|
+
kind: "manual",
|
|
962
|
+
compacted: true,
|
|
963
|
+
droppedEntries: dropped,
|
|
964
|
+
},
|
|
965
|
+
});
|
|
802
966
|
return `✓ Compaction complete · ${dropped} log entr${dropped === 1 ? "y" : "ies"} summarized`;
|
|
803
967
|
},
|
|
804
968
|
},
|
|
@@ -9,6 +9,7 @@ import type { McpManager } from "../mcp/manager.js";
|
|
|
9
9
|
import type { LspService } from "../lsp/index.js";
|
|
10
10
|
import type { MemoryScope } from "../memory/index.js";
|
|
11
11
|
import type { ThemeMode } from "../config.js";
|
|
12
|
+
import type { ExternalHookController } from "../hooks/controller.js";
|
|
12
13
|
export type SidebarMode = "auto" | "expanded" | "collapsed";
|
|
13
14
|
export interface SidebarCommandState {
|
|
14
15
|
mode: SidebarMode;
|
|
@@ -28,6 +29,7 @@ export interface SlashCommandContext {
|
|
|
28
29
|
skillRegistry: SkillRegistry;
|
|
29
30
|
bashAllowlist?: BashAllowlist;
|
|
30
31
|
settingsManager?: SettingsManager;
|
|
32
|
+
hookController?: ExternalHookController;
|
|
31
33
|
mcpManager?: McpManager;
|
|
32
34
|
lspService?: LspService;
|
|
33
35
|
flushMemory?: () => Promise<void>;
|
|
@@ -46,6 +48,10 @@ export interface SlashCommandContext {
|
|
|
46
48
|
setSidebarMode?: (mode: SidebarMode) => SidebarCommandState;
|
|
47
49
|
/** Open the feedback dialog. `initialDescription` prefills the description field. */
|
|
48
50
|
openFeedback?: (initialDescription: string) => void;
|
|
51
|
+
/** Open the interactive rewind picker. When absent, /rewind falls back to a text listing. */
|
|
52
|
+
openRewindPicker?: () => void;
|
|
53
|
+
/** Replace the composer/input box content (e.g. /rewind restores the rewound message for re-editing). */
|
|
54
|
+
fillComposer?: (text: string) => void;
|
|
49
55
|
/** Open the interactive usage stats panel. */
|
|
50
56
|
openStats?: () => void;
|
|
51
57
|
}
|
package/dist/tools/bash.js
CHANGED
|
@@ -21,6 +21,10 @@ export function createBashTool(cwd, approval, _fileState) {
|
|
|
21
21
|
type: "object",
|
|
22
22
|
properties: {
|
|
23
23
|
command: { type: "string", description: "Bash command to execute" },
|
|
24
|
+
description: {
|
|
25
|
+
type: "string",
|
|
26
|
+
description: "One short sentence (5-10 words) describing what this command does, shown to the user in the UI. Write it in the same language the user is conversing in.",
|
|
27
|
+
},
|
|
24
28
|
timeout: { type: "number", description: "Timeout in seconds (optional)" },
|
|
25
29
|
},
|
|
26
30
|
required: ["command"],
|
package/dist/tools/edit-apply.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isSensitivePath } from "./sensitive-paths.js";
|
|
1
2
|
export class EditApplyError extends Error {
|
|
2
3
|
status;
|
|
3
4
|
constructor(message, status = "no_match") {
|
|
@@ -6,6 +7,9 @@ export class EditApplyError extends Error {
|
|
|
6
7
|
this.name = "EditApplyError";
|
|
7
8
|
}
|
|
8
9
|
}
|
|
10
|
+
const CANDIDATE_EXCERPT_CONTEXT_LINES = 3;
|
|
11
|
+
const CANDIDATE_EXCERPT_MAX_LINES = 8;
|
|
12
|
+
const CANDIDATE_EXCERPT_MAX_CHARS = 1200;
|
|
9
13
|
function detectLineEnding(content) {
|
|
10
14
|
const crlf = content.indexOf("\r\n");
|
|
11
15
|
const lf = content.indexOf("\n");
|
|
@@ -234,19 +238,75 @@ function findBestLineHint(content, oldText) {
|
|
|
234
238
|
return undefined;
|
|
235
239
|
const contentLines = nonBlankLines(splitLines(content));
|
|
236
240
|
let best;
|
|
241
|
+
let tieCount = 0;
|
|
237
242
|
for (let i = 0; i < contentLines.length; i++) {
|
|
238
243
|
let score = 0;
|
|
239
244
|
for (let j = 0; j < oldLines.length && i + j < contentLines.length; j++) {
|
|
240
245
|
if (contentLines[i + j].normalized === oldLines[j])
|
|
241
246
|
score++;
|
|
242
247
|
}
|
|
243
|
-
if (!best || score > best.score)
|
|
248
|
+
if (!best || score > best.score) {
|
|
244
249
|
best = { index: i, score };
|
|
250
|
+
tieCount = 1;
|
|
251
|
+
}
|
|
252
|
+
else if (score === best.score) {
|
|
253
|
+
tieCount++;
|
|
254
|
+
}
|
|
245
255
|
}
|
|
246
256
|
if (!best || best.score === 0)
|
|
247
257
|
return undefined;
|
|
248
258
|
const startLine = contentLines[best.index].lineIndex + 1;
|
|
249
|
-
return
|
|
259
|
+
return {
|
|
260
|
+
startLine,
|
|
261
|
+
score: best.score,
|
|
262
|
+
total: oldLines.length,
|
|
263
|
+
lineIndex: contentLines[best.index].lineIndex,
|
|
264
|
+
tieCount,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
function isHighConfidenceLineHint(hint) {
|
|
268
|
+
return hint.score >= 2 && hint.score / hint.total >= 0.5 && hint.tieCount === 1;
|
|
269
|
+
}
|
|
270
|
+
function formatLineHint(hint) {
|
|
271
|
+
if (hint.tieCount > 1) {
|
|
272
|
+
return `Closest ambiguous line-based candidate starts near line ${hint.startLine} and matched ${hint.score}/${hint.total} non-blank lines, but ${hint.tieCount} candidates tied. Current bytes were not included because the candidate may be unrelated.`;
|
|
273
|
+
}
|
|
274
|
+
if (!isHighConfidenceLineHint(hint)) {
|
|
275
|
+
return `Closest low-confidence line-based candidate starts near line ${hint.startLine} and matched ${hint.score}/${hint.total} non-blank lines. Current bytes were not included because the candidate may be unrelated.`;
|
|
276
|
+
}
|
|
277
|
+
return `Closest line-based candidate starts near line ${hint.startLine} and matched ${hint.score}/${hint.total} non-blank lines.`;
|
|
278
|
+
}
|
|
279
|
+
function formatFence(content) {
|
|
280
|
+
let fence = "```";
|
|
281
|
+
while (content.includes(fence))
|
|
282
|
+
fence += "`";
|
|
283
|
+
return `${fence}\n${content}\n${fence}`;
|
|
284
|
+
}
|
|
285
|
+
function truncateExcerpt(excerpt) {
|
|
286
|
+
if (excerpt.length <= CANDIDATE_EXCERPT_MAX_CHARS)
|
|
287
|
+
return excerpt;
|
|
288
|
+
const marker = "\n...[truncated current candidate excerpt]";
|
|
289
|
+
return excerpt.slice(0, Math.max(0, CANDIDATE_EXCERPT_MAX_CHARS - marker.length)) + marker;
|
|
290
|
+
}
|
|
291
|
+
function formatCandidateExcerpt(content, hint) {
|
|
292
|
+
const lines = splitLines(content);
|
|
293
|
+
const startLineIndex = Math.max(0, hint.lineIndex - CANDIDATE_EXCERPT_CONTEXT_LINES);
|
|
294
|
+
const requestedEnd = Math.min(lines.length, hint.lineIndex + CANDIDATE_EXCERPT_CONTEXT_LINES + 1);
|
|
295
|
+
const endLineIndex = Math.min(requestedEnd, startLineIndex + CANDIDATE_EXCERPT_MAX_LINES);
|
|
296
|
+
const excerpt = truncateExcerpt(lines.slice(startLineIndex, endLineIndex).map((line) => line.text).join("\n"));
|
|
297
|
+
return [
|
|
298
|
+
`Current candidate excerpt (high confidence, current file lines ${startLineIndex + 1}-${endLineIndex}, not guaranteed target):`,
|
|
299
|
+
formatFence(excerpt),
|
|
300
|
+
].join("\n");
|
|
301
|
+
}
|
|
302
|
+
function formatBestLineHint(content, hint, options) {
|
|
303
|
+
const lineHint = formatLineHint(hint);
|
|
304
|
+
if (!isHighConfidenceLineHint(hint))
|
|
305
|
+
return lineHint;
|
|
306
|
+
if (options?.path && isSensitivePath(options.path)) {
|
|
307
|
+
return `${lineHint}\nCurrent bytes were not included because this path is blocked by the sensitive-path read policy.`;
|
|
308
|
+
}
|
|
309
|
+
return `${lineHint}\n\n${formatCandidateExcerpt(content, hint)}`;
|
|
250
310
|
}
|
|
251
311
|
function matchEdit(content, edit, index, total, options) {
|
|
252
312
|
if (edit.oldText.length === 0) {
|
|
@@ -350,7 +410,7 @@ function matchEdit(content, edit, index, total, options) {
|
|
|
350
410
|
}
|
|
351
411
|
}
|
|
352
412
|
const hint = findBestLineHint(content, oldText);
|
|
353
|
-
const hintSuffix = hint ? `\n${hint}` : "";
|
|
413
|
+
const hintSuffix = hint ? `\n${formatBestLineHint(content, hint, options)}` : "";
|
|
354
414
|
const recovery = [
|
|
355
415
|
"",
|
|
356
416
|
"How to recover:",
|
package/dist/tools/edit.d.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* This is the safest way to edit files: old_string must exist exactly once.
|
|
5
5
|
*/
|
|
6
6
|
import type { ApprovalController } from "../approval/types.js";
|
|
7
|
+
import type { CheckpointStore } from "../checkpoints.js";
|
|
7
8
|
import type { ToolRegistryEntry } from "../types.js";
|
|
8
9
|
import { type LspService } from "../lsp/index.js";
|
|
9
10
|
import { type FileStateTracker } from "./file-state.js";
|
|
@@ -14,4 +15,4 @@ export interface EditArgs {
|
|
|
14
15
|
newText: string;
|
|
15
16
|
}>;
|
|
16
17
|
}
|
|
17
|
-
export declare function createEditTool(cwd: string, approval?: ApprovalController, lsp?: LspService, fileState?: FileStateTracker): ToolRegistryEntry;
|
|
18
|
+
export declare function createEditTool(cwd: string, approval?: ApprovalController, lsp?: LspService, fileState?: FileStateTracker, checkpoints?: () => CheckpointStore | undefined): ToolRegistryEntry;
|
package/dist/tools/edit.js
CHANGED
|
@@ -60,17 +60,17 @@ function firstString(...values) {
|
|
|
60
60
|
}
|
|
61
61
|
return undefined;
|
|
62
62
|
}
|
|
63
|
-
export function createEditTool(cwd, approval, lsp, fileState) {
|
|
63
|
+
export function createEditTool(cwd, approval, lsp, fileState, checkpoints) {
|
|
64
64
|
return {
|
|
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
|
|
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.",
|
|
69
69
|
promptSnippet: "Make precise file edits with exact text replacement, including multiple disjoint edits in one call",
|
|
70
70
|
promptGuidelines: [
|
|
71
|
-
"Use edit for precise changes; edits[].oldText
|
|
72
|
-
"When changing multiple
|
|
73
|
-
"Each edits[].oldText is matched against the original file, not after earlier edits are applied. Do not emit overlapping or nested edits
|
|
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.",
|
|
72
|
+
"When changing multiple small, clearly disjoint locations copied from the same fresh read, you may use one edit call with multiple entries in edits[]. Use separate smaller edit calls after re-reading when anchors are uncertain, stale, or likely to drift.",
|
|
73
|
+
"Each edits[].oldText is matched against the original file, not after earlier edits are applied. Do not emit overlapping or nested edits; merge only truly overlapping targets.",
|
|
74
74
|
"Keep edits[].oldText as small as possible while still being unique in the file. Do not pad with large unchanged regions.",
|
|
75
75
|
],
|
|
76
76
|
parameters: {
|
|
@@ -178,6 +178,7 @@ export function createEditTool(cwd, approval, lsp, fileState) {
|
|
|
178
178
|
},
|
|
179
179
|
};
|
|
180
180
|
}
|
|
181
|
+
await checkpoints?.()?.captureBefore(filePath, original);
|
|
181
182
|
await writeFile(filePath, applied.content, "utf-8");
|
|
182
183
|
await fileState?.observe(filePath, "edit", applied.content).catch(() => undefined);
|
|
183
184
|
let output = `Edited ${filePath}${formatEditMatchNotes(applied.matches)}\n\nDiff:\n${diff}`;
|
package/dist/tools/index.d.ts
CHANGED
|
@@ -28,6 +28,7 @@ import { type LspService } from "../lsp/index.js";
|
|
|
28
28
|
import { type TodoStore } from "./todo.js";
|
|
29
29
|
import { type ToolSearchController } from "./tool-search.js";
|
|
30
30
|
import type { QuestionController } from "../question/index.js";
|
|
31
|
+
import type { CheckpointStore } from "../checkpoints.js";
|
|
31
32
|
import { FileStateTracker } from "./file-state.js";
|
|
32
33
|
export interface CreateAllToolsOptions {
|
|
33
34
|
todoStore?: TodoStore;
|
|
@@ -37,5 +38,11 @@ export interface CreateAllToolsOptions {
|
|
|
37
38
|
toolSearchController?: ToolSearchController;
|
|
38
39
|
lspService?: LspService;
|
|
39
40
|
fileStateTracker?: FileStateTracker;
|
|
41
|
+
/**
|
|
42
|
+
* Lazy accessor for the session's checkpoint store (the session manager may
|
|
43
|
+
* not exist yet when tools are created). Used by edit/write to snapshot
|
|
44
|
+
* files before mutating them so /rewind can restore.
|
|
45
|
+
*/
|
|
46
|
+
checkpoints?: () => CheckpointStore | undefined;
|
|
40
47
|
}
|
|
41
48
|
export declare function createAllTools(cwd: string, skillRegistry?: SkillRegistry, options?: CreateAllToolsOptions): ToolRegistryEntry[];
|
package/dist/tools/index.js
CHANGED
|
@@ -48,8 +48,8 @@ export function createAllTools(cwd, skillRegistry, options = {}) {
|
|
|
48
48
|
createReadTool(cwd, approval, lsp, fileState),
|
|
49
49
|
createBashTool(cwd, approval, fileState),
|
|
50
50
|
...createManagedServerTools(cwd, approval),
|
|
51
|
-
createWriteTool(cwd, {}, approval, lsp, fileState),
|
|
52
|
-
createEditTool(cwd, approval, lsp, fileState),
|
|
51
|
+
createWriteTool(cwd, {}, approval, lsp, fileState, options.checkpoints),
|
|
52
|
+
createEditTool(cwd, approval, lsp, fileState, options.checkpoints),
|
|
53
53
|
createGlobTool(cwd),
|
|
54
54
|
createGrepTool(cwd),
|
|
55
55
|
createLspTool(cwd, lsp, approval),
|
package/dist/tools/write.d.ts
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
* Write tool - create files or replace full file contents.
|
|
3
3
|
*/
|
|
4
4
|
import type { ApprovalController } from "../approval/types.js";
|
|
5
|
+
import type { CheckpointStore } from "../checkpoints.js";
|
|
5
6
|
import type { ToolRegistryEntry } from "../types.js";
|
|
6
7
|
import { type LspService } from "../lsp/index.js";
|
|
7
8
|
import { type FileStateTracker } from "./file-state.js";
|
|
8
9
|
export type WriteToolOptions = Record<string, never>;
|
|
9
|
-
export declare function createWriteTool(cwd: string, _options?: WriteToolOptions, approval?: ApprovalController, lsp?: LspService, fileState?: FileStateTracker): ToolRegistryEntry;
|
|
10
|
+
export declare function createWriteTool(cwd: string, _options?: WriteToolOptions, approval?: ApprovalController, lsp?: LspService, fileState?: FileStateTracker, checkpoints?: () => CheckpointStore | undefined): ToolRegistryEntry;
|
package/dist/tools/write.js
CHANGED
|
@@ -18,7 +18,7 @@ function prepareWriteArguments(input) {
|
|
|
18
18
|
}
|
|
19
19
|
return args;
|
|
20
20
|
}
|
|
21
|
-
export function createWriteTool(cwd, _options = {}, approval, lsp, fileState) {
|
|
21
|
+
export function createWriteTool(cwd, _options = {}, approval, lsp, fileState, checkpoints) {
|
|
22
22
|
return {
|
|
23
23
|
name: "write",
|
|
24
24
|
effect: "write_direct",
|
|
@@ -76,6 +76,7 @@ export function createWriteTool(cwd, _options = {}, approval, lsp, fileState) {
|
|
|
76
76
|
return changedDuringApprovalResult(filePath, changed);
|
|
77
77
|
}
|
|
78
78
|
try {
|
|
79
|
+
await checkpoints?.()?.captureBefore(filePath, existed ? oldContent : null);
|
|
79
80
|
await mkdir(dirname(filePath), { recursive: true });
|
|
80
81
|
await writeFile(filePath, args.content, "utf-8");
|
|
81
82
|
await fileState?.observe(filePath, "write", args.content).catch(() => undefined);
|
|
@@ -2,7 +2,6 @@ import type { ToolResultMetadata, TokenUsage } from "../types.js";
|
|
|
2
2
|
export interface CompactionMeta {
|
|
3
3
|
turns: number;
|
|
4
4
|
messages: number;
|
|
5
|
-
tokensSaved: number;
|
|
6
5
|
summarySections: Array<{
|
|
7
6
|
label: string;
|
|
8
7
|
content: string;
|
|
@@ -10,11 +9,12 @@ export interface CompactionMeta {
|
|
|
10
9
|
contextWindow?: number;
|
|
11
10
|
compactedAt: number;
|
|
12
11
|
}
|
|
12
|
+
export type UserInputStatus = "queued" | "pending_steer";
|
|
13
13
|
export interface DisplayMessage {
|
|
14
14
|
role: "user" | "assistant" | "error";
|
|
15
15
|
content: string;
|
|
16
16
|
clientId?: string;
|
|
17
|
-
|
|
17
|
+
inputStatus?: UserInputStatus;
|
|
18
18
|
reasoning?: string;
|
|
19
19
|
toolCalls?: DisplayToolCall[];
|
|
20
20
|
parts?: DisplayMessagePart[];
|
|
@@ -53,11 +53,12 @@ export interface DisplayToolCall {
|
|
|
53
53
|
startedAt?: number;
|
|
54
54
|
completedAt?: number;
|
|
55
55
|
}
|
|
56
|
+
export declare function userInputStatusBadgeLabel(status?: UserInputStatus): string | undefined;
|
|
57
|
+
export declare function setUserInputStatus(message: DisplayMessage, inputStatus?: UserInputStatus): DisplayMessage;
|
|
56
58
|
export declare function appendTextPart(parts: DisplayMessagePart[], content: string): void;
|
|
57
59
|
export declare function appendToolPart(parts: DisplayMessagePart[], toolCall: DisplayToolCall): void;
|
|
58
60
|
export declare function snapshotDisplayParts(parts: DisplayMessagePart[]): DisplayMessagePart[];
|
|
59
61
|
export declare function contentFromParts(parts: DisplayMessagePart[]): string;
|
|
60
62
|
export declare function toolCallsFromParts(parts: DisplayMessagePart[]): DisplayToolCall[];
|
|
61
63
|
export declare function compactDisplayMessages(messages: DisplayMessage[]): DisplayMessage[];
|
|
62
|
-
export declare function truncateText(value: string, maxChars: number): string;
|
|
63
64
|
export declare function formatCompactNumber(n: number): string;
|