@bubblebrain-ai/bubble 0.0.6 → 0.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/dist/agent/execution-governor.d.ts +5 -13
  2. package/dist/agent/execution-governor.js +33 -142
  3. package/dist/agent.d.ts +6 -0
  4. package/dist/agent.js +36 -3
  5. package/dist/context/budget.d.ts +1 -0
  6. package/dist/context/budget.js +1 -1
  7. package/dist/context/usage.d.ts +34 -0
  8. package/dist/context/usage.js +213 -0
  9. package/dist/diff-stats.d.ts +5 -0
  10. package/dist/diff-stats.js +21 -0
  11. package/dist/main.js +83 -44
  12. package/dist/mcp/transports.d.ts +1 -0
  13. package/dist/mcp/transports.js +8 -0
  14. package/dist/model-catalog.js +1 -1
  15. package/dist/orchestrator/default-hooks.js +9 -33
  16. package/dist/prompt/compose.js +2 -1
  17. package/dist/prompt/provider-prompts/kimi.js +3 -1
  18. package/dist/prompt/reminders.d.ts +2 -1
  19. package/dist/prompt/reminders.js +4 -3
  20. package/dist/provider-registry.js +3 -3
  21. package/dist/provider-transform.d.ts +3 -1
  22. package/dist/provider-transform.js +15 -0
  23. package/dist/provider.d.ts +4 -1
  24. package/dist/provider.js +89 -4
  25. package/dist/reasoning-debug.d.ts +7 -0
  26. package/dist/reasoning-debug.js +30 -0
  27. package/dist/session-log.js +13 -2
  28. package/dist/session-types.d.ts +1 -1
  29. package/dist/slash-commands/commands.js +36 -19
  30. package/dist/tools/edit.js +5 -0
  31. package/dist/tools/file-state.d.ts +19 -0
  32. package/dist/tools/file-state.js +15 -0
  33. package/dist/tools/read.d.ts +1 -1
  34. package/dist/tools/read.js +92 -11
  35. package/dist/tui/escape-confirmation.d.ts +15 -0
  36. package/dist/tui/escape-confirmation.js +30 -0
  37. package/dist/tui/run.js +93 -23
  38. package/dist/tui-ink/app.d.ts +43 -0
  39. package/dist/tui-ink/app.js +1016 -0
  40. package/dist/tui-ink/approval/approval-dialog.d.ts +13 -0
  41. package/dist/tui-ink/approval/approval-dialog.js +129 -0
  42. package/dist/tui-ink/approval/diff-view.d.ts +7 -0
  43. package/dist/tui-ink/approval/diff-view.js +43 -0
  44. package/dist/tui-ink/approval/select.d.ts +35 -0
  45. package/dist/tui-ink/approval/select.js +87 -0
  46. package/dist/tui-ink/code-highlight.d.ts +6 -0
  47. package/dist/tui-ink/code-highlight.js +94 -0
  48. package/dist/tui-ink/display-history.d.ts +38 -0
  49. package/dist/tui-ink/display-history.js +130 -0
  50. package/dist/tui-ink/edit-diff.d.ts +11 -0
  51. package/dist/tui-ink/edit-diff.js +52 -0
  52. package/dist/tui-ink/file-mentions.d.ts +29 -0
  53. package/dist/tui-ink/file-mentions.js +174 -0
  54. package/dist/tui-ink/footer.d.ts +19 -0
  55. package/dist/tui-ink/footer.js +44 -0
  56. package/dist/tui-ink/image-paste.d.ts +54 -0
  57. package/dist/tui-ink/image-paste.js +288 -0
  58. package/dist/tui-ink/input-box.d.ts +41 -0
  59. package/dist/tui-ink/input-box.js +637 -0
  60. package/dist/tui-ink/markdown.d.ts +38 -0
  61. package/dist/tui-ink/markdown.js +384 -0
  62. package/dist/tui-ink/message-list.d.ts +33 -0
  63. package/dist/tui-ink/message-list.js +571 -0
  64. package/dist/tui-ink/model-picker.d.ts +43 -0
  65. package/dist/tui-ink/model-picker.js +326 -0
  66. package/dist/tui-ink/plan-confirm.d.ts +7 -0
  67. package/dist/tui-ink/plan-confirm.js +104 -0
  68. package/dist/tui-ink/question-dialog.d.ts +8 -0
  69. package/dist/tui-ink/question-dialog.js +98 -0
  70. package/dist/tui-ink/recent-activity.d.ts +8 -0
  71. package/dist/tui-ink/recent-activity.js +71 -0
  72. package/dist/tui-ink/run.d.ts +33 -0
  73. package/dist/tui-ink/run.js +25 -0
  74. package/dist/tui-ink/theme.d.ts +37 -0
  75. package/dist/tui-ink/theme.js +42 -0
  76. package/dist/tui-ink/todos.d.ts +7 -0
  77. package/dist/tui-ink/todos.js +44 -0
  78. package/dist/tui-ink/trace-groups.d.ts +25 -0
  79. package/dist/tui-ink/trace-groups.js +310 -0
  80. package/dist/tui-ink/use-terminal-size.d.ts +4 -0
  81. package/dist/tui-ink/use-terminal-size.js +21 -0
  82. package/dist/tui-ink/welcome.d.ts +18 -0
  83. package/dist/tui-ink/welcome.js +119 -0
  84. package/dist/types.d.ts +4 -0
  85. package/package.json +6 -1
