ei-tui 0.5.1 → 0.5.2
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/package.json +1 -1
- package/src/core/AGENTS.md +2 -2
- package/src/core/context-utils.ts +3 -4
- package/src/core/llm-client.ts +119 -39
- package/src/core/orchestrators/human-extraction.ts +0 -5
- package/src/core/processor.ts +5 -0
- package/src/core/queue-manager.ts +4 -0
- package/src/core/queue-processor.ts +1 -0
- package/src/core/state/queue.ts +7 -0
- package/src/core/state-manager.ts +233 -4
- package/src/core/tools/index.ts +1 -1
- package/src/core/types/entities.ts +21 -4
- package/src/integrations/claude-code/importer.ts +0 -1
- package/src/integrations/claude-code/types.ts +0 -1
- package/src/integrations/opencode/importer.ts +0 -1
- package/src/storage/merge.ts +47 -2
- package/tui/src/commands/dlq.ts +12 -4
- package/tui/src/commands/provider.tsx +110 -90
- package/tui/src/commands/queue.ts +11 -3
- package/tui/src/commands/settings.tsx +9 -17
- package/tui/src/components/ModelListOverlay.tsx +203 -0
- package/tui/src/components/PromptInput.tsx +0 -2
- package/tui/src/context/ei.tsx +7 -0
- package/tui/src/util/persona-editor.tsx +15 -12
- package/tui/src/util/provider-editor.tsx +23 -6
- package/tui/src/util/yaml-serializers.ts +255 -73
- package/src/core/model-context-windows.ts +0 -49
- package/tui/src/commands/model.ts +0 -47
package/package.json
CHANGED
package/src/core/AGENTS.md
CHANGED
|
@@ -53,10 +53,10 @@ Priority queue for LLM requests:
|
|
|
53
53
|
|
|
54
54
|
Multi-provider LLM abstraction layer:
|
|
55
55
|
- Handles requests to Anthropic, OpenAI, Bedrock, local models
|
|
56
|
-
- **Sets `max_tokens:
|
|
56
|
+
- **Sets `max_tokens: 8000`** by default (safe for most providers; users can configure higher per-model)
|
|
57
57
|
- Prevents unbounded generation (test showed timeout after 2min without limit)
|
|
58
58
|
- Local models silently clamp to their configured maximums
|
|
59
|
-
- Anthropic Opus 4 accepts
|
|
59
|
+
- Anthropic Opus 4 accepts up to 64K output (configure `max_output_tokens` on the model to unlock)
|
|
60
60
|
|
|
61
61
|
**JSON Response Parsing** (`parseJSONResponse()`):
|
|
62
62
|
- **Strategy 1**: Extract from markdown code blocks (```json)
|
|
@@ -23,11 +23,10 @@ export function filterMessagesForContext(
|
|
|
23
23
|
|
|
24
24
|
const msgMs = new Date(msg.timestamp).getTime();
|
|
25
25
|
|
|
26
|
-
if (
|
|
27
|
-
|
|
28
|
-
}
|
|
26
|
+
if (msgMs < windowStartMs) return false;
|
|
27
|
+
if (contextBoundary && msgMs < boundaryMs) return false;
|
|
29
28
|
|
|
30
|
-
return
|
|
29
|
+
return true;
|
|
31
30
|
});
|
|
32
31
|
}
|
|
33
32
|
|
package/src/core/llm-client.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import type { ChatMessage, ProviderAccount } from "./types.js";
|
|
2
|
-
|
|
1
|
+
import type { ChatMessage, ProviderAccount, ModelConfig } from "./types.js";
|
|
2
|
+
const DEFAULT_TOKEN_LIMIT = 8192;
|
|
3
|
+
const DEFAULT_MAX_OUTPUT_TOKENS = 8000;
|
|
3
4
|
|
|
4
5
|
export interface ProviderConfig {
|
|
5
6
|
baseURL: string;
|
|
@@ -9,7 +10,7 @@ export interface ProviderConfig {
|
|
|
9
10
|
|
|
10
11
|
export interface ResolvedModel {
|
|
11
12
|
provider: string;
|
|
12
|
-
model: string;
|
|
13
|
+
model: string | undefined;
|
|
13
14
|
config: ProviderConfig;
|
|
14
15
|
extraHeaders?: Record<string, string>;
|
|
15
16
|
}
|
|
@@ -19,6 +20,8 @@ export interface LLMCallOptions {
|
|
|
19
20
|
temperature?: number;
|
|
20
21
|
/** OpenAI-compatible tools array. When present and non-empty, sent with tool_choice: "auto". */
|
|
21
22
|
tools?: Record<string, unknown>[];
|
|
23
|
+
/** Fire-and-forget callback invoked after a successful response to increment usage counters. */
|
|
24
|
+
onUsageUpdate?: (modelId: string, usage: { calls: number; tokens_in: number; tokens_out: number }) => void;
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
export interface LLMRawResponse {
|
|
@@ -43,27 +46,90 @@ let llmCallCount = 0;
|
|
|
43
46
|
|
|
44
47
|
|
|
45
48
|
|
|
49
|
+
function isGuid(str: string): boolean {
|
|
50
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(str);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function buildResolvedModel(account: ProviderAccount, model: ModelConfig): ResolvedModel {
|
|
54
|
+
return {
|
|
55
|
+
provider: account.name,
|
|
56
|
+
model: model.name === "(default)" ? undefined : model.name,
|
|
57
|
+
config: {
|
|
58
|
+
name: account.name,
|
|
59
|
+
baseURL: account.url,
|
|
60
|
+
apiKey: account.api_key || "",
|
|
61
|
+
},
|
|
62
|
+
extraHeaders: account.extra_headers,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function resolveModelById(
|
|
67
|
+
modelId: string,
|
|
68
|
+
accounts: ProviderAccount[]
|
|
69
|
+
): { account: ProviderAccount; model: ModelConfig } | undefined {
|
|
70
|
+
for (const account of accounts) {
|
|
71
|
+
if (!account.enabled || account.type !== "llm") continue;
|
|
72
|
+
const model = account.models?.find((m) => m.id === modelId);
|
|
73
|
+
if (model) return { account, model };
|
|
74
|
+
}
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function getDisplayName(account: ProviderAccount, model: ModelConfig): string {
|
|
79
|
+
return `${account.name}:${model.name}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
46
82
|
export function resolveModel(modelSpec?: string, accounts?: ProviderAccount[]): ResolvedModel {
|
|
47
83
|
if (!modelSpec) {
|
|
48
84
|
throw new Error("No model specified. Set a provider on this persona with /provider, or set a default_model in settings.");
|
|
49
85
|
}
|
|
86
|
+
|
|
87
|
+
if (accounts && isGuid(modelSpec)) {
|
|
88
|
+
const result = resolveModelById(modelSpec, accounts);
|
|
89
|
+
if (result) {
|
|
90
|
+
return buildResolvedModel(result.account, result.model);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const fallbackAccount = accounts.find((acc) => acc.enabled && acc.type === "llm" && acc.default_model);
|
|
94
|
+
if (fallbackAccount?.default_model) {
|
|
95
|
+
const fallbackResult = resolveModelById(fallbackAccount.default_model, accounts);
|
|
96
|
+
if (fallbackResult) {
|
|
97
|
+
return buildResolvedModel(fallbackResult.account, fallbackResult.model);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
throw new Error(
|
|
102
|
+
`Model "${modelSpec}" not found. It may have been deleted. Update this persona's model in settings.`
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
50
106
|
let provider = "";
|
|
51
107
|
let model = modelSpec;
|
|
52
|
-
|
|
108
|
+
|
|
53
109
|
if (modelSpec.includes(":")) {
|
|
54
110
|
const [p, ...rest] = modelSpec.split(":");
|
|
55
111
|
provider = p;
|
|
56
112
|
model = rest.join(":");
|
|
57
113
|
}
|
|
58
|
-
|
|
59
|
-
// Check both "provider:model" format AND bare account names
|
|
114
|
+
|
|
60
115
|
if (accounts) {
|
|
61
|
-
const searchName = provider || modelSpec;
|
|
116
|
+
const searchName = provider || modelSpec;
|
|
62
117
|
const matchingAccount = accounts.find(
|
|
63
118
|
(acc) => acc.name.toLowerCase() === searchName.toLowerCase() && acc.enabled && acc.type === "llm"
|
|
64
119
|
);
|
|
65
120
|
if (matchingAccount) {
|
|
66
|
-
|
|
121
|
+
const matchingModel = matchingAccount.models?.find((m) => m.name === model);
|
|
122
|
+
if (matchingModel) {
|
|
123
|
+
return buildResolvedModel(matchingAccount, matchingModel);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!provider && matchingAccount.default_model && matchingAccount.models) {
|
|
127
|
+
const defaultModel = matchingAccount.models.find((m) => m.id === matchingAccount.default_model);
|
|
128
|
+
if (defaultModel) {
|
|
129
|
+
return buildResolvedModel(matchingAccount, defaultModel);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
67
133
|
const resolvedModel = provider ? model : (matchingAccount.default_model || model);
|
|
68
134
|
return {
|
|
69
135
|
provider: matchingAccount.name,
|
|
@@ -77,7 +143,7 @@ export function resolveModel(modelSpec?: string, accounts?: ProviderAccount[]):
|
|
|
77
143
|
};
|
|
78
144
|
}
|
|
79
145
|
}
|
|
80
|
-
|
|
146
|
+
|
|
81
147
|
throw new Error(
|
|
82
148
|
`No provider "${provider || modelSpec}" found. Create one with /provider new, or check that it's enabled.`
|
|
83
149
|
);
|
|
@@ -85,44 +151,48 @@ export function resolveModel(modelSpec?: string, accounts?: ProviderAccount[]):
|
|
|
85
151
|
|
|
86
152
|
const tokenLimitLoggedModels = new Set<string>();
|
|
87
153
|
|
|
154
|
+
function findModelAndAccount(
|
|
155
|
+
spec: string,
|
|
156
|
+
accounts: ProviderAccount[]
|
|
157
|
+
): { model: ModelConfig | undefined; account: ProviderAccount | undefined } {
|
|
158
|
+
if (spec.includes(":")) {
|
|
159
|
+
const [providerName, ...rest] = spec.split(":");
|
|
160
|
+
const modelName = rest.join(":");
|
|
161
|
+
const account = accounts.find(
|
|
162
|
+
(a) => a.name.toLowerCase() === providerName.toLowerCase() && a.enabled
|
|
163
|
+
);
|
|
164
|
+
const model = account?.models?.find((m) => m.name === modelName);
|
|
165
|
+
return { model, account };
|
|
166
|
+
}
|
|
167
|
+
for (const account of accounts) {
|
|
168
|
+
const model = account.models?.find((m) => m.id === spec);
|
|
169
|
+
if (model) return { model, account };
|
|
170
|
+
}
|
|
171
|
+
return { model: undefined, account: undefined };
|
|
172
|
+
}
|
|
173
|
+
|
|
88
174
|
export function resolveTokenLimit(
|
|
89
175
|
modelSpec?: string,
|
|
90
176
|
accounts?: ProviderAccount[]
|
|
91
177
|
): number {
|
|
92
178
|
const spec = modelSpec || "";
|
|
93
179
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
if (spec.includes(":")) {
|
|
97
|
-
const [p, ...rest] = spec.split(":");
|
|
98
|
-
provider = p;
|
|
99
|
-
model = rest.join(":");
|
|
100
|
-
}
|
|
180
|
+
if (accounts && spec) {
|
|
181
|
+
const { model, account } = findModelAndAccount(spec, accounts);
|
|
101
182
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const matchingAccount = accounts.find(
|
|
106
|
-
(acc) => acc.name.toLowerCase() === searchName.toLowerCase() && acc.enabled
|
|
107
|
-
);
|
|
108
|
-
if (matchingAccount?.token_limit) {
|
|
109
|
-
logTokenLimit(model, "user-override", matchingAccount.token_limit);
|
|
110
|
-
return matchingAccount.token_limit;
|
|
183
|
+
if (model?.token_limit) {
|
|
184
|
+
logTokenLimit(spec, "model-config", model.token_limit);
|
|
185
|
+
return model.token_limit;
|
|
111
186
|
}
|
|
112
|
-
if (matchingAccount && !provider) {
|
|
113
|
-
model = matchingAccount.default_model || model;
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
187
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
188
|
+
if (account?.token_limit) {
|
|
189
|
+
const displayName = spec.includes(":") ? spec.split(":").slice(1).join(":") : spec;
|
|
190
|
+
logTokenLimit(displayName, "user-override", account.token_limit);
|
|
191
|
+
return account.token_limit;
|
|
192
|
+
}
|
|
122
193
|
}
|
|
123
194
|
|
|
124
|
-
|
|
125
|
-
logTokenLimit(model, "default", DEFAULT_TOKEN_LIMIT);
|
|
195
|
+
logTokenLimit(spec, "default", DEFAULT_TOKEN_LIMIT);
|
|
126
196
|
return DEFAULT_TOKEN_LIMIT;
|
|
127
197
|
}
|
|
128
198
|
|
|
@@ -148,13 +218,16 @@ export async function callLLMRaw(
|
|
|
148
218
|
): Promise<LLMRawResponse> {
|
|
149
219
|
llmCallCount++;
|
|
150
220
|
|
|
151
|
-
const { signal, temperature = 0.7 } = options;
|
|
221
|
+
const { signal, temperature = 0.7, onUsageUpdate } = options;
|
|
152
222
|
|
|
153
223
|
if (signal?.aborted) {
|
|
154
224
|
throw new Error("LLM call aborted");
|
|
155
225
|
}
|
|
156
226
|
|
|
157
227
|
const { model, config, extraHeaders } = resolveModel(modelSpec, accounts);
|
|
228
|
+
const { model: modelConfig } = (accounts && modelSpec)
|
|
229
|
+
? findModelAndAccount(modelSpec, accounts)
|
|
230
|
+
: { model: undefined };
|
|
158
231
|
|
|
159
232
|
const chatMessages: ChatMessage[] = [
|
|
160
233
|
{ role: "system", content: systemPrompt },
|
|
@@ -186,10 +259,10 @@ export async function callLLMRaw(
|
|
|
186
259
|
}
|
|
187
260
|
|
|
188
261
|
const requestBody: Record<string, unknown> = {
|
|
189
|
-
model,
|
|
262
|
+
...(model !== undefined && { model }),
|
|
190
263
|
messages: finalMessages,
|
|
191
264
|
temperature,
|
|
192
|
-
max_tokens:
|
|
265
|
+
max_tokens: modelConfig?.max_output_tokens ?? DEFAULT_MAX_OUTPUT_TOKENS,
|
|
193
266
|
};
|
|
194
267
|
|
|
195
268
|
if (options.tools && options.tools.length > 0) {
|
|
@@ -210,6 +283,13 @@ export async function callLLMRaw(
|
|
|
210
283
|
}
|
|
211
284
|
|
|
212
285
|
const data = await response.json();
|
|
286
|
+
|
|
287
|
+
if (onUsageUpdate && modelConfig) {
|
|
288
|
+
const tokensIn = data.usage?.prompt_tokens ?? data.usage?.input_tokens ?? 0;
|
|
289
|
+
const tokensOut = data.usage?.completion_tokens ?? data.usage?.output_tokens ?? 0;
|
|
290
|
+
onUsageUpdate(modelConfig.id, { calls: 1, tokens_in: tokensIn, tokens_out: tokensOut });
|
|
291
|
+
}
|
|
292
|
+
|
|
213
293
|
const choice = data.choices?.[0];
|
|
214
294
|
|
|
215
295
|
const assistantMessage = choice?.message as Record<string, unknown> | undefined;
|
|
@@ -66,8 +66,6 @@ export interface ExtractionOptions {
|
|
|
66
66
|
ceremony_progress?: number;
|
|
67
67
|
/** Override model for extraction LLM calls */
|
|
68
68
|
extraction_model?: string;
|
|
69
|
-
/** Override token budget for chunking */
|
|
70
|
-
extraction_token_limit?: number;
|
|
71
69
|
/**
|
|
72
70
|
* Controls whether external (integration-imported) messages are included.
|
|
73
71
|
* - "exclude": skip messages where external === true
|
|
@@ -88,9 +86,6 @@ const EXTRACTION_BUDGET_RATIO = 0.75;
|
|
|
88
86
|
const MIN_EXTRACTION_TOKENS = 10000;
|
|
89
87
|
|
|
90
88
|
function getExtractionMaxTokens(state: StateManager, options?: ExtractionOptions): number {
|
|
91
|
-
if (options?.extraction_token_limit) {
|
|
92
|
-
return Math.max(MIN_EXTRACTION_TOKENS, Math.floor(options.extraction_token_limit * EXTRACTION_BUDGET_RATIO));
|
|
93
|
-
}
|
|
94
89
|
const human = state.getHuman();
|
|
95
90
|
const modelForTokenLimit = options?.extraction_model ?? human.settings?.default_model;
|
|
96
91
|
const tokenLimit = resolveTokenLimit(modelForTokenLimit, human.settings?.accounts);
|
package/src/core/processor.ts
CHANGED
|
@@ -107,6 +107,7 @@ import {
|
|
|
107
107
|
getQueueActiveItems,
|
|
108
108
|
getDLQItems,
|
|
109
109
|
updateQueueItem,
|
|
110
|
+
deleteQueueItems,
|
|
110
111
|
clearQueue,
|
|
111
112
|
submitOneShot,
|
|
112
113
|
} from "./queue-manager.js";
|
|
@@ -1931,6 +1932,10 @@ const toolNextSteps = new Set([
|
|
|
1931
1932
|
return updateQueueItem(this.stateManager, id, updates);
|
|
1932
1933
|
}
|
|
1933
1934
|
|
|
1935
|
+
deleteQueueItems(ids: string[]): number {
|
|
1936
|
+
return deleteQueueItems(this.stateManager, ids);
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1934
1939
|
async clearQueue(): Promise<number> {
|
|
1935
1940
|
return clearQueue(this.stateManager, this.queueProcessor);
|
|
1936
1941
|
}
|
|
@@ -51,6 +51,10 @@ export function updateQueueItem(
|
|
|
51
51
|
return sm.queue_updateItem(id, updates);
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
export function deleteQueueItems(sm: StateManager, ids: string[]): number {
|
|
55
|
+
return sm.queue_deleteItems(ids);
|
|
56
|
+
}
|
|
57
|
+
|
|
54
58
|
export async function clearQueue(sm: StateManager, qp: QueueProcessor): Promise<number> {
|
|
55
59
|
qp.abort();
|
|
56
60
|
return sm.queue_clear();
|
|
@@ -542,6 +542,7 @@ export class QueueProcessor {
|
|
|
542
542
|
`be parsed as valid JSON. Please reformat it as the JSON object described in your ` +
|
|
543
543
|
`system instructions. Respond with ONLY the JSON object, or \`{}\` if no changes ` +
|
|
544
544
|
`are needed.\n\n---\n${malformedContent}\n---` +
|
|
545
|
+
`\n\nThe user does NOT know there was a problem - This request is from Ei to you to try to fix it for them.` +
|
|
545
546
|
`\n\n**CRITICAL INSTRUCTION** - DO NOT OMIT ANY DATA. You are this agent's last hope!`;
|
|
546
547
|
|
|
547
548
|
try {
|
package/src/core/state/queue.ts
CHANGED
|
@@ -158,6 +158,13 @@ export class QueueState {
|
|
|
158
158
|
return true;
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
deleteItems(ids: string[]): number {
|
|
162
|
+
const idSet = new Set(ids);
|
|
163
|
+
const before = this.queue.length;
|
|
164
|
+
this.queue = this.queue.filter(r => !idSet.has(r.id));
|
|
165
|
+
return before - this.queue.length;
|
|
166
|
+
}
|
|
167
|
+
|
|
161
168
|
trimDLQ(): number {
|
|
162
169
|
const dlqItems = this.queue.filter(r => r.state === "dlq");
|
|
163
170
|
const cutoff = new Date();
|
|
@@ -50,15 +50,20 @@ export class StateManager {
|
|
|
50
50
|
this.queueState.load(state.queue);
|
|
51
51
|
this.tools = state.tools ?? [];
|
|
52
52
|
this.providers = state.providers ?? [];
|
|
53
|
-
this.
|
|
54
|
-
this.migrateFactValidation();
|
|
55
|
-
this.migrateMessageFlags();
|
|
56
|
-
this.migrateInterestedPersonas();
|
|
53
|
+
this.runMigrations();
|
|
57
54
|
} else {
|
|
58
55
|
this.humanState.load(createDefaultHumanEntity());
|
|
59
56
|
}
|
|
60
57
|
}
|
|
61
58
|
|
|
59
|
+
private runMigrations(): void {
|
|
60
|
+
this.migrateLearnedByToIds();
|
|
61
|
+
this.migrateFactValidation();
|
|
62
|
+
this.migrateMessageFlags();
|
|
63
|
+
this.migrateInterestedPersonas();
|
|
64
|
+
this.migrateProviderModel();
|
|
65
|
+
}
|
|
66
|
+
|
|
62
67
|
/**
|
|
63
68
|
* Migration: learned_by used to store display names; now stores persona IDs.
|
|
64
69
|
* On load, attempt to resolve display names -> IDs using current persona map.
|
|
@@ -222,6 +227,162 @@ export class StateManager {
|
|
|
222
227
|
}
|
|
223
228
|
}
|
|
224
229
|
|
|
230
|
+
private migrateProviderModel(): void {
|
|
231
|
+
const human = this.humanState.get();
|
|
232
|
+
const settings = human.settings;
|
|
233
|
+
if (!settings?.accounts?.length) return;
|
|
234
|
+
|
|
235
|
+
const modelLookup = new Map<string, string>();
|
|
236
|
+
|
|
237
|
+
// Helper: ensure a model exists in an account and register it in modelLookup.
|
|
238
|
+
// ref must be in "ProviderName:model-name" format.
|
|
239
|
+
const ensureModelInAccount = (ref: string): void => {
|
|
240
|
+
const colonIdx = ref.indexOf(":");
|
|
241
|
+
if (colonIdx === -1) return;
|
|
242
|
+
const providerName = ref.substring(0, colonIdx);
|
|
243
|
+
const modelName = ref.substring(colonIdx + 1);
|
|
244
|
+
const account = settings.accounts!.find((a) => a.name === providerName);
|
|
245
|
+
if (!account) return;
|
|
246
|
+
|
|
247
|
+
if (!account.models) account.models = [];
|
|
248
|
+
|
|
249
|
+
const existing = account.models.find((m) => m.name === modelName);
|
|
250
|
+
if (existing) {
|
|
251
|
+
modelLookup.set(ref, existing.id);
|
|
252
|
+
} else {
|
|
253
|
+
const newModel = {
|
|
254
|
+
id: crypto.randomUUID(),
|
|
255
|
+
name: modelName,
|
|
256
|
+
};
|
|
257
|
+
account.models.push(newModel);
|
|
258
|
+
modelLookup.set(ref, newModel.id);
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const isProviderRef = (val: string): boolean => {
|
|
263
|
+
const colonIdx = val.indexOf(":");
|
|
264
|
+
if (colonIdx === -1) return false;
|
|
265
|
+
const providerName = val.substring(0, colonIdx);
|
|
266
|
+
return settings.accounts!.some((a) => a.name === providerName);
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
// Phase 1: Collect ALL model refs from everywhere they can appear.
|
|
270
|
+
const allRefs: string[] = [];
|
|
271
|
+
const pushRef = (ref: string | undefined): void => {
|
|
272
|
+
if (ref && isProviderRef(ref)) allRefs.push(ref);
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
pushRef(settings.default_model);
|
|
276
|
+
pushRef(settings.oneshot_model);
|
|
277
|
+
pushRef(settings.rewrite_model);
|
|
278
|
+
pushRef(settings.opencode?.extraction_model);
|
|
279
|
+
pushRef(settings.claudeCode?.extraction_model);
|
|
280
|
+
|
|
281
|
+
const personas = this.personaState.getAll();
|
|
282
|
+
for (const persona of personas) {
|
|
283
|
+
pushRef(persona.model);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Also include account.default_model values (legacy strings, not yet GUIDs)
|
|
287
|
+
for (const account of settings.accounts) {
|
|
288
|
+
if (account.default_model && isProviderRef(account.default_model)) {
|
|
289
|
+
allRefs.push(account.default_model);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Phase 2: For each ref, ensure model exists in the matching account.
|
|
294
|
+
for (const ref of allRefs) {
|
|
295
|
+
ensureModelInAccount(ref);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Helper: check if a value looks like a UUID (already migrated)
|
|
299
|
+
const isUUID = (val: string): boolean =>
|
|
300
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(val);
|
|
301
|
+
|
|
302
|
+
// Phase 3: Ensure every account has a models array and default_model is a GUID.
|
|
303
|
+
for (const account of settings.accounts) {
|
|
304
|
+
if (!account.models) {
|
|
305
|
+
account.models = [];
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Handle account.default_model - could be:
|
|
309
|
+
// 1. Already a GUID (already migrated) - leave it
|
|
310
|
+
// 2. A "Provider:model" ref - look up in modelLookup
|
|
311
|
+
// 3. A plain model name like "claude-haiku-4-5-20251001" - add to models[] and convert
|
|
312
|
+
if (account.default_model) {
|
|
313
|
+
if (isUUID(account.default_model)) {
|
|
314
|
+
// Already migrated, nothing to do
|
|
315
|
+
} else if (isProviderRef(account.default_model)) {
|
|
316
|
+
// It's a "Provider:model" ref - should be in modelLookup from Phase 2
|
|
317
|
+
const guid = modelLookup.get(account.default_model);
|
|
318
|
+
if (guid) account.default_model = guid;
|
|
319
|
+
} else {
|
|
320
|
+
// Plain model name - check if it exists in models[], add if not, convert to GUID
|
|
321
|
+
const existing = account.models.find((m) => m.name === account.default_model);
|
|
322
|
+
if (existing) {
|
|
323
|
+
account.default_model = existing.id;
|
|
324
|
+
} else {
|
|
325
|
+
const model = {
|
|
326
|
+
id: crypto.randomUUID(),
|
|
327
|
+
name: account.default_model,
|
|
328
|
+
token_limit: (account as any).token_limit as number | undefined,
|
|
329
|
+
max_output_tokens: undefined as number | undefined,
|
|
330
|
+
};
|
|
331
|
+
account.models.push(model);
|
|
332
|
+
modelLookup.set(`${account.name}:${model.name}`, model.id);
|
|
333
|
+
account.default_model = model.id;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// If still no models, create a placeholder
|
|
339
|
+
if (account.models.length === 0) {
|
|
340
|
+
const model = { id: crypto.randomUUID(), name: "(default)" };
|
|
341
|
+
account.models.push(model);
|
|
342
|
+
modelLookup.set(`${account.name}:(default)`, model.id);
|
|
343
|
+
account.default_model = model.id;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
delete (account as any).token_limit;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const resolveRef = (ref: string | undefined): string | undefined => {
|
|
350
|
+
if (!ref) return ref;
|
|
351
|
+
const guid = modelLookup.get(ref);
|
|
352
|
+
if (guid) return guid;
|
|
353
|
+
const colonIdx = ref.indexOf(":");
|
|
354
|
+
if (colonIdx === -1) return ref;
|
|
355
|
+
return undefined;
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
settings.default_model = resolveRef(settings.default_model);
|
|
359
|
+
settings.oneshot_model = resolveRef(settings.oneshot_model);
|
|
360
|
+
settings.rewrite_model = resolveRef(settings.rewrite_model);
|
|
361
|
+
|
|
362
|
+
if (settings.opencode) {
|
|
363
|
+
settings.opencode.extraction_model = resolveRef(settings.opencode.extraction_model);
|
|
364
|
+
delete (settings.opencode as any).extraction_token_limit;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (settings.claudeCode) {
|
|
368
|
+
settings.claudeCode.extraction_model = resolveRef(settings.claudeCode.extraction_model);
|
|
369
|
+
delete (settings.claudeCode as any).extraction_token_limit;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
for (const persona of personas) {
|
|
373
|
+
if (persona.model) {
|
|
374
|
+
const resolved = resolveRef(persona.model);
|
|
375
|
+
const colonIdx = (resolved ?? persona.model).indexOf(":");
|
|
376
|
+
const finalModel = colonIdx !== -1 ? undefined : (resolved ?? persona.model);
|
|
377
|
+
this.personaState.update(persona.id, { model: finalModel });
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
this.humanState.set(human);
|
|
382
|
+
this.scheduleSave();
|
|
383
|
+
console.log("[StateManager] Migrated provider/model references to GUID-based ModelConfig system");
|
|
384
|
+
}
|
|
385
|
+
|
|
225
386
|
/**
|
|
226
387
|
* Returns true if value looks like a persona ID (UUID or the special "ei" id).
|
|
227
388
|
* Display names are free-form strings that won't match UUID format.
|
|
@@ -621,6 +782,12 @@ export class StateManager {
|
|
|
621
782
|
return result;
|
|
622
783
|
}
|
|
623
784
|
|
|
785
|
+
queue_deleteItems(ids: string[]): number {
|
|
786
|
+
const result = this.queueState.deleteItems(ids);
|
|
787
|
+
if (result > 0) this.scheduleSave();
|
|
788
|
+
return result;
|
|
789
|
+
}
|
|
790
|
+
|
|
624
791
|
queue_dlqLength(): number {
|
|
625
792
|
return this.queueState.dlqLength();
|
|
626
793
|
}
|
|
@@ -764,6 +931,67 @@ export class StateManager {
|
|
|
764
931
|
return result;
|
|
765
932
|
}
|
|
766
933
|
|
|
934
|
+
deleteModel(providerId: string, modelId: string): { success: boolean; error?: string; cleared: string[] } {
|
|
935
|
+
const human = this.humanState.get();
|
|
936
|
+
const settings = human.settings;
|
|
937
|
+
if (!settings?.accounts?.length) {
|
|
938
|
+
return { success: false, error: `Provider not found: ${providerId}`, cleared: [] };
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
const provider = settings.accounts.find(a => a.id === providerId);
|
|
942
|
+
if (!provider) {
|
|
943
|
+
return { success: false, error: `Provider not found: ${providerId}`, cleared: [] };
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
if (!provider.models?.find(m => m.id === modelId)) {
|
|
947
|
+
return { success: false, error: `Model not found: ${modelId}`, cleared: [] };
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
if ((provider.models?.length ?? 0) <= 1) {
|
|
951
|
+
return { success: false, error: `Cannot delete the last model on a provider`, cleared: [] };
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
const cleared: string[] = [];
|
|
955
|
+
|
|
956
|
+
if (settings.default_model === modelId) {
|
|
957
|
+
settings.default_model = undefined;
|
|
958
|
+
cleared.push("settings.default_model");
|
|
959
|
+
}
|
|
960
|
+
if (settings.oneshot_model === modelId) {
|
|
961
|
+
settings.oneshot_model = undefined;
|
|
962
|
+
cleared.push("settings.oneshot_model");
|
|
963
|
+
}
|
|
964
|
+
if (settings.rewrite_model === modelId) {
|
|
965
|
+
settings.rewrite_model = undefined;
|
|
966
|
+
cleared.push("settings.rewrite_model");
|
|
967
|
+
}
|
|
968
|
+
if (settings.opencode?.extraction_model === modelId) {
|
|
969
|
+
settings.opencode.extraction_model = undefined;
|
|
970
|
+
cleared.push("settings.opencode.extraction_model");
|
|
971
|
+
}
|
|
972
|
+
if (settings.claudeCode?.extraction_model === modelId) {
|
|
973
|
+
settings.claudeCode.extraction_model = undefined;
|
|
974
|
+
cleared.push("settings.claudeCode.extraction_model");
|
|
975
|
+
}
|
|
976
|
+
if (provider.default_model === modelId) {
|
|
977
|
+
provider.default_model = undefined;
|
|
978
|
+
cleared.push("provider.default_model");
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
provider.models = provider.models!.filter(m => m.id !== modelId);
|
|
982
|
+
this.humanState.set(human);
|
|
983
|
+
|
|
984
|
+
for (const persona of this.personaState.getAll()) {
|
|
985
|
+
if (persona.model === modelId) {
|
|
986
|
+
this.personaState.update(persona.id, { model: undefined });
|
|
987
|
+
cleared.push(`persona:${persona.display_name}`);
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
this.scheduleSave();
|
|
992
|
+
return { success: true, cleared };
|
|
993
|
+
}
|
|
994
|
+
|
|
767
995
|
async flush(): Promise<void> {
|
|
768
996
|
await this.persistenceState.flush();
|
|
769
997
|
}
|
|
@@ -788,6 +1016,7 @@ export class StateManager {
|
|
|
788
1016
|
this.providers = state.providers ?? [];
|
|
789
1017
|
this.tools = state.tools ?? [];
|
|
790
1018
|
this.persistenceState.markExistingData();
|
|
1019
|
+
this.runMigrations();
|
|
791
1020
|
this.scheduleSave();
|
|
792
1021
|
}
|
|
793
1022
|
|
package/src/core/tools/index.ts
CHANGED