ei-tui 0.5.0 → 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.
@@ -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.migrateLearnedByToIds();
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
 
@@ -81,7 +81,7 @@ export function toOpenAITools(tools: ToolDefinition[]): Record<string, unknown>[
81
81
  name: t.name,
82
82
  description: t.description,
83
83
  parameters: t.input_schema,
84
- ...(t.is_submit ? { strict: true } : {}),
84
+
85
85
  },
86
86
  }));
87
87
  }
@@ -67,7 +67,9 @@ export interface Quote {
67
67
  data_item_ids: string[]; // FK[] to DataItemBase.id
68
68
  persona_groups: string[]; // Visibility groups
69
69
  text: string; // The quote content
70
- speaker: "human" | string; // Who said it (persona ID or "human")
70
+ speaker: "human" | string; // Actual speaker: "human" or the persona's display_name
71
+ channel?: string; // Display name of the Channel (persona or room) where captured.
72
+ // Undefined on pre-migration quotes.
71
73
  timestamp: string; // ISO timestamp (from original message)
72
74
  start: number | null; // Character offset in message (null = can't highlight)
73
75
  end: number | null; // Character offset in message (null = can't highlight)
@@ -15,7 +15,6 @@ export interface OpenCodeSettings {
15
15
  integration?: boolean;
16
16
  polling_interval_ms?: number; // Default: 60000 (1 min)
17
17
  extraction_model?: string; // "Provider:model" for extraction. Unset = uses default_model.
18
- extraction_token_limit?: number; // Token budget for extraction chunking. Unset = resolved from model.
19
18
  last_sync?: string; // ISO timestamp
20
19
  extraction_point?: string; // ISO timestamp - cursor for single-session archive scan
21
20
  processed_sessions?: Record<string, string>; // sessionId → ISO timestamp of last import
@@ -37,6 +36,23 @@ export interface BackupConfig {
37
36
  last_backup?: string; // ISO timestamp of last backup run
38
37
  }
39
38
 
39
+ /**
40
+ * ModelConfig - Configuration and usage tracking for a specific LLM model
41
+ *
42
+ * Models are first-class entities tied to a ProviderAccount. Each model has
43
+ * its own capability configuration and usage statistics.
44
+ */
45
+ export interface ModelConfig {
46
+ id: string; // GUID (crypto.randomUUID())
47
+ name: string; // Model identifier, e.g. "claude-haiku-4-5", "(default)"
48
+ token_limit?: number; // Input token limit (user sets effective limit)
49
+ max_output_tokens?: number; // Output token limit (API-enforced)
50
+ total_calls?: number; // Usage counter
51
+ total_tokens_in?: number; // Usage counter
52
+ total_tokens_out?: number; // Usage counter
53
+ last_used?: string; // ISO timestamp
54
+ }
55
+
40
56
  /**
41
57
  * ProviderAccount - Configuration for external service connections
42
58
  *
@@ -60,6 +76,7 @@ export interface ProviderAccount {
60
76
  // LLM-specific
61
77
  default_model?: string; // Default model for this account
62
78
  token_limit?: number; // Context window override (tokens). Used for extraction chunking.
79
+ models?: ModelConfig[]; // First-class model registry for this account
63
80
 
64
81
  // Provider-specific extras (e.g., OpenRouter needs HTTP-Referer, X-Title)
65
82
  extra_headers?: Record<string, string>;
@@ -73,9 +90,9 @@ export interface ProviderAccount {
73
90
  }
74
91
 
75
92
  export interface HumanSettings {
76
- default_model?: string;
77
- oneshot_model?: string; // Model for AI-assist (wand) requests; falls back to default_model
78
- rewrite_model?: string; // Model for rewrite ceremony step; must be capable (Sonnet/Opus class). Unset = rewrite disabled.
93
+ default_model?: string; // Will store ModelConfig.id GUID post-migration
94
+ oneshot_model?: string; // Model for AI-assist (wand) requests; falls back to default_model. Will store ModelConfig.id GUID post-migration.
95
+ rewrite_model?: string; // Model for rewrite ceremony step; must be capable (Sonnet/Opus class). Unset = rewrite disabled. Will store ModelConfig.id GUID post-migration.
79
96
  queue_paused?: boolean;
80
97
  skip_quote_delete_confirm?: boolean;
81
98
  name_display?: string;
@@ -327,7 +327,6 @@ export async function importClaudeCodeSessions(
327
327
  const ccSettings = stateManager.getHuman().settings?.claudeCode;
328
328
  queueAllScans(context, stateManager, {
329
329
  extraction_model: ccSettings?.extraction_model,
330
- extraction_token_limit: ccSettings?.extraction_token_limit,
331
330
  external_filter: "only",
332
331
  });
333
332
  result.extractionScansQueued += 4;
@@ -159,7 +159,6 @@ export interface ClaudeCodeSettings {
159
159
  integration?: boolean;
160
160
  polling_interval_ms?: number; // Default: 60000 (1 min)
161
161
  extraction_model?: string; // "Provider:model" for extraction. Unset = uses default_model.
162
- extraction_token_limit?: number; // Token budget for extraction chunking. Unset = resolved from model.
163
162
  last_sync?: string; // ISO timestamp
164
163
  extraction_point?: string; // ISO timestamp - floor cursor for processed-session skip
165
164
  processed_sessions?: Record<string, string>; // sessionId → ISO timestamp of last import
@@ -256,7 +256,6 @@ export async function importOpenCodeSessions(
256
256
  const openCodeSettings = stateManager.getHuman().settings?.opencode;
257
257
  queueAllScans(context, stateManager, {
258
258
  extraction_model: openCodeSettings?.extraction_model,
259
- extraction_token_limit: openCodeSettings?.extraction_token_limit,
260
259
  external_filter: "only",
261
260
  });
262
261
  result.extractionScansQueued += 4;
@@ -1,4 +1,4 @@
1
- import type { StorageState, DataItem, Quote, ToolProvider, ToolDefinition, ProviderAccount } from "../core/types.js";
1
+ import type { StorageState, DataItem, Quote, ToolProvider, ToolDefinition, ProviderAccount, ModelConfig } from "../core/types.js";
2
2
 
3
3
  function mergeByName<T extends { name: string }>(
4
4
  local: T[],
@@ -19,6 +19,51 @@ function mergeByName<T extends { name: string }>(
19
19
  return merged;
20
20
  }
21
21
 
22
+ function mergeModels(local: ModelConfig[], remote: ModelConfig[], preferRemote: boolean): ModelConfig[] {
23
+ const merged = [...local];
24
+
25
+ for (const remoteModel of remote) {
26
+ const localIndex = merged.findIndex(m => m.id === remoteModel.id);
27
+
28
+ if (localIndex === -1) {
29
+ merged.push(remoteModel);
30
+ } else {
31
+ const localModel = merged[localIndex];
32
+ const localCalls = localModel.total_calls ?? 0;
33
+ const remoteCalls = remoteModel.total_calls ?? 0;
34
+
35
+ const total_calls = Math.max(localCalls, remoteCalls);
36
+ const total_tokens_in = Math.max(localModel.total_tokens_in ?? 0, remoteModel.total_tokens_in ?? 0);
37
+ const total_tokens_out = Math.max(localModel.total_tokens_out ?? 0, remoteModel.total_tokens_out ?? 0);
38
+ const last_used = localCalls >= remoteCalls ? localModel.last_used : remoteModel.last_used;
39
+
40
+ const base = preferRemote ? { ...localModel, ...remoteModel } : localModel;
41
+ merged[localIndex] = { ...base, total_calls, total_tokens_in, total_tokens_out, last_used };
42
+ }
43
+ }
44
+
45
+ return merged;
46
+ }
47
+
48
+ function mergeById(local: ProviderAccount[], remote: ProviderAccount[], preferRemote: boolean): ProviderAccount[] {
49
+ const merged = [...local];
50
+
51
+ for (const remoteProvider of remote) {
52
+ const localIndex = merged.findIndex(p => p.id === remoteProvider.id);
53
+
54
+ if (localIndex === -1) {
55
+ merged.push(remoteProvider);
56
+ } else {
57
+ const localProvider = merged[localIndex];
58
+ const mergedModels = mergeModels(localProvider.models ?? [], remoteProvider.models ?? [], preferRemote);
59
+ const base = preferRemote ? { ...localProvider, ...remoteProvider } : localProvider;
60
+ merged[localIndex] = { ...base, models: mergedModels };
61
+ }
62
+ }
63
+
64
+ return merged;
65
+ }
66
+
22
67
  function mergeDataItems<T extends DataItem>(local: T[], remote: T[]): T[] {
23
68
  const merged = [...local];
24
69
 
@@ -88,7 +133,7 @@ export function yoloMerge(local: StorageState, remote: StorageState): StorageSta
88
133
  const preferRemote = remote.timestamp > local.timestamp;
89
134
 
90
135
  if (remote.human.settings?.accounts && merged.human.settings) {
91
- merged.human.settings.accounts = mergeByName<ProviderAccount>(
136
+ merged.human.settings.accounts = mergeById(
92
137
  merged.human.settings?.accounts || [],
93
138
  remote.human.settings.accounts,
94
139
  preferRemote,
@@ -17,7 +17,9 @@ export const dlqCommand: Command = {
17
17
  return;
18
18
  }
19
19
 
20
- let yamlContent = queueItemsToYAML(items);
20
+ const human = await ctx.ei.getHuman();
21
+ const accounts = human.settings?.accounts ?? [];
22
+ let yamlContent = queueItemsToYAML(items, accounts);
21
23
 
22
24
  while (true) {
23
25
  const result = await spawnEditor({
@@ -37,14 +39,20 @@ export const dlqCommand: Command = {
37
39
  }
38
40
 
39
41
  try {
40
- const updates = queueItemsFromYAML(result.content);
42
+ const { updates, deletedIds } = queueItemsFromYAML(result.content, accounts);
43
+ if (deletedIds.length > 0) {
44
+ ctx.ei.deleteQueueItems(deletedIds);
45
+ }
41
46
  let recovered = 0;
42
47
  for (const update of updates) {
43
48
  await ctx.ei.updateQueueItem(update.id, update);
44
49
  if (update.state === "pending") recovered++;
45
50
  }
46
- const msg = recovered > 0
47
- ? `DLQ updated ${recovered} item(s) requeued`
51
+ const parts: string[] = [];
52
+ if (recovered > 0) parts.push(`${recovered} item(s) requeued`);
53
+ if (deletedIds.length > 0) parts.push(`${deletedIds.length} deleted`);
54
+ const msg = parts.length > 0
55
+ ? `DLQ updated — ${parts.join(", ")}`
48
56
  : `DLQ updated (no items requeued)`;
49
57
  ctx.showNotification(msg, "info");
50
58
  return;