@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.
- package/dist/agent/categories.d.ts +34 -0
- package/dist/agent/categories.js +98 -0
- package/dist/agent/profiles.d.ts +4 -0
- package/dist/agent/profiles.js +2 -3
- package/dist/agent/subagent-control.d.ts +5 -0
- package/dist/agent/subagent-control.js +4 -0
- package/dist/agent/subagent-lifecycle-reminder.d.ts +3 -0
- package/dist/agent/subagent-lifecycle-reminder.js +102 -0
- package/dist/agent/subagent-route-format.d.ts +8 -0
- package/dist/agent/subagent-route-format.js +18 -0
- package/dist/agent/subtask-policy.d.ts +0 -1
- package/dist/agent/subtask-policy.js +0 -4
- package/dist/agent.d.ts +12 -0
- package/dist/agent.js +152 -13
- package/dist/config.d.ts +23 -3
- package/dist/config.js +59 -6
- package/dist/context/budget.d.ts +3 -3
- package/dist/context/budget.js +29 -15
- package/dist/context/compact.d.ts +23 -0
- package/dist/context/compact.js +129 -0
- package/dist/context/llm-compactor.d.ts +19 -0
- package/dist/context/llm-compactor.js +200 -0
- package/dist/context/projector.js +28 -12
- package/dist/context/token-estimator.d.ts +14 -0
- package/dist/context/token-estimator.js +106 -0
- package/dist/context/tool-output-truncate.d.ts +8 -0
- package/dist/context/tool-output-truncate.js +59 -0
- package/dist/context/usage.js +9 -9
- package/dist/main.js +43 -6
- package/dist/model-catalog.d.ts +9 -0
- package/dist/model-catalog.js +16 -0
- package/dist/orchestrator/default-hooks.js +18 -0
- package/dist/provider-openai-codex.d.ts +13 -2
- package/dist/provider-openai-codex.js +81 -32
- package/dist/provider-registry.js +20 -4
- package/dist/slash-commands/commands.js +24 -0
- package/dist/slash-commands/types.d.ts +7 -0
- package/dist/tools/agent-lifecycle.js +22 -4
- package/dist/tools/edit.js +2 -2
- package/dist/tools/glob.js +2 -1
- package/dist/tools/grep.js +2 -2
- package/dist/tools/lsp.js +2 -2
- package/dist/tools/path-utils.d.ts +2 -0
- package/dist/tools/path-utils.js +16 -0
- package/dist/tools/read.js +117 -5
- package/dist/tools/write.js +3 -2
- package/dist/tui-ink/app.d.ts +11 -2
- package/dist/tui-ink/app.js +191 -78
- package/dist/tui-ink/approval/approval-dialog.js +4 -1
- package/dist/tui-ink/approval/diff-view.js +2 -1
- package/dist/tui-ink/approval/select.js +2 -1
- package/dist/tui-ink/code-highlight.d.ts +2 -0
- package/dist/tui-ink/code-highlight.js +30 -2
- package/dist/tui-ink/detect-theme.d.ts +19 -0
- package/dist/tui-ink/detect-theme.js +123 -0
- package/dist/tui-ink/footer.js +4 -3
- package/dist/tui-ink/input-box.js +83 -26
- package/dist/tui-ink/input-history.d.ts +16 -0
- package/dist/tui-ink/input-history.js +81 -0
- package/dist/tui-ink/markdown.js +30 -20
- package/dist/tui-ink/message-list.js +112 -16
- package/dist/tui-ink/model-picker.js +6 -1
- package/dist/tui-ink/plan-confirm.js +2 -1
- package/dist/tui-ink/question-dialog.js +2 -1
- package/dist/tui-ink/run.d.ts +5 -1
- package/dist/tui-ink/run.js +30 -2
- package/dist/tui-ink/theme.d.ts +64 -35
- package/dist/tui-ink/theme.js +81 -8
- package/dist/tui-ink/todos.js +5 -3
- package/dist/tui-ink/trace-groups.d.ts +3 -1
- package/dist/tui-ink/trace-groups.js +93 -14
- package/dist/tui-ink/welcome.js +23 -4
- package/dist/types.d.ts +6 -0
- 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:
|
|
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
|
-
|
|
825
|
-
|
|
826
|
-
|
|
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:
|
|
932
|
-
configuredModel:
|
|
933
|
-
configuredModelId:
|
|
934
|
-
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
|
|
949
|
-
providerId:
|
|
950
|
-
model:
|
|
1069
|
+
provider,
|
|
1070
|
+
providerId: route.providerId,
|
|
1071
|
+
model: route.model,
|
|
951
1072
|
tools,
|
|
952
1073
|
temperature: this.temperature,
|
|
953
|
-
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
|
-
|
|
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():
|
|
38
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
135
|
-
|
|
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) {
|
package/dist/context/budget.d.ts
CHANGED
|
@@ -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;
|
package/dist/context/budget.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
19
|
+
return estimate(message.content);
|
|
12
20
|
case "assistant":
|
|
13
|
-
return
|
|
14
|
-
+
|
|
15
|
-
+ (message.toolCalls?.reduce((sum, toolCall) => sum +
|
|
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
|
|
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 +
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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;
|