ei-tui 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +170 -0
  3. package/package.json +63 -0
  4. package/src/README.md +96 -0
  5. package/src/cli/README.md +47 -0
  6. package/src/cli/commands/facts.ts +25 -0
  7. package/src/cli/commands/people.ts +25 -0
  8. package/src/cli/commands/quotes.ts +19 -0
  9. package/src/cli/commands/topics.ts +25 -0
  10. package/src/cli/commands/traits.ts +25 -0
  11. package/src/cli/retrieval.ts +269 -0
  12. package/src/cli.ts +176 -0
  13. package/src/core/AGENTS.md +104 -0
  14. package/src/core/embedding-service.ts +241 -0
  15. package/src/core/handlers/index.ts +1057 -0
  16. package/src/core/index.ts +4 -0
  17. package/src/core/llm-client.ts +265 -0
  18. package/src/core/model-context-windows.ts +49 -0
  19. package/src/core/orchestrators/ceremony.ts +500 -0
  20. package/src/core/orchestrators/extraction-chunker.ts +138 -0
  21. package/src/core/orchestrators/human-extraction.ts +457 -0
  22. package/src/core/orchestrators/index.ts +28 -0
  23. package/src/core/orchestrators/persona-generation.ts +76 -0
  24. package/src/core/orchestrators/persona-topics.ts +117 -0
  25. package/src/core/personas/index.ts +5 -0
  26. package/src/core/personas/opencode-agent.ts +81 -0
  27. package/src/core/processor.ts +1413 -0
  28. package/src/core/queue-processor.ts +197 -0
  29. package/src/core/state/checkpoints.ts +68 -0
  30. package/src/core/state/human.ts +176 -0
  31. package/src/core/state/index.ts +5 -0
  32. package/src/core/state/personas.ts +217 -0
  33. package/src/core/state/queue.ts +144 -0
  34. package/src/core/state-manager.ts +347 -0
  35. package/src/core/types.ts +421 -0
  36. package/src/core/utils/decay.ts +33 -0
  37. package/src/index.ts +1 -0
  38. package/src/integrations/opencode/importer.ts +896 -0
  39. package/src/integrations/opencode/index.ts +16 -0
  40. package/src/integrations/opencode/json-reader.ts +304 -0
  41. package/src/integrations/opencode/reader-factory.ts +35 -0
  42. package/src/integrations/opencode/sqlite-reader.ts +189 -0
  43. package/src/integrations/opencode/types.ts +244 -0
  44. package/src/prompts/AGENTS.md +62 -0
  45. package/src/prompts/ceremony/description-check.ts +47 -0
  46. package/src/prompts/ceremony/expire.ts +30 -0
  47. package/src/prompts/ceremony/explore.ts +60 -0
  48. package/src/prompts/ceremony/index.ts +11 -0
  49. package/src/prompts/ceremony/types.ts +42 -0
  50. package/src/prompts/generation/descriptions.ts +91 -0
  51. package/src/prompts/generation/index.ts +15 -0
  52. package/src/prompts/generation/persona.ts +155 -0
  53. package/src/prompts/generation/seeds.ts +31 -0
  54. package/src/prompts/generation/types.ts +47 -0
  55. package/src/prompts/heartbeat/check.ts +179 -0
  56. package/src/prompts/heartbeat/ei.ts +208 -0
  57. package/src/prompts/heartbeat/index.ts +15 -0
  58. package/src/prompts/heartbeat/types.ts +70 -0
  59. package/src/prompts/human/fact-scan.ts +152 -0
  60. package/src/prompts/human/index.ts +32 -0
  61. package/src/prompts/human/item-match.ts +74 -0
  62. package/src/prompts/human/item-update.ts +322 -0
  63. package/src/prompts/human/person-scan.ts +115 -0
  64. package/src/prompts/human/topic-scan.ts +135 -0
  65. package/src/prompts/human/trait-scan.ts +115 -0
  66. package/src/prompts/human/types.ts +127 -0
  67. package/src/prompts/index.ts +90 -0
  68. package/src/prompts/message-utils.ts +39 -0
  69. package/src/prompts/persona/index.ts +16 -0
  70. package/src/prompts/persona/topics-match.ts +69 -0
  71. package/src/prompts/persona/topics-scan.ts +98 -0
  72. package/src/prompts/persona/topics-update.ts +157 -0
  73. package/src/prompts/persona/traits.ts +117 -0
  74. package/src/prompts/persona/types.ts +74 -0
  75. package/src/prompts/response/index.ts +147 -0
  76. package/src/prompts/response/sections.ts +355 -0
  77. package/src/prompts/response/types.ts +38 -0
  78. package/src/prompts/validation/ei.ts +93 -0
  79. package/src/prompts/validation/index.ts +6 -0
  80. package/src/prompts/validation/types.ts +22 -0
  81. package/src/storage/crypto.ts +96 -0
  82. package/src/storage/index.ts +5 -0
  83. package/src/storage/interface.ts +9 -0
  84. package/src/storage/local.ts +79 -0
  85. package/src/storage/merge.ts +69 -0
  86. package/src/storage/remote.ts +145 -0
  87. package/src/templates/welcome.ts +91 -0
  88. package/tui/README.md +62 -0
  89. package/tui/bunfig.toml +4 -0
  90. package/tui/src/app.tsx +55 -0
  91. package/tui/src/commands/archive.tsx +93 -0
  92. package/tui/src/commands/context.tsx +124 -0
  93. package/tui/src/commands/delete.tsx +71 -0
  94. package/tui/src/commands/details.tsx +41 -0
  95. package/tui/src/commands/editor.tsx +46 -0
  96. package/tui/src/commands/help.tsx +12 -0
  97. package/tui/src/commands/me.tsx +145 -0
  98. package/tui/src/commands/model.ts +47 -0
  99. package/tui/src/commands/new.ts +31 -0
  100. package/tui/src/commands/pause.ts +46 -0
  101. package/tui/src/commands/persona.tsx +58 -0
  102. package/tui/src/commands/provider.tsx +124 -0
  103. package/tui/src/commands/quit.ts +22 -0
  104. package/tui/src/commands/quotes.tsx +172 -0
  105. package/tui/src/commands/registry.test.ts +137 -0
  106. package/tui/src/commands/registry.ts +130 -0
  107. package/tui/src/commands/resume.ts +39 -0
  108. package/tui/src/commands/setsync.tsx +43 -0
  109. package/tui/src/commands/settings.tsx +83 -0
  110. package/tui/src/components/ConfirmOverlay.tsx +51 -0
  111. package/tui/src/components/ConflictOverlay.tsx +78 -0
  112. package/tui/src/components/HelpOverlay.tsx +69 -0
  113. package/tui/src/components/Layout.tsx +24 -0
  114. package/tui/src/components/MessageList.tsx +174 -0
  115. package/tui/src/components/PersonaListOverlay.tsx +186 -0
  116. package/tui/src/components/PromptInput.tsx +145 -0
  117. package/tui/src/components/ProviderListOverlay.tsx +208 -0
  118. package/tui/src/components/QuotesOverlay.tsx +157 -0
  119. package/tui/src/components/Sidebar.tsx +95 -0
  120. package/tui/src/components/StatusBar.tsx +77 -0
  121. package/tui/src/components/WelcomeOverlay.tsx +73 -0
  122. package/tui/src/context/ei.tsx +623 -0
  123. package/tui/src/context/keyboard.tsx +164 -0
  124. package/tui/src/context/overlay.tsx +53 -0
  125. package/tui/src/index.tsx +8 -0
  126. package/tui/src/storage/file.ts +185 -0
  127. package/tui/src/util/duration.ts +32 -0
  128. package/tui/src/util/editor.ts +188 -0
  129. package/tui/src/util/logger.ts +109 -0
  130. package/tui/src/util/persona-editor.tsx +181 -0
  131. package/tui/src/util/provider-editor.tsx +168 -0
  132. package/tui/src/util/syntax.ts +35 -0
  133. package/tui/src/util/yaml-serializers.ts +755 -0