@@ -3,6 +3,13 @@ export { getAvailableThinkingLevels, getDefaultThinkingLevel, normalizeThinkingL
3
3
  const MOONSHOT_PROVIDER_IDS = new Set(["moonshot-cn", "moonshot-intl", "kimi-for-coding"]);
4
4
  const KIMI_K25_FAMILY = new Set(["kimi-k2.5", "k2.6-code-preview", "kimi-k2.6"]);
5
5
  const KIMI_THINKING_FAMILY = new Set(["kimi-k2-thinking", "kimi-k2-thinking-turbo"]);
6
+ const KIMI_K26_DEFAULT_MAX_TOKENS = 32768;
7
+ function isFireworksKimi(providerId, modelId) {
8
+ const model = modelId.toLowerCase();
9
+ return providerId === "fireworks" && (model.includes("kimi")
10
+ || model.includes("k2p6")
11
+ || model === "k2.6");
12
+ }
6
13
  export function resolveProviderRequestConfig(providerId, modelId, requestedLevel) {
7
14
  const supportedLevels = getAvailableThinkingLevels(providerId, modelId);
8
15
  const effectiveThinkingLevel = normalizeThinkingLevel(requestedLevel, supportedLevels);
@@ -11,6 +18,14 @@ export function resolveProviderRequestConfig(providerId, modelId, requestedLevel
11
18
  if (providerId === "openai-codex") {
12
19
  return { effectiveThinkingLevel };
13
20
  }
21
+ if (isFireworksKimi(providerId, modelId)) {
22
+ return {
23
+ effectiveThinkingLevel,
24
+ reasoningContentEcho: "none",
25
+ parallelToolCalls: false,
26
+ maxTokens: KIMI_K26_DEFAULT_MAX_TOKENS,
27
+ };
28
+ }
14
29
  if (providerId === "deepseek" && (modelId === "deepseek-v4-flash" || modelId === "deepseek-v4-pro")) {
15
30
  return {
16
31
  effectiveThinkingLevel,
@@ -4,10 +4,13 @@
4
4
  * Works with OpenRouter, OpenAI, DeepSeek, Google, Groq, Together, and local OpenAI-compatible endpoints.
5
5
  */
6
6
  import type { Provider, ProviderMessage, StreamChunk, ThinkingLevel } from "./types.js";
7
- type ReasoningContentEcho = "tool_calls" | "all";
7
+ type ReasoningContentEcho = "tool_calls" | "all" | "none";
8
8
  export type ToolArgsMergeMode = "delta" | "snapshot";
9
9
  export interface TranslateOpenAIStreamOptions {
10
10
  toolArgsMergeMode?: ToolArgsMergeMode;
11
+ reasoningMergeMode?: ToolArgsMergeMode;
12
+ debugProviderId?: string;
13
+ debugModelId?: string;
11
14
  }
12
15
  export declare function toChatCompletionsMessage(message: ProviderMessage, options?: {
13
16
  reasoningContentEcho?: ReasoningContentEcho;
package/dist/provider.js CHANGED
@@ -8,6 +8,7 @@ import { appendFileSync } from "node:fs";
8
8
  import { createOpenAICodexProvider, isOpenAICodexBaseUrl } from "./provider-openai-codex.js";
9
9
  import { createProviderProtocolArtifactFilter } from "./provider-artifacts.js";
10
10
  import { resolveProviderRequestConfig } from "./provider-transform.js";
11
+ import { debugReasoningStream, summarizeDebugText } from "./reasoning-debug.js";
11
12
  // Diagnostic logger for tool-args byte-loss investigation. Activate with
12
13
  // BUBBLE_DEBUG_TOOL_ARGS=/path/to/log.jsonl (any writable path)
13
14
  // Each line is a JSON record describing a transition. When debugging is off,
@@ -101,6 +102,12 @@ export function createProviderInstance(options) {
101
102
  if (requestConfig.extraBody) {
102
103
  Object.assign(body, requestConfig.extraBody);
103
104
  }
105
+ if (tools && tools.length > 0 && requestConfig.parallelToolCalls !== undefined) {
106
+ body.parallel_tool_calls = requestConfig.parallelToolCalls;
107
+ }
108
+ if (requestConfig.maxTokens !== undefined) {
109
+ body.max_tokens = requestConfig.maxTokens;
110
+ }
104
111
  if (requestConfig.reasoningEffort && requestConfig.reasoningEffort !== "off") {
105
112
  body.reasoning = { enabled: true };
106
113
  }
@@ -109,6 +116,9 @@ export function createProviderInstance(options) {
109
116
  }));
110
117
  yield* translateOpenAIStream(stream, {
111
118
  toolArgsMergeMode: resolveToolArgsMergeMode(options.providerId || "", options.baseURL),
119
+ reasoningMergeMode: resolveReasoningMergeMode(options.providerId || "", options.baseURL),
120
+ debugProviderId: options.providerId || "",
121
+ debugModelId: chatOptions.model,
112
122
  });
113
123
  yield { type: "done" };
114
124
  }
@@ -126,6 +136,9 @@ export function createProviderInstance(options) {
126
136
  if (requestConfig.extraBody) {
127
137
  Object.assign(body, requestConfig.extraBody);
128
138
  }
139
+ if (requestConfig.maxTokens !== undefined) {
140
+ body.max_tokens = requestConfig.maxTokens;
141
+ }
129
142
  if (requestConfig.reasoningEffort && requestConfig.reasoningEffort !== "off") {
130
143
  body.reasoning = { enabled: true };
131
144
  }
@@ -188,6 +201,13 @@ function resolveToolArgsMergeMode(providerId, baseURL) {
188
201
  return "snapshot";
189
202
  return "delta";
190
203
  }
204
+ function resolveReasoningMergeMode(providerId, baseURL) {
205
+ const id = providerId.toLowerCase();
206
+ const url = baseURL.toLowerCase();
207
+ if (id === "fireworks" || url.includes("fireworks.ai"))
208
+ return "snapshot";
209
+ return "delta";
210
+ }
191
211
  function extractBalancedJson(s, start) {
192
212
  if (s[start] !== "{")
193
213
  return null;
@@ -232,6 +252,9 @@ export async function* translateOpenAIStream(stream, options = {}) {
232
252
  const toolCalls = new Map();
233
253
  const textFilter = createProviderProtocolArtifactFilter();
234
254
  const toolArgsMergeMode = options.toolArgsMergeMode ?? "delta";
255
+ const reasoningMergeMode = options.reasoningMergeMode ?? "delta";
256
+ let reasoningBuffer = "";
257
+ let rawChunkSeq = 0;
235
258
  // DeepSeek (and some inference re-hosts) sometimes deliver reasoning twice:
236
259
  // once via a dedicated `reasoning_content` / `thinking` field, and again
237
260
  // embedded as `<think>...</think>` inside `delta.content`. Track whether we
@@ -277,8 +300,21 @@ export async function* translateOpenAIStream(stream, options = {}) {
277
300
  }
278
301
  }
279
302
  for await (const chunk of stream) {
303
+ rawChunkSeq += 1;
280
304
  const delta = chunk.choices?.[0]?.delta;
281
305
  const usage = chunk.usage;
306
+ const finishReason = chunk.choices?.[0]?.finish_reason;
307
+ debugReasoningStream({
308
+ stage: "provider_raw",
309
+ providerId: options.debugProviderId,
310
+ modelId: options.debugModelId,
311
+ chunkSeq: rawChunkSeq,
312
+ finishReason,
313
+ content: summarizeDebugText(delta?.content),
314
+ reasoning: summarizeDebugText(delta?.reasoning),
315
+ thinking: summarizeDebugText(delta?.thinking),
316
+ reasoningContent: summarizeDebugText(delta?.reasoning_content),
317
+ });
282
318
  if (usage) {
283
319
  yield {
284
320
  type: "usage",
@@ -294,16 +330,53 @@ export async function* translateOpenAIStream(stream, options = {}) {
294
330
  },
295
331
  };
296
332
  }
297
- const reasoning = delta?.reasoning ?? delta?.thinking ?? delta?.reasoning_content;
333
+ const reasoningField = delta?.reasoning !== undefined
334
+ ? "reasoning"
335
+ : delta?.thinking !== undefined
336
+ ? "thinking"
337
+ : delta?.reasoning_content !== undefined
338
+ ? "reasoning_content"
339
+ : undefined;
340
+ const reasoning = reasoningField ? delta[reasoningField] : undefined;
298
341
  if (reasoning) {
299
342
  hasDedicatedReasoningChannel = true;
300
- yield { type: "reasoning_delta", content: reasoning };
343
+ const merged = mergeStreamingText(reasoningBuffer, reasoning, reasoningMergeMode);
344
+ reasoningBuffer = merged.args;
345
+ debugReasoningStream({
346
+ stage: "provider_emit",
347
+ providerId: options.debugProviderId,
348
+ modelId: options.debugModelId,
349
+ chunkSeq: rawChunkSeq,
350
+ source: reasoningField,
351
+ mergeMode: reasoningMergeMode,
352
+ suppressed: !merged.delta,
353
+ emitted: summarizeDebugText(merged.delta),
354
+ buffer: summarizeDebugText(reasoningBuffer),
355
+ });
356
+ if (merged.delta) {
357
+ yield { type: "reasoning_delta", content: merged.delta };
358
+ }
301
359
  }
302
360
  if (delta?.content) {
303
361
  const thinkMatch = delta.content.match(/<think>([\s\S]*?)<\/think>/);
304
362
  if (thinkMatch) {
305
363
  if (thinkMatch[1] && !hasDedicatedReasoningChannel) {
306
- yield { type: "reasoning_delta", content: thinkMatch[1] };
364
+ const merged = mergeStreamingText(reasoningBuffer, thinkMatch[1], reasoningMergeMode);
365
+ reasoningBuffer = merged.args;
366
+ debugReasoningStream({
367
+ stage: "provider_emit",
368
+ providerId: options.debugProviderId,
369
+ modelId: options.debugModelId,
370
+ chunkSeq: rawChunkSeq,
371
+ source: "content_think",
372
+ mergeMode: reasoningMergeMode,
373
+ suppressed: !merged.delta,
374
+ emitted: summarizeDebugText(merged.delta),
375
+ buffer: summarizeDebugText(reasoningBuffer),
376
+ });
377
+ if (merged.delta) {
378
+ yield { type: "reasoning_delta", content: merged.delta };
379
+ }
307
380
  }
308
381
  const remaining = delta.content.replace(/<think>[\s\S]*?<\/think>/, "");
309
382
  const cleaned = textFilter.push(remaining);
@@ -348,7 +421,6 @@ export async function* translateOpenAIStream(stream, options = {}) {
348
421
  }
349
422
  }
350
423
  }
351
- const finishReason = chunk.choices?.[0]?.finish_reason;
352
424
  if (finishReason === "tool_calls") {
353
425
  yield* flushToolCalls();
354
426
  }
@@ -386,3 +458,16 @@ function mergeToolArgumentDelta(current, incoming, mode) {
386
458
  debugToolArgs({ stage: "merge", branch: mode === "delta" ? "delta-append" : "snapshot-fallback-concat", current, incoming, args: current + incoming, delta: incoming });
387
459
  return { args: current + incoming, delta: incoming };
388
460
  }
461
+ function mergeStreamingText(current, incoming, mode) {
462
+ if (!current)
463
+ return { args: incoming, delta: incoming };
464
+ if (!incoming)
465
+ return { args: current, delta: "" };
466
+ if (mode === "snapshot") {
467
+ if (incoming === current)
468
+ return { args: current, delta: "" };
469
+ if (incoming.startsWith(current))
470
+ return { args: incoming, delta: incoming.slice(current.length) };
471
+ }
472
+ return { args: current + incoming, delta: incoming };
473
+ }
@@ -0,0 +1,7 @@
1
+ export interface DebugTextSummary {
2
+ length: number;
3
+ hash: string;
4
+ preview?: string;
5
+ }
6
+ export declare function summarizeDebugText(value: unknown): DebugTextSummary | undefined;
7
+ export declare function debugReasoningStream(event: Record<string, unknown>): void;
@@ -0,0 +1,30 @@
1
+ import { createHash } from "node:crypto";
2
+ import { appendFileSync, mkdirSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+ const DEBUG_PATH = process.env.BUBBLE_DEBUG_REASONING_STREAM?.trim();
5
+ const INCLUDE_PREVIEW = process.env.BUBBLE_DEBUG_REASONING_PREVIEW !== "0";
6
+ const PREVIEW_CHARS = 180;
7
+ let sequence = 0;
8
+ export function summarizeDebugText(value) {
9
+ if (!DEBUG_PATH)
10
+ return undefined;
11
+ if (typeof value !== "string" || value.length === 0)
12
+ return undefined;
13
+ const hash = createHash("sha256").update(value).digest("hex").slice(0, 16);
14
+ const summary = { length: value.length, hash };
15
+ if (INCLUDE_PREVIEW) {
16
+ summary.preview = value.replace(/\s+/g, " ").slice(0, PREVIEW_CHARS);
17
+ }
18
+ return summary;
19
+ }
20
+ export function debugReasoningStream(event) {
21
+ if (!DEBUG_PATH)
22
+ return;
23
+ try {
24
+ mkdirSync(dirname(DEBUG_PATH), { recursive: true });
25
+ appendFileSync(DEBUG_PATH, JSON.stringify({ t: Date.now(), seq: ++sequence, ...event }) + "\n", "utf-8");
26
+ }
27
+ catch {
28
+ // Debug logging must never affect an agent run.
29
+ }
30
+ }
@@ -72,6 +72,9 @@ export class SessionLog {
72
72
  getTodos() {
73
73
  for (let i = this.entries.length - 1; i >= 0; i--) {
74
74
  const entry = this.entries[i];
75
+ if (entry.type === "marker" && entry.kind === "conversation_clear") {
76
+ return [];
77
+ }
75
78
  if (entry.type === "todos_snapshot") {
76
79
  return entry.todos.map((todo) => ({ ...todo }));
77
80
  }
@@ -92,20 +95,28 @@ export class SessionLog {
92
95
  toMessages() {
93
96
  const messages = [];
94
97
  let latestSummaryIndex = -1;
98
+ let latestClearIndex = -1;
95
99
  for (let index = this.entries.length - 1; index >= 0; index--) {
96
100
  if (this.entries[index].type === "summary") {
97
101
  latestSummaryIndex = index;
98
102
  break;
99
103
  }
100
104
  }
101
- if (latestSummaryIndex >= 0) {
105
+ for (let index = this.entries.length - 1; index >= 0; index--) {
106
+ const entry = this.entries[index];
107
+ if (entry.type === "marker" && entry.kind === "conversation_clear") {
108
+ latestClearIndex = index;
109
+ break;
110
+ }
111
+ }
112
+ if (latestSummaryIndex > latestClearIndex) {
102
113
  const summary = this.entries[latestSummaryIndex];
103
114
  messages.push({
104
115
  role: "system",
105
116
  content: `Previous conversation summary: ${summary.summary}`,
106
117
  });
107
118
  }
108
- const startIndex = latestSummaryIndex >= 0 ? latestSummaryIndex + 1 : 0;
119
+ const startIndex = Math.max(latestSummaryIndex > latestClearIndex ? latestSummaryIndex + 1 : 0, latestClearIndex + 1);
109
120
  for (let index = startIndex; index < this.entries.length; index++) {
110
121
  const entry = this.entries[index];
111
122
  switch (entry.type) {
@@ -5,7 +5,7 @@ export interface SessionMetadata {
5
5
  reasoningEffort?: ThinkingLevel;
6
6
  cwd?: string;
7
7
  }
8
- export type SessionMarkerKind = "model_switch" | "provider_switch" | "thinking_level_switch" | "skill_activated" | "mode_switch";
8
+ export type SessionMarkerKind = "model_switch" | "provider_switch" | "thinking_level_switch" | "skill_activated" | "mode_switch" | "conversation_clear";
9
9
  interface BaseSessionLogEntry {
10
10
  id: string;
11
11
  timestamp: number;
@@ -1,4 +1,5 @@
1
1
  import { UserConfig, maskKey } from "../config.js";
2
+ import { formatContextUsage } from "../context/usage.js";
2
3
  import { formatDiagnostics } from "../lsp/index.js";
3
4
  import { normalizeNameForMCP } from "../mcp/name.js";
4
5
  import { parseRule } from "../permissions/rule.js";
@@ -70,6 +71,29 @@ function syncSystemPrompt(ctx, model) {
70
71
  memoryPrompt: buildMemoryPrompt(ctx.cwd),
71
72
  }));
72
73
  }
74
+ function formatMcpContextStatus(ctx) {
75
+ const states = ctx.mcpManager?.getStates() ?? [];
76
+ const lines = ["MCP"];
77
+ if (!ctx.mcpManager || states.length === 0) {
78
+ lines.push("- No MCP servers configured for this session.");
79
+ lines.push("- Context impact: none.");
80
+ return lines.join("\n");
81
+ }
82
+ for (const state of states) {
83
+ if (state.status.kind === "connected") {
84
+ lines.push(`- ${state.name} (${state.scope}): connected · ${state.status.tools.length} deferred tool${state.status.tools.length === 1 ? "" : "s"} · ${state.status.prompts.length} prompt${state.status.prompts.length === 1 ? "" : "s"}`);
85
+ continue;
86
+ }
87
+ if (state.status.kind === "failed") {
88
+ lines.push(`- ${state.name} (${state.scope}): failed · ${state.status.error}`);
89
+ continue;
90
+ }
91
+ lines.push(`- ${state.name} (${state.scope}): ${state.status.kind}`);
92
+ }
93
+ lines.push("- Context impact: MCP tool schemas are deferred. The prompt pays only a small deferred-tool reminder until tool_search unlocks a tool; unlocked MCP schemas then count under Tools.");
94
+ lines.push("- MCP prompts are slash commands; they do not enter context until invoked.");
95
+ return lines.join("\n");
96
+ }
73
97
  function switchToProviderModel(providerId, modelId, ctx, thinkingLevel) {
74
98
  const provider = ctx.registry.getConfigured().find((item) => item.id === providerId);
75
99
  if (!provider?.apiKey) {
@@ -270,37 +294,30 @@ const builtinSlashCommandEntries = [
270
294
  return handleMemoryCommand(args, ctx);
271
295
  },
272
296
  },
297
+ {
298
+ name: "context",
299
+ description: "Show current context window usage and breakdown",
300
+ async handler(args, ctx) {
301
+ return `${formatContextUsage(ctx.agent.getContextUsageSnapshot())}\n\n${formatMcpContextStatus(ctx)}`;
302
+ },
303
+ },
273
304
  {
274
305
  name: "quit",
275
306
  description: "Exit the application",
276
307
  async handler(args, ctx) {
277
- // Shut MCP stdio children down first; their stdout/stderr listeners
278
- // otherwise hold the Node event loop open even after ink unmounts.
279
- try {
280
- await ctx.mcpManager?.shutdown();
281
- }
282
- catch {
283
- // ignore — we're quitting anyway
284
- }
285
- try {
286
- await ctx.flushMemory?.();
287
- }
288
- catch {
289
- // memory shutdown hooks are best-effort during exit
290
- }
291
308
  ctx.exit();
292
- // Belt-and-braces: if anything else (raw-mode tty handle, pending
293
- // timer, etc.) still holds the loop, force-exit shortly after.
294
- setTimeout(() => process.exit(0), 100).unref();
295
309
  },
296
310
  },
297
311
  {
298
312
  name: "clear",
299
313
  description: "Clear the current conversation history",
300
314
  async handler(args, ctx) {
315
+ ctx.agent.messages = ctx.agent.messages.filter((m) => m.role === "system" || m.role === "meta");
316
+ ctx.sessionManager?.appendMarker("conversation_clear", "");
317
+ if (ctx.agent.getTodos().length > 0) {
318
+ ctx.agent.setTodos([]);
319
+ }
301
320
  ctx.clearMessages();
302
- ctx.agent.messages = ctx.agent.messages.filter((m) => m.role === "system");
303
- return "Conversation cleared.";
304
321
  },
305
322
  },
306
323
  {
@@ -8,6 +8,7 @@ import { access, readFile, writeFile } from "node:fs/promises";
8
8
  import { resolve } from "node:path";
9
9
  import { createTwoFilesPatch } from "diff";
10
10
  import { gateToolAction } from "../approval/tool-helper.js";
11
+ import { countUnifiedDiffChanges } from "../diff-stats.js";
11
12
  import { formatDiagnosticBlocks } from "../lsp/index.js";
12
13
  import { applyEditsToContent, EditApplyError, formatEditMatchNotes } from "./edit-apply.js";
13
14
  import { withFileMutationQueue } from "./file-mutation-queue.js";
@@ -70,6 +71,7 @@ export function createEditTool(cwd, approval, lsp, fileState) {
70
71
  throw err;
71
72
  }
72
73
  const diff = createTwoFilesPatch(filePath, filePath, original, applied.content, "original", "modified", { context: 3 });
74
+ const diffStats = countUnifiedDiffChanges(diff);
73
75
  // Gate on the approval controller BEFORE persisting the change.
74
76
  const gate = await gateToolAction(approval, {
75
77
  type: "edit",
@@ -111,6 +113,9 @@ export function createEditTool(cwd, approval, lsp, fileState) {
111
113
  metadata: {
112
114
  kind: "edit",
113
115
  path: filePath,
116
+ diff,
117
+ addedLines: diffStats.added,
118
+ removedLines: diffStats.removed,
114
119
  },
115
120
  };
116
121
  });
@@ -13,10 +13,29 @@ export type FileFreshnessResult = {
13
13
  observed?: FileVersion;
14
14
  current?: FileVersion;
15
15
  };
16
+ export interface ReadHistoryEntry {
17
+ argOffset: number | undefined;
18
+ argLimit: number | undefined;
19
+ effectiveOffset: number;
20
+ effectiveLimit: number;
21
+ returnedLines: number;
22
+ totalLines: number;
23
+ mtimeMs: number;
24
+ truncated: boolean;
25
+ }
16
26
  export declare class FileStateTracker {
17
27
  private readonly cwd;
18
28
  private readonly observed;
29
+ private readonly readHistory;
19
30
  constructor(cwd: string);
31
+ getReadHistory(filePath: string): ReadHistoryEntry | undefined;
32
+ setReadHistory(filePath: string, entry: ReadHistoryEntry): void;
33
+ /**
34
+ * Drops all read-dedup state. Call this whenever conversation history is
35
+ * compacted or pruned, because the dedup stub points the model back at
36
+ * earlier tool_result content that may no longer be resident.
37
+ */
38
+ invalidateReadHistory(): void;
20
39
  observe(filePath: string, source: FileObservationSource, content?: string): Promise<FileVersion>;
21
40
  checkFresh(filePath: string): Promise<FileFreshnessResult>;
22
41
  private resolvePath;
@@ -4,9 +4,24 @@ import { isAbsolute, relative, resolve } from "node:path";
4
4
  export class FileStateTracker {
5
5
  cwd;
6
6
  observed = new Map();
7
+ readHistory = new Map();
7
8
  constructor(cwd) {
8
9
  this.cwd = cwd;
9
10
  }
11
+ getReadHistory(filePath) {
12
+ return this.readHistory.get(this.resolvePath(filePath));
13
+ }
14
+ setReadHistory(filePath, entry) {
15
+ this.readHistory.set(this.resolvePath(filePath), entry);
16
+ }
17
+ /**
18
+ * Drops all read-dedup state. Call this whenever conversation history is
19
+ * compacted or pruned, because the dedup stub points the model back at
20
+ * earlier tool_result content that may no longer be resident.
21
+ */
22
+ invalidateReadHistory() {
23
+ this.readHistory.clear();
24
+ }
10
25
  async observe(filePath, source, content) {
11
26
  const absolute = this.resolvePath(filePath);
12
27
  const version = await this.computeVersion(absolute, content);
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Read tool - read file contents with truncation.
2
+ * Read tool - read file contents with truncation, dedup, and auto-pagination.
3
3
  */
4
4
  import type { ApprovalController } from "../approval/types.js";
5
5
  import type { ToolRegistryEntry } from "../types.js";
@@ -1,18 +1,28 @@
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";
5
+ import { access, readFile, stat } from "node:fs/promises";
6
6
  import { resolve } from "node:path";
7
7
  import { isSensitivePath } from "./sensitive-paths.js";
8
- const MAX_LINES = 250;
9
- const MAX_BYTES = 100 * 1024;
8
+ const MAX_LINES = 2500;
9
+ const MAX_BYTES = 256 * 1024;
10
+ 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.";
11
+ 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
12
  export function createReadTool(cwd, approval, lsp, fileState) {
13
+ const localHistory = new Map();
14
+ const getHistory = (path) => fileState?.getReadHistory(path) ?? localHistory.get(path);
15
+ const setHistory = (path, entry) => {
16
+ if (fileState)
17
+ fileState.setReadHistory(path, entry);
18
+ else
19
+ localHistory.set(path, entry);
20
+ };
11
21
  return {
12
22
  name: "read",
13
23
  readOnly: true,
14
24
  effect: "read",
15
- description: `Read the contents of a file. Output is truncated to ${MAX_LINES} lines or ${MAX_BYTES / 1024}KB (whichever is hit first). Use offset/limit for large files.`,
25
+ 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
26
  parameters: {
17
27
  type: "object",
18
28
  properties: {
@@ -51,11 +61,62 @@ export function createReadTool(cwd, approval, lsp, fileState) {
51
61
  catch {
52
62
  return { content: `Error: Cannot read file: ${filePath}`, isError: true };
53
63
  }
54
- let content = await readFile(filePath, "utf-8");
64
+ const argOffset = typeof args.offset === "number" ? args.offset : undefined;
65
+ const argLimit = typeof args.limit === "number" ? args.limit : undefined;
66
+ let currentMtimeMs;
67
+ try {
68
+ currentMtimeMs = (await stat(filePath)).mtimeMs;
69
+ }
70
+ catch {
71
+ currentMtimeMs = undefined;
72
+ }
73
+ const prior = getHistory(filePath);
74
+ const sameArgs = prior !== undefined
75
+ && prior.argOffset === argOffset
76
+ && prior.argLimit === argLimit;
77
+ const mtimeUnchanged = prior !== undefined
78
+ && currentMtimeMs !== undefined
79
+ && Math.floor(prior.mtimeMs) === Math.floor(currentMtimeMs);
80
+ let effectiveOffset = argOffset !== undefined ? Math.max(0, argOffset - 1) : 0;
81
+ let autoAdvanceNote;
82
+ if (prior && sameArgs && mtimeUnchanged) {
83
+ if (prior.truncated && argOffset === undefined) {
84
+ const nextStart = prior.effectiveOffset + prior.returnedLines;
85
+ if (nextStart >= prior.totalLines) {
86
+ return {
87
+ content: END_OF_FILE_STUB(prior.totalLines),
88
+ status: "success",
89
+ metadata: { kind: "read", path: filePath, dedup: "end_of_file" },
90
+ };
91
+ }
92
+ effectiveOffset = nextStart;
93
+ autoAdvanceNote =
94
+ `[Auto-advanced from previous truncated read of ${filePath}. ` +
95
+ `Showing lines ${effectiveOffset + 1}+ (file has ${prior.totalLines} lines). ` +
96
+ `Pass an explicit offset/limit to override this auto-paging.]`;
97
+ }
98
+ else if (argOffset === undefined
99
+ && prior.effectiveOffset > 0
100
+ && !prior.truncated) {
101
+ return {
102
+ content: END_OF_FILE_STUB(prior.totalLines),
103
+ status: "success",
104
+ metadata: { kind: "read", path: filePath, dedup: "end_of_file" },
105
+ };
106
+ }
107
+ else {
108
+ return {
109
+ content: FILE_UNCHANGED_STUB,
110
+ status: "success",
111
+ metadata: { kind: "read", path: filePath, dedup: "unchanged" },
112
+ };
113
+ }
114
+ }
115
+ const content = await readFile(filePath, "utf-8");
55
116
  const lines = content.split("\n");
56
- const offset = typeof args.offset === "number" ? Math.max(0, args.offset - 1) : 0;
57
- const limit = typeof args.limit === "number" ? args.limit : lines.length;
58
- let sliced = lines.slice(offset, offset + limit);
117
+ const totalLines = lines.length;
118
+ const effectiveLimit = argLimit !== undefined ? argLimit : totalLines;
119
+ let sliced = lines.slice(effectiveOffset, effectiveOffset + effectiveLimit);
59
120
  let truncated = false;
60
121
  if (sliced.length > MAX_LINES) {
61
122
  sliced = sliced.slice(0, MAX_LINES);
@@ -67,10 +128,28 @@ export function createReadTool(cwd, approval, lsp, fileState) {
67
128
  result = Buffer.from(result, "utf-8").subarray(0, MAX_BYTES).toString("utf-8");
68
129
  truncated = true;
69
130
  }
131
+ if (autoAdvanceNote) {
132
+ result = `${autoAdvanceNote}\n${result}`;
133
+ }
70
134
  if (truncated) {
71
- result += `\n[Output truncated: exceeded ${MAX_LINES} lines or ${MAX_BYTES / 1024}KB limit]`;
135
+ const lastLine = effectiveOffset + sliced.length;
136
+ 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.]`;
137
+ }
138
+ if (currentMtimeMs !== undefined) {
139
+ setHistory(filePath, {
140
+ argOffset,
141
+ argLimit,
142
+ effectiveOffset,
143
+ effectiveLimit,
144
+ returnedLines: sliced.length,
145
+ totalLines,
146
+ mtimeMs: currentMtimeMs,
147
+ truncated,
148
+ });
72
149
  }
73
- const isFullRead = offset === 0 && !truncated && offset + limit >= lines.length;
150
+ const isFullRead = effectiveOffset === 0
151
+ && !truncated
152
+ && effectiveOffset + effectiveLimit >= totalLines;
74
153
  if (isFullRead) {
75
154
  await fileState?.observe(filePath, "read", content).catch(() => undefined);
76
155
  }
@@ -81,6 +160,8 @@ export function createReadTool(cwd, approval, lsp, fileState) {
81
160
  metadata: {
82
161
  kind: "read",
83
162
  path: filePath,
163
+ ...(autoAdvanceNote ? { autoAdvanced: true } : {}),
164
+ ...(truncated ? { truncated: true } : {}),
84
165
  },
85
166
  };
86
167
  },
@@ -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
+ }