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 +1 -1
- package/src/cli/README.md +16 -7
- package/src/cli/commands/people.ts +1 -0
- package/src/cli/mcp.ts +1 -1
- package/src/cli/retrieval.ts +3 -1
- package/src/core/handlers/human-matching.ts +10 -4
- package/src/core/orchestrators/human-extraction.ts +19 -7
- package/src/core/persona-manager.ts +3 -0
- package/src/core/state-manager.ts +48 -0
- package/src/core/tools/builtin/read-memory.ts +1 -1
- package/src/core/types/entities.ts +13 -0
- package/src/core/types/integrations.ts +3 -0
- package/src/core/utils/identifier-utils.ts +24 -0
- package/src/core/utils/theme-codec.ts +78 -0
package/package.json
CHANGED
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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,
|
|
91
|
-
"how did we do X
|
|
92
|
-
|
|
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 /
|
|
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
|
|
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
|
package/src/cli/retrieval.ts
CHANGED
|
@@ -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 ? "
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
@@ -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
|
+
}
|