ei-tui 1.3.1 → 1.3.3

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.1",
3
+ "version": "1.3.3",
4
4
  "author": "Flare576",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,4 +1,5 @@
1
1
  import type { ChatMessage, ProviderAccount, ModelConfig } from "./types.js";
2
+ import { resolveDataPath } from "./utils/resolve-data-path.js";
2
3
  const DEFAULT_TOKEN_LIMIT = 8192;
3
4
  const DEFAULT_MAX_OUTPUT_TOKENS = 8000;
4
5
 
@@ -11,8 +12,7 @@ async function writeNetworkDump(
11
12
  request: unknown,
12
13
  response: unknown
13
14
  ): Promise<void> {
14
- const dataPath = (typeof process !== "undefined" && process.env?.EI_DATA_PATH) ||
15
- (typeof Bun !== "undefined" && (Bun as Record<string, unknown>).env && ((Bun as { env: Record<string, string> }).env.EI_DATA_PATH));
15
+ const dataPath = resolveDataPath();
16
16
  if (!dataPath) return;
17
17
 
18
18
  try {
@@ -1,5 +1,6 @@
1
1
  import { crossFind } from "./crossFind.js";
2
2
  import { applyDecayToValue } from "./decay.js";
3
3
  import { UUID_REGEX, sanitizeEiPersonaIdentifiers } from "./identifier-utils.js";
4
+ import { resolveDataPath } from "./resolve-data-path.js";
4
5
 
5
- export { crossFind, applyDecayToValue, UUID_REGEX, sanitizeEiPersonaIdentifiers };
6
+ export { crossFind, applyDecayToValue, UUID_REGEX, sanitizeEiPersonaIdentifiers, resolveDataPath };
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Resolves the Ei data directory path using the same precedence everywhere:
3
+ * 1. EI_DATA_PATH env var (explicit override)
4
+ * 2. $XDG_DATA_HOME/ei
5
+ * 3. ~/.local/share/ei
6
+ *
7
+ * Cross-env: works in Bun, Node, and browser (browser will always return null
8
+ * since no filesystem env is available, which is the correct behaviour there).
9
+ *
10
+ * Trailing slashes are stripped so callers can safely do `path.join(dataPath, "logs")`.
11
+ */
12
+ export function resolveDataPath(): string | null {
13
+ const env: Record<string, string | undefined> =
14
+ (typeof Bun !== "undefined" && (Bun as { env?: Record<string, string> }).env) ||
15
+ (typeof process !== "undefined" && process.env) ||
16
+ {};
17
+
18
+ const raw = env.EI_DATA_PATH ||
19
+ (() => {
20
+ const xdg = env.XDG_DATA_HOME ||
21
+ (env.HOME ? `${env.HOME}/.local/share` : null);
22
+ return xdg ? `${xdg}/ei` : null;
23
+ })();
24
+
25
+ if (!raw) return null;
26
+ return raw.replace(/\/+$/, "");
27
+ }
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.
@@ -11,6 +11,7 @@ import {
11
11
  personaPreviewFromYAML,
12
12
  } from "../util/yaml-serializers.js";
13
13
  import { useKeyboardNav } from "../context/keyboard.js";
14
+ import { resolveDataPath } from "../util/resolve-data-path.js";
14
15
 
15
16
  function wrapComment(text: string, width = 78): string {
16
17
  const words = text.split(/\s+/);
@@ -28,16 +29,10 @@ function wrapComment(text: string, width = 78): string {
28
29
  return lines.join("\n");
29
30
  }
30
31
 
31
- function getDataPath(): string {
32
- const raw = process.env.EI_DATA_PATH ??
33
- join(process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"), "ei");
34
- return raw.replace(/\/+$/, "");
35
- }
36
-
37
32
  function getReflectFolder(persona: PersonaEntity): string {
38
33
  const datePrefix = persona.pending_update!.created_at.slice(0, 10);
39
34
  const safeName = persona.display_name.replace(/\s+/g, "_");
40
- return join(getDataPath(), "reflect", `${datePrefix}_${safeName}`);
35
+ return join(resolveDataPath(), "reflect", `${datePrefix}_${safeName}`);
41
36
  }
42
37
 
43
38
  async function resolveReflectionPersona(
@@ -102,7 +97,7 @@ export const reflectCommand: Command = {
102
97
 
103
98
  const headerName = hasPending ? activePersona!.display_name : undefined;
104
99
  const pendingNames = pendingPersonas.map(p => p.display_name);
105
- const dataPath = getDataPath();
100
+ const dataPath = resolveDataPath();
106
101
 
107
102
  ctx.showOverlay((hideOverlay, _hideForEditor) => {
108
103
  const { setOverlayActive } = useKeyboardNav();
@@ -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 ");
@@ -3,6 +3,7 @@ import type { Storage } from "../../../src/storage/interface";
3
3
  import { encodeAllEmbeddings, decodeAllEmbeddings } from "../../../src/storage/embeddings";
4
4
  import { join } from "path";
5
5
  import { mkdir, rename, unlink, readdir } from "fs/promises";
6
+ import { resolveDataPath } from "../util/resolve-data-path.js";
6
7
 
7
8
  const STATE_FILE = "state.json";
8
9
  const BACKUP_FILE = "state.backup.json";
@@ -14,13 +15,7 @@ export class FileStorage implements Storage {
14
15
  private readonly dataPath: string;
15
16
 
16
17
  constructor(dataPath?: string) {
17
- const raw = dataPath ?? process.env.EI_DATA_PATH;
18
- if (raw) {
19
- this.dataPath = raw.replace(/\/+$/, "");
20
- } else {
21
- const xdgData = process.env.XDG_DATA_HOME || join(process.env.HOME || "~", ".local", "share");
22
- this.dataPath = join(xdgData, "ei");
23
- }
18
+ this.dataPath = resolveDataPath(dataPath);
24
19
  }
25
20
 
26
21
  getDataPath(): string {
@@ -2,17 +2,12 @@
2
2
 
3
3
  import { appendFileSync, mkdirSync, existsSync, readdirSync, unlinkSync, renameSync } from "node:fs";
4
4
  import { join } from "node:path";
5
+ import { resolveDataPath } from "./resolve-data-path.js";
5
6
 
6
7
  const MAX_ROLLED_LOGS = 10;
7
8
 
8
- function getDataPath(): string {
9
- if (Bun.env.EI_DATA_PATH) return Bun.env.EI_DATA_PATH;
10
- const xdgData = Bun.env.XDG_DATA_HOME || join(Bun.env.HOME || "~", ".local", "share");
11
- return join(xdgData, "ei");
12
- }
13
-
14
9
  function getLogPath(): string {
15
- return join(getDataPath(), "tui.log");
10
+ return join(resolveDataPath(), "tui.log");
16
11
  }
17
12
 
18
13
  type LogLevel = "debug" | "info" | "warn" | "error";
@@ -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
  }
@@ -0,0 +1,7 @@
1
+ import { join } from "node:path";
2
+
3
+ export function resolveDataPath(override?: string): string {
4
+ const raw = override ?? Bun.env.EI_DATA_PATH ??
5
+ join(Bun.env.XDG_DATA_HOME ?? join(Bun.env.HOME ?? "~", ".local", "share"), "ei");
6
+ return raw.replace(/\/+$/, "");
7
+ }