@bubblebrain-ai/bubble 0.0.7 → 0.0.9

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 (119) hide show
  1. package/dist/agent/categories.d.ts +34 -0
  2. package/dist/agent/categories.js +98 -0
  3. package/dist/agent/profiles.d.ts +4 -0
  4. package/dist/agent/profiles.js +2 -3
  5. package/dist/agent/subagent-control.d.ts +5 -0
  6. package/dist/agent/subagent-control.js +4 -0
  7. package/dist/agent/subagent-lifecycle-reminder.d.ts +3 -0
  8. package/dist/agent/subagent-lifecycle-reminder.js +102 -0
  9. package/dist/agent/subagent-route-format.d.ts +8 -0
  10. package/dist/agent/subagent-route-format.js +18 -0
  11. package/dist/agent/subtask-policy.d.ts +0 -1
  12. package/dist/agent/subtask-policy.js +0 -4
  13. package/dist/agent.d.ts +18 -0
  14. package/dist/agent.js +188 -16
  15. package/dist/config.d.ts +23 -3
  16. package/dist/config.js +59 -6
  17. package/dist/context/budget.d.ts +3 -2
  18. package/dist/context/budget.js +29 -15
  19. package/dist/context/compact.d.ts +23 -0
  20. package/dist/context/compact.js +129 -0
  21. package/dist/context/llm-compactor.d.ts +19 -0
  22. package/dist/context/llm-compactor.js +200 -0
  23. package/dist/context/projector.js +28 -12
  24. package/dist/context/token-estimator.d.ts +14 -0
  25. package/dist/context/token-estimator.js +106 -0
  26. package/dist/context/tool-output-truncate.d.ts +8 -0
  27. package/dist/context/tool-output-truncate.js +59 -0
  28. package/dist/context/usage.d.ts +34 -0
  29. package/dist/context/usage.js +213 -0
  30. package/dist/diff-stats.d.ts +5 -0
  31. package/dist/diff-stats.js +21 -0
  32. package/dist/main.js +68 -7
  33. package/dist/mcp/transports.d.ts +1 -0
  34. package/dist/mcp/transports.js +8 -0
  35. package/dist/model-catalog.d.ts +9 -0
  36. package/dist/model-catalog.js +17 -1
  37. package/dist/orchestrator/default-hooks.js +24 -18
  38. package/dist/prompt/compose.js +2 -1
  39. package/dist/prompt/provider-prompts/kimi.js +3 -1
  40. package/dist/provider-openai-codex.d.ts +13 -2
  41. package/dist/provider-openai-codex.js +81 -32
  42. package/dist/provider-registry.js +22 -6
  43. package/dist/provider-transform.d.ts +3 -1
  44. package/dist/provider-transform.js +15 -0
  45. package/dist/provider.d.ts +4 -1
  46. package/dist/provider.js +89 -4
  47. package/dist/reasoning-debug.d.ts +7 -0
  48. package/dist/reasoning-debug.js +30 -0
  49. package/dist/session-log.js +13 -2
  50. package/dist/session-types.d.ts +1 -1
  51. package/dist/slash-commands/commands.js +60 -2
  52. package/dist/slash-commands/types.d.ts +7 -0
  53. package/dist/tools/agent-lifecycle.js +22 -4
  54. package/dist/tools/edit.js +7 -2
  55. package/dist/tools/file-state.d.ts +19 -0
  56. package/dist/tools/file-state.js +15 -0
  57. package/dist/tools/glob.js +2 -1
  58. package/dist/tools/grep.js +2 -2
  59. package/dist/tools/lsp.js +2 -2
  60. package/dist/tools/path-utils.d.ts +2 -0
  61. package/dist/tools/path-utils.js +16 -0
  62. package/dist/tools/read.d.ts +1 -1
  63. package/dist/tools/read.js +207 -14
  64. package/dist/tools/write.js +3 -2
  65. package/dist/tui/escape-confirmation.d.ts +15 -0
  66. package/dist/tui/escape-confirmation.js +30 -0
  67. package/dist/tui/run.js +93 -23
  68. package/dist/tui-ink/app.d.ts +52 -0
  69. package/dist/tui-ink/app.js +1129 -0
  70. package/dist/tui-ink/approval/approval-dialog.d.ts +13 -0
  71. package/dist/tui-ink/approval/approval-dialog.js +132 -0
  72. package/dist/tui-ink/approval/diff-view.d.ts +7 -0
  73. package/dist/tui-ink/approval/diff-view.js +44 -0
  74. package/dist/tui-ink/approval/select.d.ts +35 -0
  75. package/dist/tui-ink/approval/select.js +88 -0
  76. package/dist/tui-ink/code-highlight.d.ts +8 -0
  77. package/dist/tui-ink/code-highlight.js +122 -0
  78. package/dist/tui-ink/detect-theme.d.ts +19 -0
  79. package/dist/tui-ink/detect-theme.js +123 -0
  80. package/dist/tui-ink/display-history.d.ts +38 -0
  81. package/dist/tui-ink/display-history.js +130 -0
  82. package/dist/tui-ink/edit-diff.d.ts +11 -0
  83. package/dist/tui-ink/edit-diff.js +52 -0
  84. package/dist/tui-ink/file-mentions.d.ts +29 -0
  85. package/dist/tui-ink/file-mentions.js +174 -0
  86. package/dist/tui-ink/footer.d.ts +19 -0
  87. package/dist/tui-ink/footer.js +45 -0
  88. package/dist/tui-ink/image-paste.d.ts +54 -0
  89. package/dist/tui-ink/image-paste.js +288 -0
  90. package/dist/tui-ink/input-box.d.ts +41 -0
  91. package/dist/tui-ink/input-box.js +694 -0
  92. package/dist/tui-ink/input-history.d.ts +16 -0
  93. package/dist/tui-ink/input-history.js +81 -0
  94. package/dist/tui-ink/markdown.d.ts +38 -0
  95. package/dist/tui-ink/markdown.js +394 -0
  96. package/dist/tui-ink/message-list.d.ts +33 -0
  97. package/dist/tui-ink/message-list.js +667 -0
  98. package/dist/tui-ink/model-picker.d.ts +43 -0
  99. package/dist/tui-ink/model-picker.js +331 -0
  100. package/dist/tui-ink/plan-confirm.d.ts +7 -0
  101. package/dist/tui-ink/plan-confirm.js +105 -0
  102. package/dist/tui-ink/question-dialog.d.ts +8 -0
  103. package/dist/tui-ink/question-dialog.js +99 -0
  104. package/dist/tui-ink/recent-activity.d.ts +8 -0
  105. package/dist/tui-ink/recent-activity.js +71 -0
  106. package/dist/tui-ink/run.d.ts +37 -0
  107. package/dist/tui-ink/run.js +53 -0
  108. package/dist/tui-ink/theme.d.ts +66 -0
  109. package/dist/tui-ink/theme.js +115 -0
  110. package/dist/tui-ink/todos.d.ts +7 -0
  111. package/dist/tui-ink/todos.js +46 -0
  112. package/dist/tui-ink/trace-groups.d.ts +27 -0
  113. package/dist/tui-ink/trace-groups.js +389 -0
  114. package/dist/tui-ink/use-terminal-size.d.ts +4 -0
  115. package/dist/tui-ink/use-terminal-size.js +21 -0
  116. package/dist/tui-ink/welcome.d.ts +18 -0
  117. package/dist/tui-ink/welcome.js +138 -0
  118. package/dist/types.d.ts +10 -0
  119. package/package.json +7 -1
