@bubblebrain-ai/bubble 0.0.8 → 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 (74) 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 +12 -0
  14. package/dist/agent.js +152 -13
  15. package/dist/config.d.ts +23 -3
  16. package/dist/config.js +59 -6
  17. package/dist/context/budget.d.ts +3 -3
  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.js +9 -9
  29. package/dist/main.js +43 -6
  30. package/dist/model-catalog.d.ts +9 -0
  31. package/dist/model-catalog.js +16 -0
  32. package/dist/orchestrator/default-hooks.js +18 -0
  33. package/dist/provider-openai-codex.d.ts +13 -2
  34. package/dist/provider-openai-codex.js +81 -32
  35. package/dist/provider-registry.js +20 -4
  36. package/dist/slash-commands/commands.js +24 -0
  37. package/dist/slash-commands/types.d.ts +7 -0
  38. package/dist/tools/agent-lifecycle.js +22 -4
  39. package/dist/tools/edit.js +2 -2
  40. package/dist/tools/glob.js +2 -1
  41. package/dist/tools/grep.js +2 -2
  42. package/dist/tools/lsp.js +2 -2
  43. package/dist/tools/path-utils.d.ts +2 -0
  44. package/dist/tools/path-utils.js +16 -0
  45. package/dist/tools/read.js +117 -5
  46. package/dist/tools/write.js +3 -2
  47. package/dist/tui-ink/app.d.ts +11 -2
  48. package/dist/tui-ink/app.js +191 -78
  49. package/dist/tui-ink/approval/approval-dialog.js +4 -1
  50. package/dist/tui-ink/approval/diff-view.js +2 -1
  51. package/dist/tui-ink/approval/select.js +2 -1
  52. package/dist/tui-ink/code-highlight.d.ts +2 -0
  53. package/dist/tui-ink/code-highlight.js +30 -2
  54. package/dist/tui-ink/detect-theme.d.ts +19 -0
  55. package/dist/tui-ink/detect-theme.js +123 -0
  56. package/dist/tui-ink/footer.js +4 -3
  57. package/dist/tui-ink/input-box.js +83 -26
  58. package/dist/tui-ink/input-history.d.ts +16 -0
  59. package/dist/tui-ink/input-history.js +81 -0
  60. package/dist/tui-ink/markdown.js +30 -20
  61. package/dist/tui-ink/message-list.js +112 -16
  62. package/dist/tui-ink/model-picker.js +6 -1
  63. package/dist/tui-ink/plan-confirm.js +2 -1
  64. package/dist/tui-ink/question-dialog.js +2 -1
  65. package/dist/tui-ink/run.d.ts +5 -1
  66. package/dist/tui-ink/run.js +30 -2
  67. package/dist/tui-ink/theme.d.ts +64 -35
  68. package/dist/tui-ink/theme.js +81 -8
  69. package/dist/tui-ink/todos.js +5 -3
  70. package/dist/tui-ink/trace-groups.d.ts +3 -1
  71. package/dist/tui-ink/trace-groups.js +93 -14
  72. package/dist/tui-ink/welcome.js +23 -4
  73. package/dist/types.d.ts +6 -0
  74. package/package.json +2 -1
package/dist/agent.js CHANGED
@@ -10,9 +10,11 @@ import { buildContextUsageSnapshot } from "./context/usage.js";
10
10
  import { isContextOverflowError } from "./context/overflow.js";
11
11
  import { projectMessages } from "./context/projector.js";
12
12
  import { aggressivePruneMessages } from "./context/prune.js";
13
+ import { truncateToolOutputForModel } from "./context/tool-output-truncate.js";
13
14
  import { buildDeferredToolsReminder, buildToolFreezeReminder, isPermissionModeReminder, reminderForMode } from "./prompt/reminders.js";
14
15
  import { HookBus } from "./orchestrator/hooks.js";
15
16
  import { createDefaultHooks } from "./orchestrator/default-hooks.js";
17
+ import { resolveModelRoute, resolveSubagentRoute } from "./agent/categories.js";
16
18
  import { getSubtaskPolicy } from "./agent/subtask-policy.js";
