ei-tui 0.6.5 → 0.6.7

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": "0.6.5",
3
+ "version": "0.6.7",
4
4
  "author": "Flare576",
5
5
  "repository": {
6
6
  "type": "git",
package/src/cli/README.md CHANGED
@@ -72,9 +72,11 @@ ei "What are the user's current preferences, active projects, and workflow?"
72
72
 
73
73
  Ei is a persistent knowledge base built from the user's conversations — facts, preferences,
74
74
  people, topics, personas. Use it when the user references past work, mentions how they like things done,
75
- or asks "how did we do X." Use `ei --persona "Beta" "walruses"` to scope results to what a specific
76
- persona has learned. Use `ei personas "name"` to find personas by name. Query again mid-session when
77
- they correct you or reference something from a previous session.
75
+ asks "how did we do X," or needs to look up a person by any name, handle, or account (GitHub username,
76
+ Discord handle, email, nickname, etc.) people results include an `identifiers` array covering all
77
+ known accounts and aliases for that person. Use `ei --persona "Beta" "walruses"` to scope results to
78
+ what a specific persona has learned. Use `ei personas "name"` to find personas by name. Query again
79
+ mid-session when they correct you or reference something from a previous session.
78
80
  ```
79
81
 
80
82
  ### Claude Code
@@ -87,9 +89,11 @@ natural-language query about the user's preferences, active projects, and workfl
87
89
  A `persona` filter is available to scope results to what a specific persona has learned.
88
90
  Use `type: "personas"` to search for personas by name.
89
91
 
90
- Use Ei when the user references past decisions, mentions people or preferences, or asks
91
- "how did we do X." Query again when they correct you or reference something from a previous
92
- session.
92
+ Use Ei when the user references past decisions, mentions people or preferences, asks
93
+ "how did we do X," or needs to look up a person by any name, handle, or account — people
94
+ results include an `identifiers` array (GitHub username, Discord handle, email, nickname, etc.)
95
+ covering all known accounts and aliases. Query again when they correct you or reference
96
+ something from a previous session.
93
97
  ```
94
98
 
95
99
  ### Cursor
@@ -111,6 +115,9 @@ conversations (facts, people, topics, quotes, personas).
111
115
  doesn't have that context.
112
116
  - You need the user's preferences, contacts, or project conventions (e.g. who to ask for
113
117
  access, how something was fixed).
118
+ - You need to look up a person by any name, handle, or account — people results include an
119
+ `identifiers` array (GitHub username, Discord handle, email, nickname, etc.) covering all
120
+ known accounts and aliases for that person.
114
121
  - The question is about the user personally (people, workflow, prior discussions) rather
115
122
  than only code.
116
123
 
@@ -138,7 +145,9 @@ The installed tool gives OpenCode agents access to all five data types with prop
138
145
 
139
146
  All search commands return arrays. Each result includes a `type` field.
140
147
 
141
- **Fact / Person / Topic**: `{ type, id, name, description, sentiment, ...type-specific fields }`
148
+ **Fact / Topic**: `{ type, id, name, description, sentiment, ...type-specific fields }`
149
+
150
+ **Person**: `{ type, id, name, description, relationship, sentiment, identifiers[] }` — `identifiers` contains all known accounts and aliases (e.g. `{ type: "GitHub", value: "flare576" }`)
142
151
 
143
152
  **Quote**: `{ type, id, text, speaker, timestamp, linked_items[] }`
144
153
 
@@ -21,5 +21,6 @@ export async function execute(query: string, limit: number, options: { recent?:
21
21
  description: person.description,
22
22
  relationship: person.relationship,
23
23
  sentiment: person.sentiment,
24
+ identifiers: person.identifiers ?? [],
24
25
  }));
25
26
  }
