ei-tui 0.6.6 → 0.7.0

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.
Files changed (45) hide show
  1. package/package.json +1 -1
  2. package/src/cli/README.md +16 -7
  3. package/src/cli/commands/people.ts +1 -0
  4. package/src/cli/mcp.ts +36 -11
  5. package/src/cli/persona-filter.ts +42 -0
  6. package/src/cli/retrieval.ts +3 -1
  7. package/src/cli.ts +18 -6
  8. package/src/core/handlers/human-extraction.ts +1 -0
  9. package/src/core/handlers/human-matching.ts +20 -4
  10. package/src/core/handlers/index.ts +2 -1
  11. package/src/core/handlers/persona-response.ts +5 -0
  12. package/src/core/handlers/utils.ts +4 -1
  13. package/src/core/orchestrators/ceremony.ts +2 -2
  14. package/src/core/orchestrators/human-extraction.ts +24 -7
  15. package/src/core/persona-manager.ts +3 -0
  16. package/src/core/processor.ts +22 -2
  17. package/src/core/prompt-context-builder.ts +40 -10
  18. package/src/core/queue-manager.ts +18 -0
  19. package/src/core/room-manager.ts +21 -4
  20. package/src/core/state-manager.ts +74 -0
  21. package/src/core/tools/builtin/read-memory.ts +1 -1
  22. package/src/core/types/data-items.ts +1 -0
  23. package/src/core/types/entities.ts +13 -0
  24. package/src/core/types/enums.ts +1 -0
  25. package/src/core/types/integrations.ts +4 -0
  26. package/src/core/types/rooms.ts +2 -0
  27. package/src/core/utils/identifier-utils.ts +24 -0
  28. package/src/core/utils/theme-codec.ts +78 -0
  29. package/src/integrations/claude-code/importer.ts +3 -57
  30. package/src/integrations/cursor/importer.ts +2 -52
  31. package/src/integrations/opencode/importer.ts +1 -0
  32. package/src/prompts/response/sections.ts +1 -1
  33. package/src/prompts/response/types.ts +1 -0
  34. package/src/prompts/room/index.ts +2 -2
  35. package/src/prompts/room/sections.ts +4 -4
  36. package/src/prompts/room/types.ts +4 -0
  37. package/tui/src/commands/activate.tsx +7 -6
  38. package/tui/src/commands/context.tsx +188 -2
  39. package/tui/src/components/CYPTreeOverlay.tsx +357 -0
  40. package/tui/src/components/MAPScoreOverlay.tsx +300 -0
  41. package/tui/src/components/MessageList.tsx +14 -3
  42. package/tui/src/components/RoomMessageList.tsx +15 -3
  43. package/tui/src/context/ei.tsx +20 -0
  44. package/tui/src/util/cyp-tree.ts +62 -0
  45. package/tui/src/util/yaml-context.ts +87 -1
@@ -1,4 +1,5 @@
1
- import type { PersonaEntity, HumanEntity, DataItemBase, Quote, RoomEntity } from "./types.js";
1
+ import type { PersonaEntity, HumanEntity, DataItemBase, Quote, RoomEntity, RoomMessage } from "./types.js";
2
+ import { RoomMode, ContextStatus } from "./types.js";
2
3
  import { StateManager } from "./state-manager.js";
3
4
  import { getEmbeddingService, findTopK } from "./embedding-service.js";
4
5
  import type { ResponsePromptData, PromptOutput } from "../prompts/index.js";
@@ -121,7 +122,12 @@ export async function filterHumanDataByVisibility(
121
122
  selectRelevantQuotes(human.quotes ?? [], currentMessage),
122
123
  ]);
123
124
  const { topics, people } = capTopicsAndPeople(rawTopics, rawPeople);