package/dist/agent.js CHANGED
@@ -6,18 +6,22 @@ import { compactMessages } from "./context/compact.js";
6
6
  import { randomUUID } from "node:crypto";
7
7
  import { compactMessagesWithLLM } from "./context/compact-llm.js";
8
8
  import { getContextBudget } from "./context/budget.js";
9
+ import { buildContextUsageSnapshot } from "./context/usage.js";
9
10
  import { isContextOverflowError } from "./context/overflow.js";
10
11
  import { projectMessages } from "./context/projector.js";
11
12
  import { aggressivePruneMessages } from "./context/prune.js";
13
+ import { truncateToolOutputForModel } from "./context/tool-output-truncate.js";
12
14
  import { buildDeferredToolsReminder, buildToolFreezeReminder, isPermissionModeReminder, reminderForMode } from "./prompt/reminders.js";
13
15
  import { HookBus } from "./orchestrator/hooks.js";
14
16
  import { createDefaultHooks } from "./orchestrator/default-hooks.js";
17
+ import { resolveModelRoute, resolveSubagentRoute } from "./agent/categories.js";
15
18
  import { getSubtaskPolicy } from "./agent/subtask-policy.js";
16
19
  import { composeAbortSignals } from "./agent/budget-ledger.js";
17
20
  import { assignAgentNickname, builtinAgentProfiles, mergeUsage, selectToolsForAgentProfile, validateAgentProfileTools } from "./agent/profiles.js";
18
21
  import { snapshotSubagentThread, subagentResultFromThread } from "./agent/subagent-control.js";
19
22
  import { buildSystemPrompt } from "./system-prompt.js";
20
23
  import { isOnlyProviderProtocolArtifacts, stripProviderProtocolArtifacts } from "./provider-artifacts.js";
24
+ import { debugReasoningStream, summarizeDebugText } from "./reasoning-debug.js";
21
25
  const MAX_CONSECUTIVE_OVERFLOW_RECOVERIES = 3;
22
26
  const RESIDENT_HISTORY_KEEP_RECENT_TURNS = 3;
23
27
  const RESIDENT_HISTORY_MESSAGE_LIMIT = 160;