package/src/cli/mcp.ts CHANGED
@@ -16,7 +16,7 @@ export function createMcpServer(): McpServer {
16
16
  "ei_search",
17
17
  {
18
18
  description:
19
- "Search the user's Ei knowledge base — a persistent memory store built from conversations. Returns facts, people, topics of interest, and quotes. Results include entity IDs that can be passed back to ei_lookup for full detail. Omit query to browse by recency (use with recent=true or persona filter).",
19
+ "Search the user's Ei knowledge base — a persistent memory store built from conversations. Returns facts, people, topics of interest, and quotes. People results include an identifiers array (e.g. GitHub username, Discord handle, email, nickname) — query by any name or handle to find what Ei knows about that person. Results include entity IDs that can be passed back to ei_lookup for full detail. Omit query to browse by recency (use with recent=true or persona filter).",
20
20
  inputSchema: {
21
21
  query: z.string().optional().describe("Search text. Supports natural language. Omit to browse without semantic filtering — useful with recent=true or persona filter."),
22
22
  type: z
@@ -1,6 +1,6 @@
1
1
  import type { StorageState, Quote, Fact, Person, Topic } from "../core/types";
2
2
  import type { PersonaEntity } from "../core/types/entities.js";
3
- import type { PersonaTrait, PersonaTopic } from "../core/types/data-items.js";
3
+ import type { PersonaTrait, PersonaTopic, PersonIdentifier } from "../core/types/data-items.js";
4
4
  import { decodeAllEmbeddings } from "../storage/embeddings";
5
5
  import { crossFind } from "../core/utils/index.ts";
6
6
  import { join } from "path";
@@ -102,6 +102,7 @@ export interface PersonResult {
102
102
  description: string;
103
103
  relationship: string;
104
104
  sentiment: number;
105
+ identifiers: PersonIdentifier[];
105
106
  }
106
107
 
107
108
  export interface TopicResult {
@@ -182,6 +183,7 @@ function mapPerson(person: Person): PersonResult {
182
183
  description: person.description,
183
184
  relationship: person.relationship,
184
185
  sentiment: person.sentiment,
186
+ identifiers: person.identifiers ?? [],
185
187
  };
186
188
  }
187
189
 
@@ -14,7 +14,7 @@ import { calculateExposureCurrent } from "../utils/exposure.js";
14
14
 
15
15
 
16
16
  import { resolveMessageWindow, getMessageText, normalizeRoomMessages } from "./utils.js";
17
- import { sanitizeEiPersonaIdentifiers } from "../utils/identifier-utils.js";
17
+ import { sanitizeEiPersonaIdentifiers, normalizeIdentifierType } from "../utils/identifier-utils.js";
18
18
 
19
19
  export function handleTopicMatch(response: LLMResponse, state: StateManager): void {
20
20
  const result = response.parsed as ItemMatchResult | undefined;
@@ -284,7 +284,7 @@ export async function handlePersonUpdate(response: LLMResponse, state: StateMana
284
284
  if (isNewItem) {
285
285
  const llmIdentifiers: PersonIdentifier[] = sanitizeEiPersonaIdentifiers(
286
286
  (result.identifiers ?? []).map(i => ({
287
- type: i.type,
287
+ type: normalizeIdentifierType(i.type, state),
288
288
  value: i.value,
289
289
  ...(i.is_primary ? { is_primary: i.is_primary } : {}),
290
290
  })),
@@ -293,7 +293,7 @@ export async function handlePersonUpdate(response: LLMResponse, state: StateMana
293
293
  const allCandidateIds = [...llmIdentifiers, ...candidateIdentifiers];
294
294
  if (allCandidateIds.length === 0) {
295
295
  const hasSpace = candidateName.includes(' ');
296
- allCandidateIds.push({ type: hasSpace ? "full_name" : "nickname", value: candidateName, is_primary: true });
296
+ allCandidateIds.push({ type: hasSpace ? "Full Name" : "Nickname", value: candidateName, is_primary: true });
297
297
  }
298
298
  const deduped: PersonIdentifier[] = [];
299
299
  for (const id of allCandidateIds) {
@@ -304,7 +304,13 @@ export async function handlePersonUpdate(response: LLMResponse, state: StateMana
304
304
  resolvedIdentifiers = deduped;
305
305
  } else {
306
306
  const base = [...(existingPerson?.identifiers ?? [])];
307
- const sanitizedToAdd = sanitizeEiPersonaIdentifiers(result.identifiers_to_add ?? [], state);
307
+ const sanitizedToAdd = sanitizeEiPersonaIdentifiers(
308
+ (result.identifiers_to_add ?? []).map(i => ({
309
+ ...i,
310
+ type: normalizeIdentifierType(i.type, state),
311
+ })),
312
+ state
313
+ );
308
314
  for (const id of sanitizedToAdd) {
309
315
  if (!base.some(e => e.value === id.value)) {
310
316
  base.push({ type: id.type, value: id.value, ...(id.is_primary ? { is_primary: id.is_primary } : {}) });
@@ -51,9 +51,10 @@ function isGuid(str: string): boolean {
51
51
  }
52
52
 
53
53
  function buildResolvedModel(account: ProviderAccount, model: ModelConfig): ResolvedModel {
54
+ const apiModelId = model.model_id ?? model.name;
54
55
  return {
55
56
  provider: account.name,
56
- model: model.name === "(default)" ? undefined : model.name,
57
+ model: apiModelId === "(default)" ? undefined : apiModelId,
57
58
  config: {
58
59
  name: account.name,
59
60
  baseURL: account.url,
@@ -164,10 +165,16 @@ function findModelAndAccount(
164
165
  const model = account?.models?.find((m) => m.name === modelName);
165
166
  return { model, account };
166
167
  }
168
+ // Try matching by model UUID first
167
169
  for (const account of accounts) {
168
170
  const model = account.models?.find((m) => m.id === spec);
169
171
  if (model) return { model, account };
170
172
  }
173
+ // Fall back to matching by account name (bare spec like "EG" or "RnP")
174
+ const accountByName = accounts.find(
175
+ (a) => a.name.toLowerCase() === spec.toLowerCase() && a.enabled
176
+ );
177
+ if (accountByName) return { model: undefined, account: accountByName };
171
178
  return { model: undefined, account: undefined };
172
179
  }
173
180
 
@@ -265,6 +272,10 @@ export async function callLLMRaw(
265
272
  max_tokens: modelConfig?.max_output_tokens ?? DEFAULT_MAX_OUTPUT_TOKENS,
266
273
  };
267
274
 
275
+ if (modelConfig?.thinking_budget !== undefined) {
276
+ requestBody.think = { budget_tokens: modelConfig.thinking_budget };
277
+ }
278
+
268
279
  if (options.tools && options.tools.length > 0) {
269
280
  requestBody.tools = options.tools;
270
281
  requestBody.tool_choice = "auto";
@@ -281,6 +281,7 @@ export function queueDirectTopicUpdate(
281
281
  isNewItem: false,
282
282
  existingItemId: topic.id,
283
283
  analyze_from_timestamp: getAnalyzeFromTimestamp(chunk),
284
+ extraction_model: extractionModel,
284
285
  },
285
286
  });
286
287
  }
@@ -425,14 +426,11 @@ export function queueTopicUpdate(
425
426
  user: prompt.user,
426
427
  next_step: LLMNextStep.HandleTopicUpdate,
427
428
  data: {
428
- personaId: context.personaId,
429
- personaDisplayName: context.personaDisplayName,
430
- roomId: context.roomId,
429
+ ...context,
431
430
  isNewItem,
432
431
  existingItemId: existingItem?.id,
433
432
  candidateName: isNewItem ? context.candidateName : undefined,
434
433
  candidateDescription: isNewItem ? context.candidateDescription : undefined,
435
- candidateCategory: context.candidateCategory,
436
434
  analyze_from_timestamp: getAnalyzeFromTimestamp(chunk),
437
435
  },
438
436
  });
@@ -469,13 +467,25 @@ export function queueEventSummary(
469
467
 
470
468
  const allMessages = state.messages_get(personaId);
471
469
  const extractionModel = options?.extraction_model;
470
+ const gapMs = gapHours * 60 * 60 * 1000;
471
+ const now = Date.now();
472
472
  let totalChunks = 0;
473
473
 
474
- state.messages_markExtracted(personaId, sorted.map(m => m.id), "e");
475
-
476
- for (const windowMessages of windows) {
474
+ for (let i = 0; i < windows.length; i++) {
475
+ const windowMessages = windows[i];
477
476
  if (windowMessages.length === 0) continue;
478
477
 
478
+ const isLastWindow = i === windows.length - 1;
479
+ if (isLastWindow) {
480
+ const lastMsgTime = new Date(windowMessages[windowMessages.length - 1].timestamp).getTime();
481
+ if (now - lastMsgTime < gapMs) {
482
+ console.log(`[queueEventSummary] Skipping open window for ${persona.display_name} — last message < ${gapHours}h ago`);
483
+ continue;
484
+ }
485
+ }
486
+
487
+ state.messages_markExtracted(personaId, windowMessages.map(m => m.id), "e");
488
+
479
489
  const windowStartTime = new Date(windowMessages[0].timestamp).getTime();
480
490
  const messages_context = allMessages.filter(
481
491
  m => m.e === true && new Date(m.timestamp).getTime() < windowStartTime
@@ -599,6 +609,7 @@ export function queuePersonUpdate(
599
609
  candidateRelationship: context.candidateRelationship,
600
610
  candidateIdentifiers: isNewItem ? candidateIdentifiers : undefined,
601
611
  analyze_from_timestamp: getAnalyzeFromTimestamp(chunk),
612
+ extraction_model: context.extraction_model,
602
613
  },
603
614
  });
604
615
  }
@@ -653,6 +664,7 @@ export async function queueTopicValidate(
653
664
  data: {
654
665
  entity_type: "topic",
655
666
  entity_ids: [existingTopic.id, newTopic.id],
667
+ extraction_model: extractionModel,
656
668
  },
657
669
  });
658
670
  }
@@ -20,6 +20,9 @@ export async function getPersonaList(sm: StateManager): Promise<PersonaSummary[]
20
20
  unread_count: sm.messages_countUnread(entity.id),
21
21
  last_activity: entity.last_activity,
22
22
  context_boundary: entity.context_boundary,
23
+ avatar_emoji: entity.avatar_emoji,
24
+ avatar_image: entity.avatar_image,
25
+ preferred_theme: entity.preferred_theme,
23
26
  }));
24
27
  }
25
28
 
@@ -1130,6 +1130,9 @@ const toolNextSteps = new Set([
1130
1130
  });
1131
1131
  }
1132
1132
  },
1133
+ onUsageUpdate: (modelId, usage) => {
1134
+ this.stateManager.model_update_usage(modelId, usage);
1135
+ },
1133
1136
  }
1134
1137
  );
1135
1138
 
@@ -37,9 +37,10 @@ export interface QueueProcessorStartOptions {
37
37
  onEnqueue?: EnqueueCallback;
38
38
  /**
39
39
  * Called when a tool executor updates its provider config (e.g. Spotify refresh token rotation).
40
- * Injected by Processor to persist the updated config back to storage.
40
+ * Injected by Processor pointing to stateManager.queue_enqueue.
41
41
  */
42
42
  onProviderConfigUpdate?: (providerId: string, updates: Record<string, string>) => void;
43
+ onUsageUpdate?: (modelId: string, usage: { calls: number; tokens_in: number; tokens_out: number }) => void;
43
44
  }
44
45
 
45
46
  export class QueueProcessor {
@@ -52,6 +53,7 @@ export class QueueProcessor {
52
53
  private currentTools: ToolDefinition[] | undefined;
53
54
  private currentOnEnqueue: EnqueueCallback | undefined;
54
55
  private currentOnProviderConfigUpdate: ((providerId: string, updates: Record<string, string>) => void) | undefined;
56
+ private currentOnUsageUpdate: ((modelId: string, usage: { calls: number; tokens_in: number; tokens_out: number }) => void) | undefined;
55
57
 
56
58
  getState(): QueueProcessorState {
57
59
  return this.state;
@@ -70,6 +72,7 @@ export class QueueProcessor {
70
72
  this.currentTools = options?.tools;
71
73
  this.currentOnEnqueue = options?.onEnqueue;
72
74
  this.currentOnProviderConfigUpdate = options?.onProviderConfigUpdate;
75
+ this.currentOnUsageUpdate = options?.onUsageUpdate;
73
76
  this.abortController = new AbortController();
74
77
 
75
78
  this.processRequest(request)
@@ -197,7 +200,7 @@ export class QueueProcessor {
197
200
  hydratedUser,
198
201
  messages,
199
202
  request.model,
200
- { signal: this.abortController?.signal, tools: openAITools },
203
+ { signal: this.abortController?.signal, tools: openAITools, onUsageUpdate: this.currentOnUsageUpdate },
201
204
  this.currentAccounts
202
205
  );
203
206
 
@@ -304,7 +307,7 @@ export class QueueProcessor {
304
307
  hydratedUser,
305
308
  messages,
306
309
  request.model,
307
- { signal: this.abortController?.signal, tools: openAITools },
310
+ { signal: this.abortController?.signal, tools: openAITools, onUsageUpdate: this.currentOnUsageUpdate },
308
311
  this.currentAccounts
309
312
  );
310
313
  if (thinking) {
@@ -496,7 +499,7 @@ export class QueueProcessor {
496
499
  reformatUserPrompt,
497
500
  messages, // existing tool history — gives full context without duplicating the ask
498
501
  request.model,
499
- { signal: this.abortController?.signal },
502
+ { signal: this.abortController?.signal, onUsageUpdate: this.currentOnUsageUpdate },
500
503
  this.currentAccounts
501
504
  );
502
505
 
@@ -553,7 +556,7 @@ export class QueueProcessor {
553
556
  reformatUserPrompt,
554
557
  [], // no message history needed — schema is already in the system prompt
555
558
  request.model,
556
- { signal: this.abortController?.signal },
559
+ { signal: this.abortController?.signal, onUsageUpdate: this.currentOnUsageUpdate },
557
560
  this.currentAccounts
558
561
  );
559
562
 
@@ -18,6 +18,7 @@ import type {
18
18
  RoomCreationInput,
19
19
  } from "./types.js";
20
20
  import { BUILT_IN_FACT_NAMES } from './constants/built-in-facts.js';
21
+ import type { ThemeDefinition } from './types/entities.js';
21
22
  import type { Storage } from "../storage/interface.js";
22
23
  import {
23
24
  HumanState,
@@ -68,6 +69,7 @@ export class StateManager {
68
69
  this.migrateInterestedPersonas();
69
70
  this.migrateProviderModel();
70
71
  this.migrateRoomMessageContent();
72
+ this.migrateThemes();
71
73
  }
72
74
 
73
75
  private migrateRoomMessageContent(): void {
@@ -566,6 +568,14 @@ export class StateManager {
566
568
  this.persistenceState.scheduleSave(this.buildStorageState());
567
569
  }
568
570
 
571
+ private migrateThemes(): void {
572
+ const human = this.humanState.get();
573
+ if (!human.settings) return;
574
+ if (human.settings.custom_themes !== undefined) return;
575
+ human.settings.custom_themes = [];
576
+ this.humanState.set(human);
577
+ }
578
+
569
579
  getHuman(): HumanEntity {
570
580
  return this.humanState.get();
571
581
  }
@@ -575,6 +585,44 @@ export class StateManager {
575
585
  this.scheduleSave();
576
586
  }
577
587
 
588
+ human_theme_getActive(): string | undefined {
589
+ return this.getHuman().settings?.active_theme;
590
+ }
591
+
592
+ human_theme_setActive(id: string | undefined): void {
593
+ const human = this.getHuman();
594
+ human.settings ??= {};
595
+ human.settings.active_theme = id;
596
+ this.setHuman(human);
597
+ }
598
+
599
+ human_theme_getAll(): ThemeDefinition[] {
600
+ return this.getHuman().settings?.custom_themes ?? [];
601
+ }
602
+
603
+ human_theme_upsert(theme: ThemeDefinition): void {
604
+ const human = this.getHuman();
605
+ human.settings ??= {};
606
+ human.settings.custom_themes ??= [];
607
+ const idx = human.settings.custom_themes.findIndex(t => t.id === theme.id);
608
+ if (idx >= 0) {
609
+ human.settings.custom_themes[idx] = theme;
610
+ } else {
611
+ human.settings.custom_themes.push(theme);
612
+ }
613
+ this.setHuman(human);
614
+ }
615
+
616
+ human_theme_remove(id: string): boolean {
617
+ const human = this.getHuman();
618
+ const themes = human.settings?.custom_themes ?? [];
619
+ const idx = themes.findIndex(t => t.id === id);
620
+ if (idx < 0) return false;
621
+ themes.splice(idx, 1);
622
+ this.setHuman(human);
623
+ return true;
624
+ }
625
+
578
626
  human_fact_upsert(fact: Fact): void {
579
627
  this.humanState.fact_upsert(fact);
580
628
  this.scheduleSave();
@@ -1064,6 +1112,24 @@ export class StateManager {
1064
1112
  return { success: true, cleared };
1065
1113
  }
1066
1114
 
1115
+ model_update_usage(modelId: string, delta: { calls: number; tokens_in: number; tokens_out: number }): void {
1116
+ const human = this.humanState.get();
1117
+ const accounts = human.settings?.accounts;
1118
+ if (!accounts) return;
1119
+
1120
+ for (const account of accounts) {
1121
+ const model = account.models?.find(m => m.id === modelId);
1122
+ if (model) {
1123
+ model.total_calls = (model.total_calls ?? 0) + delta.calls;
1124
+ model.total_tokens_in = (model.total_tokens_in ?? 0) + delta.tokens_in;
1125
+ model.total_tokens_out = (model.total_tokens_out ?? 0) + delta.tokens_out;
1126
+ model.last_used = new Date().toISOString();
1127
+ this.scheduleSave();
1128
+ return;
1129
+ }
1130
+ }
1131
+ }
1132
+
1067
1133
  async flush(): Promise<void> {
1068
1134
  await this.persistenceState.flush();
1069
1135
  }
@@ -57,7 +57,7 @@ export function createReadMemoryExecutor(searchHumanData: SearchHumanData, getPe
57
57
  const output: Record<string, unknown[]> = {};
58
58
  if (results.facts.length > 0) output.facts = results.facts.map(f => ({ name: f.name, description: f.description }));
59
59
  if (results.topics.length > 0) output.topics = results.topics.map(t => ({ name: t.name, description: t.description }));
60
- if (results.people.length > 0) output.people = results.people.map(p => ({ name: p.name, relationship: p.relationship, description: p.description }));
60
+ if (results.people.length > 0) output.people = results.people.map(p => ({ name: p.name, relationship: p.relationship, description: p.description, identifiers: p.identifiers ?? [] }));
61
61
  if (results.quotes.length > 0) output.quotes = results.quotes.map(q => ({ text: q.text, speaker: q.speaker }));
62
62
 
63
63
  if (Object.keys(output).length === 0) {
@@ -44,9 +44,11 @@ export interface BackupConfig {
44
44
  */
45
45
  export interface ModelConfig {
46
46
  id: string; // GUID (crypto.randomUUID())
47
- name: string; // Model identifier, e.g. "claude-haiku-4-5", "(default)"
47
+ name: string; // Display name shown in UI, e.g. "Gemma4 (thinking)", "(default)"
48
+ model_id?: string; // Actual model identifier sent to API — falls back to name if absent
48
49
  token_limit?: number; // Input token limit (user sets effective limit)
49
50
  max_output_tokens?: number; // Output token limit (API-enforced)
51
+ thinking_budget?: number; // Thinking token budget: 0 = disabled, N = enable with N tokens, undefined = don't send
50
52
  total_calls?: number; // Usage counter
51
53
  total_tokens_in?: number; // Usage counter
52
54
  total_tokens_out?: number; // Usage counter
@@ -89,6 +91,14 @@ export interface ProviderAccount {
89
91
  created_at: string; // ISO timestamp
90
92
  }
91
93
 
94
+ export interface ThemeDefinition {
95
+ id: string;
96
+ name: string;
97
+ base?: string;
98
+ encoded: string;
99
+ created_at: string;
100
+ }
101
+
92
102
  export interface HumanSettings {
93
103
  default_model?: string; // Will store ModelConfig.id GUID post-migration
94
104
  oneshot_model?: string; // Model for AI-assist (wand) requests; falls back to default_model. Will store ModelConfig.id GUID post-migration.
@@ -109,6 +119,8 @@ export interface HumanSettings {
109
119
  backup?: BackupConfig;
110
120
  claudeCode?: import("../../integrations/claude-code/types.js").ClaudeCodeSettings;
111
121
  cursor?: import("../../integrations/cursor/types.js").CursorSettings;
122
+ active_theme?: string;
123
+ custom_themes?: ThemeDefinition[];
112
124
  }
113
125
 
114
126
  export interface HumanEntity {
@@ -150,6 +162,9 @@ export interface PersonaEntity {
150
162
  tools?: string[]; // IDs of ToolDefinitions this persona can use. Empty/absent = no tool access.
151
163
  reflection_last_asked?: string; // ISO timestamp. Set ONLY when Persona explicitly surfaces identity drift (mentioned_reflection: true).
152
164
  description_embedding?: number[]; // Embedding of long_description (short_description fallback). Excludes traits. See embedding-service.ts:getPersonaDescriptionText.
165
+ avatar_emoji?: string; // Single emoji character used as avatar in place of initials.
166
+ avatar_image?: string; // Base64-encoded 64×64 image used as avatar (takes priority over avatar_emoji).
167
+ preferred_theme?: string; // Theme ID (built-in name or ThemeDefinition.id). Applied to chat panel when this persona is active.
153
168
  }
154
169
 
155
170
  export interface PersonaCreationInput {
@@ -56,6 +56,9 @@ export interface PersonaSummary {
56
56
  unread_count: number;
57
57
  last_activity?: string;
58
58
  context_boundary?: string;
59
+ avatar_emoji?: string;
60
+ avatar_image?: string;
61
+ preferred_theme?: string;
59
62
  }
60
63
 
61
64
  export interface MessageQueryOptions {
@@ -1,8 +1,32 @@
1
1
  import type { PersonIdentifier } from "../types/data-items.js";
2
2
  import type { StateManager } from "../state-manager.js";
3
+ import { BUILT_IN_IDENTIFIER_TYPES } from "../constants/built-in-identifier-types.js";
3
4
 
4
5
  export const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
5
6
 
7
+ function toNormalizedKey(s: string): string {
8
+ return s.replace(/[^a-z0-9]/gi, '').toLowerCase();
9
+ }
10
+
11
+ // Fuzzy-matches LLM-provided type against built-in + in-use types (strip non-alphanumeric, lowercase).
12
+ // "nickname" -> "Nickname", "full_name" -> "Full Name", "EMAIL" -> "Email", "Slack RNP" -> "Slack RNP" (custom, no match)
13
+ export function normalizeIdentifierType(llmType: string, state: StateManager): string {
14
+ const inUseTypes = state.getHuman().people.flatMap(p =>
15
+ (p.identifiers ?? []).map(i => i.type)
16
+ );
17
+
18
+ const canonicalMap = new Map<string, string>();
19
+ for (const t of [...BUILT_IN_IDENTIFIER_TYPES, ...inUseTypes]) {
20
+ const key = toNormalizedKey(t);
21
+ if (!canonicalMap.has(key)) {
22
+ canonicalMap.set(key, t);
23
+ }
24
+ }
25
+
26
+ const normalized = toNormalizedKey(llmType);
27
+ return canonicalMap.get(normalized) ?? llmType;
28
+ }
29
+
6
30
  export function sanitizeEiPersonaIdentifiers(
7
31
  identifiers: PersonIdentifier[],
8
32
  state: StateManager
@@ -0,0 +1,78 @@
1
+ import type { ThemeDefinition } from "../types/entities.js";
2
+
3
+ const VERSION = "v1";
4
+ const PREFIX = `ei-theme:${VERSION}:`;
5
+ const TOKEN_COUNT = 37;
6
+ const HEX_LENGTH = 6;
7
+
8
+ export const THEME_TOKEN_ORDER: readonly string[] = [
9
+ "bg-primary", "bg-secondary", "bg-tertiary",
10
+ "border", "border-light",
11
+ "text-primary", "text-secondary", "text-muted",
12
+ "accent", "accent-hover",
13
+ "success", "success-hover",
14
+ "warning", "warning-text",
15
+ "danger",
16
+ "status-thinking", "status-ready", "status-unread", "status-paused",
17
+ "room-cyp", "room-ffa", "room-map",
18
+ "archive-bg-start", "archive-bg-end", "archive-border",
19
+ "ai-assist-start", "ai-assist-end",
20
+ "code-bg", "code-bg-controls", "code-border",
21
+ "code-text", "code-text-muted",
22
+ "code-accent", "code-string", "code-error", "code-success", "code-special",
23
+ ] as const;
24
+
25
+ export const BUILT_IN_THEME_NAMES: readonly string[] = [
26
+ "default", "dark", "coder", "depressing", "cotton-candy",
27
+ "crimuh", "spoopy", "lovey-dovey", "lucky",
28
+ ] as const;
29
+
30
+ export type ThemeTokenMap = Record<string, string>;
31
+
32
+ export function encodeTheme(tokens: ThemeTokenMap): string {
33
+ const hex = THEME_TOKEN_ORDER.map((key) => {
34
+ const value = tokens[`--ei-${key}`] ?? tokens[key] ?? "000000";
35
+ return value.replace(/^#/, "").toLowerCase().padEnd(HEX_LENGTH, "0").slice(0, HEX_LENGTH);
36
+ }).join("");
37
+ return PREFIX + btoa(hex);
38
+ }
39
+
40
+ export function decodeTheme(encoded: string): ThemeTokenMap | null {
41
+ if (!encoded.startsWith(PREFIX)) return null;
42
+ try {
43
+ const hex = atob(encoded.slice(PREFIX.length));
44
+ if (hex.length !== TOKEN_COUNT * HEX_LENGTH) return null;
45
+ const tokens: ThemeTokenMap = {};
46
+ for (let i = 0; i < TOKEN_COUNT; i++) {
47
+ const key = THEME_TOKEN_ORDER[i];
48
+ tokens[`--ei-${key}`] = `#${hex.slice(i * HEX_LENGTH, (i + 1) * HEX_LENGTH)}`;
49
+ }
50
+ return tokens;
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ export function themeToStyleString(tokens: ThemeTokenMap): string {
57
+ return Object.entries(tokens)
58
+ .map(([k, v]) => ` ${k}: ${v};`)
59
+ .join("\n");
60
+ }
61
+
62
+ export function isBuiltInTheme(id: string): boolean {
63
+ return (BUILT_IN_THEME_NAMES as readonly string[]).includes(id);
64
+ }
65
+
66
+ export function makeThemeDefinition(
67
+ name: string,
68
+ tokens: ThemeTokenMap,
69
+ base?: string,
70
+ ): ThemeDefinition {
71
+ return {
72
+ id: crypto.randomUUID(),
73
+ name,
74
+ base,
75
+ encoded: encodeTheme(tokens),
76
+ created_at: new Date().toISOString(),
77
+ };
78
+ }
@@ -103,7 +103,8 @@ export const meCommand: Command = {
103
103
  }
104
104
 
105
105
  try {
106
- const parsed = humanFromYAML(result.content, filteredHuman);
106
+ const currentHuman = await ctx.ei.getHuman();
107
+ const parsed = humanFromYAML(result.content, filteredHuman, currentHuman);
107
108
 
108
109
  for (const id of parsed.deletedFactIds) {
109
110
  await ctx.ei.removeDataItem("fact", id);
@@ -131,14 +132,20 @@ export const meCommand: Command = {
131
132
  }
132
133
  }
133
134
 
134
- const deleteCount = parsed.deletedFactIds.length +
135
- parsed.deletedTopicIds.length +
135
+ const deleteCount = parsed.deletedFactIds.length +
136
+ parsed.deletedTopicIds.length +
136
137
  parsed.deletedPersonIds.length;
137
- const updateCount = parsed.changedFactIds.size +
138
- parsed.changedTopicIds.size +
138
+ const updateCount = parsed.changedFactIds.size +
139
+ parsed.changedTopicIds.size +
139
140
  parsed.changedPersonIds.size;
140
-
141
- ctx.showNotification(`Updated ${updateCount} items, deleted ${deleteCount}`, "info");
141
+ const skippedCount = parsed.skippedFactCount +
142
+ parsed.skippedTopicCount +
143
+ parsed.skippedPersonCount;
144
+
145
+ const msg = skippedCount > 0
146
+ ? `Updated ${updateCount}, deleted ${deleteCount}, skipped ${skippedCount} (changed by another process)`
147
+ : `Updated ${updateCount} items, deleted ${deleteCount}`;
148
+ ctx.showNotification(msg, "info");
142
149
  return;
143
150
 
144
151
  } catch (parseError) {
@@ -14,6 +14,7 @@ import { Processor } from "../../../src/core/processor.js";
14
14
  import { FileStorage } from "../storage/file.js";
15
15
  import { remoteSync } from "../../../src/storage/remote.js";
16
16
  import { logger, clearLog, interceptConsole } from "../util/logger.js";
17
+ import { E2E_SKIP_LOCAL_DETECT } from "../util/e2e-flags.js";
17
18
  import { ConflictOverlay } from "../components/ConflictOverlay.js";
18
19
  import type {
19
20
  Ei_Interface,
@@ -698,7 +699,10 @@ export const EiProvider: ParentComponent = (props) => {
698
699
  try {
699
700
  const human = await processor!.getHuman();
700
701
  const hasAccounts = human.settings?.accounts && human.settings.accounts.length > 0;
701
- if (!hasAccounts) {
702
+ if (!hasAccounts && E2E_SKIP_LOCAL_DETECT) {
703
+ logger.info("E2E_SKIP_LOCAL_DETECT active, skipping local LLM check");
704
+ setShowWelcomeOverlay(true);
705
+ } else if (!hasAccounts) {
702
706
  logger.info("No LLM accounts configured, checking for local LLM...");
703
707
  try {
704
708
  const response = await fetch("http://127.0.0.1:1234/v1/models", {
@@ -707,6 +711,7 @@ export const EiProvider: ParentComponent = (props) => {
707
711
  });
708
712
  if (response.ok) {
709
713
  logger.info("Local LLM detected, auto-configuring...");
714
+ const defaultModelId = crypto.randomUUID();
710
715
  const localAccount: ProviderAccount = {
711
716
  id: crypto.randomUUID(),
712
717
  name: "Local LLM",
@@ -714,13 +719,15 @@ export const EiProvider: ParentComponent = (props) => {
714
719
  url: "http://127.0.0.1:1234/v1",
715
720
  enabled: true,
716
721
  created_at: new Date().toISOString(),
722
+ default_model: defaultModelId,
723
+ models: [{ id: defaultModelId, name: "(default)" }],
717
724
  };
718
725
  const currentHuman = await processor!.getHuman();
719
726
  await processor!.updateHuman({
720
727
  settings: {
721
728
  ...currentHuman.settings,
722
729
  accounts: [localAccount],
723
- default_model: "Local LLM",
730
+ default_model: defaultModelId,
724
731
  },
725
732
  });
726
733
  showNotification("Local LLM detected and configured!", "info");
@@ -0,0 +1,13 @@
1
+ /**
2
+ * EI_E2E_MODE — bitfield for test seams that can't be solved via data seeding.
3
+ *
4
+ * Use prime-power bits so combinations are unambiguous:
5
+ * 1 — skip local LLM auto-detect (fetch to :1234/:11434)
6
+ * 2 — (reserved for next scenario)
7
+ * 3 — flags 1 + 2 combined
8
+ *
9
+ * Production code should never set this. Tests pass it via env in test.use({ env: { EI_E2E_MODE: "1" } }).
10
+ */
11
+ const E2E_MODE = parseInt(process.env.EI_E2E_MODE ?? "0", 10);
12
+
13
+ export const E2E_SKIP_LOCAL_DETECT = (E2E_MODE & 1) !== 0;
@@ -225,6 +225,9 @@ export interface HumanYAMLResult {
225
225
  changedFactIds: Set<string>;
226
226
  changedTopicIds: Set<string>;
227
227
  changedPersonIds: Set<string>;
228
+ skippedFactCount: number;
229
+ skippedTopicCount: number;
230
+ skippedPersonCount: number;
228
231
  }
229
232
 
230
233
  function identifiersEqual(a: PersonIdentifier[] | undefined, b: PersonIdentifier[] | undefined): boolean {
@@ -279,7 +282,7 @@ function personChanged(parsed: Person, original: Person): boolean {
279
282
  return !identifiersEqual(parsed.identifiers, original.identifiers);
280
283
  }
281
284
 
282
- export function humanFromYAML(yamlContent: string, original?: HumanEntity): HumanYAMLResult {
285
+ export function humanFromYAML(yamlContent: string, original?: HumanEntity, current?: HumanEntity): HumanYAMLResult {
283
286
  const stripped = yamlContent
284
287
  .split('\n')
285
288
  .filter(line => !/^\s*#\s*\[read-only\]/.test(line))
@@ -292,6 +295,15 @@ export function humanFromYAML(yamlContent: string, original?: HumanEntity): Huma
292
295
  const changedFactIds = new Set<string>();
293
296
  const changedTopicIds = new Set<string>();
294
297
  const changedPersonIds = new Set<string>();
298
+ let skippedFactCount = 0;
299
+ let skippedTopicCount = 0;
300
+ let skippedPersonCount = 0;
301
+
302
+ const staleInState = (id: string | undefined, originalItem: { last_updated: string } | undefined, currentItems: { id: string; last_updated: string }[] | undefined): boolean => {
303
+ if (!id || !originalItem || !current || !currentItems) return false;
304
+ const currentItem = currentItems.find(i => i.id === id);
305
+ return !!currentItem && currentItem.last_updated !== originalItem.last_updated;
306
+ };
295
307
 
296
308
  const facts: Fact[] = [];
297
309
  for (const f of data.facts ?? []) {
@@ -306,10 +318,14 @@ export function humanFromYAML(yamlContent: string, original?: HumanEntity): Huma
306
318
  : { ...parsed, last_updated: new Date().toISOString(), persona_groups: parseGroupCheckboxMap(groupMap) };
307
319
  facts.push(fact);
308
320
  if (!originalFact || factChanged(fact, originalFact)) {
309
- if (fact.description && !originalFact?.validated_date) {
310
- fact.validated_date = new Date().toISOString();
321
+ if (staleInState(parsed.id, originalFact, current?.facts)) {
322
+ skippedFactCount++;
323
+ } else {
324
+ if (fact.description && !originalFact?.validated_date) {
325
+ fact.validated_date = new Date().toISOString();
326
+ }
327
+ changedFactIds.add(fact.id);
311
328
  }
312
- changedFactIds.add(fact.id);
313
329
  }
314
330
  }
315
331
  }
@@ -327,7 +343,11 @@ export function humanFromYAML(yamlContent: string, original?: HumanEntity): Huma
327
343
  : { ...parsed, last_updated: new Date().toISOString(), persona_groups: parseGroupCheckboxMap(groupMap) };
328
344
  topics.push(topic);
329
345
  if (!originalTopic || topicChanged(topic, originalTopic)) {
330
- changedTopicIds.add(topic.id);
346
+ if (staleInState(parsed.id, originalTopic, current?.topics)) {
347
+ skippedTopicCount++;
348
+ } else {
349
+ changedTopicIds.add(topic.id);
350
+ }
331
351
  }
332
352
  }
333
353
  }
@@ -350,7 +370,11 @@ export function humanFromYAML(yamlContent: string, original?: HumanEntity): Huma
350
370
  : { ...parsed, last_updated: new Date().toISOString(), identifiers, persona_groups: parseGroupCheckboxMap(groupMap) };
351
371
  people.push(person);
352
372
  if (!originalPerson || personChanged(person, originalPerson)) {
353
- changedPersonIds.add(person.id);
373
+ if (staleInState(parsed.id, originalPerson, current?.people)) {
374
+ skippedPersonCount++;
375
+ } else {
376
+ changedPersonIds.add(person.id);
377
+ }
354
378
  }
355
379
  }
356
380
  }
@@ -365,5 +389,8 @@ export function humanFromYAML(yamlContent: string, original?: HumanEntity): Huma
365
389
  changedFactIds,
366
390
  changedTopicIds,
367
391
  changedPersonIds,
392
+ skippedFactCount,
393
+ skippedTopicCount,
394
+ skippedPersonCount,
368
395
  };
369
396
  }
@@ -5,10 +5,15 @@ import type {
5
5
  } from "../../../src/core/types.js";
6
6
  import { modelGuidToDisplay } from "./yaml-shared.js";
7
7
 
8
+ const tokenFormatter = new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 });
9
+ const formatTokens = (n: number) => tokenFormatter.format(n);
10
+
8
11
  interface EditableModelData {
9
12
  name: string;
13
+ model_id?: string;
10
14
  token_limit?: number;
11
15
  max_output_tokens?: number;
16
+ thinking_budget?: number;
12
17
  _delete?: boolean;
13
18
  }
14
19
 
@@ -45,11 +50,14 @@ function parseModels(editableModels: EditableModelData[]): import('../../../src/
45
50
  const result: import('../../../src/core/types.js').ModelConfig[] = [];
46
51
  for (const m of editableModels) {
47
52
  if (m._delete) continue;
53
+ const modelId = m.model_id ?? undefined;
48
54
  result.push({
49
55
  id: crypto.randomUUID(),
50
56
  name: m.name,
51
- token_limit: m.token_limit,
52
- max_output_tokens: m.max_output_tokens,
57
+ model_id: (modelId === null || modelId === m.name) ? undefined : modelId,
58
+ token_limit: m.token_limit ?? undefined,
59
+ max_output_tokens: m.max_output_tokens ?? undefined,
60
+ thinking_budget: m.thinking_budget ?? undefined,
53
61
  });
54
62
  }
55
63
  return result;
@@ -70,6 +78,10 @@ export function newProviderToYAML(name?: string): string {
70
78
  const modelsYAML = [
71
79
  "models:",
72
80
  " - name: (default)",
81
+ " model_id: (default)",
82
+ " token_limit: null",
83
+ " max_output_tokens: null",
84
+ " thinking_budget: null",
73
85
  " # _delete: true",
74
86
  "# _delete: true # Delete this entire provider",
75
87
  ].join("\n");
@@ -141,16 +153,26 @@ export function providerToYAML(account: ProviderAccount): string {
141
153
  if (modelList.length > 0) {
142
154
  for (const m of modelList) {
143
155
  modelLines.push(` - name: ${m.name}`);
144
- if (m.token_limit !== undefined) {
145
- modelLines.push(` token_limit: ${m.token_limit}`);
146
- }
147
- if (m.max_output_tokens !== undefined) {
148
- modelLines.push(` max_output_tokens: ${m.max_output_tokens}`);
156
+ modelLines.push(` model_id: ${m.model_id ?? m.name}`);
157
+ modelLines.push(` token_limit: ${m.token_limit ?? null}`);
158
+ modelLines.push(` max_output_tokens: ${m.max_output_tokens ?? null}`);
159
+ modelLines.push(` thinking_budget: ${m.thinking_budget ?? null}`);
160
+ if (m.total_calls !== undefined || m.total_tokens_in !== undefined) {
161
+ const tokensIn = m.total_tokens_in ?? 0;
162
+ const tokensOut = m.total_tokens_out ?? 0;
163
+ modelLines.push(` # stats: ${formatTokens(m.total_calls ?? 0)} calls · ${formatTokens(tokensIn)} in / ${formatTokens(tokensOut)} out`);
164
+ if (m.last_used) {
165
+ modelLines.push(` # used: ${m.last_used}`);
166
+ }
149
167
  }
150
168
  modelLines.push(` _delete: false`);
151
169
  }
152
170
  } else {
153
171
  modelLines.push(" - name: (default)");
172
+ modelLines.push(` model_id: (default)`);
173
+ modelLines.push(` token_limit: null`);
174
+ modelLines.push(` max_output_tokens: null`);
175
+ modelLines.push(` thinking_budget: null`);
154
176
  modelLines.push(" _delete: false");
155
177
  }
156
178
  modelLines.push("_delete: false # Set to true to delete this entire provider");
@@ -185,11 +207,14 @@ export function providerFromYAML(yamlContent: string, original: ProviderAccount)
185
207
  for (const m of data.models ?? []) {
186
208
  if (m._delete) continue;
187
209
  const existing = existingModels.find(em => em.name === m.name);
210
+ const modelId = m.model_id ?? undefined;
188
211
  parsedModels.push({
189
212
  id: existing?.id ?? crypto.randomUUID(),
190
213
  name: m.name,
191
- token_limit: m.token_limit,
192
- max_output_tokens: m.max_output_tokens,
214
+ model_id: (modelId === null || modelId === m.name) ? undefined : modelId,
215
+ token_limit: m.token_limit ?? undefined,
216
+ max_output_tokens: m.max_output_tokens ?? undefined,
217
+ thinking_budget: m.thinking_budget ?? undefined,
193
218
  total_calls: existing?.total_calls,
194
219
  total_tokens_in: existing?.total_tokens_in,
195
220
  total_tokens_out: existing?.total_tokens_out,