125
+ const humanName =
126
+ human.settings?.name_display ||
127
+ human.facts?.find(f => f.name === "Nickname/Preferred Name")?.description ||
128
+ "Human";
124
129
  return {
130
+ name: humanName,
125
131
  facts,
126
132
  topics,
127
133
  people,
@@ -158,7 +164,12 @@ export async function filterHumanDataByVisibility(
158
164
  ]);
159
165
  const { topics, people } = capTopicsAndPeople(rawTopics, rawPeople);
160
166
 
167
+ const humanName =
168
+ human.settings?.name_display ||
169
+ human.facts?.find(f => f.name === "Nickname/Preferred Name")?.description ||
170
+ "Human";
161
171
  return {
172
+ name: humanName,
162
173
  facts,
163
174
  topics,
164
175
  people,
@@ -264,14 +275,30 @@ export async function buildRoomResponsePromptData(
264
275
  ? [...sm.getRoomMessages(room.id)].sort((a, b) => a.timestamp.localeCompare(b.timestamp))
265
276
  : activePath;
266
277
 
267
- // Apply time window (same hours setting as 1:1 personas), but guarantee
268
- // at least MIN_ROOM_MESSAGES so rooms never feel like they're starting over.
269
- // Whichever anchor reaches further back wins.
270
- const contextWindowHours = human.settings?.default_context_window_hours ?? 8;
271
- const windowCutoff = new Date(Date.now() - contextWindowHours * 60 * 60 * 1000).toISOString();
272
- const byTime = allSourceMessages.filter(m => m.timestamp >= windowCutoff);
273
- const byCount = allSourceMessages.slice(-MIN_ROOM_MESSAGES);
274
- const sourceMessages = byTime.length >= byCount.length ? byTime : byCount;
278
+ let sourceMessages: RoomMessage[];
279
+ if (room.mode === RoomMode.FreeForAll) {
280
+ const contextWindowHours = room.context_window_hours
281
+ ?? human.settings?.default_context_window_hours
282
+ ?? 8;
283
+ const windowCutoff = new Date(Date.now() - contextWindowHours * 60 * 60 * 1000).toISOString();
284
+ const boundaryMs = room.context_boundary ? new Date(room.context_boundary).getTime() : 0;
285
+ sourceMessages = allSourceMessages.filter(m => {
286
+ const msgMs = new Date(m.timestamp).getTime();
287
+ if (m.context_status === ContextStatus.Always) return true;
288
+ if (m.context_status === ContextStatus.Never) return false;
289
+ if (msgMs < new Date(windowCutoff).getTime()) return false;
290
+ if (boundaryMs && msgMs < boundaryMs) return false;
291
+ return true;
292
+ });
293
+ const byCount = allSourceMessages.slice(-MIN_ROOM_MESSAGES);
294
+ if (byCount.length > sourceMessages.length) sourceMessages = byCount;
295
+ } else {
296
+ const contextWindowHours = human.settings?.default_context_window_hours ?? 8;
297
+ const windowCutoff = new Date(Date.now() - contextWindowHours * 60 * 60 * 1000).toISOString();
298
+ const byTime = allSourceMessages.filter(m => m.timestamp >= windowCutoff);
299
+ const byCount = allSourceMessages.slice(-MIN_ROOM_MESSAGES);
300
+ sourceMessages = byTime.length >= byCount.length ? byTime : byCount;
301
+ }
275
302
 
276
303
  const lastMessage = sourceMessages[sourceMessages.length - 1];
277
304
  const currentMessage = lastMessage ? getMessageContent(lastMessage) : undefined;
@@ -297,7 +324,10 @@ export async function buildRoomResponsePromptData(
297
324
  }
298
325
  otherParticipants.push({
299
326
  id: "human",
300
- name: human.settings?.name_display ?? "Human",
327
+ name:
328
+ human.settings?.name_display ||
329
+ human.facts?.find(f => f.name === "Nickname/Preferred Name")?.description ||
330
+ "Human",
301
331
  traits: [],
302
332
  is_human: true,
303
333
  });
@@ -77,3 +77,21 @@ export async function submitOneShot(
77
77
  data: { guid },
78
78
  });
79
79
  }
