@bubblebrain-ai/bubble 0.0.15 → 0.0.17

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 (51) hide show
  1. package/README.md +24 -0
  2. package/dist/agent/discovery-barrier.d.ts +21 -0
  3. package/dist/agent/discovery-barrier.js +173 -0
  4. package/dist/agent/internal-reminder-sanitizer.d.ts +9 -0
  5. package/dist/agent/internal-reminder-sanitizer.js +198 -0
  6. package/dist/agent/task-classifier.js +23 -5
  7. package/dist/agent.js +215 -30
  8. package/dist/context/budget.js +15 -0
  9. package/dist/context/projector.js +4 -3
  10. package/dist/debug-trace.js +14 -0
  11. package/dist/feishu/serve.js +1 -0
  12. package/dist/main.js +2 -0
  13. package/dist/model-catalog.d.ts +3 -0
  14. package/dist/model-catalog.js +44 -0
  15. package/dist/model-config.d.ts +3 -0
  16. package/dist/model-config.js +3 -0
  17. package/dist/model-pricing.d.ts +3 -2
  18. package/dist/model-pricing.js +8 -0
  19. package/dist/network/chatgpt-transport.d.ts +16 -0
  20. package/dist/network/chatgpt-transport.js +240 -0
  21. package/dist/oauth/openai-codex.d.ts +7 -2
  22. package/dist/oauth/openai-codex.js +7 -4
  23. package/dist/orchestrator/default-hooks.js +13 -2
  24. package/dist/orchestrator/hooks.d.ts +2 -0
  25. package/dist/prompt/compose.js +1 -1
  26. package/dist/prompt/reminders.js +3 -3
  27. package/dist/prompt/runtime.js +1 -0
  28. package/dist/provider-anthropic.d.ts +77 -0
  29. package/dist/provider-anthropic.js +544 -0
  30. package/dist/provider-openai-codex.d.ts +3 -0
  31. package/dist/provider-openai-codex.js +11 -2
  32. package/dist/provider-registry.d.ts +2 -0
  33. package/dist/provider-registry.js +29 -3
  34. package/dist/provider-transform.d.ts +1 -1
  35. package/dist/provider-transform.js +23 -0
  36. package/dist/provider.d.ts +4 -1
  37. package/dist/provider.js +119 -40
  38. package/dist/reasoning-debug.js +4 -1
  39. package/dist/session-log.js +17 -2
  40. package/dist/slash-commands/commands.js +4 -2
  41. package/dist/stats/usage.d.ts +4 -0
  42. package/dist/stats/usage.js +48 -11
  43. package/dist/tools/glob.js +3 -0
  44. package/dist/tools/grep.js +7 -0
  45. package/dist/tui/run.js +22 -12
  46. package/dist/tui-ink/app.js +3 -0
  47. package/dist/tui-ink/message-list.js +6 -3
  48. package/dist/tui-opentui/app.js +3 -0
  49. package/dist/tui-opentui/message-list.js +6 -3
  50. package/dist/types.d.ts +14 -1
  51. package/package.json +2 -1