@@ -0,0 +1,73 @@
1
+ import { useKeyboard } from "@opentui/solid";
2
+
3
+ interface WelcomeOverlayProps {
4
+ onDismiss: () => void;
5
+ }
6
+
7
+ export function WelcomeOverlay(props: WelcomeOverlayProps) {
8
+ useKeyboard((event) => {
9
+ event.preventDefault();
10
+ props.onDismiss();
11
+ });
12
+
13
+ return (
14
+ <box
15
+ position="absolute"
16
+ width="100%"
17
+ height="100%"
18
+ left={0}
19
+ top={0}
20
+ backgroundColor="#000000"
21
+ alignItems="center"
22
+ justifyContent="center"
23
+ >
24
+ <box
25
+ width={70}
26
+ backgroundColor="#1a1a2e"
27
+ borderStyle="single"
28
+ borderColor="#586e75"
29
+ padding={2}
30
+ flexDirection="column"
31
+ >
32
+ <text fg="#eee8d5">
33
+ Welcome to Ei!
34
+ </text>
35
+ <text> </text>
36
+
37
+ <text fg="#dc322f">
38
+ No LLM provider detected.
39
+ </text>
40
+ <text> </text>
41
+
42
+ <text fg="#93a1a1">
43
+ To get started, you need a local LLM running or a provider configured.
44
+ </text>
45
+ <text> </text>
46
+
47
+ <text fg="#93a1a1">
48
+ Options:
49
+ </text>
50
+ <text fg="#93a1a1">
51
+ 1. Start a local LLM (LM Studio, Ollama) on port 1234
52
+ </text>
53
+ <text fg="#93a1a1">
54
+ 2. Run /provider new to configure a cloud provider
55
+ </text>
56
+ <text> </text>
57
+
58
+ <text fg="#657b83">
59
+ Once configured, restart Ei or run /provider new to add your provider.
60
+ </text>
61
+ <text> </text>
62
+
63
+ <text fg="#586e75">
64
+ Press any key to dismiss
65
+ </text>
66
+ <text> </text>
67
+ <text fg="#2a2a3e">
68
+ Ei - 永 (ei) - eternal
69
+ </text>
70
+ </box>
71
+ </box>
72
+ );
73
+ }
@@ -0,0 +1,623 @@
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ onMount,
5
+ onCleanup,
6
+ Match,
7
+ Switch,
8
+ createSignal,
9
+ type ParentComponent,
10
+ } from "solid-js";
11
+ import { createStore } from "solid-js/store";
12
+ import { Processor } from "../../../src/core/processor.js";
13
+ import { FileStorage } from "../storage/file.js";
14
+ import { remoteSync } from "../../../src/storage/remote.js";
15
+ import { logger, clearLog, interceptConsole } from "../util/logger.js";
16
+ import { ConflictOverlay } from "../components/ConflictOverlay.js";
17
+ import type {
18
+ Ei_Interface,
19
+ PersonaSummary,
20
+ PersonaEntity,
21
+ Message,
22
+ QueueStatus,
23
+ HumanEntity,
24
+ HumanSettings,
25
+ Fact,
26
+ Trait,
27
+ Topic,
28
+ Person,
29
+ Quote,
30
+ ProviderAccount,
31
+ ProviderType,
32
+ StateConflictData,
33
+ StateConflictResolution,
34
+ ContextStatus,
35
+ } from "../../../src/core/types.js";
36
+
37
+ interface EiStore {
38
+ ready: boolean;
39
+ personas: PersonaSummary[];
40
+ activePersonaId: string | null;
41
+ activeContextBoundary: string | undefined;
42
+ messages: Message[];
43
+ queueStatus: QueueStatus;
44
+ notification: { message: string; level: "error" | "warn" | "info" } | null;
45
+ }
46
+
47
+ export interface EiContextValue {
48
+ personas: () => PersonaSummary[];
49
+ activePersonaId: () => string | null;
50
+ activeContextBoundary: () => string | undefined;
51
+ messages: () => Message[];
52
+ queueStatus: () => QueueStatus;
53
+ notification: () => { message: string; level: "error" | "warn" | "info" } | null;
54
+ selectPersona: (personaId: string) => void;
55
+ sendMessage: (content: string) => Promise<void>;
56
+ refreshPersonas: () => Promise<void>;
57
+ refreshMessages: () => Promise<void>;
58
+ abortCurrentOperation: () => Promise<void>;
59
+ resumeQueue: () => Promise<void>;
60
+ stopProcessor: () => Promise<void>;
61
+ saveAndExit: () => Promise<{ success: boolean; error?: string }>;
62
+ showNotification: (message: string, level: "error" | "warn" | "info") => void;
63
+ createPersona: (input: { name: string }) => Promise<string>;
64
+ archivePersona: (personaId: string) => Promise<void>;
65
+ unarchivePersona: (personaId: string) => Promise<void>;
66
+ deletePersona: (personaId: string) => Promise<void>;
67
+ setContextBoundary: (personaId: string, timestamp: string | null) => Promise<void>;
68
+ updatePersona: (personaId: string, updates: Partial<PersonaEntity>) => Promise<void>;
69
+ getPersona: (personaId: string) => Promise<PersonaEntity | null>;
70
+ resolvePersonaName: (nameOrAlias: string) => Promise<string | null>;
71
+ getHuman: () => Promise<HumanEntity>;
72
+ updateHuman: (updates: Partial<HumanEntity>) => Promise<void>;
73
+ updateSettings: (updates: Partial<HumanSettings>) => Promise<void>;
74
+ upsertFact: (fact: Fact) => Promise<void>;
75
+ upsertTrait: (trait: Trait) => Promise<void>;
76
+ upsertTopic: (topic: Topic) => Promise<void>;
77
+ upsertPerson: (person: Person) => Promise<void>;
78
+ removeDataItem: (type: "fact" | "trait" | "topic" | "person", id: string) => Promise<void>;
79
+ syncStatus: () => { configured: boolean; envBased: boolean };
80
+ triggerSync: () => Promise<{ success: boolean; error?: string }>;
81
+ getGroupList: () => Promise<string[]>;
82
+ getQuotes: (filter?: { message_id?: string; speaker?: string }) => Promise<Quote[]>;
83
+ getQuotesForMessage: (messageId: string) => Promise<Quote[]>;
84
+ updateQuote: (id: string, updates: Partial<Quote>) => Promise<void>;
85
+ removeQuote: (id: string) => Promise<void>;
86
+ quotesVersion: () => number;
87
+ searchHumanData: (
88
+ query: string,
89
+ options?: { types?: Array<"fact" | "trait" | "topic" | "person" | "quote">; limit?: number }
90
+ ) => Promise<{
91
+ facts: Fact[];
92
+ traits: Trait[];
93
+ topics: Topic[];
94
+ people: Person[];
95
+ quotes: Quote[];
96
+ }>;
97
+ showWelcomeOverlay: () => boolean;
98
+ dismissWelcomeOverlay: () => void;
99
+ deleteMessages: (personaId: string, messageIds: string[]) => Promise<void>;
100
+ setMessageContextStatus: (personaId: string, messageId: string, status: ContextStatus) => Promise<void>;
101
+ }
102
+
103
+ const EiContext = createContext<EiContextValue>();
104
+
105
+ export const EiProvider: ParentComponent = (props) => {
106
+ const [store, setStore] = createStore<EiStore>({
107
+ ready: false,
108
+ personas: [],
109
+ activePersonaId: null,
110
+ activeContextBoundary: undefined,
111
+ messages: [],
112
+ queueStatus: { state: "idle", pending_count: 0 },
113
+ notification: null,
114
+ });
115
+
116
+ const [contextBoundarySignal, setContextBoundarySignal] = createSignal<string | undefined>(undefined);
117
+ const [quotesVersion, setQuotesVersion] = createSignal(0);
118
+ const [showWelcomeOverlay, setShowWelcomeOverlay] = createSignal(false);
119
+ const [conflictData, setConflictData] = createSignal<StateConflictData | null>(null);
120
+
121
+ let processor: Processor | null = null;
122
+ let notificationTimer: Timer | null = null;
123
+ let readTimer: Timer | null = null;
124
+ let dwelledPersona: string | null = null;
125
+ let syncConfiguredFromEnv = false;
126
+
127
+ const showNotification = (message: string, level: "error" | "warn" | "info") => {
128
+ if (notificationTimer) clearTimeout(notificationTimer);
129
+ setStore("notification", { message, level });
130
+ notificationTimer = setTimeout(() => {
131
+ setStore("notification", null);
132
+ notificationTimer = null;
133
+ }, 5000);
134
+ };
135
+
136
+ const refreshPersonas = async () => {
137
+ if (!processor) return;
138
+ const list = await processor.getPersonaList();
139
+ setStore("personas", list);
140
+ };
141
+
142
+ const refreshMessages = async () => {
143
+ if (!processor) return;
144
+ const currentId = store.activePersonaId;
145
+ if (!currentId) return;
146
+ const msgs = await processor.getMessages(currentId);
147
+ setStore("messages", [...msgs]);
148
+ };
149
+
150
+ const selectPersona = (personaId: string) => {
151
+ // Mark previous persona as read ONLY if we dwelled there 5+ seconds
152
+ const previousId = store.activePersonaId;
153
+ if (previousId && previousId === dwelledPersona && processor) {
154
+ void processor.markAllMessagesRead(previousId);
155
+ void refreshPersonas();
156
+ }
157
+
158
+ // Cancel any pending timer and reset dwell tracking
159
+ if (readTimer) {
160
+ clearTimeout(readTimer);
161
+ readTimer = null;
162
+ }
163
+ dwelledPersona = null;
164
+
165
+ // Set new persona
166
+ setStore("activePersonaId", personaId);
167
+ setStore("messages", []);
168
+ const persona = store.personas.find(p => p.id === personaId);
169
+ setStore("activeContextBoundary", persona?.context_boundary);
170
+ setContextBoundarySignal(persona?.context_boundary);
171
+ if (processor) {
172
+ processor.getMessages(personaId).then((msgs) => {
173
+ setStore("messages", [...msgs]);
174
+ });
175
+ }
176
+
177
+ // Start 5-second dwell timer
178
+ readTimer = setTimeout(async () => {
179
+ if (store.activePersonaId === personaId && processor) {
180
+ dwelledPersona = personaId; // Mark that we've dwelled
181
+ await processor.markAllMessagesRead(personaId);
182
+ await refreshPersonas();
183
+ }
184
+ readTimer = null;
185
+ }, 5000);
186
+ };
187
+
188
+ const sendMessage = async (content: string) => {
189
+ const currentId = store.activePersonaId;
190
+ if (!currentId || !processor) return;
191
+
192
+ // Mark all read immediately - user is engaged
193
+ await processor.markAllMessagesRead(currentId);
194
+ dwelledPersona = currentId;
195
+
196
+ await processor.sendMessage(currentId, content);
197
+ await refreshPersonas();
198
+ };
199
+
200
+ const abortCurrentOperation = async () => {
201
+ if (!processor) return;
202
+ logger.info("Aborting current LLM operation");
203
+ await processor.abortCurrentOperation();
204
+ };
205
+
206
+ const resumeQueue = async () => {
207
+ if (!processor) return;
208
+ logger.info("Resuming queue");
209
+ await processor.resumeQueue();
210
+ };
211
+
212
+ const stopProcessor = async () => {
213
+ if (processor) {
214
+ await processor.stop();
215
+ }
216
+ };
217
+
218
+ const createPersona = async (input: { name: string }): Promise<string> => {
219
+ if (!processor) return "";
220
+ return await processor.createPersona(input);
221
+ };
222
+
223
+ const archivePersona = async (personaId: string) => {
224
+ if (!processor) return;
225
+ await processor.archivePersona(personaId);
226
+ await refreshPersonas();
227
+ };
228
+
229
+ const unarchivePersona = async (personaId: string) => {
230
+ if (!processor) return;
231
+ await processor.unarchivePersona(personaId);
232
+ await refreshPersonas();
233
+ };
234
+
235
+ const deletePersona = async (personaId: string) => {
236
+ if (!processor) return;
237
+ await processor.deletePersona(personaId, false);
238
+ await refreshPersonas();
239
+ };
240
+
241
+ const setContextBoundary = async (personaId: string, timestamp: string | null) => {
242
+ if (!processor) return;
243
+ // Set signal BEFORE processor call - processor fires callback synchronously
244
+ // which triggers refreshMessages() that needs the NEW boundary value
245
+ const newValue = timestamp ?? undefined;
246
+ logger.debug(`setContextBoundary: ${personaId}, timestamp=${timestamp}, newValue=${newValue}`);
247
+ if (personaId === store.activePersonaId) {
248
+ logger.debug(`setContextBoundary: updating signal to ${newValue}`);
249
+ setContextBoundarySignal(newValue);
250
+ }
251
+ await processor.setContextBoundary(personaId, timestamp);
252
+ await refreshPersonas();
253
+ if (personaId === store.activePersonaId) {
254
+ await refreshMessages();
255
+ }
256
+ };
257
+
258
+ const updatePersona = async (personaId: string, updates: Partial<PersonaEntity>) => {
259
+ if (!processor) return;
260
+ await processor.updatePersona(personaId, updates);
261
+ await refreshPersonas();
262
+ };
263
+
264
+ const getPersona = async (personaId: string) => {
265
+ if (!processor) return null;
266
+ return processor.getPersona(personaId);
267
+ };
268
+
269
+ const resolvePersonaName = async (nameOrAlias: string) => {
270
+ if (!processor) return null;
271
+ return processor.resolvePersonaName(nameOrAlias);
272
+ };
273
+
274
+ const getHuman = async () => {
275
+ if (!processor) throw new Error("Processor not initialized");
276
+ return processor.getHuman();
277
+ };
278
+
279
+ const updateHuman = async (updates: Partial<HumanEntity>) => {
280
+ if (!processor) return;
281
+ await processor.updateHuman(updates);
282
+ };
283
+
284
+ const upsertFact = async (fact: Fact) => {
285
+ if (!processor) return;
286
+ await processor.upsertFact(fact);
287
+ };
288
+
289
+ const upsertTrait = async (trait: Trait) => {
290
+ if (!processor) return;
291
+ await processor.upsertTrait(trait);
292
+ };
293
+
294
+ const upsertTopic = async (topic: Topic) => {
295
+ if (!processor) return;
296
+ await processor.upsertTopic(topic);
297
+ };
298
+
299
+ const upsertPerson = async (person: Person) => {
300
+ if (!processor) return;
301
+ await processor.upsertPerson(person);
302
+ };
303
+
304
+ const removeDataItem = async (type: "fact" | "trait" | "topic" | "person", id: string) => {
305
+ if (!processor) return;
306
+ await processor.removeDataItem(type, id);
307
+ };
308
+
309
+ const saveAndExit = async (): Promise<{ success: boolean; error?: string }> => {
310
+ if (!processor) return { success: false, error: "Processor not initialized" };
311
+ return processor.saveAndExit();
312
+ };
313
+
314
+ const updateSettings = async (updates: Partial<HumanSettings>): Promise<void> => {
315
+ if (!processor) return;
316
+ const human = await processor.getHuman();
317
+ const newSettings = { ...human.settings, ...updates };
318
+ await processor.updateHuman({ settings: newSettings });
319
+ };
320
+
321
+ const syncStatus = (): { configured: boolean; envBased: boolean } => {
322
+ return {
323
+ configured: remoteSync.isConfigured(),
324
+ envBased: syncConfiguredFromEnv,
325
+ };
326
+ };
327
+
328
+ const triggerSync = async (): Promise<{ success: boolean; error?: string }> => {
329
+ if (!processor) return { success: false, error: "Processor not initialized" };
330
+ if (!remoteSync.isConfigured()) {
331
+ return { success: false, error: "Sync not configured" };
332
+ }
333
+ const human = await processor.getHuman();
334
+ const hasSyncCreds = !!human.settings?.sync?.username && !!human.settings?.sync?.passphrase;
335
+ if (!hasSyncCreds) {
336
+ return { success: false, error: "No sync credentials in settings" };
337
+ }
338
+ const state = await processor.getStorageState();
339
+ return remoteSync.sync(state);
340
+ };
341
+
342
+ const getGroupList = async (): Promise<string[]> => {
343
+ if (!processor) return [];
344
+ return processor.getGroupList();
345
+ };
346
+
347
+ const getQuotes = async (filter?: { message_id?: string; speaker?: string }): Promise<Quote[]> => {
348
+ if (!processor) return [];
349
+ const all = await processor.getQuotes(filter?.message_id ? { message_id: filter.message_id } : undefined);
350
+ if (filter?.speaker) {
351
+ return all.filter(q => q.speaker.toLowerCase() === filter.speaker!.toLowerCase());
352
+ }
353
+ return all;
354
+ };
355
+
356
+ const getQuotesForMessage = async (messageId: string): Promise<Quote[]> => {
357
+ if (!processor) return [];
358
+ return processor.getQuotesForMessage(messageId);
359
+ };
360
+
361
+ const updateQuote = async (id: string, updates: Partial<Quote>): Promise<void> => {
362
+ if (!processor) return;
363
+ await processor.updateQuote(id, updates);
364
+ };
365
+
366
+ const removeQuote = async (id: string): Promise<void> => {
367
+ if (!processor) return;
368
+ await processor.removeQuote(id);
369
+ };
370
+
371
+ const deleteMessages = async (personaId: string, messageIds: string[]): Promise<void> => {
372
+ if (!processor) return;
373
+ await processor.deleteMessages(personaId, messageIds);
374
+ };
375
+
376
+ const setMessageContextStatus = async (personaId: string, messageId: string, status: ContextStatus): Promise<void> => {
377
+ if (!processor) return;
378
+ await processor.setMessageContextStatus(personaId, messageId, status);
379
+ };
380
+
381
+ const searchHumanData = async (
382
+ query: string,
383
+ options?: { types?: Array<"fact" | "trait" | "topic" | "person" | "quote">; limit?: number }
384
+ ) => {
385
+ if (!processor) return { facts: [], traits: [], topics: [], people: [], quotes: [] };
386
+ return processor.searchHumanData(query, options);
387
+ };
388
+
389
+ // Post-start initialization: refresh UI state, select first persona, detect LLM
390
+ async function finishBootstrap() {
391
+ if (!processor) return;
392
+
393
+ // If env vars provided sync creds, ensure they're written to settings
394
+ // (needed for first-ever-use where bootstrapFirstRun was called)
395
+ const syncUsername = Bun.env.EI_SYNC_USERNAME;
396
+ const syncPassphrase = Bun.env.EI_SYNC_PASSPHRASE;
397
+ if (syncUsername && syncPassphrase) {
398
+ const human = await processor.getHuman();
399
+ if (!human.settings?.sync?.username || !human.settings?.sync?.passphrase) {
400
+ await processor.updateHuman({
401
+ settings: { ...human.settings, sync: { username: syncUsername, passphrase: syncPassphrase } }
402
+ });
403
+ logger.debug("Sync credentials written to settings");
404
+ }
405
+ }
406
+ await refreshPersonas();
407
+ logger.debug(`refreshPersonas done, count: ${store.personas.length}`);
408
+ const status = await processor.getQueueStatus();
409
+ logger.debug("Initial getQueueStatus:", status);
410
+ setStore("queueStatus", status);
411
+ logger.debug("Initial queueStatus set in store:", store.queueStatus);
412
+ const list = store.personas;
413
+ if (list.length > 0 && !store.activePersonaId && list[0].id) {
414
+ selectPersona(list[0].id);
415
+ }
416
+ // LLM detection: run async after processor starts, don't block ready state
417
+ void (async () => {
418
+ try {
419
+ const human = await processor!.getHuman();
420
+ const hasAccounts = human.settings?.accounts && human.settings.accounts.length > 0;
421
+ if (!hasAccounts) {
422
+ logger.info("No LLM accounts configured, checking for local LLM...");
423
+ try {
424
+ const response = await fetch("http://127.0.0.1:1234/v1/models", {
425
+ method: "GET",
426
+ signal: AbortSignal.timeout(3000),
427
+ });
428
+ if (response.ok) {
429
+ logger.info("Local LLM detected, auto-configuring...");
430
+ const localAccount: ProviderAccount = {
431
+ id: crypto.randomUUID(),
432
+ name: "Local LLM",
433
+ type: "llm" as ProviderType,
434
+ url: "http://127.0.0.1:1234/v1",
435
+ enabled: true,
436
+ created_at: new Date().toISOString(),
437
+ };
438
+ const currentHuman = await processor!.getHuman();
439
+ await processor!.updateHuman({
440
+ settings: {
441
+ ...currentHuman.settings,
442
+ accounts: [localAccount],
443
+ default_model: "Local LLM",
444
+ },
445
+ });
446
+ showNotification("Local LLM detected and configured!", "info");
447
+ logger.info("Local LLM auto-configured successfully");
448
+ } else {
449
+ logger.info("Local LLM check failed, showing welcome overlay");
450
+ setShowWelcomeOverlay(true);
451
+ }
452
+ } catch {
453
+ logger.info("No local LLM found, showing welcome overlay");
454
+ setShowWelcomeOverlay(true);
455
+ }
456
+ }
457
+ } catch (err: any) {
458
+ logger.warn(`LLM detection failed: ${err?.message || err}`);
459
+ }
460
+ })();
461
+ setStore("ready", true);
462
+ }
463
+
464
+ const resolveStateConflict = async (resolution: StateConflictResolution): Promise<void> => {
465
+ if (!processor) return;
466
+ logger.info(`Resolving state conflict: ${resolution}`);
467
+ await processor.resolveStateConflict(resolution);
468
+ setConflictData(null);
469
+ await finishBootstrap();
470
+ };
471
+ async function bootstrap() {
472
+ clearLog();
473
+ interceptConsole();
474
+ logger.info("Ei TUI bootstrap starting");
475
+ try {
476
+ const storage = new FileStorage(Bun.env.EI_DATA_PATH);
477
+ // Pre-configure remoteSync from env vars BEFORE processor.start()
478
+ // so the processor's sync decision tree can detect remote state
479
+ const syncUsername = Bun.env.EI_SYNC_USERNAME;
480
+ const syncPassphrase = Bun.env.EI_SYNC_PASSPHRASE;
481
+ if (syncUsername && syncPassphrase) {
482
+ logger.info("Sync credentials found in env, pre-configuring remoteSync");
483
+ await remoteSync.configure({ username: syncUsername, passphrase: syncPassphrase });
484
+ syncConfiguredFromEnv = true;
485
+ }
486
+ const eiInterface: Ei_Interface = {
487
+ onPersonaAdded: () => void refreshPersonas(),
488
+ onPersonaRemoved: () => void refreshPersonas(),
489
+ onPersonaUpdated: () => void refreshPersonas(),
490
+ onMessageAdded: (personaId) => {
491
+ void refreshPersonas();
492
+ if (personaId === store.activePersonaId) {
493
+ void refreshMessages();
494
+ }
495
+ },
496
+ onQueueStateChanged: (state) => {
497
+ logger.debug(`onQueueStateChanged called with state: ${state}`);
498
+ if (processor) {
499
+ processor.getQueueStatus().then((status) => {
500
+ setStore("queueStatus", { state: status.state, pending_count: status.pending_count });
501
+ logger.debug(`store.queueStatus after setStore:`, store.queueStatus);
502
+ });
503
+ } else {
504
+ setStore("queueStatus", { state, pending_count: 0 });
505
+ }
506
+ },
507
+ onContextBoundaryChanged: (personaId) => {
508
+ logger.debug(`onContextBoundaryChanged: ${personaId}`);
509
+ void refreshPersonas();
510
+ },
511
+ onQuoteAdded: () => setQuotesVersion(v => v + 1),
512
+ onQuoteUpdated: () => setQuotesVersion(v => v + 1),
513
+ onQuoteRemoved: () => setQuotesVersion(v => v + 1),
514
+ onError: (error) => {
515
+ logger.error(`${error.code}: ${error.message}`);
516
+ showNotification(`${error.code}: ${error.message}`, "error");
517
+ },
518
+ onStateConflict: (data) => {
519
+ logger.info("State conflict detected, waiting for user resolution");
520
+ setConflictData(data);
521
+ },
522
+ };
523
+ processor = new Processor(eiInterface);
524
+ logger.debug("Processor created, calling start()");
525
+ await processor.start(storage);
526
+ logger.debug("Processor started");
527
+ // If start() detected a conflict, it returned without starting the loop.
528
+ // Don't set ready — wait for resolveStateConflict() to be called.
529
+ if (conflictData()) {
530
+ logger.info("Conflict pending, waiting for user resolution before finishing bootstrap");
531
+ return;
532
+ }
533
+
534
+ await finishBootstrap();
535
+ } catch (err: any) {
536
+ logger.error(`bootstrap() failed: ${err?.message || err}`);
537
+ }
538
+ }
539
+
540
+ onMount(() => {
541
+ void bootstrap();
542
+ });
543
+
544
+ onCleanup(() => {
545
+ if (readTimer) clearTimeout(readTimer);
546
+ processor?.stop();
547
+ });
548
+
549
+ const value: EiContextValue = {
550
+ personas: () => store.personas,
551
+ activePersonaId: () => store.activePersonaId,
552
+ activeContextBoundary: contextBoundarySignal,
553
+ messages: () => store.messages,
554
+ queueStatus: () => store.queueStatus,
555
+ notification: () => store.notification,
556
+ selectPersona,
557
+ sendMessage,
558
+ refreshPersonas,
559
+ refreshMessages,
560
+ abortCurrentOperation,
561
+ resumeQueue,
562
+ stopProcessor,
563
+ saveAndExit,
564
+ showNotification,
565
+ createPersona,
566
+ archivePersona,
567
+ unarchivePersona,
568
+ deletePersona,
569
+ setContextBoundary,
570
+ updatePersona,
571
+ getPersona,
572
+ resolvePersonaName,
573
+ getHuman,
574
+ updateHuman,
575
+ updateSettings,
576
+ upsertFact,
577
+ upsertTrait,
578
+ upsertTopic,
579
+ upsertPerson,
580
+ removeDataItem,
581
+ syncStatus,
582
+ triggerSync,
583
+ getGroupList,
584
+ getQuotes,
585
+ getQuotesForMessage,
586
+ updateQuote,
587
+ removeQuote,
588
+ quotesVersion,
589
+ searchHumanData,
590
+ showWelcomeOverlay,
591
+ dismissWelcomeOverlay: () => setShowWelcomeOverlay(false),
592
+ deleteMessages,
593
+ setMessageContextStatus,
594
+ };
595
+
596
+ return (
597
+ <Switch>
598
+ <Match when={conflictData()}>
599
+ <ConflictOverlay
600
+ localTimestamp={conflictData()!.localTimestamp}
601
+ remoteTimestamp={conflictData()!.remoteTimestamp}
602
+ onResolve={(resolution) => void resolveStateConflict(resolution)}
603
+ />
604
+ </Match>
605
+ <Match when={store.ready}>
606
+ <EiContext.Provider value={value}>{props.children}</EiContext.Provider>
607
+ </Match>
608
+ <Match when={!store.ready}>
609
+ <box width="100%" height="100%" justifyContent="center" alignItems="center">
610
+ <text>Loading Ei...</text>
611
+ </box>
612
+ </Match>
613
+ </Switch>
614
+ );
615
+ };
616
+
617
+ export const useEi = () => {
618
+ const ctx = useContext(EiContext);
619
+ if (!ctx) {
620
+ throw new Error("useEi must be used within EiProvider");
621
+ }
622
+ return ctx;
623
+ };