@@ -56,6 +60,9 @@ export class Agent {
56
60
  budgetSource;
57
61
  skillSummaries;
58
62
  memoryPrompt;
63
+ fileStateTracker;
64
+ agentCategories;
65
+ providerFactory;
59
66
  subagentThreads = new Map();
60
67
  pendingSubagentUpdates = [];
61
68
  lastInputTokens = null;
@@ -80,6 +87,9 @@ export class Agent {
80
87
  this.budgetSource = options.budgetSource ?? { runId: this.sessionID ?? "agent" };
81
88
  this.skillSummaries = options.skills ?? [];
82
89
  this.memoryPrompt = options.memoryPrompt;
90
+ this.fileStateTracker = options.fileStateTracker;
91
+ this.agentCategories = options.agentCategories ?? {};
92
+ this.providerFactory = options.providerFactory;
83
93
  if (options.systemPrompt) {
84
94
  this.messages.push({ role: "system", content: options.systemPrompt });
85
95
  }
@@ -111,11 +121,26 @@ export class Agent {
111
121
  listDeferredTools() {
112
122
  return [...this.tools.values()].filter((t) => t.deferred);
113
123
  }
124
+ getContextUsageSnapshot() {
125
+ return buildContextUsageSnapshot({
126
+ providerId: this.providerId,
127
+ modelId: this.apiModel,
128
+ messages: this.messages,
129
+ toolEntries: this.getActiveToolEntries(),
130
+ deferredToolEntries: this.listDeferredTools(),
131
+ skills: this.skillSummaries,
132
+ });
133
+ }
114
134
  /** Whether a given tool is deferred and not yet unlocked. */
115
135
  isDeferredAndLocked(name) {
116
136
  const tool = this.tools.get(name);
117
137
  return !!tool?.deferred && !this.unlockedDeferred.has(name);
118
138
  }
139
+ getActiveToolEntries() {
140
+ return [...this.tools.values()]
141
+ .filter((tool) => !tool.deferred || this.unlockedDeferred.has(tool.name))
142
+ .filter((tool) => this._mode === "plan" || tool.name !== "exit_plan_mode");
143
+ }
119
144
  injectSystemReminder(content) {
120
145
  this.appendMessage({ role: "meta", kind: "system-reminder", content });
121
146
  }
@@ -297,6 +322,12 @@ export class Agent {
297
322
  description: t.description,
298
323
  parameters: t.parameters,
299
324
  }));
325
+ // LLM-driven compaction runs ahead of projector's algorithmic passes. If
326
+ // it succeeds, this.messages is replaced with [preserved system+meta] +
327
+ // [LLM summary] + [last user msg], and the projector becomes a no-op for
328
+ // budget. If it fails (network error, etc.), the projector's existing
329
+ // algorithmic fallback still kicks in.
330
+ await this.maybeCompactWithLLM();
300
331
  try {
301
332
  const projectedMessages = projectMessages(this.messages, {
302
333
  mode: "budgeted",
@@ -320,6 +351,15 @@ export class Agent {
320
351
  yield { type: "text_delta", content: chunk.content };
321
352
  break;
322
353
  case "reasoning_delta":
354
+ debugReasoningStream({
355
+ stage: "agent_receive",
356
+ providerId: this._providerId,
357
+ modelId: this.apiModel,
358
+ turnStep: step,
359
+ beforeLength: assistantMsg.reasoning?.length ?? 0,
360
+ delta: summarizeDebugText(chunk.content),
361
+ afterLength: (assistantMsg.reasoning?.length ?? 0) + chunk.content.length,
362
+ });
323
363
  assistantMsg.reasoning = (assistantMsg.reasoning || "") + chunk.content;
324
364
  yield { type: "reasoning_delta", content: chunk.content };
325
365
  break;
@@ -498,10 +538,14 @@ export class Agent {
498
538
  result = next;
499
539
  },
500
540
  });
541
+ // Honor the model's server-declared per-tool-output token cap (e.g.
542
+ // gpt-5.5 reports 10000). Without this, 4-5 large file reads in a row
543
+ // blow past the input window even though our local estimate looks fine.
544
+ const truncatedOutput = truncateToolOutputForModel(result.content, this.providerId, this.apiModel);
501
545
  this.appendMessage({
502
546
  role: "tool",
503
547
  toolCallId: tc.id,
504
- content: result.content,
548
+ content: truncatedOutput.content,
505
549
  metadata: result.metadata,
506
550
  isError: result.isError,
507
551
  });
@@ -533,7 +577,7 @@ export class Agent {
533
577
  },
534
578
  });
535
579
  flushGovernorReminders();
536
- yield { type: "turn_end", usage: turnUsage };
580
+ yield { type: "turn_end", usage: turnUsage, willContinue: true };
537
581
  // Auto-continue: if we have tool results, the LLM needs to respond to them.
538
582
  // Emitting the turn boundary keeps UI renderers aligned with the persisted
539
583
  // assistant/tool message sequence instead of merging the next answer into
@@ -549,8 +593,9 @@ export class Agent {
549
593
  flushReminders: flushGovernorReminders,
550
594
  });
551
595
  flushGovernorReminders();