@@ -3,7 +3,7 @@ export { getAvailableThinkingLevels, getDefaultThinkingLevel, normalizeThinkingL
3
3
  export interface ProviderRequestConfig {
4
4
  effectiveThinkingLevel: ThinkingLevel;
5
5
  reasoningEffort?: ThinkingLevel;
6
- reasoningContentEcho?: "tool_calls" | "all" | "none";
6
+ reasoningContentEcho?: "tool_calls" | "all" | "none" | "minimax";
7
7
  parallelToolCalls?: boolean;
8
8
  maxTokens?: number;
9
9
  extraBody?: Record<string, unknown>;
@@ -4,6 +4,7 @@ const MOONSHOT_PROVIDER_IDS = new Set(["moonshot-cn", "moonshot-intl", "kimi-for
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
6
  const KIMI_K26_DEFAULT_MAX_TOKENS = 32768;
7
+ const MINIMAX_M3_FAMILY = new Set(["MiniMax-M3"]);
7
8
  function isFireworksKimi(providerId, modelId) {
8
9
  const model = modelId.toLowerCase();
9
10
  return providerId === "fireworks" && (model.includes("kimi")
@@ -36,6 +37,28 @@ export function resolveProviderRequestConfig(providerId, modelId, requestedLevel
36
37
  },
37
38
  };
38
39
  }
40
+ if (providerId === "stepfun") {
41
+ return {
42
+ effectiveThinkingLevel,
43
+ reasoningContentEcho: "none",
44
+ extraBody: effectiveThinkingLevel === "off"
45
+ ? undefined
46
+ : { reasoning_effort: effectiveThinkingLevel },
47
+ };
48
+ }
49
+ if (providerId === "minimax" || providerId === "minimax-openai") {
50
+ const extraBody = { reasoning_split: true };
51
+ if (MINIMAX_M3_FAMILY.has(modelId)) {
52
+ extraBody.thinking = {
53
+ type: effectiveThinkingLevel === "off" ? "disabled" : "adaptive",
54
+ };
55
+ }
56
+ return {
57
+ effectiveThinkingLevel,
58
+ reasoningContentEcho: "minimax",
59
+ extraBody,
60
+ };
61
+ }
39
62
  // Zhipu/Z.AI OpenAI-compatible endpoints expose reasoning via a provider-specific
40
63
  // `thinking` block rather than OpenAI's `reasoning_effort` shape.
41
64
  if (["zhipuai", "zhipuai-coding-plan", "zai", "zai-coding-plan"].includes(providerId)) {
@@ -4,12 +4,14 @@
4
4
  * Works with OpenRouter, OpenAI, DeepSeek, Google, Groq, Together, and local OpenAI-compatible endpoints.
5
5
  */
6
6
  import { type OpenAICodexAuthAdapter } from "./provider-openai-codex.js";
7
+ import type { ProviderProtocol } from "./model-catalog.js";
7
8
  import type { Provider, ProviderMessage, StreamChunk, ThinkingLevel } from "./types.js";
8
- type ReasoningContentEcho = "tool_calls" | "all" | "none";
9
+ type ReasoningContentEcho = "tool_calls" | "all" | "none" | "minimax";
9
10
  export type ToolArgsMergeMode = "delta" | "snapshot";
10
11
  export interface TranslateOpenAIStreamOptions {
11
12
  toolArgsMergeMode?: ToolArgsMergeMode;
12
13
  reasoningMergeMode?: ToolArgsMergeMode;
14
+ textMergeMode?: ToolArgsMergeMode;
13
15
  debugProviderId?: string;
14
16
  debugModelId?: string;
15
17
  }
@@ -24,6 +26,7 @@ export interface ProviderInstanceOptions {
24
26
  thinkingLevel?: ThinkingLevel;
25
27
  /** Stable per-session seed for provider prompt caches. */
26
28
  promptCacheKey?: string;
29
+ protocol?: ProviderProtocol;
27
30
  /** Dynamic OAuth access-token loader/refresh hook for ChatGPT Codex requests. */
28
31
  openAICodexAuth?: OpenAICodexAuthAdapter;
29
32
  }
package/dist/provider.js CHANGED
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import OpenAI from "openai";
7
7
  import { appendFileSync } from "node:fs";
8
+ import { createAnthropicMessagesProvider } from "./provider-anthropic.js";
8
9
  import { createOpenAICodexProvider, isOpenAICodexBaseUrl } from "./provider-openai-codex.js";
9
10
  import { createProviderProtocolArtifactFilter } from "./provider-artifacts.js";
10
11
  import { resolveProviderRequestConfig } from "./provider-transform.js";
@@ -36,6 +37,15 @@ export function toChatCompletionsMessage(message, options = {}) {
36
37
  // provider field, even when the original value is an empty string.
37
38
  out.reasoning_content = message.reasoning ?? "";
38
39
  }
40
+ if (reasoningContentEcho === "minimax" && message.reasoning) {
41
+ out.reasoning_details = [{
42
+ type: "reasoning.text",
43
+ id: "reasoning-text-1",
44
+ format: "MiniMax-response-v1",
45
+ index: 0,
46
+ text: message.reasoning,
47
+ }];
48
+ }
39
49
  if (message.toolCalls && message.toolCalls.length > 0) {
40
50
  out.tool_calls = message.toolCalls.map((tc) => ({
41
51
  id: tc.id,
@@ -65,6 +75,9 @@ export function createUnavailableProvider(message) {
65
75
  return { streamChat, complete };
66
76
  }
67
77
  export function createProviderInstance(options) {
78
+ if (resolveProviderProtocol(options) === "anthropic-messages") {
79
+ return createAnthropicMessagesProvider(options);
80
+ }
68
81
  if (isOpenAICodexBaseUrl(options.baseURL)) {
69
82
  return createOpenAICodexProvider({
70
83
  ...options,
@@ -96,8 +109,8 @@ export function createProviderInstance(options) {
96
109
  tool_choice: tools && tools.length > 0 ? "auto" : undefined,
97
110
  stream: true,
98
111
  };
99
- // DeepSeek only emits final usage in streaming mode when this flag is set.
100
- if (options.providerId === "deepseek") {
112
+ // DeepSeek and MiniMax only emit final usage in streaming mode when this flag is set.
113
+ if (options.providerId === "deepseek" || isMiniMaxOpenAICompatible(options)) {
101
114
  body.stream_options = { include_usage: true };
102
115
  }
103
116
  if (!requestConfig.omitTemperature) {
@@ -121,6 +134,7 @@ export function createProviderInstance(options) {
121
134
  yield* translateOpenAIStream(stream, {
122
135
  toolArgsMergeMode: resolveToolArgsMergeMode(options.providerId || "", options.baseURL),
123
136
  reasoningMergeMode: resolveReasoningMergeMode(options.providerId || "", options.baseURL),
137
+ textMergeMode: resolveTextMergeMode(options.providerId || "", options.baseURL),
124
138
  debugProviderId: options.providerId || "",
125
139
  debugModelId: chatOptions.model,
126
140
  });
@@ -153,6 +167,26 @@ export function createProviderInstance(options) {
153
167
  }
154
168
  return { streamChat, complete };
155
169
  }
170
+ function resolveProviderProtocol(options) {
171
+ if (options.protocol)
172
+ return options.protocol;
173
+ const providerId = (options.providerId || "").toLowerCase();
174
+ const baseURL = options.baseURL.toLowerCase();
175
+ if (providerId === "anthropic"
176
+ || providerId.endsWith("-anthropic")
177
+ || baseURL.includes("/anthropic")) {
178
+ return "anthropic-messages";
179
+ }
180
+ return "openai-chat";
181
+ }
182
+ function isMiniMaxOpenAICompatible(options) {
183
+ const providerId = (options.providerId || "").toLowerCase();
184
+ const baseURL = options.baseURL.toLowerCase();
185
+ return providerId === "minimax-openai"
186
+ || (providerId === "minimax" && !baseURL.includes("/anthropic"))
187
+ || baseURL.includes("api.minimaxi.com/v1")
188
+ || baseURL.includes("api.minimax.io/v1");
189
+ }
156
190
  export function normalizeToolArgsDetailed(raw) {
157
191
  const s = (raw ?? "").trim();
158
192
  if (!s) {
@@ -210,6 +244,15 @@ function resolveReasoningMergeMode(providerId, baseURL) {
210
244
  const url = baseURL.toLowerCase();
211
245
  if (id === "fireworks" || url.includes("fireworks.ai"))
212
246
  return "snapshot";
247
+ if (id === "minimax" || url.includes("api.minimaxi.com") || url.includes("api.minimax.io"))
248
+ return "snapshot";
249
+ return "delta";
250
+ }
251
+ function resolveTextMergeMode(providerId, baseURL) {
252
+ const id = providerId.toLowerCase();
253
+ const url = baseURL.toLowerCase();
254
+ if (id === "minimax" || url.includes("api.minimaxi.com") || url.includes("api.minimax.io"))
255
+ return "snapshot";
213
256
  return "delta";
214
257
  }
215
258
  function extractBalancedJson(s, start) {
@@ -257,7 +300,9 @@ export async function* translateOpenAIStream(stream, options = {}) {
257
300
  const textFilter = createProviderProtocolArtifactFilter();
258
301
  const toolArgsMergeMode = options.toolArgsMergeMode ?? "delta";
259
302
  const reasoningMergeMode = options.reasoningMergeMode ?? "delta";
303
+ const textMergeMode = options.textMergeMode ?? "delta";
260
304
  let reasoningBuffer = "";
305
+ let textBuffer = "";
261
306
  let rawChunkSeq = 0;
262
307
  // DeepSeek (and some inference re-hosts) sometimes deliver reasoning twice:
263
308
  // once via a dedicated `reasoning_content` / `thinking` field, and again
@@ -318,6 +363,7 @@ export async function* translateOpenAIStream(stream, options = {}) {
318
363
  reasoning: summarizeDebugText(delta?.reasoning),
319
364
  thinking: summarizeDebugText(delta?.thinking),
320
365
  reasoningContent: summarizeDebugText(delta?.reasoning_content),
366
+ reasoningDetails: summarizeDebugText(extractReasoningDetailsText(delta?.reasoning_details)),
321
367
  });
322
368
  if (usage) {
323
369
  yield {
@@ -325,8 +371,16 @@ export async function* translateOpenAIStream(stream, options = {}) {
325
371
  usage: {
326
372
  promptTokens: typeof usage.prompt_tokens === "number" ? usage.prompt_tokens : 0,
327
373
  completionTokens: typeof usage.completion_tokens === "number" ? usage.completion_tokens : 0,
328
- promptCacheHitTokens: typeof usage.prompt_cache_hit_tokens === "number" ? usage.prompt_cache_hit_tokens : undefined,
329
- promptCacheMissTokens: typeof usage.prompt_cache_miss_tokens === "number" ? usage.prompt_cache_miss_tokens : undefined,
374
+ promptCacheHitTokens: typeof usage.prompt_cache_hit_tokens === "number"
375
+ ? usage.prompt_cache_hit_tokens
376
+ : typeof usage.prompt_tokens_details?.cached_tokens === "number"
377
+ ? usage.prompt_tokens_details.cached_tokens
378
+ : undefined,
379
+ promptCacheMissTokens: typeof usage.prompt_cache_miss_tokens === "number"
380
+ ? usage.prompt_cache_miss_tokens
381
+ : typeof usage.prompt_tokens_details?.cached_tokens === "number" && typeof usage.prompt_tokens === "number"
382
+ ? Math.max(0, usage.prompt_tokens - usage.prompt_tokens_details.cached_tokens)
383
+ : undefined,
330
384
  reasoningTokens: typeof usage.completion_tokens_details?.reasoning_tokens === "number"
331
385
  ? usage.completion_tokens_details.reasoning_tokens
332
386
  : undefined,
@@ -334,14 +388,21 @@ export async function* translateOpenAIStream(stream, options = {}) {
334
388
  },
335
389
  };
336
390
  }
337
- const reasoningField = delta?.reasoning !== undefined
338
- ? "reasoning"
339
- : delta?.thinking !== undefined
340
- ? "thinking"
341
- : delta?.reasoning_content !== undefined
342
- ? "reasoning_content"
343
- : undefined;
344
- const reasoning = reasoningField ? delta[reasoningField] : undefined;
391
+ const reasoningDetails = extractReasoningDetailsText(delta?.reasoning_details);
392
+ const reasoningField = reasoningDetails !== undefined
393
+ ? "reasoning_details"
394
+ : delta?.reasoning !== undefined
395
+ ? "reasoning"
396
+ : delta?.thinking !== undefined
397
+ ? "thinking"
398
+ : delta?.reasoning_content !== undefined
399
+ ? "reasoning_content"
400
+ : undefined;
401
+ const reasoning = reasoningDetails !== undefined
402
+ ? reasoningDetails
403
+ : reasoningField
404
+ ? delta[reasoningField]
405
+ : undefined;
345
406
  if (reasoning) {
346
407
  hasDedicatedReasoningChannel = true;
347
408
  const merged = mergeStreamingText(reasoningBuffer, reasoning, reasoningMergeMode);
@@ -362,36 +423,41 @@ export async function* translateOpenAIStream(stream, options = {}) {
362
423
  }
363
424
  }
364
425
  if (delta?.content) {
365
- const thinkMatch = delta.content.match(/<think>([\s\S]*?)<\/think>/);
366
- if (thinkMatch) {
367
- if (thinkMatch[1] && !hasDedicatedReasoningChannel) {
368
- const merged = mergeStreamingText(reasoningBuffer, thinkMatch[1], reasoningMergeMode);
369
- reasoningBuffer = merged.args;
370
- debugReasoningStream({
371
- stage: "provider_emit",
372
- providerId: options.debugProviderId,
373
- modelId: options.debugModelId,
374
- chunkSeq: rawChunkSeq,
375
- source: "content_think",
376
- mergeMode: reasoningMergeMode,
377
- suppressed: !merged.delta,
378
- emitted: summarizeDebugText(merged.delta),
379
- buffer: summarizeDebugText(reasoningBuffer),
380
- });
381
- if (merged.delta) {
382
- yield { type: "reasoning_delta", content: merged.delta };
426
+ const mergedContent = mergeStreamingText(textBuffer, delta.content, textMergeMode);
427
+ textBuffer = mergedContent.args;
428
+ const content = mergedContent.delta;
429
+ if (content) {
430
+ const thinkMatch = content.match(/<think>([\s\S]*?)<\/think>/);
431
+ if (thinkMatch) {
432
+ if (thinkMatch[1] && !hasDedicatedReasoningChannel) {
433
+ const merged = mergeStreamingText(reasoningBuffer, thinkMatch[1], reasoningMergeMode);
434
+ reasoningBuffer = merged.args;
435
+ debugReasoningStream({
436
+ stage: "provider_emit",
437
+ providerId: options.debugProviderId,
438
+ modelId: options.debugModelId,
439
+ chunkSeq: rawChunkSeq,
440
+ source: "content_think",
441
+ mergeMode: reasoningMergeMode,
442
+ suppressed: !merged.delta,
443
+ emitted: summarizeDebugText(merged.delta),
444
+ buffer: summarizeDebugText(reasoningBuffer),
445
+ });
446
+ if (merged.delta) {
447
+ yield { type: "reasoning_delta", content: merged.delta };
448
+ }
449
+ }
450
+ const remaining = content.replace(/<think>[\s\S]*?<\/think>/, "");
451
+ const cleaned = textFilter.push(remaining);
452
+ if (cleaned) {
453
+ yield { type: "text", content: cleaned };
383
454
  }
384
455
  }
385
- const remaining = delta.content.replace(/<think>[\s\S]*?<\/think>/, "");
386
- const cleaned = textFilter.push(remaining);
387
- if (cleaned) {
388
- yield { type: "text", content: cleaned };
389
- }
390
- }
391
- else {
392
- const cleaned = textFilter.push(delta.content);
393
- if (cleaned) {
394
- yield { type: "text", content: cleaned };
456
+ else {
457
+ const cleaned = textFilter.push(content);
458
+ if (cleaned) {
459
+ yield { type: "text", content: cleaned };
460
+ }
395
461
  }
396
462
  }
397
463
  }
@@ -435,6 +501,19 @@ export async function* translateOpenAIStream(stream, options = {}) {
435
501
  }
436
502
  yield* flushToolCalls();
437
503
  }
504
+ function extractReasoningDetailsText(value) {
505
+ if (!value)
506
+ return undefined;
507
+ const details = Array.isArray(value) ? value : [value];
508
+ const parts = details.flatMap((item) => {
509
+ if (!item || typeof item !== "object")
510
+ return [];
511
+ const record = item;
512
+ const text = record.text ?? record.thinking ?? record.content;
513
+ return typeof text === "string" ? [text] : [];
514
+ });
515
+ return parts.length > 0 ? parts.join("") : undefined;
516
+ }
438
517
  function mergeToolArgumentDelta(current, incoming, mode) {
439
518
  if (!current) {
440
519
  debugToolArgs({ stage: "merge", branch: "empty-current", current, incoming, args: incoming, delta: incoming });
@@ -1,8 +1,10 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { appendFileSync, mkdirSync } from "node:fs";
3
3
  import { dirname } from "node:path";
4
+ import { sanitizeInternalReminderBlocks } from "./agent/internal-reminder-sanitizer.js";
4
5
  const DEBUG_PATH = process.env.BUBBLE_DEBUG_REASONING_STREAM?.trim();
5
6
  const INCLUDE_PREVIEW = process.env.BUBBLE_DEBUG_REASONING_PREVIEW !== "0";
7
+ const INCLUDE_RAW_PREVIEW = ["1", "true", "yes", "on"].includes(process.env.BUBBLE_DEBUG_REASONING_RAW?.trim().toLowerCase() ?? "");
6
8
  const PREVIEW_CHARS = 180;
7
9
  let sequence = 0;
8
10
  export function summarizeDebugText(value) {
@@ -13,7 +15,8 @@ export function summarizeDebugText(value) {
13
15
  const hash = createHash("sha256").update(value).digest("hex").slice(0, 16);
14
16
  const summary = { length: value.length, hash };
15
17
  if (INCLUDE_PREVIEW) {
16
- summary.preview = value.replace(/\s+/g, " ").slice(0, PREVIEW_CHARS);
18
+ const previewValue = INCLUDE_RAW_PREVIEW ? value : sanitizeInternalReminderBlocks(value);
19
+ summary.preview = previewValue.replace(/\s+/g, " ").slice(0, PREVIEW_CHARS);
17
20
  }
18
21
  return summary;
19
22
  }
@@ -1,3 +1,4 @@
1
+ import { sanitizeAssistantProviderMetadata, sanitizeInternalReminderBlocks } from "./agent/internal-reminder-sanitizer.js";
1
2
  export class SessionLog {
2
3
  entries = [];
3
4
  load(lines) {
@@ -127,6 +128,11 @@ export class SessionLog {
127
128
  messages.push({
128
129
  ...entry.message,
129
130
  role: "assistant",
131
+ content: sanitizeInternalReminderBlocks(entry.message.content),
132
+ reasoning: entry.message.reasoning !== undefined
133
+ ? sanitizeInternalReminderBlocks(entry.message.reasoning)
134
+ : undefined,
135
+ providerMetadata: sanitizeAssistantProviderMetadata(cloneProviderMetadata(entry.message.providerMetadata)),
130
136
  });
131
137
  break;
132
138
  case "tool_call": {
@@ -189,13 +195,16 @@ function normalizeMessageToEntries(message, id, timestamp) {
189
195
  type: "assistant_message",
190
196
  message: {
191
197
  role: "assistant",
192
- content: message.content,
193
- reasoning: message.reasoning,
198
+ content: sanitizeInternalReminderBlocks(message.content),
199
+ reasoning: message.reasoning !== undefined
200
+ ? sanitizeInternalReminderBlocks(message.reasoning)
201
+ : undefined,
194
202
  model: message.model,
195
203
  providerId: message.providerId,
196
204
  modelId: message.modelId,
197
205
  usage: message.usage,
198
206
  error: message.error,
207
+ providerMetadata: sanitizeAssistantProviderMetadata(cloneProviderMetadata(message.providerMetadata)),
199
208
  },
200
209
  timestamp,
201
210
  };
@@ -247,6 +256,7 @@ function cloneMessage(message) {
247
256
  return {
248
257
  ...message,
249
258
  toolCalls: message.toolCalls?.map((toolCall) => ({ ...toolCall })),
259
+ providerMetadata: cloneProviderMetadata(message.providerMetadata),
250
260
  };
251
261
  }
252
262
  if (message.role === "user" && Array.isArray(message.content)) {
@@ -260,6 +270,11 @@ function cloneMessage(message) {
260
270
  }
261
271
  return { ...message };
262
272
  }
273
+ function cloneProviderMetadata(metadata) {
274
+ if (metadata === undefined)
275
+ return undefined;
276
+ return JSON.parse(JSON.stringify(metadata));
277
+ }
263
278
  function pruneIncompleteTail(messages) {
264
279
  let currentTurnStart = -1;
265
280
  let hasCompletedAssistant = false;
@@ -4,7 +4,7 @@ import { formatDiagnostics } from "../lsp/index.js";
4
4
  import { normalizeNameForMCP } from "../mcp/name.js";
5
5
  import { parseRule } from "../permissions/rule.js";
6
6
  import { encodeModel, decodeModel, displayModel, BUILTIN_PROVIDERS, isUserVisibleProvider } from "../provider-registry.js";
7
- import { getAvailableThinkingLevels, normalizeThinkingLevel } from "../provider-transform.js";
7
+ import { getAvailableThinkingLevels, getDefaultThinkingLevel, normalizeThinkingLevel } from "../provider-transform.js";
8
8
  import { buildSystemPrompt } from "../system-prompt.js";
9
9
  import { isThinkingLevel } from "../variant/thinking-level.js";
10
10
  import { collectUsageStatsBundle, formatStatsText } from "../stats/usage.js";
@@ -131,7 +131,9 @@ function parseModelArgs(args) {
131
131
  }
132
132
  function displaySelectedModel(model, thinkingLevel) {
133
133
  const label = displayModel(model);
134
- return thinkingLevel === "off" ? label : `${label} (${thinkingLevel})`;
134
+ const { providerId, modelId } = decodeModel(model);
135
+ const defaultLevel = providerId ? getDefaultThinkingLevel(providerId, modelId) : "off";
136
+ return thinkingLevel === "off" || thinkingLevel === defaultLevel ? label : `${label} (${thinkingLevel})`;
135
137
  }
136
138
  function parseMemoryScopeArgs(args) {
137
139
  const tokens = args.trim().split(/\s+/).filter(Boolean);
@@ -1,3 +1,4 @@
1
+ import type { PricingCurrency } from "../model-pricing.js";
1
2
  export type StatsRange = "7d" | "30d";
2
3
  export interface DailyUsage {
3
4
  date: string;
@@ -22,6 +23,7 @@ export interface ModelUsageStats {
22
23
  reasoningTokens: number;
23
24
  totalTokens: number;
24
25
  cost?: number;
26
+ costCurrency?: PricingCurrency;
25
27
  }
26
28
  export interface UsageStats {
27
29
  range: StatsRange;
@@ -32,7 +34,9 @@ export interface UsageStats {
32
34
  heatmap: HeatmapColumn[];
33
35
  models: ModelUsageStats[];
34
36
  totalTokens: number;
37
+ trackedCosts?: Partial<Record<PricingCurrency, number>>;
35
38
  trackedCost?: number;
39
+ trackedCostCurrency?: PricingCurrency;
36
40
  activeDays: number;
37
41
  sessionsScanned: number;
38
42
  sessionsWithoutTokenData: number;
@@ -61,11 +61,15 @@ export function formatCompactNumber(value) {
61
61
  return String(Math.round(value));
62
62
  }
63
63
  export function formatCurrency(value) {
64
- if (value >= 1)
65
- return `$${value.toFixed(2)}`;
66
- if (value >= 0.01)
67
- return `$${value.toFixed(3)}`;
68
- return `$${value.toFixed(4)}`;
64
+ return formatCurrencyFor(value, "USD");
65
+ }
66
+ function formatCurrencyFor(value, currency) {
67
+ const amount = value >= 1
68
+ ? value.toFixed(2)
69
+ : value >= 0.01
70
+ ? value.toFixed(3)
71
+ : value.toFixed(4);
72
+ return currency === "USD" ? `$${amount}` : `CNY ${amount}`;
69
73
  }
70
74
  function createAccumulator(range, days, now) {
71
75
  const end = startOfLocalDay(now);
@@ -146,7 +150,11 @@ function finalizeAccumulator(accumulator) {
146
150
  .filter((model) => model.totalTokens > 0)
147
151
  .sort((a, b) => b.totalTokens - a.totalTokens);
148
152
  const totalTokens = models.reduce((sum, model) => sum + model.totalTokens, 0);
149
- const trackedCost = models.reduce((sum, model) => sum + (model.cost ?? 0), 0);
153
+ const trackedCosts = aggregateCosts(models);
154
+ const trackedCostEntries = trackedCosts
155
+ ? Object.entries(trackedCosts)
156
+ : [];
157
+ const trackedCostEntry = trackedCostEntries.length === 1 ? trackedCostEntries[0] : undefined;
150
158
  return {
151
159
  range: accumulator.range,
152
160
  days: accumulator.days,
@@ -156,7 +164,9 @@ function finalizeAccumulator(accumulator) {
156
164
  heatmap: buildHeatmap(daily),
157
165
  models,
158
166
  totalTokens,
159
- trackedCost: trackedCost > 0 ? trackedCost : undefined,
167
+ trackedCosts,
168
+ trackedCost: trackedCostEntry ? trackedCostEntry[1] : undefined,
169
+ trackedCostCurrency: trackedCostEntry ? trackedCostEntry[0] : undefined,
160
170
  activeDays: daily.filter((day) => day.active).length,
161
171
  sessionsScanned: accumulator.sessionsScanned,
162
172
  sessionsWithoutTokenData: accumulator.sessionsWithoutTokenData,
@@ -189,8 +199,10 @@ function addModelUsage(accumulator, model, message, usage) {
189
199
  existing.totalTokens += tokenTotal(usage);
190
200
  if (providerId && modelId) {
191
201
  const cost = calculateUsageCost(providerId, modelId, usage);
192
- if (cost)
202
+ if (cost) {
193
203
  existing.cost = (existing.cost ?? 0) + cost.cost;
204
+ existing.costCurrency = cost.currency;
205
+ }
194
206
  }
195
207
  accumulator.modelUsage.set(key, existing);
196
208
  }
@@ -254,7 +266,9 @@ function formatModelUsageLines(stats, width) {
254
266
  const percentText = `${Math.round(percent * 100)}%`.padStart(4, " ");
255
267
  const tokenText = formatCompactNumber(model.totalTokens).padStart(6, " ");
256
268
  const turnsText = `${model.turns}t`.padStart(4, " ");
257
- const costText = showCost ? ` ${(model.cost !== undefined ? formatCurrency(model.cost) : "").padStart(7, " ")}` : "";
269
+ const costText = showCost
270
+ ? ` ${(model.cost !== undefined ? formatCurrencyFor(model.cost, model.costCurrency ?? "USD") : "").padStart(7, " ")}`
271
+ : "";
258
272
  return ` ${truncate(model.displayName, labelWidth).padEnd(labelWidth, " ")} ${bar} ${percentText} ${tokenText} ${turnsText}${costText}`.trimEnd();
259
273
  });
260
274
  if (stats.models.length > MAX_MODEL_ROWS) {
@@ -270,14 +284,37 @@ function formatSummaryLines(stats, width) {
270
284
  if (favorite) {
271
285
  lines.push(` Favorite model ${truncate(favorite, Math.max(12, width - 17))}`);
272
286
  }
273
- if (stats.trackedCost !== undefined)
274
- lines.push(` Tracked cost ${formatCurrency(stats.trackedCost)}`);
287
+ const trackedCostText = formatTrackedCosts(stats);
288
+ if (trackedCostText)
289
+ lines.push(` Tracked cost ${trackedCostText}`);
275
290
  lines.push(` Sessions scanned ${stats.sessionsScanned}`);
276
291
  if (stats.sessionsWithoutTokenData > 0) {
277
292
  lines.push(` Sessions without token data ${stats.sessionsWithoutTokenData}`);
278
293
  }
279
294
  return lines;
280
295
  }
296
+ function aggregateCosts(models) {
297
+ const totals = {};
298
+ for (const model of models) {
299
+ if (model.cost === undefined)
300
+ continue;
301
+ const currency = model.costCurrency ?? "USD";
302
+ totals[currency] = (totals[currency] ?? 0) + model.cost;
303
+ }
304
+ return Object.keys(totals).length > 0 ? totals : undefined;
305
+ }
306
+ function formatTrackedCosts(stats) {
307
+ if (stats.trackedCosts) {
308
+ const parts = Object.entries(stats.trackedCosts)
309
+ .filter(([, value]) => value > 0)
310
+ .map(([currency, value]) => formatCurrencyFor(value, currency));
311
+ return parts.length > 0 ? parts.join(" + ") : undefined;
312
+ }
313
+ if (stats.trackedCost !== undefined) {
314
+ return formatCurrencyFor(stats.trackedCost, stats.trackedCostCurrency ?? "USD");
315
+ }
316
+ return undefined;
317
+ }
281
318
  function heatmapCell(day, maxTokens) {
282
319
  if (!day)
283
320
  return " ";
@@ -65,6 +65,7 @@ export function createGlobTool(cwd) {
65
65
  }
66
66
  files.sort((a, b) => b.mtimeMs - a.mtimeMs || a.path.localeCompare(b.path));
67
67
  const matches = files.slice(0, MAX_RESULTS).map((item) => item.path);
68
+ const absoluteMatches = matches.map((item) => resolve(root, item));
68
69
  const wasTruncated = truncated.value || files.length > MAX_RESULTS;
69
70
  if (matches.length === 0) {
70
71
  return {
@@ -78,6 +79,7 @@ export function createGlobTool(cwd) {
78
79
  truncated: false,
79
80
  searchSignature: `glob:${root}:${pattern}`,
80
81
  searchFamily: `glob:${pattern}`,
82
+ paths: [],
81
83
  },
82
84
  };
83
85
  }
@@ -92,6 +94,7 @@ export function createGlobTool(cwd) {
92
94
  truncated: wasTruncated,
93
95
  searchSignature: `glob:${root}:${pattern}`,
94
96
  searchFamily: `glob:${pattern}`,
97
+ paths: absoluteMatches,
95
98
  },
96
99
  };
97
100
  },
@@ -2,6 +2,7 @@
2
2
  * Grep tool - search file contents using ripgrep.
3
3
  */
4
4
  import { execFile } from "node:child_process";
5
+ import { resolve as resolvePath } from "node:path";
5
6
  import { isSensitivePath } from "./sensitive-paths.js";
6
7
  import { analyzeToolIntent } from "../agent/tool-intent.js";
7
8
  import { resolveToolPath } from "./path-utils.js";
@@ -57,6 +58,7 @@ export function createGrepTool(cwd) {
57
58
  // rg returns exit code 1 when no matches found, which is not an error for us
58
59
  const lines = stdout.split("\n").filter((l) => l.trim() !== "");
59
60
  const matches = [];
61
+ const matchedPaths = new Set();
60
62
  for (const line of lines) {
61
63
  try {
62
64
  const obj = JSON.parse(line);
@@ -64,6 +66,9 @@ export function createGrepTool(cwd) {
64
66
  const path = obj.data.path.text;
65
67
  const lineNum = obj.data.line_number;
66
68
  const text = obj.data.lines.text?.trim() ?? "";
69
+ if (typeof path === "string" && path.trim()) {
70
+ matchedPaths.add(resolvePath(cwd, path));
71
+ }
67
72
  matches.push(`${path}:${lineNum}: ${text}`);
68
73
  }
69
74
  }
@@ -83,6 +88,7 @@ export function createGrepTool(cwd) {
83
88
  truncated: false,
84
89
  searchSignature: intent.search?.signature,
85
90
  searchFamily: intent.search?.familyKey,
91
+ paths: [],
86
92
  },
87
93
  });
88
94
  return;
@@ -103,6 +109,7 @@ export function createGrepTool(cwd) {
103
109
  truncated,
104
110
  searchSignature: intent.search?.signature,
105
111
  searchFamily: intent.search?.familyKey,
112
+ paths: [...matchedPaths],
106
113
  },
107
114
  });
108
115
  });