80
+
81
+ export async function submitOneShotJSON(
82
+ sm: StateManager,
83
+ getOneshotModel: () => string | undefined,
84
+ guid: string,
85
+ systemPrompt: string,
86
+ userPrompt: string
87
+ ): Promise<void> {
88
+ sm.queue_enqueue({
89
+ type: LLMRequestType.JSON,
90
+ priority: LLMPriority.High,
91
+ system: systemPrompt,
92
+ user: userPrompt,
93
+ next_step: LLMNextStep.HandleOneShotJSON,
94
+ model: getOneshotModel(),
95
+ data: { guid },
96
+ });
97
+ }
@@ -149,8 +149,19 @@ export async function sendFfaMessage(
149
149
  }
150
150
 
151
151
  const now = new Date().toISOString();
152
+
153
+ // FFA human messages always hang off the room root (the initial message with parent_id === null)
154
+ // so the tree is a flat star: root → every human turn, each human turn → persona responses.
155
+ // This gives the context window a bounded, predictable shape instead of a chain.
156
+ const ffaRootMsg = sm.getRoomMessages(roomId).find(m => m.parent_id === null);
157
+ if (!ffaRootMsg) {
158
+ onError({ code: "ROOM_NO_ROOT", message: "FFA room has no root message. Try archiving and recreating the room." });
159
+ return;
160
+ }
161
+ const ffaParentId = ffaRootMsg.id;
162
+
152
163
  const existing = sm.getRoomMessages(roomId).find(
153
- m => m.role === "human" && m.parent_id === room.active_node_id
164
+ m => m.role === "human" && m.id === room.active_node_id && m.parent_id === ffaParentId
154
165
  );
155
166
 
156
167
  let humanMsgId: string;
