ei-tui 1.3.0 → 1.3.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ei-tui",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
4
4
  "author": "Flare576",
5
5
  "repository": {
6
6
  "type": "git",
@@ -251,15 +251,15 @@ export class Processor {
251
251
  registerFetchMemoryExecutor(createFetchMemoryExecutor(this.stateManager.getHuman.bind(this.stateManager)));
252
252
  if (this.isTUI) {
253
253
  await registerFileReadExecutor();
254
- const { createOpenCodeReader } = await import("../integrations/opencode/reader-factory.js");
255
- const openCodeReader = await createOpenCodeReader().catch(() => null);
254
+ const retrievalPath = "../cli/retrieval.js";
255
+ const { resolveExternalMessage } = await import(/* @vite-ignore */ retrievalPath);
256
256
  registerFetchMessageExecutor(createFetchMessageExecutor(
257
257
  this.stateManager.persona_getAll.bind(this.stateManager),
258
258
  this.stateManager.messages_get.bind(this.stateManager),
259
259
  this.stateManager.getRoomList.bind(this.stateManager),
260
260
  this.stateManager.getRoomMessages.bind(this.stateManager),
261
261
  (roomId: string) => this.stateManager.getRoom(roomId)?.display_name ?? null,
262
- openCodeReader ? (id, before, after) => openCodeReader.getMessageById(id, before, after) : undefined
262
+ resolveExternalMessage
263
263
  ));
264
264
  } else {
265
265
  registerFetchMessageExecutor(createFetchMessageExecutor(
@@ -2,7 +2,6 @@ import type { ToolExecutor } from "../types.js";
2
2
  import type { Message } from "../../types.js";
3
3
  import type { RoomMessage, RoomSummary } from "../../types/rooms.js";
4
4
  import type { PersonaEntity } from "../../types/entities.js";
5
- import type { OpenCodeMessageWindow } from "../../../integrations/opencode/types.js";
6
5
 
7
6
  interface CleanMessage {
8
7
  id: string;
@@ -18,9 +17,7 @@ type GetPersonaMessages = (personaId: string) => Message[];
18
17
  type GetRoomList = () => RoomSummary[];
19
18
  type GetRoomMessages = (roomId: string) => RoomMessage[];
20
19
  type GetRoomDisplayName = (roomId: string) => string | null;
21
- type GetOpenCodeMessage = (id: string, before: number, after: number) => Promise<OpenCodeMessageWindow | null>;
22
-
23
- const OPENCODE_MESSAGE_ID = /^msg_[a-zA-Z0-9]+$/;
20
+ type ResolveExternalMessage = (id: string, before: number, after: number) => Promise<Record<string, unknown> | null>;
24
21
 
25
22
  function stripMessage(m: Message): CleanMessage {
26
23
  return {
@@ -50,7 +47,7 @@ export function createFetchMessageExecutor(
50
47
  getRoomList: GetRoomList,
51
48
  getRoomMessages: GetRoomMessages,
52
49
  getRoomDisplayName: GetRoomDisplayName,
53
- getOpenCodeMessage?: GetOpenCodeMessage
50
+ resolveExternalMessage?: ResolveExternalMessage
54
51
  ): ToolExecutor {
55
52
  return {
56
53
  name: "fetch_message",
@@ -67,27 +64,6 @@ export function createFetchMessageExecutor(
67
64
  return JSON.stringify({ error: "Missing required argument: id" });
68
65
  }
69
66
 
70
- if (OPENCODE_MESSAGE_ID.test(id)) {
71
- if (!getOpenCodeMessage) {
72
- return JSON.stringify({ error: "OpenCode message lookup not available in this runtime", id });
73
- }
74
- const window = await getOpenCodeMessage(id, before, after);
75
- if (!window) {
76
- return JSON.stringify({
77
- error: "OpenCode message not found on this machine. It may exist on another device.",
78
- id,
79
- hint: "Check the linked topic's sources for the originating machine and session.",
80
- });
81
- }
82
- return JSON.stringify({
83
- message: { id: window.message.id, role: window.message.role, content: window.message.content, timestamp: window.message.timestamp, agent: window.message.agent },
84
- before: window.before.map(m => ({ id: m.id, role: m.role, content: m.content, timestamp: m.timestamp, agent: m.agent })),
85
- after: window.after.map(m => ({ id: m.id, role: m.role, content: m.content, timestamp: m.timestamp, agent: m.agent })),
86
- session: { id: window.session.id, title: window.session.title, directory: window.session.directory },
87
- source: "opencode",
88
- });
89
- }
90
-
91
67
  const personas = getAllPersonas();
92
68
 
93
69
  // TODO: add persona access gate when calling context is available —
@@ -142,6 +118,14 @@ export function createFetchMessageExecutor(
142
118
  });
143
119
  }
144
120
 
121
+ if (resolveExternalMessage) {
122
+ const external = await resolveExternalMessage(id, before, after);
123
+ if (external) {
124
+ if ("error" in external) return JSON.stringify(external);
125
+ return JSON.stringify(external);
126
+ }
127
+ }
128
+
145
129
  console.log(`[fetch_message] message not found for id="${id}"`);
146
130
  return JSON.stringify({ error: "Message not found" });
147
131
  },
package/tui/README.md CHANGED
@@ -10,6 +10,14 @@ Ei is designed to run consistently across machines and environments, so it keeps
10
10
 
11
11
  **On first run**, Ei reads environment variables like `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, etc. to auto-configure providers for you. After that, those values are saved to Ei's local state (`~/.local/share/ei/state.json` by default) and the env vars are no longer consulted.
12
12
 
13
+ Detected providers are configured with sensible defaults out of the box:
14
+
15
+ - **Models**: Only chat-capable models are included — TTS, image generation, embeddings, and other non-chat model families are filtered out. You get one model per tier (e.g. fast/mini for extraction, capable for chat, powerful for complex work) rather than a wall of 100+ options.
16
+ - **Token limits**: Known models get pre-configured `token_limit` and `max_output_tokens` values based on real-world Ei usage, not just the provider's advertised maximums.
17
+ - **Rewrite model**: If a high-capability model is detected (Anthropic Opus, OpenAI o-series), it's automatically set as your `rewrite_model` — used by `/generate` and `/dedupe`. No manual `/settings` step needed.
18
+
19
+ All of this only applies on first run. Existing profiles are never modified by detection.
20
+
13
21
  This means:
14
22
 
15
23
  - **Rotating an API key?** Update it in Ei with `/provider`, not just in your shell.
@@ -832,7 +832,7 @@ export const EiProvider: ParentComponent = (props) => {
832
832
  setDetectedProviders(allStatuses);
833
833
 
834
834
  if (detected.length > 0) {
835
- const accounts = buildProviderAccounts(detected);
835
+ const { accounts, suggestedRewriteModelId } = buildProviderAccounts(detected);
836
836
  const topProvider = detected[0];
837
837
  const defaultModel = `${topProvider.name}:${topProvider.selected.extractionModel}`;
838
838
  setFirstBootDefaultModel(defaultModel);
@@ -842,6 +842,9 @@ export const EiProvider: ParentComponent = (props) => {
842
842
  ...currentHuman.settings,
843
843
  accounts,
844
844
  default_model: defaultModel,
845
+ ...(!currentHuman.settings?.rewrite_model && suggestedRewriteModelId && {
846
+ rewrite_model: suggestedRewriteModelId,
847
+ }),
845
848
  },
846
849
  });
847
850
  const names = detected.map((d) => d.name).join(" and ");
@@ -58,10 +58,141 @@ export const ALL_PROVIDER_NAMES: ReadonlyArray<string> = [
58
58
  ...CLOUD_PROVIDERS.map((p) => p.name),
59
59
  ];
60
60
 
61
+ // Ei-curated effective limits for known models.
62
+ // These are NOT the provider's advertised maximums — they're the limits Ei uses in practice.
63
+ // For example, Haiku's advertised context is 200k but real-world extraction quality degrades
64
+ // above ~100k, so we cap it there. When adding new models, prefer conservative values based
65
+ // on actual usage over marketing specs.
66
+ export const KNOWN_MODEL_LIMITS: Readonly<Record<string, { token_limit?: number; max_output_tokens?: number }>> = {
67
+ // Anthropic — claude-opus-4.x
68
+ "claude-opus-4-7": { token_limit: 200000, max_output_tokens: 128000 },
69
+ "claude-opus-4-6": { token_limit: 200000, max_output_tokens: 128000 },
70
+ "claude-opus-4-5-20251101": { token_limit: 200000, max_output_tokens: 64000 },
71
+ "claude-opus-4-1-20250805": { token_limit: 200000, max_output_tokens: 32000 },
72
+ // Anthropic — claude-sonnet-4.x
73
+ "claude-sonnet-4-6": { token_limit: 200000, max_output_tokens: 64000 },
74
+ "claude-sonnet-4-5-20250929": { token_limit: 200000, max_output_tokens: 64000 },
75
+ // Anthropic — claude-haiku-4.x
76
+ // Note: advertised context is 200k but extraction quality degrades above ~100k in practice
77
+ "claude-haiku-4-5-20251001": { token_limit: 100000, max_output_tokens: 64000 },
78
+ };
79
+
80
+ // Sort model IDs by version numerically descending so "4-6" correctly beats "4-5".
81
+ // Snapshot date suffixes (8-digit YYYYMMDD) are stripped before comparison so that
82
+ // "claude-sonnet-4-6" sorts higher than "claude-sonnet-4-5-20250929".
83
+ function sortModelsDesc(modelIds: string[]): string[] {
84
+ const stripDate = (id: string) => id.replace(/-\d{8}$/, "");
85
+ return [...modelIds].sort((a, b) => {
86
+ const aParts = stripDate(a).split(/[-.]/).map((p) => (isNaN(Number(p)) ? p : Number(p)));
87
+ const bParts = stripDate(b).split(/[-.]/).map((p) => (isNaN(Number(p)) ? p : Number(p)));
88
+ for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
89
+ const av = aParts[i] ?? 0;
90
+ const bv = bParts[i] ?? 0;
91
+ if (av < bv) return 1;
92
+ if (av > bv) return -1;
93
+ }
94
+ return 0;
95
+ });
96
+ }
97
+
61
98
  function latestMatch(modelIds: string[], pattern: string): string | undefined {
62
99
  const matches = modelIds.filter((id) => id.toLowerCase().includes(pattern));
63
100
  if (matches.length === 0) return undefined;
64
- return [...matches].sort().reverse()[0];
101
+ return sortModelsDesc(matches)[0];
102
+ }
103
+
104
+ // For Anthropic: keep only the single latest model per tier (haiku/sonnet/opus).
105
+ // Drops older snapshots and deprecated models (e.g. claude-opus-4-20250514) so the
106
+ // initial provider config stays clean. Users can add older models manually if needed.
107
+ function filterAnthropicModels(modelIds: string[]): string[] {
108
+ const tiers = ["haiku", "sonnet", "opus"];
109
+ const kept: string[] = [];
110
+ for (const tier of tiers) {
111
+ const latest = latestMatch(modelIds, tier);
112
+ if (latest) kept.push(latest);
113
+ }
114
+ // Preserve any models that don't match a known tier (future-proofing)
115
+ const unknowns = modelIds.filter((id) => !tiers.some((t) => id.toLowerCase().includes(t)));
116
+ return [...kept, ...unknowns];
117
+ }
118
+
119
+ // For OpenAI: the /models endpoint returns everything — TTS, image generation, audio,
120
+ // embeddings, moderation, legacy completions, etc. Keep only chat-capable model families
121
+ // and trim to one latest per tier so the provider config stays useful.
122
+ function filterOpenAIModels(modelIds: string[]): string[] {
123
+ const NON_CHAT_PATTERNS = [
124
+ "tts", "whisper", "dall-e", "embedding", "davinci", "babbage",
125
+ "moderation", "audio", "realtime", "transcribe", "image", "sora",
126
+ "chat-latest", "codex",
127
+ ];
128
+ const isNonChat = (id: string) => {
129
+ const lower = id.toLowerCase();
130
+ return NON_CHAT_PATTERNS.some((p) => lower.includes(p));
131
+ };
132
+
133
+ const chatModels = modelIds.filter((id) => !isNonChat(id));
134
+
135
+ // Tiers in priority order. Mini variants are their own tier for extraction use.
136
+ const tiers = [
137
+ { name: "o-series", match: (id: string) => /^o\d/.test(id.toLowerCase()) && !id.toLowerCase().includes("mini") },
138
+ { name: "gpt-5", match: (id: string) => id.toLowerCase().includes("gpt-5") && !id.toLowerCase().includes("mini") },
139
+ { name: "gpt-4.1", match: (id: string) => id.toLowerCase().includes("gpt-4.1") && !id.toLowerCase().includes("mini") },
140
+ { name: "gpt-4o", match: (id: string) => id.toLowerCase().includes("gpt-4o") && !id.toLowerCase().includes("mini") },
141
+ { name: "mini", match: (id: string) => id.toLowerCase().includes("mini") },
142
+ ];
143
+
144
+ const kept: string[] = [];
145
+ const consumed = new Set<string>();
146
+
147
+ for (const tier of tiers) {
148
+ const matches = chatModels.filter((id) => tier.match(id) && !consumed.has(id));
149
+ const latest = sortModelsDesc(matches)[0];
150
+ if (latest) {
151
+ kept.push(latest);
152
+ consumed.add(latest);
153
+ }
154
+ }
155
+
156
+ return kept;
157
+ }
158
+
159
+ // For Gemini: the /models endpoint returns chat models, embedding models, image/video
160
+ // generation (Imagen, Veo), audio (Lyria), TTS variants, robotics previews, and research
161
+ // models. Keep only plain gemini-N.N-flash and gemini-N.N-pro chat families, latest per tier.
162
+ function filterGeminiModels(modelIds: string[]): string[] {
163
+ const NON_CHAT_PATTERNS = [
164
+ "embedding", "imagen", "veo", "lyria", "robotics", "tts", "audio",
165
+ "native-audio", "computer-use", "deep-research", "aqa", "live",
166
+ "-image-", "gemma",
167
+ ];
168
+ const isNonChat = (id: string) => {
169
+ const lower = id.toLowerCase();
170
+ return NON_CHAT_PATTERNS.some((p) => lower.includes(p));
171
+ };
172
+
173
+ const chatModels = modelIds.filter((id) => !isNonChat(id));
174
+
175
+ const tiers = ["pro", "flash"];
176
+ const kept: string[] = [];
177
+ const consumed = new Set<string>();
178
+
179
+ for (const tier of tiers) {
180
+ const latest = latestMatch(chatModels.filter((id) => !consumed.has(id)), tier);
181
+ if (latest) {
182
+ kept.push(latest);
183
+ consumed.add(latest);
184
+ }
185
+ }
186
+
187
+ return kept;
188
+ }
189
+
190
+ function filterModelsForProvider(providerName: string, modelIds: string[]): string[] {
191
+ const name = providerName.toLowerCase();
192
+ if (name === "anthropic") return filterAnthropicModels(modelIds);
193
+ if (name === "openai") return filterOpenAIModels(modelIds);
194
+ if (name === "gemini") return filterGeminiModels(modelIds);
195
+ return modelIds;
65
196
  }
66
197
 
67
198
  export function selectModelsForProvider(
@@ -82,22 +213,20 @@ export function selectModelsForProvider(
82
213
  }
83
214
 
84
215
  if (name === "anthropic") {
216
+ const filtered = filterAnthropicModels(modelIds);
85
217
  return {
86
- extractionModel: latestMatch(modelIds, "haiku") ?? modelIds[0],
87
- chatModel: latestMatch(modelIds, "sonnet") ?? modelIds[0],
88
- bonusModel: latestMatch(modelIds, "opus"),
218
+ extractionModel: latestMatch(filtered, "haiku") ?? filtered[0],
219
+ chatModel: latestMatch(filtered, "sonnet") ?? filtered[0],
220
+ bonusModel: latestMatch(filtered, "opus"),
89
221
  };
90
222
  }
91
223
 
92
224
  if (name === "openai") {
93
- const gpt4oNonMini = modelIds.filter(
94
- (id) => id.toLowerCase().includes("gpt-4o") && !id.toLowerCase().includes("mini")
95
- );
225
+ const filtered = filterOpenAIModels(modelIds);
226
+ const list = filtered.length > 0 ? filtered : modelIds;
96
227
  return {
97
- extractionModel: latestMatch(modelIds, "mini") ?? modelIds[0],
98
- chatModel: gpt4oNonMini.length > 0
99
- ? [...gpt4oNonMini].sort().reverse()[0]
100
- : modelIds[0],
228
+ extractionModel: latestMatch(list, "mini") ?? list[0],
229
+ chatModel: list[0],
101
230
  };
102
231
  }
103
232
 
@@ -109,9 +238,11 @@ export function selectModelsForProvider(
109
238
  }
110
239
 
111
240
  if (name === "gemini") {
241
+ const filtered = filterGeminiModels(modelIds);
242
+ const list = filtered.length > 0 ? filtered : modelIds;
112
243
  return {
113
- extractionModel: latestMatch(modelIds, "flash") ?? modelIds[0],
114
- chatModel: latestMatch(modelIds, "pro") ?? modelIds[0],
244
+ extractionModel: latestMatch(list, "flash") ?? list[0],
245
+ chatModel: latestMatch(list, "pro") ?? list[0],
115
246
  };
116
247
  }
117
248
 
@@ -212,29 +343,50 @@ export async function detectProviders(
212
343
  return { detected, statuses };
213
344
  }
214
345
 
346
+ export interface ProviderBootstrapResult {
347
+ accounts: ProviderAccount[];
348
+ suggestedRewriteModelId?: string;
349
+ }
350
+
215
351
  export function buildProviderAccounts(
216
352
  detected: ProviderDetectionResult[]
217
- ): ProviderAccount[] {
218
- return detected.map((d) => {
219
- const makeModel = (modelName: string): ModelConfig => ({
220
- id: crypto.randomUUID(),
221
- name: modelName,
222
- });
353
+ ): ProviderBootstrapResult {
354
+ let suggestedRewriteModelId: string | undefined;
355
+
356
+ const accounts = detected.map((d) => {
357
+ const makeModel = (modelName: string): ModelConfig => {
358
+ const limits = KNOWN_MODEL_LIMITS[modelName];
359
+ return {
360
+ id: crypto.randomUUID(),
361
+ name: modelName,
362
+ ...(limits?.token_limit !== undefined && { token_limit: limits.token_limit }),
363
+ ...(limits?.max_output_tokens !== undefined && { max_output_tokens: limits.max_output_tokens }),
364
+ };
365
+ };
223
366
 
224
367
  const seenNames = new Set<string>();
225
368
  const models: ModelConfig[] = [];
226
369
 
227
- const pushIfNew = (name: string) => {
370
+ const pushIfNew = (name: string): ModelConfig => {
228
371
  if (!seenNames.has(name)) {
229
372
  seenNames.add(name);
230
- models.push(makeModel(name));
373
+ const model = makeModel(name);
374
+ models.push(model);
375
+ return model;
231
376
  }
377
+ return models.find((m) => m.name === name)!;
232
378
  };
233
379
 
234
380
  pushIfNew(d.selected.chatModel);
235
381
  pushIfNew(d.selected.extractionModel);
236
- if (d.selected.bonusModel) pushIfNew(d.selected.bonusModel);
237
- for (const id of d.modelIds) pushIfNew(id);
382
+ if (d.selected.bonusModel) {
383
+ const bonusConfig = pushIfNew(d.selected.bonusModel);
384
+ if (!suggestedRewriteModelId) {
385
+ suggestedRewriteModelId = bonusConfig.id;
386
+ }
387
+ }
388
+ const modelList = filterModelsForProvider(d.name, d.modelIds);
389
+ for (const id of modelList) pushIfNew(id);
238
390
 
239
391
  const cloudConfig = CLOUD_PROVIDERS.find((p) => p.name === d.name);
240
392
  const apiKey = cloudConfig ? `$${cloudConfig.envVar}` : d.apiKey;
@@ -251,4 +403,6 @@ export function buildProviderAccounts(
251
403
  models,
252
404
  };
253
405
  });
406
+
407
+ return { accounts, suggestedRewriteModelId };
254
408
  }