ei-tui 0.6.6 → 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.6",
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 } : {}) });
@@ -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
 
@@ -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();
@@ -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) {
@@ -91,6 +91,14 @@ export interface ProviderAccount {
91
91
  created_at: string; // ISO timestamp
92
92
  }
93
93
 
94
+ export interface ThemeDefinition {
95
+ id: string;
96
+ name: string;
97
+ base?: string;
98
+ encoded: string;
99
+ created_at: string;
100
+ }
101
+
94
102
  export interface HumanSettings {
95
103
  default_model?: string; // Will store ModelConfig.id GUID post-migration
96
104
  oneshot_model?: string; // Model for AI-assist (wand) requests; falls back to default_model. Will store ModelConfig.id GUID post-migration.
@@ -111,6 +119,8 @@ export interface HumanSettings {
111
119
  backup?: BackupConfig;
112
120
  claudeCode?: import("../../integrations/claude-code/types.js").ClaudeCodeSettings;
113
121
  cursor?: import("../../integrations/cursor/types.js").CursorSettings;
122
+ active_theme?: string;
123
+ custom_themes?: ThemeDefinition[];
114
124
  }
115
125
 
116
126
  export interface HumanEntity {
@@ -152,6 +162,9 @@ export interface PersonaEntity {
152
162
  tools?: string[]; // IDs of ToolDefinitions this persona can use. Empty/absent = no tool access.
153
163
  reflection_last_asked?: string; // ISO timestamp. Set ONLY when Persona explicitly surfaces identity drift (mentioned_reflection: true).
154
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.
155
168
  }
156
169
 
157
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
+ }