552
- yield { type: "turn_end", usage: turnUsage };
553
- if (hookState.forceContinuationReason) {
596
+ const willContinue = !!hookState.forceContinuationReason;
597
+ yield { type: "turn_end", usage: turnUsage, willContinue };
598
+ if (willContinue) {
554
599
  delete hookState.forceContinuationReason;
555
600
  continue;
556
601
  }
@@ -569,6 +614,7 @@ export class Agent {
569
614
  if (afterTokens < beforeTokens) {
570
615
  this.lastInputTokens = null;
571
616
  this.lastAnchorMessageCount = null;
617
+ this.fileStateTracker?.invalidateReadHistory();
572
618
  return before - this.messages.length;
573
619
  }
574
620
  }
@@ -583,6 +629,22 @@ export class Agent {
583
629
  this.messages = llmResult.messages;
584
630
  this.lastInputTokens = null;
585
631
  this.lastAnchorMessageCount = null;
632
+ this.fileStateTracker?.invalidateReadHistory();
633
+ return before - this.messages.length;
634
+ }
635
+ // Single-turn capable LLM compactor. compactMessagesWithLLM above no-ops
636
+ // when there's only one user turn (the "single huge prompt with many tool
637
+ // calls" case), so try the turn-internal compactor before giving up.
638
+ const { compactWithLLM } = await import("./context/llm-compactor.js");
639
+ const singleTurnResult = await compactWithLLM(this.messages, {
640
+ provider: this.provider,
641
+ modelId: this.apiModel,
642
+ });
643
+ if (singleTurnResult.compacted && singleTurnResult.messages) {
644
+ this.messages = singleTurnResult.messages;
645
+ this.lastInputTokens = null;
646
+ this.lastAnchorMessageCount = null;
647
+ this.fileStateTracker?.invalidateReadHistory();
586
648
  return before - this.messages.length;
587
649
  }
588
650
  const fallback = compactMessages(this.messages, { keepRecentTurns });
@@ -590,6 +652,21 @@ export class Agent {
590
652
  this.messages = fallback.messages;
591
653
  this.lastInputTokens = null;
592
654
  this.lastAnchorMessageCount = null;
655
+ this.fileStateTracker?.invalidateReadHistory();
656
+ return before - this.messages.length;
657
+ }
658
+ // Codex-style last-resort: drop the single oldest non-protected message
659
+ // and let the retry loop try again. Cheap, but eventually narrows even an
660
+ // intractable single-turn overflow.
661
+ const oldestIdx = this.messages.findIndex((m) => m.role !== "system" && m.role !== "meta");
662
+ if (oldestIdx >= 0 && oldestIdx < this.messages.length - 1) {
663
+ this.messages = [
664
+ ...this.messages.slice(0, oldestIdx),
665
+ ...this.messages.slice(oldestIdx + 1),
666
+ ];
667
+ this.lastInputTokens = null;
668
+ this.lastAnchorMessageCount = null;
669
+ this.fileStateTracker?.invalidateReadHistory();
593
670
  return before - this.messages.length;
594
671
  }
595
672
  return 0;
@@ -597,6 +674,34 @@ export class Agent {
597
674
  compactResidentHistory() {
598
675
  this.maybeCompactResidentHistory();
599
676
  }
677
+ async maybeCompactWithLLM() {
678
+ if (!this.providerId || !this.apiModel)
679
+ return;
680
+ if (this.messages.length === 0)
681
+ return;
682
+ const tail = this.lastAnchorMessageCount !== null
683
+ ? this.messages.slice(this.lastAnchorMessageCount)
684
+ : undefined;
685
+ const budget = getContextBudget(this.providerId, this.apiModel, this.messages, {
686
+ usageAnchorTokens: this.lastInputTokens ?? undefined,
687
+ tailMessages: tail,
688
+ });
689
+ if (!budget.shouldCompact)
690
+ return;
691
+ const { compactWithLLM } = await import("./context/llm-compactor.js");
692
+ const result = await compactWithLLM(this.messages, {
693
+ provider: this.provider,
694
+ modelId: this.apiModel,
695
+ });
696
+ if (result.compacted && result.messages) {
697
+ this.messages = result.messages;
698
+ this.lastInputTokens = null;
699
+ this.lastAnchorMessageCount = null;
700
+ this.fileStateTracker?.invalidateReadHistory();
701
+ }
702
+ // If LLM compaction failed for any reason, leave this.messages alone —
703
+ // the projector's algorithmic budgeted-mode passes will still try.
704
+ }
600
705
  async runSubtask(input, cwd, options) {
601
706
  const subtaskType = options?.subtaskType;
602
707
  const profile = builtinAgentProfiles().find((item) => item.subtaskType === (subtaskType ?? "general_readonly"))
@@ -606,6 +711,7 @@ export class Agent {
606
711
  runId: randomUUID(),
607
712
  subAgentId: randomUUID(),
608
713
  parentToolCallId: "task",
714
+ route: this.resolveRouteForSubagent(profile, undefined),
609
715
  description: options?.description,
610
716
  });
611
717
  const lines = [
@@ -641,6 +747,7 @@ export class Agent {
641
747
  parentToolCallId: options.parentToolCallId,
642
748
  parentToolName: "subagent",
643
749
  nickname: options.nickname,
750
+ route: options.route ?? this.resolveRouteForSubagent(options.profile, options.category),
644
751
  });
645
752
  await this.runSubagentThread(record, input, cwd, {
646
753
  approval: options.approval ?? options.profile.approval,
@@ -656,6 +763,7 @@ export class Agent {
656
763
  task: typeof input === "string" ? input : "(multimodal task)",
657
764
  parentToolCallId: options.parentToolCallId,
658
765
  parentToolName: "spawn_agent",
766
+ route: options.route ?? this.resolveRouteForSubagent(options.profile, options.category),
659
767
  });
660
768
  this.subagentThreads.set(record.agentId, record);
661
769
  this.queueSubagentUpdate(record, "queued", undefined, `Queued ${record.nickname} (${record.profile.name})`);
@@ -746,6 +854,31 @@ export class Agent {
746
854
  listSubAgents() {
747
855
  return [...this.subagentThreads.values()].map(snapshotSubagentThread);
748
856
  }
857
+ resolveRouteForSubagent(profile, category) {
858
+ const parentRoute = {
859
+ providerId: this.providerId,
860
+ model: this.apiModel,
861
+ thinkingLevel: this.thinkingLevel,
862
+ };
863
+ const resolved = resolveSubagentRoute(category ?? profile.category, {
864
+ ...parentRoute,
865
+ }, this.agentCategories);
866
+ if ("error" in resolved) {
867
+ throw new Error(resolved.error);
868
+ }
869
+ if (profile.model && profile.model !== "inherit") {
870
+ const model = resolveModelRoute(profile.model, parentRoute.providerId);
871
+ if (model.model !== "inherit") {
872
+ return {
873
+ ...resolved.route,
874
+ providerId: model.providerId,
875
+ model: model.model,
876
+ inherited: false,
877
+ };
878
+ }
879
+ }
880
+ return resolved.route;
881
+ }
749
882
  createSubagentThreadRecord(options) {
750
883
  const now = Date.now();
751
884
  const nickname = options.nickname ?? assignAgentNickname(options.profile, this.activeSubagentNicknames());
@@ -754,6 +887,8 @@ export class Agent {
754
887
  runId: options.runId ?? randomUUID(),
755
888
  nickname,
756
889
  profile: options.profile,
890
+ category: options.route?.category,
891
+ route: options.route,
757
892
  parentToolCallId: options.parentToolCallId,
758
893
  parentToolName: options.parentToolName,
759
894
  status: "queued",
@@ -789,9 +924,20 @@ export class Agent {
789
924
  return;
790
925
  }
791
926
  const tools = selectToolsForAgentProfile(allTools, record.profile, options.approval);
792
- const subAgent = options.reuseAgent && record.agent
793
- ? record.agent
794
- : this.createSubAgentInstance(record, tools, cwd, options.forkContext);
927
+ let subAgent;
928
+ try {
929
+ subAgent = options.reuseAgent && record.agent
930
+ ? record.agent
931
+ : await this.createSubAgentInstance(record, tools, cwd, options.forkContext);
932
+ }
933
+ catch (error) {
934
+ record.status = "blocked";
935
+ record.error = error?.message || String(error);
936
+ record.updatedAt = Date.now();
937
+ emit("blocked", undefined, record.error);
938
+ this.notifySubagentWaiters(record);
939
+ return;
940
+ }
795
941
  record.agent = subAgent;
796
942
  record.status = "running";
797
943
  record.updatedAt = Date.now();
@@ -892,14 +1038,21 @@ export class Agent {
892
1038
  record.summary = finalSummary;
893
1039
  }
894
1040
  }
895
- createSubAgentInstance(record, tools, cwd, forkContext) {
1041
+ async createSubAgentInstance(record, tools, cwd, forkContext) {
896
1042
  const childToolNames = tools.map((tool) => tool.name);
1043
+ const route = record.route ?? {
1044
+ providerId: this.providerId,
1045
+ model: this.apiModel,
1046
+ thinkingLevel: this.thinkingLevel,
1047
+ inherited: true,
1048
+ };
1049
+ const provider = await this.resolveProviderForRoute(route);
897
1050
  const childSystemPrompt = buildSystemPrompt({
898
1051
  agentName: "Bubble",
899
- configuredProvider: this.providerId || "none",
900
- configuredModel: this.model || "none",
901
- configuredModelId: this.model || "none",
902
- thinkingLevel: this.thinkingLevel,
1052
+ configuredProvider: route.providerId || "none",
1053
+ configuredModel: route.model || "none",
1054
+ configuredModelId: route.providerId && route.model ? `${route.providerId}:${route.model}` : route.model || "none",
1055
+ thinkingLevel: route.thinkingLevel,
903
1056
  mode: "plan",
904
1057
  workingDir: cwd,
905
1058
  tools: childToolNames,
@@ -913,24 +1066,38 @@ export class Agent {
913
1066
  ].filter(Boolean).join("\n\n"),
914
1067
  });
915
1068
  const subAgent = new Agent({
916
- provider: this.provider,
917
- providerId: this.providerId,
918
- model: record.profile.model && record.profile.model !== "inherit" ? record.profile.model : this.model,
1069
+ provider,
1070
+ providerId: route.providerId,
1071
+ model: route.model,
919
1072
  tools,
920
1073
  temperature: this.temperature,
921
- thinkingLevel: this.thinkingLevel,
1074
+ thinkingLevel: route.thinkingLevel,
922
1075
  mode: "plan",
923
1076
  maxTurns: record.profile.maxTurns,
924
1077
  budgetLedger: this.budgetLedger,
925
1078
  budgetSource: { runId: record.runId, subAgentId: record.agentId },
926
1079
  systemPrompt: childSystemPrompt,
927
1080
  hooks: this.hookDefinitions,
1081
+ agentCategories: this.agentCategories,
1082
+ providerFactory: this.providerFactory,
928
1083
  });
929
1084
  if (forkContext) {
930
1085
  subAgent.messages = this.forkMessagesForSubagent(childSystemPrompt);
931
1086
  }
932
1087
  return subAgent;
933
1088
  }
1089
+ async resolveProviderForRoute(route) {
1090
+ if (!route.providerId || route.providerId === this.providerId) {
1091
+ return this.provider;
1092
+ }
1093
+ if (!this.providerFactory) {
1094
+ throw new Error([
1095
+ `Subagent route requires provider "${route.providerId}" for model "${route.model}",`,
1096
+ `but the parent agent only has provider "${this.providerId || "none"}" and no provider factory is configured.`,
1097
+ ].join(" "));
1098
+ }
1099
+ return this.providerFactory(route);
1100
+ }
934
1101
  forkMessagesForSubagent(childSystemPrompt) {
935
1102
  const forked = this.messages
936
1103
  .filter((message) => {
@@ -955,6 +1122,8 @@ export class Agent {
955
1122
  subAgentId: record.agentId,
956
1123
  agentName: record.profile.name,
957
1124
  nickname: record.nickname,
1125
+ category: record.category,
1126
+ route: record.route,
958
1127
  status,
959
1128
  childEvent: event,
960
1129
  summaryDelta: event?.type === "text_delta" ? event.content : undefined,
@@ -968,6 +1137,8 @@ export class Agent {
968
1137
  subAgentId: record.agentId,
969
1138
  agentName: record.profile.name,
970
1139
  nickname: record.nickname,
1140
+ category: record.category,
1141
+ route: record.route,
971
1142
  status,
972
1143
  profileSource: record.profile.source,
973
1144
  task: record.task,
@@ -1056,6 +1227,7 @@ export class Agent {
1056
1227
  this.messages = candidate;
1057
1228
  this.lastInputTokens = null;
1058
1229
  this.lastAnchorMessageCount = null;
1230
+ this.fileStateTracker?.invalidateReadHistory();
1059
1231
  }
1060
1232
  }
1061
1233
  appendMessage(message) {
package/dist/config.d.ts CHANGED
@@ -3,17 +3,33 @@
3
3
  *
4
4
  * Uses a single JSON file in Bubble home, normally ~/.bubble/config.json.
5
5
  */
6
+ import { type AgentCategoriesConfig } from "./agent/categories.js";
6
7
  import type { ProviderProfile } from "./provider-registry.js";
7
8
  import type { ThinkingLevel } from "./types.js";
9
+ export type ThemeMode = "auto" | "light" | "dark";
10
+ export interface ThemeConfig {
11
+ mode: ThemeMode;
12
+ overrides?: Record<string, string>;
13
+ }
8
14
  export interface UserConfigData {
9
15
  defaultModel?: string;
10
16
  defaultThinkingLevel?: ThinkingLevel;
11
17
  skillPaths?: string[];
12
- theme?: Record<string, string>;
18
+ /**
19
+ * Three shapes are accepted on disk so we can evolve without breaking
20
+ * existing configs:
21
+ * - `"auto" | "light" | "dark"` — mode only
22
+ * - `{ mode, overrides? }` — mode + optional per-key palette overrides
23
+ * - `Record<string, string>` (legacy) — treated as `{ mode: "dark", overrides }`
24
+ * so users who customized colors before light-mode existed keep their
25
+ * palette and stay on dark, which was the only palette at the time.
26
+ */
27
+ theme?: ThemeMode | ThemeConfig | Record<string, string>;
13
28
  recentModels?: string[];
14
29
  apiKey?: string;
15
30
  providers?: ProviderProfile[];
16
31
  defaultProvider?: string;
32
+ agentCategories?: AgentCategoriesConfig;
17
33
  }
18
34
  export declare class UserConfig {
19
35
  private data;
@@ -34,8 +50,12 @@ export declare class UserConfig {
34
50
  setDefaultProvider(id: string): void;
35
51
  getSkillPaths(): string[];
36
52
  setSkillPaths(paths: string[]): void;
37
- getTheme(): Record<string, string>;
38
- setTheme(theme: Record<string, string>): void;
53
+ getTheme(): ThemeConfig;
54
+ getThemeMode(): ThemeMode;
55
+ getThemeOverrides(): Record<string, string>;
56
+ setThemeMode(mode: ThemeMode): void;
57
+ setThemeOverrides(overrides: Record<string, string>): void;
58
+ getAgentCategories(): AgentCategoriesConfig;
39
59
  }
40
60
  /** Mask an API key for safe display. */
41
61
  export declare function maskKey(key: string): string;
package/dist/config.js CHANGED
@@ -6,6 +6,7 @@
6
6
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
7
  import { dirname, join } from "node:path";
8
8
  import { getBubbleHome } from "./bubble-home.js";
9
+ import { sanitizeAgentCategories } from "./agent/categories.js";
9
10
  const HIDDEN_PROVIDER_IDS = new Set(["openrouter", "openai-codex"]);
10
11
  function getConfigPath() {
11
12
  return join(getBubbleHome(), "config.json");
@@ -36,6 +37,39 @@ function sanitizeDefaultModel(model) {
36
37
  function sanitizeDefaultProvider(providerId) {
37
38
  return isHiddenProviderId(providerId) ? undefined : providerId;
38
39
  }
40
+ function sanitizeTheme(value) {
41
+ if (value == null)
42
+ return undefined;
43
+ if (typeof value === "string") {
44
+ return value === "auto" || value === "light" || value === "dark"
45
+ ? { mode: value }
46
+ : undefined;
47
+ }
48
+ if (typeof value !== "object" || Array.isArray(value))
49
+ return undefined;
50
+ // Discriminate the new `{ mode, overrides }` shape from the legacy
51
+ // `Record<string, string>` shape. A legacy config has no `mode` key.
52
+ const maybeNew = value;
53
+ if (typeof maybeNew.mode === "string") {
54
+ const mode = maybeNew.mode;
55
+ if (mode !== "auto" && mode !== "light" && mode !== "dark")
56
+ return undefined;
57
+ const overrides = isStringMap(maybeNew.overrides) ? maybeNew.overrides : undefined;
58
+ return overrides ? { mode, overrides } : { mode };
59
+ }
60
+ const overrides = pickStringEntries(value);
61
+ if (Object.keys(overrides).length === 0)
62
+ return undefined;
63
+ return { mode: "dark", overrides };
64
+ }
65
+ function isStringMap(value) {
66
+ if (!value || typeof value !== "object" || Array.isArray(value))
67
+ return false;
68
+ return Object.values(value).every((entry) => typeof entry === "string");
69
+ }
70
+ function pickStringEntries(value) {
71
+ return Object.fromEntries(Object.entries(value).filter(([, v]) => typeof v === "string"));
72
+ }
39
73
  export class UserConfig {
40
74
  data = {};
41
75
  constructor() {
@@ -54,6 +88,8 @@ export class UserConfig {
54
88
  recentModels: sanitizeRecentModels(parsed.recentModels),
55
89
  providers: sanitizeProviders(parsed.providers),
56
90
  defaultProvider: sanitizeDefaultProvider(parsed.defaultProvider),
91
+ agentCategories: sanitizeAgentCategories(parsed.agentCategories),
92
+ theme: sanitizeTheme(parsed.theme),
57
93
  };
58
94
  }
59
95
  catch {
@@ -126,15 +162,32 @@ export class UserConfig {
126
162
  this.save();
127
163
  }
128
164
  getTheme() {
129
- const theme = this.data.theme;
130
- if (!theme || typeof theme !== "object" || Array.isArray(theme))
131
- return {};
132
- return Object.fromEntries(Object.entries(theme).filter(([, value]) => typeof value === "string"));
165
+ const theme = sanitizeTheme(this.data.theme);
166
+ return theme ?? { mode: "auto" };
167
+ }
168
+ getThemeMode() {
169
+ return this.getTheme().mode;
170
+ }
171
+ getThemeOverrides() {
172
+ return this.getTheme().overrides ?? {};
133
173
  }
134
- setTheme(theme) {
135
- this.data.theme = { ...theme };
174
+ setThemeMode(mode) {
175
+ const current = this.getTheme();
176
+ this.data.theme = current.overrides
177
+ ? { mode, overrides: current.overrides }
178
+ : { mode };
136
179
  this.save();
137
180
  }
181
+ setThemeOverrides(overrides) {
182
+ const current = this.getTheme();
183
+ this.data.theme = Object.keys(overrides).length === 0
184
+ ? { mode: current.mode }
185
+ : { mode: current.mode, overrides: { ...overrides } };
186
+ this.save();
187
+ }
188
+ getAgentCategories() {
189
+ return sanitizeAgentCategories(this.data.agentCategories);
190
+ }
138
191
  }
139
192
  /** Mask an API key for safe display. */
140
193
  export function maskKey(key) {
@@ -16,6 +16,7 @@ export interface ContextBudgetOptions {
16
16
  /** Messages appended after the anchor (their tokens are estimated and added). */
17
17
  tailMessages?: Message[];
18
18
  }
19
- export declare function estimateMessageTokens(message: Message): number;
20
- export declare function estimateContextTokens(messages: Message[]): number;
19
+ export declare function estimateMessageTokens(message: Message, providerId?: string): number;
20
+ export declare function estimateContextTokens(messages: Message[], providerId?: string): number;
21
21
  export declare function getContextBudget(providerId: string, modelId: string, messages: Message[], options?: ContextBudgetOptions): ContextBudget;
22
+ export declare function estimateTextTokens(text: string, providerId?: string): number;
@@ -1,36 +1,44 @@
1
1
  import { getModelContextWindow } from "../model-catalog.js";
2
+ import { getTokenEstimator } from "./token-estimator.js";
2
3
  export const OUTPUT_RESERVE_TOKENS = 20_000;
3
4
  export const AUTOCOMPACT_BUFFER_TOKENS = 13_000;
4
5
  export const PRUNE_BUFFER_TOKENS = 50_000;
5
6
  export const MIN_WINDOW_FOR_RESERVE = 40_000;
6
- export function estimateMessageTokens(message) {
7
+ // Safety margins applied to estimator-derived token counts. The estimator can
8
+ // undercount on dense / CJK / tool-payload content; treating its output as a
9
+ // hard floor means we'd routinely overshoot the real server-side count. These
10
+ // multipliers bias the budget decision toward earlier compaction.
11
+ const TAIL_SAFETY_MARGIN = 1.15; // applied to estimated tail when anchored
12
+ const FIRST_TURN_SAFETY_MARGIN = 1.25; // applied when there's no anchor yet
13
+ export function estimateMessageTokens(message, providerId) {
14
+ const estimate = (text) => estimateTextTokens(text, providerId);
7
15
  switch (message.role) {
8
16
  case "system":
9
17
  case "meta":
10
18
  case "tool":
11
- return estimateTextTokens(message.content);
19
+ return estimate(message.content);
12
20
  case "assistant":
13
- return estimateTextTokens(message.content)
14
- + estimateTextTokens(message.reasoning ?? "")
15
- + (message.toolCalls?.reduce((sum, toolCall) => sum + estimateTextTokens(toolCall.arguments) + 12, 0) ?? 0)
21
+ return estimate(message.content)
22
+ + estimate(message.reasoning ?? "")
23
+ + (message.toolCalls?.reduce((sum, toolCall) => sum + estimate(toolCall.arguments) + 12, 0) ?? 0)
16
24
  + 8;
17
25
  case "user":
18
26
  if (typeof message.content === "string") {
19
- return estimateTextTokens(message.content) + 8;
27
+ return estimate(message.content) + 8;
20
28
  }
21
29
  return message.content.reduce((sum, part) => {
22
30
  if (part.type === "text") {
23
- return sum + estimateTextTokens(part.text);
31
+ return sum + estimate(part.text);
24
32
  }
25
33
  return sum + 256;
26
34
  }, 8);
27
35
  }
28
36
  }
29
- export function estimateContextTokens(messages) {
30
- return messages.reduce((sum, message) => sum + estimateMessageTokens(message), 0);
37
+ export function estimateContextTokens(messages, providerId) {
38
+ return messages.reduce((sum, message) => sum + estimateMessageTokens(message, providerId), 0);
31
39
  }
32
40
  export function getContextBudget(providerId, modelId, messages, options = {}) {
33
- const estimatedTokens = computeEstimatedTokens(messages, options);
41
+ const estimatedTokens = computeEstimatedTokens(providerId, messages, options);
34
42
  const contextWindow = getModelContextWindow(providerId, modelId);
35
43
  const percent = contextWindow ? Math.min(100, (estimatedTokens / contextWindow) * 100) : undefined;
36
44
  return {
@@ -41,11 +49,17 @@ export function getContextBudget(providerId, modelId, messages, options = {}) {
41
49
  shouldCompact: shouldTriggerCompact(estimatedTokens, contextWindow),
42
50
  };
43
51
  }
44
- function computeEstimatedTokens(messages, options) {
52
+ function computeEstimatedTokens(providerId, messages, options) {
45
53
  if (options.usageAnchorTokens !== undefined && options.tailMessages) {
46
- return options.usageAnchorTokens + estimateContextTokens(options.tailMessages);
54
+ // Anchor is authoritative (server-reported input tokens from the last
55
+ // response). Tail goes through our estimator and may undercount on dense /
56
+ // tool-output content, so we inflate it by a small margin before adding.
57
+ const tailEstimate = estimateContextTokens(options.tailMessages, providerId);
58
+ return options.usageAnchorTokens + Math.ceil(tailEstimate * TAIL_SAFETY_MARGIN);
47
59
  }
48
- return estimateContextTokens(messages);
60
+ // First turn (or anchor lost): there's no server-reported baseline at all,
61
+ // so apply a larger safety margin to the pure estimate.
62
+ return Math.ceil(estimateContextTokens(messages, providerId) * FIRST_TURN_SAFETY_MARGIN);
49
63
  }
50
64
  function shouldTriggerPrune(estimatedTokens, contextWindow) {
51
65
  if (!contextWindow) {
@@ -65,9 +79,9 @@ function shouldTriggerCompact(estimatedTokens, contextWindow) {
65
79
  : contextWindow * 0.75;
66
80
  return estimatedTokens >= threshold;
67
81
  }
68
- function estimateTextTokens(text) {
82
+ export function estimateTextTokens(text, providerId) {
69
83
  if (!text) {
70
84
  return 0;
71
85
  }
72
- return Math.ceil(text.length / 4);
86
+ return getTokenEstimator(providerId).estimate(text);
73
87
  }
@@ -13,3 +13,26 @@ export interface CompactResult {
13
13
  }
14
14
  export declare function compactSessionEntries(entries: SessionLogEntry[], options?: CompactOptions): CompactResult;
15
15
  export declare function compactMessages(messages: Message[], options?: CompactOptions): CompactResult;
16
+ /**
17
+ * Sub-turn compaction.
18
+ *
19
+ * When the active user turn has accumulated many (assistant + tool-result) groups
20
+ * — typically a single "look at this project" prompt that triggers a dozen file
21
+ * reads — multi-turn compactMessages above is a no-op (there's only one user turn
22
+ * to summarize). This variant operates one level finer: it groups messages inside
23
+ * the last user turn by assistant message, keeps the most recent K groups intact,
24
+ * and replaces the older ones with a synthetic system message that names the tools
25
+ * called and files inspected.
26
+ *
27
+ * Constraints honored:
28
+ * - Older groups are dropped WHOLE (assistant + its tool results). Dropping just
29
+ * the tool results would leave orphan tool_calls; repairToolCallChains would
30
+ * then synthesize "[no result captured]" placeholders, undoing the win.
31
+ * - Pre-turn content (earlier user turns) is left untouched — that's the
32
+ * multi-turn compactor's territory.
33
+ */
34
+ export interface SubTurnCompactOptions {
35
+ keepRecentGroups?: number;
36
+ maxSummaryItems?: number;
37
+ }
38
+ export declare function compactCurrentTurnToolGroups(messages: Message[], options?: SubTurnCompactOptions): CompactResult;