@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.
- package/dist/agent/execution-governor.d.ts +5 -13
- package/dist/agent/execution-governor.js +33 -142
- package/dist/agent.d.ts +6 -0
- package/dist/agent.js +36 -3
- package/dist/context/budget.d.ts +1 -0
- package/dist/context/budget.js +1 -1
- package/dist/context/usage.d.ts +34 -0
- package/dist/context/usage.js +213 -0
- package/dist/diff-stats.d.ts +5 -0
- package/dist/diff-stats.js +21 -0
- package/dist/main.js +83 -44
- package/dist/mcp/transports.d.ts +1 -0
- package/dist/mcp/transports.js +8 -0
- package/dist/model-catalog.js +1 -1
- package/dist/orchestrator/default-hooks.js +9 -33
- package/dist/prompt/compose.js +2 -1
- package/dist/prompt/provider-prompts/kimi.js +3 -1
- package/dist/prompt/reminders.d.ts +2 -1
- package/dist/prompt/reminders.js +4 -3
- package/dist/provider-registry.js +3 -3
- package/dist/provider-transform.d.ts +3 -1
- package/dist/provider-transform.js +15 -0
- package/dist/provider.d.ts +4 -1
- package/dist/provider.js +89 -4
- package/dist/reasoning-debug.d.ts +7 -0
- package/dist/reasoning-debug.js +30 -0
- package/dist/session-log.js +13 -2
- package/dist/session-types.d.ts +1 -1
- package/dist/slash-commands/commands.js +36 -19
- package/dist/tools/edit.js +5 -0
- package/dist/tools/file-state.d.ts +19 -0
- package/dist/tools/file-state.js +15 -0
- package/dist/tools/read.d.ts +1 -1
- package/dist/tools/read.js +92 -11
- package/dist/tui/escape-confirmation.d.ts +15 -0
- package/dist/tui/escape-confirmation.js +30 -0
- package/dist/tui/run.js +93 -23
- package/dist/tui-ink/app.d.ts +43 -0
- package/dist/tui-ink/app.js +1016 -0
- package/dist/tui-ink/approval/approval-dialog.d.ts +13 -0
- package/dist/tui-ink/approval/approval-dialog.js +129 -0
- package/dist/tui-ink/approval/diff-view.d.ts +7 -0
- package/dist/tui-ink/approval/diff-view.js +43 -0
- package/dist/tui-ink/approval/select.d.ts +35 -0
- package/dist/tui-ink/approval/select.js +87 -0
- package/dist/tui-ink/code-highlight.d.ts +6 -0
- package/dist/tui-ink/code-highlight.js +94 -0
- package/dist/tui-ink/display-history.d.ts +38 -0
- package/dist/tui-ink/display-history.js +130 -0
- package/dist/tui-ink/edit-diff.d.ts +11 -0
- package/dist/tui-ink/edit-diff.js +52 -0
- package/dist/tui-ink/file-mentions.d.ts +29 -0
- package/dist/tui-ink/file-mentions.js +174 -0
- package/dist/tui-ink/footer.d.ts +19 -0
- package/dist/tui-ink/footer.js +44 -0
- package/dist/tui-ink/image-paste.d.ts +54 -0
- package/dist/tui-ink/image-paste.js +288 -0
- package/dist/tui-ink/input-box.d.ts +41 -0
- package/dist/tui-ink/input-box.js +637 -0
- package/dist/tui-ink/markdown.d.ts +38 -0
- package/dist/tui-ink/markdown.js +384 -0
- package/dist/tui-ink/message-list.d.ts +33 -0
- package/dist/tui-ink/message-list.js +571 -0
- package/dist/tui-ink/model-picker.d.ts +43 -0
- package/dist/tui-ink/model-picker.js +326 -0
- package/dist/tui-ink/plan-confirm.d.ts +7 -0
- package/dist/tui-ink/plan-confirm.js +104 -0
- package/dist/tui-ink/question-dialog.d.ts +8 -0
- package/dist/tui-ink/question-dialog.js +98 -0
- package/dist/tui-ink/recent-activity.d.ts +8 -0
- package/dist/tui-ink/recent-activity.js +71 -0
- package/dist/tui-ink/run.d.ts +33 -0
- package/dist/tui-ink/run.js +25 -0
- package/dist/tui-ink/theme.d.ts +37 -0
- package/dist/tui-ink/theme.js +42 -0
- package/dist/tui-ink/todos.d.ts +7 -0
- package/dist/tui-ink/todos.js +44 -0
- package/dist/tui-ink/trace-groups.d.ts +25 -0
- package/dist/tui-ink/trace-groups.js +310 -0
- package/dist/tui-ink/use-terminal-size.d.ts +4 -0
- package/dist/tui-ink/use-terminal-size.js +21 -0
- package/dist/tui-ink/welcome.d.ts +18 -0
- package/dist/tui-ink/welcome.js +119 -0
- package/dist/types.d.ts +4 -0
- 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,
|
package/dist/provider.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/session-log.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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) {
|
package/dist/session-types.d.ts
CHANGED
|
@@ -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
|
{
|
package/dist/tools/edit.js
CHANGED
|
@@ -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;
|
package/dist/tools/file-state.js
CHANGED
|
@@ -4,9 +4,24 @@ import { isAbsolute, relative, resolve } from "node:path";
|
|
|
4
4
|
export class FileStateTracker {
|
|
5
5
|
cwd;
|
|
6
6
|
observed = new Map();
|
|
7
|
+
readHistory = new Map();
|
|
7
8
|
constructor(cwd) {
|
|
8
9
|
this.cwd = cwd;
|
|
9
10
|
}
|
|
11
|
+
getReadHistory(filePath) {
|
|
12
|
+
return this.readHistory.get(this.resolvePath(filePath));
|
|
13
|
+
}
|
|
14
|
+
setReadHistory(filePath, entry) {
|
|
15
|
+
this.readHistory.set(this.resolvePath(filePath), entry);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Drops all read-dedup state. Call this whenever conversation history is
|
|
19
|
+
* compacted or pruned, because the dedup stub points the model back at
|
|
20
|
+
* earlier tool_result content that may no longer be resident.
|
|
21
|
+
*/
|
|
22
|
+
invalidateReadHistory() {
|
|
23
|
+
this.readHistory.clear();
|
|
24
|
+
}
|
|
10
25
|
async observe(filePath, source, content) {
|
|
11
26
|
const absolute = this.resolvePath(filePath);
|
|
12
27
|
const version = await this.computeVersion(absolute, content);
|
package/dist/tools/read.d.ts
CHANGED
package/dist/tools/read.js
CHANGED
|
@@ -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 =
|
|
9
|
-
const MAX_BYTES =
|
|
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).
|
|
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
|
-
|
|
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
|
|
57
|
-
const
|
|
58
|
-
let sliced = lines.slice(
|
|
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
|
-
|
|
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 =
|
|
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
|
+
}
|