@@ -164,7 +175,7 @@ export async function sendFfaMessage(
164
175
  } else {
165
176
  const msg: RoomMessage = {
166
177
  id: crypto.randomUUID(),
167
- parent_id: room.active_node_id,
178
+ parent_id: ffaParentId,
168
179
  role: "human",
169
180
  verbal_response: content ?? undefined,
170
181
  silence_reason: content ? undefined : (silenceReason ?? "passed"),
@@ -278,9 +289,14 @@ export async function activateRoom(
278
289
 
279
290
  const currentRound = allMessages.filter(m => m.parent_id === room.active_node_id);
280
291
 
292
+ const humanDisplayName =
293
+ human.settings?.name_display ||
294
+ human.facts?.find(f => f.name === "Nickname/Preferred Name")?.description ||
295
+ "Human";
296
+
281
297
  const context: RoomHistoryMessage[] = sm.getRoomActivePath(roomId).map(m => ({
282
298
  speaker_name: m.role === "human"
283
- ? (human.settings?.name_display ?? "Human")
299
+ ? humanDisplayName
284
300
  : (sm.persona_getById(m.persona_id ?? "")?.display_name ?? "Unknown"),
285
301
  speaker_id: m.role === "human" ? "human" : (m.persona_id ?? ""),
286
302
  verbal_response: getMessageContent(m) || undefined,
@@ -290,7 +306,7 @@ export async function activateRoom(
290
306
  const candidates: RoomJudgeCandidate[] = currentRound.map(m => ({
291
307
  message_id: m.id,
292
308
  speaker_name: m.role === "human"
293
- ? (human.settings?.name_display ?? "Human")
309
+ ? humanDisplayName
294
310
  : (sm.persona_getById(m.persona_id ?? "")?.display_name ?? "Unknown"),
295
311
  speaker_id: m.role === "human" ? "human" : (m.persona_id ?? ""),
296
312
  verbal_response: getMessageContent(m) || undefined,
@@ -305,6 +321,7 @@ export async function activateRoom(
305
321
  long_description: judgePersona.long_description,
306
322
  traits: judgePersona.traits,
307
323
  },
324
+ human: { name: humanDisplayName },
308
325
  context,
309
326
  candidates,
310
327
  });
@@ -17,7 +17,9 @@ import type {
17
17
  RoomSummary,
18
18
  RoomCreationInput,
19
19
  } from "./types.js";
20
+ import { RoomMode } from "./types.js";
20
21
  import { BUILT_IN_FACT_NAMES } from './constants/built-in-facts.js';
22
+ import type { ThemeDefinition } from './types/entities.js';
21
23
  import type { Storage } from "../storage/interface.js";
22
24
  import {
23
25
  HumanState,
@@ -68,6 +70,8 @@ export class StateManager {
68
70
  this.migrateInterestedPersonas();
69
71
  this.migrateProviderModel();
70
72
  this.migrateRoomMessageContent();
73
+ this.migrateThemes();
74
+ this.migrateFfaParentIds();
71
75
  }
72
76
 
73
77
  private migrateRoomMessageContent(): void {
@@ -566,6 +570,38 @@ export class StateManager {
566
570
  this.persistenceState.scheduleSave(this.buildStorageState());
567
571
  }
568
572
 
573
+ private migrateThemes(): void {
574
+ const human = this.humanState.get();
575
+ if (!human.settings) return;
576
+ if (human.settings.custom_themes !== undefined) return;
577
+ human.settings.custom_themes = [];
578
+ this.humanState.set(human);
579
+ }
580
+
581
+ private migrateFfaParentIds(): void {
582
+ const rooms = this.roomState.getAll(true);
583
+ let migratedCount = 0;
584
+
585
+ for (const room of rooms) {
586
+ if (room.mode !== RoomMode.FreeForAll) continue;
587
+ const rootMsg = room.messages.find(m => m.parent_id === null);
588
+ if (!rootMsg) continue;
589
+
590
+ for (const msg of room.messages) {
591
+ if (msg.role !== "human") continue;
592
+ if (msg.id === rootMsg.id) continue;
593
+ if (msg.parent_id === rootMsg.id) continue;
594
+ msg.parent_id = rootMsg.id;
595
+ migratedCount++;
596
+ }
597
+ }
598
+
599
+ if (migratedCount > 0) {
600
+ this.scheduleSave();
601
+ console.log(`[StateManager] Migrated ${migratedCount} FFA human messages to root parent_id`);
602
+ }
603
+ }
604
+
569
605
  getHuman(): HumanEntity {
570
606
  return this.humanState.get();
571
607
  }
@@ -575,6 +611,44 @@ export class StateManager {
575
611
  this.scheduleSave();
576
612
  }
577
613
 
614
+ human_theme_getActive(): string | undefined {
615
+ return this.getHuman().settings?.active_theme;
616
+ }
617
+
618
+ human_theme_setActive(id: string | undefined): void {
619
+ const human = this.getHuman();
620
+ human.settings ??= {};
621
+ human.settings.active_theme = id;
622
+ this.setHuman(human);
623
+ }
624
+
625
+ human_theme_getAll(): ThemeDefinition[] {
626
+ return this.getHuman().settings?.custom_themes ?? [];
627
+ }
628
+
629
+ human_theme_upsert(theme: ThemeDefinition): void {
630
+ const human = this.getHuman();
631
+ human.settings ??= {};
632
+ human.settings.custom_themes ??= [];
633
+ const idx = human.settings.custom_themes.findIndex(t => t.id === theme.id);
634
+ if (idx >= 0) {
635
+ human.settings.custom_themes[idx] = theme;
636
+ } else {
637
+ human.settings.custom_themes.push(theme);
638
+ }
639
+ this.setHuman(human);
640
+ }
641
+
642
+ human_theme_remove(id: string): boolean {
643
+ const human = this.getHuman();
644
+ const themes = human.settings?.custom_themes ?? [];
645
+ const idx = themes.findIndex(t => t.id === id);
646
+ if (idx < 0) return false;
647
+ themes.splice(idx, 1);
648
+ this.setHuman(human);
649
+ return true;
650
+ }
651
+
578
652
  human_fact_upsert(fact: Fact): void {
579
653
  this.humanState.fact_upsert(fact);
580
654
  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) {
@@ -15,6 +15,7 @@ export interface DataItemBase {
15
15
  learned_by?: string; // Persona ID that originally learned this item (stable UUID)
16
16
  last_changed_by?: string; // Persona ID that most recently updated this item (stable UUID)
17
17
  interested_personas?: string[]; // Persona IDs that have extracted/touched this item (accumulated)
18
+ sources?: string[]; // Namespaced source identifiers — where items were learned from. Format: "provider:id" (e.g., "opencode:ses_abc123", "cursor:composerId"). Grow-only union.
18
19
  persona_groups?: string[];
19
20
  embedding?: number[];
20
21
  rewrite_checked?: boolean; // True after rewrite scan finds no changes. Cleared automatically when extraction upserts a fresh item.
@@ -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 {
@@ -38,6 +38,7 @@ export enum LLMNextStep {
38
38
  HandleHeartbeatCheck = "handleHeartbeatCheck",
39
39
  HandleEiHeartbeat = "handleEiHeartbeat",
40
40
  HandleOneShot = "handleOneShot",
41
+ HandleOneShotJSON = "handleOneShotJSON",
41
42
  // Tool calling continuation (second LLM call after tool execution, may loop for more tool calls).
42
43
  // data.toolHistory: serialized LLMHistoryMessage[] (assistant + tool result messages)
43
44
  // data.toolCallCounts: serialized Map entries [[name, count], ...] carrying per-tool call counts
@@ -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 {
@@ -99,6 +102,7 @@ export interface Ei_Interface {
99
102
  onError?: (error: EiError) => void;
100
103
  onStateImported?: () => void;
101
104
  onOneShotReturned?: (guid: string, content: string) => void;
105
+ onOneShotJSONReturned?: (guid: string, parsed: unknown) => void;
102
106
  onContextBoundaryChanged?: (personaId: string) => void;
103
107
  onSaveAndExitStart?: () => void;
104
108
  onSaveAndExitFinish?: () => void;
@@ -38,6 +38,8 @@ export interface RoomEntity {
38
38
  last_updated: string;
39
39
  last_activity: string;
40
40
  capture_used?: boolean;
41
+ context_window_hours?: number; // FFA only; falls back to human.settings.default_context_window_hours
42
+ context_boundary?: string; // FFA only; ISO timestamp; same semantics as persona context_boundary
41
43
  messages: RoomMessage[];
42
44
  }
43
45
 
@@ -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
+ }
@@ -1,10 +1,9 @@
1
1
  import type { StateManager } from "../../core/state-manager.js";
2
- import type { Ei_Interface, Topic, Message, ContextStatus, PersonaEntity, PersonaTrait } from "../../core/types.js";
2
+ import type { Ei_Interface, Message, ContextStatus, PersonaEntity, PersonaTrait } from "../../core/types.js";
3
3
  import { DEFAULT_SEED_TRAITS } from "../../core/constants/seed-traits.js";
4
4
  import type { IClaudeCodeReader, ClaudeCodeSession, ClaudeCodeMessage } from "./types.js";
5
5
  import {
6
6
  CLAUDE_CODE_PERSONA_NAME,
7
- CLAUDE_CODE_TOPIC_GROUPS,
8
7
  MIN_SESSION_AGE_MS,
9
8
  } from "./types.js";
10
9
  import { ClaudeCodeReader } from "./reader.js";
@@ -20,8 +19,6 @@ import { isProcessRunning } from "../process-check.js";
20
19
 
21
20
  export interface ClaudeCodeImportResult {
22
21
  sessionsProcessed: number;
23
- topicsCreated: number;
24
- topicsUpdated: number;
25
22
  messagesImported: number;
26
23
  personaCreated: boolean;
27
24
  extractionScansQueued: number;
@@ -109,47 +106,6 @@ function ensureClaudeCodePersona(
109
106
  return persona;
110
107
  }
111
108
 
112
- // =============================================================================
113
- // Topic Management
114
- // =============================================================================
115
-
116
- function ensureSessionTopic(
117
- session: ClaudeCodeSession,
118
- stateManager: StateManager
119
- ): "created" | "updated" | "unchanged" {
120
- const human = stateManager.getHuman();
121
- const existingTopic = human.topics.find((t) => t.id === session.id);
122
-
123
- if (existingTopic) {
124
- if (existingTopic.name !== session.title) {
125
- const updatedTopic: Topic = {
126
- ...existingTopic,
127
- name: session.title,
128
- last_updated: new Date().toISOString(),
129
- };
130
- stateManager.human_topic_upsert(updatedTopic);
131
- return "updated";
132
- }
133
- return "unchanged";
134
- }
135
-
136
- const newTopic: Topic = {
137
- id: session.id,
138
- name: session.title,
139
- description: `Claude Code session in ${session.cwd}`,
140
- sentiment: 0,
141
- exposure_current: 0.5,
142
- exposure_desired: 0.3,
143
- persona_groups: CLAUDE_CODE_TOPIC_GROUPS,
144
- learned_by: stateManager.persona_getByName(CLAUDE_CODE_PERSONA_NAME)?.id ?? undefined,
145
- last_updated: new Date().toISOString(),
146
- learned_on: new Date().toISOString(),
147
- };
148
-
149
- stateManager.human_topic_upsert(newTopic);
150
- return "created";
151
- }
152
-
153
109
  // =============================================================================
154
110
  // State Helpers
155
111
  // =============================================================================
@@ -205,26 +161,15 @@ export async function importClaudeCodeSessions(
205
161
 
206
162
  const result: ClaudeCodeImportResult = {
207
163
  sessionsProcessed: 0,
208
- topicsCreated: 0,
209
- topicsUpdated: 0,
210
164
  messagesImported: 0,
211
165
  personaCreated: false,
212
166
  extractionScansQueued: 0,
213
167
  };
214
168
 
215
- // ─── Step 1: Ensure topics exist for ALL sessions ─────────────────────
169
+ // ─── Step 1: Get all sessions ─────────────────────────────────────────
216
170
  const allSessions = await reader.getSessions();
217
171
 
218
- for (const session of allSessions) {
219
- const topicResult = ensureSessionTopic(session, stateManager);
220
- if (topicResult === "created") result.topicsCreated++;
221
- else if (topicResult === "updated") result.topicsUpdated++;
222
- }
223
-
224
172
  if (signal?.aborted) return result;
225
- if (result.topicsCreated > 0 || result.topicsUpdated > 0) {
226
- eiInterface?.onHumanUpdated?.();
227
- }
228
173
 
229
174
  // ─── Step 2: Find next unprocessed session ────────────────────────────
230
175
  const human = stateManager.getHuman();
@@ -323,6 +268,7 @@ export async function importClaudeCodeSessions(
323
268
  personaDisplayName: persona.display_name,
324
269
  messages_context: contextMsgs,
325
270
  messages_analyze: toAnalyze,
271
+ sources: [`claudecode:${targetSession.id}`],
326
272
  };
327
273
 
328
274
  const ccSettings = stateManager.getHuman().settings?.claudeCode;
@@ -1,10 +1,9 @@
1
1
  import type { StateManager } from "../../core/state-manager.js";
2
- import type { Ei_Interface, Topic, Message, ContextStatus, PersonaEntity, PersonaTrait } from "../../core/types.js";
2
+ import type { Ei_Interface, Message, ContextStatus, PersonaEntity, PersonaTrait } from "../../core/types.js";
3
3
  import { DEFAULT_SEED_TRAITS } from "../../core/constants/seed-traits.js";
4
4
  import type { ICursorReader, CursorSession, CursorMessage } from "./types.js";
5
5
  import {
6
6
  CURSOR_PERSONA_NAME,
7
- CURSOR_TOPIC_GROUPS,
8
7
  MIN_SESSION_AGE_MS,
9
8
  } from "./types.js";
10
9
  import { CursorReader } from "./reader.js";
@@ -16,8 +15,6 @@ import {
16
15
 
17
16
  export interface CursorImportResult {
18
17
  sessionsProcessed: number;
19
- topicsCreated: number;
20
- topicsUpdated: number;
21
18
  messagesImported: number;
22
19
  personaCreated: boolean;
23
20
  extractionScansQueued: number;
@@ -97,43 +94,6 @@ function ensureCursorPersona(
97
94
  return persona;
98
95
  }
99
96
 
100
- function ensureSessionTopic(
101
- session: CursorSession,
102
- stateManager: StateManager
103
- ): "created" | "updated" | "unchanged" {
104
- const human = stateManager.getHuman();
105
- const existingTopic = human.topics.find((t) => t.id === session.id);
106
-
107
- if (existingTopic) {
108
- if (existingTopic.name !== session.name) {
109
- const updatedTopic: Topic = {
110
- ...existingTopic,
111
- name: session.name,
112
- last_updated: new Date().toISOString(),
113
- };
114
- stateManager.human_topic_upsert(updatedTopic);
115
- return "updated";
116
- }
117
- return "unchanged";
118
- }
119
-
120
- const newTopic: Topic = {
121
- id: session.id,
122
- name: session.name,
123
- description: `Cursor session in ${session.workspacePath}`,
124
- sentiment: 0,
125
- exposure_current: 0.5,
126
- exposure_desired: 0.3,
127
- persona_groups: CURSOR_TOPIC_GROUPS,
128
- learned_by: stateManager.persona_getByName(CURSOR_PERSONA_NAME)?.id ?? undefined,
129
- last_updated: new Date().toISOString(),
130
- learned_on: new Date().toISOString(),
131
- };
132
-
133
- stateManager.human_topic_upsert(newTopic);
134
- return "created";
135
- }
136
-
137
97
  function updateProcessedState(
138
98
  stateManager: StateManager,
139
99
  session: CursorSession
@@ -173,8 +133,6 @@ export async function importCursorSessions(
173
133
 
174
134
  const result: CursorImportResult = {
175
135
  sessionsProcessed: 0,
176
- topicsCreated: 0,
177
- topicsUpdated: 0,
178
136
  messagesImported: 0,
179
137
  personaCreated: false,
180
138
  extractionScansQueued: 0,
@@ -182,16 +140,7 @@ export async function importCursorSessions(
182
140
 
183
141
  const allSessions = await reader.getSessions();
184
142
 
185
- for (const session of allSessions) {
186
- const topicResult = ensureSessionTopic(session, stateManager);
187
- if (topicResult === "created") result.topicsCreated++;
188
- else if (topicResult === "updated") result.topicsUpdated++;
189
- }
190
-
191
143
  if (signal?.aborted) return result;
192
- if (result.topicsCreated > 0 || result.topicsUpdated > 0) {
193
- eiInterface?.onHumanUpdated?.();
194
- }
195
144
 
196
145
  const human = stateManager.getHuman();
197
146
  const processedSessions = human.settings?.cursor?.processed_sessions ?? {};
@@ -278,6 +227,7 @@ export async function importCursorSessions(
278
227
  personaDisplayName: persona.display_name,
279
228
  messages_context: contextMsgs,
280
229
  messages_analyze: toAnalyze,
230
+ sources: [`cursor:${targetSession.id}`],
281
231
  };
282
232
 
283
233
  queueAllScans(context, stateManager, { external_filter: "only" });