17
19
  import { composeAbortSignals } from "./agent/budget-ledger.js";
18
20
  import { assignAgentNickname, builtinAgentProfiles, mergeUsage, selectToolsForAgentProfile, validateAgentProfileTools } from "./agent/profiles.js";
@@ -59,6 +61,8 @@ export class Agent {
59
61
  skillSummaries;
60
62
  memoryPrompt;
61
63
  fileStateTracker;
64
+ agentCategories;
65
+ providerFactory;
62
66
  subagentThreads = new Map();
63
67
  pendingSubagentUpdates = [];
64
68
  lastInputTokens = null;
@@ -84,6 +88,8 @@ export class Agent {
84
88
  this.skillSummaries = options.skills ?? [];
85
89
  this.memoryPrompt = options.memoryPrompt;
86
90
  this.fileStateTracker = options.fileStateTracker;
91
+ this.agentCategories = options.agentCategories ?? {};
92
+ this.providerFactory = options.providerFactory;
87
93
  if (options.systemPrompt) {
88
94
  this.messages.push({ role: "system", content: options.systemPrompt });
89
95
  }
@@ -316,6 +322,12 @@ export class Agent {
316
322
  description: t.description,
317
323
  parameters: t.parameters,
318
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();
319
331
  try {
320
332
  const projectedMessages = projectMessages(this.messages, {
321
333
  mode: "budgeted",
@@ -526,10 +538,14 @@ export class Agent {
526
538
  result = next;
527
539
  },
528
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);
529
545
  this.appendMessage({
530
546
  role: "tool",
531
547
  toolCallId: tc.id,
532
- content: result.content,
548
+ content: truncatedOutput.content,
533
549
  metadata: result.metadata,
534
550
  isError: result.isError,
535
551
  });
@@ -616,6 +632,21 @@ export class Agent {
616
632
  this.fileStateTracker?.invalidateReadHistory();
617
633
  return before - this.messages.length;
618
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();
648
+ return before - this.messages.length;
649
+ }
619
650
  const fallback = compactMessages(this.messages, { keepRecentTurns });
620
651
  if (fallback.compacted && fallback.messages) {
621
652
  this.messages = fallback.messages;
@@ -624,11 +655,53 @@ export class Agent {
624
655
  this.fileStateTracker?.invalidateReadHistory();
625
656
  return before - this.messages.length;
626
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();
670
+ return before - this.messages.length;
671
+ }
627
672
  return 0;
628
673
  }
629
674
  compactResidentHistory() {
630
675
  this.maybeCompactResidentHistory();
631
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
+ }
632
705
  async runSubtask(input, cwd, options) {
633
706
  const subtaskType = options?.subtaskType;
634
707
  const profile = builtinAgentProfiles().find((item) => item.subtaskType === (subtaskType ?? "general_readonly"))
@@ -638,6 +711,7 @@ export class Agent {
638
711
  runId: randomUUID(),
639
712
  subAgentId: randomUUID(),
640
713
  parentToolCallId: "task",
714
+ route: this.resolveRouteForSubagent(profile, undefined),
641
715
  description: options?.description,
642
716
  });
643
717
  const lines = [
@@ -673,6 +747,7 @@ export class Agent {
673
747
  parentToolCallId: options.parentToolCallId,
674
748
  parentToolName: "subagent",
675
749
  nickname: options.nickname,
750
+ route: options.route ?? this.resolveRouteForSubagent(options.profile, options.category),
676
751
  });
677
752
  await this.runSubagentThread(record, input, cwd, {
678
753
  approval: options.approval ?? options.profile.approval,
@@ -688,6 +763,7 @@ export class Agent {
688
763
  task: typeof input === "string" ? input : "(multimodal task)",
689
764
  parentToolCallId: options.parentToolCallId,
690
765
  parentToolName: "spawn_agent",
766
+ route: options.route ?? this.resolveRouteForSubagent(options.profile, options.category),
691
767
  });
692
768
  this.subagentThreads.set(record.agentId, record);
693
769
  this.queueSubagentUpdate(record, "queued", undefined, `Queued ${record.nickname} (${record.profile.name})`);
@@ -778,6 +854,31 @@ export class Agent {
778
854
  listSubAgents() {
779
855
  return [...this.subagentThreads.values()].map(snapshotSubagentThread);
780
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
+ }
781
882
  createSubagentThreadRecord(options) {
782
883
  const now = Date.now();
783
884
  const nickname = options.nickname ?? assignAgentNickname(options.profile, this.activeSubagentNicknames());
@@ -786,6 +887,8 @@ export class Agent {
786
887
  runId: options.runId ?? randomUUID(),
787
888
  nickname,
788
889
  profile: options.profile,
890
+ category: options.route?.category,
891
+ route: options.route,
789
892
  parentToolCallId: options.parentToolCallId,
790
893
  parentToolName: options.parentToolName,
791
894
  status: "queued",
@@ -821,9 +924,20 @@ export class Agent {
821
924
  return;
822
925
  }
823
926
  const tools = selectToolsForAgentProfile(allTools, record.profile, options.approval);
824
- const subAgent = options.reuseAgent && record.agent
825
- ? record.agent
826
- : 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
+ }
827
941
  record.agent = subAgent;
828
942
  record.status = "running";
829
943
  record.updatedAt = Date.now();
@@ -924,14 +1038,21 @@ export class Agent {
924
1038
  record.summary = finalSummary;
925
1039
  }
926
1040
  }
927
- createSubAgentInstance(record, tools, cwd, forkContext) {
1041
+ async createSubAgentInstance(record, tools, cwd, forkContext) {
928
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);
929
1050
  const childSystemPrompt = buildSystemPrompt({
930
1051
  agentName: "Bubble",
931
- configuredProvider: this.providerId || "none",
932
- configuredModel: this.model || "none",
933
- configuredModelId: this.model || "none",
934
- 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,
935
1056
  mode: "plan",
936
1057
  workingDir: cwd,
937
1058
  tools: childToolNames,
@@ -945,24 +1066,38 @@ export class Agent {
945
1066
  ].filter(Boolean).join("\n\n"),
946
1067
  });
947
1068
  const subAgent = new Agent({
948
- provider: this.provider,
949
- providerId: this.providerId,
950
- model: record.profile.model && record.profile.model !== "inherit" ? record.profile.model : this.model,
1069
+ provider,
1070
+ providerId: route.providerId,
1071
+ model: route.model,
951
1072
  tools,
952
1073
  temperature: this.temperature,
953
- thinkingLevel: this.thinkingLevel,
1074
+ thinkingLevel: route.thinkingLevel,
954
1075
  mode: "plan",
955
1076
  maxTurns: record.profile.maxTurns,
956
1077
  budgetLedger: this.budgetLedger,
957
1078
  budgetSource: { runId: record.runId, subAgentId: record.agentId },
958
1079
  systemPrompt: childSystemPrompt,
959
1080
  hooks: this.hookDefinitions,
1081
+ agentCategories: this.agentCategories,
1082
+ providerFactory: this.providerFactory,
960
1083
  });
961
1084
  if (forkContext) {
962
1085
  subAgent.messages = this.forkMessagesForSubagent(childSystemPrompt);
963
1086
  }
964
1087
  return subAgent;
965
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
+ }
966
1101
  forkMessagesForSubagent(childSystemPrompt) {
967
1102
  const forked = this.messages
968
1103
  .filter((message) => {
@@ -987,6 +1122,8 @@ export class Agent {
987
1122
  subAgentId: record.agentId,
988
1123
  agentName: record.profile.name,
989
1124
  nickname: record.nickname,
1125
+ category: record.category,
1126
+ route: record.route,
990
1127
  status,
991
1128
  childEvent: event,
992
1129
  summaryDelta: event?.type === "text_delta" ? event.content : undefined,
@@ -1000,6 +1137,8 @@ export class Agent {
1000
1137
  subAgentId: record.agentId,
1001
1138
  agentName: record.profile.name,
1002
1139
  nickname: record.nickname,
1140
+ category: record.category,
1141
+ route: record.route,
1003
1142
  status,
1004
1143
  profileSource: record.profile.source,
1005
1144
  task: record.task,
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,7 +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): number;
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
- export 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;