ei-tui 0.9.3 → 1.0.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 (94) hide show
  1. package/README.md +22 -3
  2. package/package.json +8 -1
  3. package/src/README.md +10 -26
  4. package/src/core/context-utils.ts +2 -2
  5. package/src/core/handlers/document-segmentation.ts +113 -0
  6. package/src/core/handlers/heartbeat.ts +9 -1
  7. package/src/core/handlers/human-extraction.ts +4 -1
  8. package/src/core/handlers/human-matching.ts +5 -53
  9. package/src/core/handlers/index.ts +3 -51
  10. package/src/core/handlers/persona-generation.ts +1 -28
  11. package/src/core/handlers/rewrite.ts +13 -9
  12. package/src/core/handlers/utils.ts +2 -9
  13. package/src/core/heartbeat-manager.ts +5 -5
  14. package/src/core/llm-client.ts +11 -1
  15. package/src/core/message-manager.ts +26 -23
  16. package/src/core/orchestrators/ceremony.ts +87 -49
  17. package/src/core/orchestrators/extraction-chunker.ts +3 -3
  18. package/src/core/orchestrators/human-extraction.ts +22 -18
  19. package/src/core/orchestrators/index.ts +0 -1
  20. package/src/core/orchestrators/persona-topics.ts +1 -1
  21. package/src/core/orchestrators/room-extraction.ts +5 -5
  22. package/src/core/persona-manager.ts +4 -0
  23. package/src/core/processor.ts +98 -22
  24. package/src/core/prompt-context-builder.ts +7 -6
  25. package/src/core/queue-manager.ts +35 -0
  26. package/src/core/state/personas.ts +1 -17
  27. package/src/core/state/queue.ts +9 -1
  28. package/src/core/state-manager.ts +4 -66
  29. package/src/core/types/entities.ts +17 -3
  30. package/src/core/types/enums.ts +1 -2
  31. package/src/core/types/integrations.ts +2 -0
  32. package/src/core/types/llm.ts +9 -0
  33. package/src/core/types/rooms.ts +1 -1
  34. package/src/integrations/claude-code/importer.ts +1 -1
  35. package/src/integrations/cursor/importer.ts +1 -1
  36. package/src/integrations/document/chunker.ts +88 -0
  37. package/src/integrations/document/importer.ts +82 -0
  38. package/src/integrations/document/index.ts +2 -0
  39. package/src/integrations/document/invoice.ts +63 -0
  40. package/src/integrations/document/types.ts +16 -0
  41. package/src/integrations/document/unsource.ts +164 -0
  42. package/src/integrations/opencode/importer.ts +1 -1
  43. package/src/integrations/persona-history/importer.ts +197 -0
  44. package/src/integrations/persona-history/index.ts +3 -0
  45. package/src/integrations/persona-history/types.ts +7 -0
  46. package/src/prompts/ceremony/dedup.ts +7 -3
  47. package/src/prompts/ceremony/index.ts +2 -11
  48. package/src/prompts/ceremony/people-rewrite.ts +190 -0
  49. package/src/prompts/ceremony/{rewrite.ts → topic-rewrite.ts} +103 -78
  50. package/src/prompts/ceremony/types.ts +1 -42
  51. package/src/prompts/generation/index.ts +0 -3
  52. package/src/prompts/generation/types.ts +0 -15
  53. package/src/prompts/heartbeat/check.ts +18 -6
  54. package/src/prompts/heartbeat/types.ts +2 -1
  55. package/src/prompts/human/index.ts +0 -2
  56. package/src/prompts/human/person-scan.ts +13 -4
  57. package/src/prompts/human/topic-scan.ts +16 -2
  58. package/src/prompts/human/topic-update.ts +36 -4
  59. package/src/prompts/human/types.ts +1 -16
  60. package/src/prompts/index.ts +0 -19
  61. package/src/prompts/reflection/index.ts +35 -5
  62. package/src/prompts/reflection/types.ts +1 -1
  63. package/src/prompts/response/index.ts +5 -0
  64. package/src/prompts/response/sections.ts +26 -0
  65. package/src/prompts/response/types.ts +3 -0
  66. package/src/storage/indexed.ts +4 -0
  67. package/src/storage/interface.ts +1 -0
  68. package/src/storage/local.ts +4 -0
  69. package/src/templates/emmett.ts +49 -0
  70. package/tui/README.md +22 -0
  71. package/tui/src/app.tsx +9 -6
  72. package/tui/src/commands/delete.tsx +7 -1
  73. package/tui/src/commands/import.tsx +30 -0
  74. package/tui/src/commands/registry.test.ts +10 -5
  75. package/tui/src/commands/unsource.tsx +115 -0
  76. package/tui/src/components/PromptInput.tsx +4 -0
  77. package/tui/src/components/WelcomeOverlay.tsx +58 -32
  78. package/tui/src/context/ei.tsx +80 -60
  79. package/tui/src/globals.d.ts +57 -0
  80. package/tui/src/index.tsx +14 -0
  81. package/tui/src/storage/file.ts +11 -5
  82. package/tui/src/util/e2e-flags.ts +4 -3
  83. package/tui/src/util/help-content.ts +20 -0
  84. package/tui/src/util/provider-detection.ts +251 -0
  85. package/tui/src/util/yaml-human.ts +7 -1
  86. package/tui/src/util/yaml-persona.ts +8 -4
  87. package/tui/src/util/yaml-settings.ts +3 -3
  88. package/src/core/orchestrators/person-migration.ts +0 -55
  89. package/src/prompts/ceremony/description-check.ts +0 -54
  90. package/src/prompts/ceremony/expire.ts +0 -37
  91. package/src/prompts/ceremony/explore.ts +0 -77
  92. package/src/prompts/ceremony/person-migration.ts +0 -77
  93. package/src/prompts/generation/descriptions.ts +0 -91
  94. package/src/prompts/human/fact-scan.ts +0 -150
@@ -0,0 +1,115 @@
1
+ import type { Command } from "./registry";
2
+ import { ConfirmOverlay } from "../components/ConfirmOverlay";
3
+ import { PersonaListOverlay } from "../components/PersonaListOverlay";
4
+
5
+ export const unsourceCommand: Command = {
6
+ name: "unsource",
7
+ aliases: [],
8
+ description: "Remove knowledge extracted from a specific document source",
9
+ usage: "/unsource <sourceTag>",
10
+
11
+ async execute(args, ctx) {
12
+ if (args.length === 0) {
13
+ const human = await ctx.ei.getHuman();
14
+ const docs = human.settings?.document?.processed_documents ?? {};
15
+ const sources = Object.keys(docs);
16
+
17
+ if (sources.length === 0) {
18
+ ctx.showNotification("No imported documents found. Use /import first.", "warn");
19
+ return;
20
+ }
21
+
22
+ const items = sources.map(f => ({
23
+ id: `import:document:${f}`,
24
+ display_name: `import:document:${f}`,
25
+ aliases: [] as string[],
26
+ is_paused: false,
27
+ is_archived: false,
28
+ unread_count: 0,
29
+ has_pending_update: false,
30
+ }));
31
+
32
+ ctx.showOverlay((hideOverlay) => (
33
+ <PersonaListOverlay
34
+ personas={items}
35
+ activePersonaId={null}
36
+ title="Select source to unsource"
37
+ onSelect={async (sourceTag) => {
38
+ hideOverlay();
39
+ await unsourceCommand.execute([sourceTag], ctx);
40
+ }}
41
+ onDismiss={hideOverlay}
42
+ />
43
+ ), ctx.renderer);
44
+ return;
45
+ }
46
+
47
+ const rawArg = args.join(" ").trim();
48
+
49
+ let sourceTag = rawArg;
50
+ if (!rawArg.includes(":")) {
51
+ const human = await ctx.ei.getHuman();
52
+ const docs = human.settings?.document?.processed_documents ?? {};
53
+ const allSources = Object.keys(docs).map(f => `import:document:${f}`);
54
+ const matches = allSources.filter(s => s.endsWith(rawArg) || s.includes(rawArg));
55
+ if (matches.length === 1) {
56
+ sourceTag = matches[0];
57
+ } else if (matches.length > 1) {
58
+ ctx.showNotification(`Ambiguous: "${rawArg}" matches multiple sources. Use /unsource with no args to pick.`, "warn");
59
+ return;
60
+ }
61
+ }
62
+
63
+ const preview = ctx.ei.getUnsourcePreview(sourceTag);
64
+
65
+ const totalDelete =
66
+ preview.toDelete.facts.length +
67
+ preview.toDelete.topics.length +
68
+ preview.toDelete.people.length;
69
+ const totalStrip =
70
+ preview.toStrip.facts.length +
71
+ preview.toStrip.topics.length +
72
+ preview.toStrip.people.length;
73
+
74
+ if (
75
+ totalDelete === 0 &&
76
+ preview.toDelete.quotes.length === 0 &&
77
+ totalStrip === 0
78
+ ) {
79
+ ctx.showNotification(`No knowledge found for source: ${sourceTag}`, "warn");
80
+ return;
81
+ }
82
+
83
+ const confirmed = await new Promise<boolean>((resolve) => {
84
+ const msg = [
85
+ `Unsource: ${sourceTag}`,
86
+ "",
87
+ `Delete: ${preview.toDelete.facts.length} facts, ${preview.toDelete.topics.length} topics, ${preview.toDelete.people.length} people, ${preview.toDelete.quotes.length} quotes`,
88
+ `Strip source: ${preview.toStrip.facts.length} facts, ${preview.toStrip.topics.length} topics, ${preview.toStrip.people.length} people`,
89
+ "",
90
+ "This cannot be undone. Proceed? [y/N]",
91
+ ].join("\n");
92
+
93
+ ctx.showOverlay((hideOverlay, _hideForEditor) => (
94
+ <ConfirmOverlay
95
+ message={msg}
96
+ onConfirm={() => { hideOverlay(); resolve(true); }}
97
+ onCancel={() => { hideOverlay(); resolve(false); }}
98
+ />
99
+ ), ctx.renderer);
100
+ });
101
+
102
+ if (!confirmed) {
103
+ ctx.showNotification("Cancelled", "info");
104
+ return;
105
+ }
106
+
107
+ const result = await ctx.ei.executeUnsource(preview);
108
+ const deletedTotal = result.deleted.facts + result.deleted.topics + result.deleted.people + result.deleted.quotes;
109
+ const strippedTotal = result.stripped.facts + result.stripped.topics + result.stripped.people;
110
+ ctx.showNotification(
111
+ `Unsourced ${sourceTag}: deleted ${deletedTotal} items, stripped source from ${strippedTotal} items`,
112
+ "info"
113
+ );
114
+ },
115
+ };
@@ -30,6 +30,8 @@ import { activateCommand } from "../commands/activate.js";
30
30
  import { reflectCommand } from "../commands/reflect.js";
31
31
  import { silenceCommand } from "../commands/silence.js";
32
32
  import { captureCommand } from "../commands/capture.js";
33
+ import { importCommand } from "../commands/import.js";
34
+ import { unsourceCommand } from "../commands/unsource.js";
33
35
  import { openCYPEditor } from "../util/cyp-editor.js";
34
36
  import { useOverlay } from "../context/overlay";
35
37
  import { CommandSuggest } from "./CommandSuggest";
@@ -86,6 +88,8 @@ export function PromptInput() {
86
88
  registerCommand(activateCommand);
87
89
  registerCommand(silenceCommand);
88
90
  registerCommand(captureCommand);
91
+ registerCommand(importCommand);
92
+ registerCommand(unsourceCommand);
89
93
  registerCommand(authCommand);
90
94
  registerCommand(pauseCommand);
91
95
  registerCommand(resumeCommand);
@@ -1,11 +1,16 @@
1
1
  import { useKeyboard } from "@opentui/solid";
2
- import { onMount, onCleanup } from "solid-js";
2
+ import { onMount, onCleanup, For } from "solid-js";
3
3
  import { useKeyboardNav } from "../context/keyboard.js";
4
+ import type { ProviderDetectionStatus } from "../util/provider-detection.js";
4
5
 
5
6
  interface WelcomeOverlayProps {
6
7
  onDismiss: () => void;
8
+ detectedProviders: ProviderDetectionStatus[];
9
+ defaultModel?: string;
7
10
  }
8
11
 
12
+ const COLUMNS = 3;
13
+
9
14
  export function WelcomeOverlay(props: WelcomeOverlayProps) {
10
15
  const { setOverlayActive } = useKeyboardNav();
11
16
  onMount(() => setOverlayActive(true));
@@ -16,6 +21,17 @@ export function WelcomeOverlay(props: WelcomeOverlayProps) {
16
21
  props.onDismiss();
17
22
  });
18
23
 
24
+ const hasAny = () => props.detectedProviders.some((p) => p.detected);
25
+
26
+ const rows = () => {
27
+ const items = props.detectedProviders;
28
+ const out: ProviderDetectionStatus[][] = [];
29
+ for (let i = 0; i < items.length; i += COLUMNS) {
30
+ out.push(items.slice(i, i + COLUMNS));
31
+ }
32
+ return out;
33
+ };
34
+
19
35
  return (
20
36
  <box
21
37
  position="absolute"
@@ -35,44 +51,54 @@ export function WelcomeOverlay(props: WelcomeOverlayProps) {
35
51
  padding={2}
36
52
  flexDirection="column"
37
53
  >
38
- <text fg="#eee8d5">
39
- Welcome to Ei!
40
- </text>
54
+ <text fg="#eee8d5">Welcome to Ei!</text>
41
55
  <text> </text>
42
56
 
43
- <text fg="#dc322f">
44
- No LLM provider detected.
45
- </text>
46
- <text> </text>
57
+ <box visible={hasAny()} flexDirection="column">
58
+ <text fg="#93a1a1">Detected providers:</text>
59
+ <text> </text>
47
60
 
48
- <text fg="#93a1a1">
49
- To get started, you need a local LLM running or a provider configured.
50
- </text>
51
- <text> </text>
61
+ <For each={rows()}>
62
+ {(row) => (
63
+ <box flexDirection="row">
64
+ <For each={row}>
65
+ {(provider) => (
66
+ <box width={22} flexDirection="row">
67
+ <text fg="#93a1a1">{provider.name}:</text>
68
+ <text> </text>
69
+ <text fg={provider.detected ? "#859900" : "#dc322f"}>
70
+ {provider.detected ? "[✓]" : "[✗]"}
71
+ </text>
72
+ </box>
73
+ )}
74
+ </For>
75
+ </box>
76
+ )}
77
+ </For>
52
78
 
53
- <text fg="#93a1a1">
54
- Options:
55
- </text>
56
- <text fg="#93a1a1">
57
- 1. Start LMStudio (port 1234) or Ollama (port 11434)
58
- </text>
59
- <text fg="#93a1a1">
60
- 2. Run /provider new to configure a cloud provider
61
- </text>
62
- <text> </text>
79
+ <text> </text>
80
+ <box visible={!!props.defaultModel} flexDirection="row">
81
+ <text fg="#657b83">Default model: </text>
82
+ <text fg="#eee8d5">{props.defaultModel ?? ""}</text>
83
+ </box>
84
+ <text> </text>
85
+ <text fg="#93a1a1">To chat with a smarter model, try: /provider</text>
86
+ <text fg="#93a1a1">To change your default, use: /settings</text>
87
+ <text fg="#93a1a1">See /help for... well, Help!</text>
88
+ </box>
63
89
 
64
- <text fg="#657b83">
65
- Once configured, restart Ei or run /provider new to add your provider.
66
- </text>
67
- <text> </text>
90
+ <box visible={!hasAny()} flexDirection="column">
91
+ <text fg="#dc322f">No LLM provider detected.</text>
92
+ <text> </text>
93
+ <text fg="#93a1a1">Start LMStudio (port 1234) or Ollama (port 11434), or</text>
94
+ <text fg="#93a1a1">set one of: ANTHROPIC_API_KEY, OPENAI_API_KEY, GROQ_API_KEY,</text>
95
+ <text fg="#93a1a1">MISTRAL_API_KEY, GEMINI_API_KEY and restart.</text>
96
+ </box>
68
97
 
69
- <text fg="#586e75">
70
- Press any key to dismiss
71
- </text>
72
98
  <text> </text>
73
- <text fg="#2a2a3e">
74
- Ei - 永 (ei) - eternal
75
- </text>
99
+ <text fg="#586e75">Press any key to continue</text>
100
+ <text> </text>
101
+ <text fg="#2a2a3e">Ei - 永 (ei) - eternal</text>
76
102
  </box>
77
103
  </box>
78
104
  );
@@ -14,7 +14,13 @@ import { Processor } from "../../../src/core/processor.js";
14
14
  import { FileStorage } from "../storage/file.js";
15
15
  import { remoteSync } from "../../../src/storage/remote.js";
16
16
  import { logger, clearLog, interceptConsole } from "../util/logger.js";
17
- import { E2E_SKIP_LOCAL_DETECT } from "../util/e2e-flags.js";
17
+ import { E2E_SKIP_LOCAL_DETECT, E2E_SKIP_CLOUD_DETECT } from "../util/e2e-flags.js";
18
+ import {
19
+ detectProviders,
20
+ buildProviderAccounts,
21
+ ALL_PROVIDER_NAMES,
22
+ } from "../util/provider-detection.js";
23
+ import type { ProviderDetectionStatus } from "../util/provider-detection.js";
18
24
  import { ConflictOverlay } from "../components/ConflictOverlay.js";
19
25
  import type {
20
26
  Ei_Interface,
@@ -28,8 +34,6 @@ import type {
28
34
  Topic,
29
35
  Person,
30
36
  Quote,
31
- ProviderAccount,
32
- ProviderType,
33
37
  StateConflictData,
34
38
  StateConflictResolution,
35
39
  ContextStatus,
@@ -109,6 +113,8 @@ export interface EiContextValue {
109
113
  }>;
110
114
  showWelcomeOverlay: () => boolean;
111
115
  dismissWelcomeOverlay: () => void;
116
+ detectedProviders: () => ProviderDetectionStatus[];
117
+ firstBootDefaultModel: () => string | undefined;
112
118
  deleteMessages: (personaId: string, messageIds: string[]) => Promise<void>;
113
119
  setMessageContextStatus: (personaId: string, messageId: string, status: ContextStatus) => Promise<void>;
114
120
  deleteRoomMessages: (roomId: string, messageIds: string[]) => Promise<void>;
@@ -146,6 +152,9 @@ export interface EiContextValue {
146
152
  humanRoomMessagePending: () => boolean;
147
153
  getArchivedRooms: () => RoomSummary[];
148
154
  generatePersonaPreview: (name: string, description: string, relationship?: string, personaId?: string) => Promise<import('../../../src/prompts/generation/types.js').PersonaGenerationResult>;
155
+ importDocument: (filePath: string) => Promise<import('../../../src/integrations/document/types.js').DocumentImportResult>;
156
+ getUnsourcePreview: (sourceTag: string) => import('../../../src/integrations/document/unsource.js').UnsourcePreview;
157
+ executeUnsource: (preview: import('../../../src/integrations/document/unsource.js').UnsourcePreview) => Promise<import('../../../src/integrations/document/unsource.js').UnsourceResult>;
149
158
  }
150
159
  const EiContext = createContext<EiContextValue>();
151
160
 
@@ -168,6 +177,8 @@ export const EiProvider: ParentComponent = (props) => {
168
177
  const [contextBoundarySignal, setContextBoundarySignal] = createSignal<string | undefined>(undefined);
169
178
  const [quotesVersion, setQuotesVersion] = createSignal(0);
170
179
  const [showWelcomeOverlay, setShowWelcomeOverlay] = createSignal(false);
180
+ const [detectedProviders, setDetectedProviders] = createSignal<ProviderDetectionStatus[]>([]);
181
+ const [firstBootDefaultModel, setFirstBootDefaultModel] = createSignal<string | undefined>(undefined);
171
182
  const [conflictData, setConflictData] = createSignal<StateConflictData | null>(null);
172
183
 
173
184
  let processor: Processor | null = null;
@@ -175,6 +186,7 @@ export const EiProvider: ParentComponent = (props) => {
175
186
  let readTimer: Timer | null = null;
176
187
  let dwelledPersona: string | null = null;
177
188
  let syncConfiguredFromEnv = false;
189
+ let eiDataPath = "";
178
190
 
179
191
  const showNotification = (message: string, level: "error" | "warn" | "info") => {
180
192
  if (notificationTimer) clearTimeout(notificationTimer);
@@ -320,6 +332,32 @@ export const EiProvider: ParentComponent = (props) => {
320
332
  return processor.generatePersonaPreview(name, description, relationship, personaId);
321
333
  };
322
334
 
335
+ const importDocument = async (filePath: string) => {
336
+ if (!processor) throw new Error("Processor not ready");
337
+ const { readFile } = await import("node:fs/promises");
338
+ const { basename } = await import("node:path");
339
+ const { homedir } = await import("node:os");
340
+ const expandedPath = filePath === "~" || filePath.startsWith("~/")
341
+ ? homedir() + filePath.slice(1)
342
+ : filePath.replace(/^\$HOME(?=\/|$)/, homedir());
343
+ const content = await readFile(expandedPath, "utf-8");
344
+ const filename = basename(expandedPath);
345
+ return processor.importDocument(content, filename);
346
+ };
347
+
348
+ const getUnsourcePreview = (sourceTag: string) => {
349
+ if (!processor) throw new Error("Processor not ready");
350
+ return processor.getUnsourcePreview(sourceTag);
351
+ };
352
+
353
+ const executeUnsource = async (preview: import('../../../src/integrations/document/unsource.js').UnsourcePreview) => {
354
+ if (!processor) throw new Error("Processor not ready");
355
+ const result = await processor.executeUnsource(preview);
356
+ const { writeUnsourceInvoice } = await import("../../../src/integrations/document/invoice.js");
357
+ await writeUnsourceInvoice(preview, result, eiDataPath);
358
+ return result;
359
+ };
360
+
323
361
  const archivePersona = async (personaId: string) => {
324
362
  if (!processor) return;
325
363
  await processor.archivePersona(personaId);
@@ -748,64 +786,40 @@ export const EiProvider: ParentComponent = (props) => {
748
786
  try {
749
787
  const human = await processor!.getHuman();
750
788
  const hasAccounts = human.settings?.accounts && human.settings.accounts.length > 0;
751
- if (!hasAccounts && E2E_SKIP_LOCAL_DETECT) {
752
- logger.info("E2E_SKIP_LOCAL_DETECT active, skipping local LLM check");
753
- setShowWelcomeOverlay(true);
754
- } else if (!hasAccounts) {
755
- logger.info("No LLM accounts configured, checking for local LLMs...");
756
-
757
- const candidates = [
758
- { name: "LMStudio", url: "http://127.0.0.1:1234/v1" },
759
- { name: "Ollama", url: "http://127.0.0.1:11434/v1" },
760
- ];
761
-
762
- const detected = await Promise.all(
763
- candidates.map(async (candidate) => {
764
- try {
765
- const response = await fetch(`${candidate.url}/models`, {
766
- method: "GET",
767
- signal: AbortSignal.timeout(3000),
768
- });
769
- return response.ok ? candidate : null;
770
- } catch {
771
- return null;
772
- }
773
- })
774
- );
775
-
776
- const found = detected.filter(Boolean) as typeof candidates;
777
-
778
- if (found.length > 0) {
779
- const accounts: ProviderAccount[] = found.map((candidate) => {
780
- const defaultModelId = crypto.randomUUID();
781
- return {
782
- id: crypto.randomUUID(),
783
- name: candidate.name,
784
- type: "llm" as ProviderType,
785
- url: candidate.url,
786
- enabled: true,
787
- created_at: new Date().toISOString(),
788
- default_model: defaultModelId,
789
- models: [{ id: defaultModelId, name: "default" }],
790
- };
791
- });
792
- const firstDefaultModelId = accounts[0].default_model!;
793
- const currentHuman = await processor!.getHuman();
794
- await processor!.updateHuman({
795
- settings: {
796
- ...currentHuman.settings,
797
- accounts,
798
- default_model: firstDefaultModelId,
799
- },
800
- });
801
- const names = found.map((c) => c.name).join(" and ");
802
- showNotification(`${names} detected and configured!`, "info");
803
- logger.info(`Auto-configured: ${names}`);
804
- } else {
805
- logger.info("No local LLMs found, showing welcome overlay");
806
- setShowWelcomeOverlay(true);
807
- }
789
+ if (hasAccounts) return;
790
+
791
+ const { detected, statuses } = await detectProviders({
792
+ skipLocalDetect: E2E_SKIP_LOCAL_DETECT,
793
+ skipCloudDetect: E2E_SKIP_CLOUD_DETECT,
794
+ });
795
+
796
+ const allStatuses: ProviderDetectionStatus[] = ALL_PROVIDER_NAMES.map((name) => {
797
+ const found = statuses.find((s) => s.name === name);
798
+ return found ?? { name, detected: false };
799
+ });
800
+ setDetectedProviders(allStatuses);
801
+
802
+ if (detected.length > 0) {
803
+ const accounts = buildProviderAccounts(detected);
804
+ const topProvider = detected[0];
805
+ const defaultModel = `${topProvider.name}:${topProvider.selected.extractionModel}`;
806
+ setFirstBootDefaultModel(defaultModel);
807
+ const currentHuman = await processor!.getHuman();
808
+ await processor!.updateHuman({
809
+ settings: {
810
+ ...currentHuman.settings,
811
+ accounts,
812
+ default_model: defaultModel,
813
+ },
814
+ });
815
+ const names = detected.map((d) => d.name).join(" and ");
816
+ showNotification(`${names} detected and configured!`, "info");
817
+ logger.info(`Auto-configured: ${names}`);
818
+ } else {
819
+ logger.info("No LLM providers found, showing welcome overlay");
808
820
  }
821
+
822
+ setShowWelcomeOverlay(true);
809
823
  } catch (err: any) {
810
824
  logger.warn(`LLM detection failed: ${err?.message || err}`);
811
825
  }
@@ -826,6 +840,7 @@ export const EiProvider: ParentComponent = (props) => {
826
840
  logger.info("Ei TUI bootstrap starting");
827
841
  try {
828
842
  const storage = new FileStorage(Bun.env.EI_DATA_PATH);
843
+ eiDataPath = storage.getDataPath();
829
844
  // Pre-configure remoteSync from env vars BEFORE processor.start()
830
845
  // so the processor's sync decision tree can detect remote state
831
846
  const syncUsername = Bun.env.EI_SYNC_USERNAME;
@@ -969,6 +984,8 @@ export const EiProvider: ParentComponent = (props) => {
969
984
  searchHumanData,
970
985
  showWelcomeOverlay,
971
986
  dismissWelcomeOverlay: () => setShowWelcomeOverlay(false),
987
+ detectedProviders,
988
+ firstBootDefaultModel,
972
989
  deleteMessages,
973
990
  setMessageContextStatus,
974
991
  deleteRoomMessages,
@@ -1006,6 +1023,9 @@ export const EiProvider: ParentComponent = (props) => {
1006
1023
  humanRoomMessagePending,
1007
1024
  getArchivedRooms,
1008
1025
  generatePersonaPreview,
1026
+ importDocument,
1027
+ getUnsourcePreview,
1028
+ executeUnsource,
1009
1029
  };
1010
1030
  return (
1011
1031
  <Switch>
@@ -0,0 +1,57 @@
1
+ /**
2
+ * TUI global type augmentations.
3
+ *
4
+ * TUI's lib config omits "DOM" because this is a terminal app, but the shared
5
+ * src files use `typeof document !== "undefined"` guards for runtime detection.
6
+ * Without a DOM lib declaration, TypeScript emits TS2584 for those checks.
7
+ *
8
+ * Additionally, @types/node pulls in undici-types which types Response.json()
9
+ * as Promise<unknown>, while the DOM lib types it as Promise<any>. The stricter
10
+ * undici typing causes TS18046 in shared src files that call response.json().
11
+ */
12
+
13
+ // Allow `typeof document` checks in shared src files (never actually accessed in TUI)
14
+ declare var document: unknown;
15
+
16
+ // Override undici-types' strict Response.json() -> Promise<unknown> back to any,
17
+ // matching the DOM lib behavior that the shared src files were written against.
18
+ interface Body {
19
+ json(): Promise<any>;
20
+ }
21
+
22
+ interface Response {
23
+ json(): Promise<any>;
24
+ }
25
+
26
+ // Stubs for browser IndexedDB globals referenced in src/storage/indexed.ts.
27
+ // That file is browser-only but is re-exported from src/storage/index.ts,
28
+ // so TypeScript compiles it even when run under the TUI (no DOM lib).
29
+ declare var indexedDB: { open(name: string, version?: number): IDBOpenDBRequest };
30
+
31
+ declare class IDBDatabase {
32
+ objectStoreNames: { contains(name: string): boolean };
33
+ createObjectStore(name: string): unknown;
34
+ transaction(store: string, mode?: string): { objectStore(name: string): IDBObjectStore };
35
+ close(): void;
36
+ }
37
+
38
+ declare class IDBObjectStore {
39
+ get(key: string): IDBRequest;
40
+ put(value: unknown, key?: string): IDBRequest;
41
+ delete(key: string): IDBRequest;
42
+ }
43
+
44
+ declare class IDBOpenDBRequest {
45
+ result: IDBDatabase;
46
+ error: unknown;
47
+ onupgradeneeded: ((event: any) => void) | null;
48
+ onsuccess: ((event: any) => void) | null;
49
+ onerror: ((event: any) => void) | null;
50
+ }
51
+
52
+ declare class IDBRequest {
53
+ result: any;
54
+ error: unknown;
55
+ onsuccess: ((event: any) => void) | null;
56
+ onerror: ((event: any) => void) | null;
57
+ }
package/tui/src/index.tsx CHANGED
@@ -30,6 +30,20 @@ if (!lockResult.acquired) {
30
30
  // Release lock when the app exits (keyboard context calls process.exit(0) on normal quit)
31
31
  process.on("exit", () => { void lock.release(); });
32
32
 
33
+ // Validate state.json is parseable before handing off to the app.
34
+ // A corrupt file must never silently wipe all data — exit cleanly with recovery instructions.
35
+ try {
36
+ await storage.load();
37
+ } catch (e) {
38
+ await lock.release();
39
+ process.stderr.write(
40
+ `\nEi cannot start: state.json failed to load.\n\n` +
41
+ ` ${e instanceof Error ? e.message : String(e)}\n\n` +
42
+ `Fix the file manually, restore from a backup, or delete it to start fresh (all data will be lost).\n\n`
43
+ );
44
+ process.exit(1);
45
+ }
46
+
33
47
  render(App, {
34
48
  exitOnCtrlC: false,
35
49
  targetFps: 30,
@@ -59,15 +59,21 @@ export class FileStorage implements Storage {
59
59
  async load(): Promise<StorageState | null> {
60
60
  const filePath = join(this.dataPath, STATE_FILE);
61
61
  const file = Bun.file(filePath);
62
-
62
+
63
63
  if (await file.exists()) {
64
+ let text: string;
64
65
  try {
65
- const text = await file.text();
66
- if (text) {
66
+ text = await file.text();
67
+ } catch (e) {
68
+ throw new Error(`STORAGE_READ_FAILED: Could not read ${filePath}: ${e instanceof Error ? e.message : String(e)}`);
69
+ }
70
+
71
+ if (text) {
72
+ try {
67
73
  return decodeAllEmbeddings(JSON.parse(text) as StorageState);
74
+ } catch (e) {
75
+ throw new Error(`STORAGE_PARSE_FAILED: ${filePath} exists but could not be parsed as JSON. Your data is intact — fix the file manually or restore from a backup in ${join(this.dataPath, "backups")}.\n Parse error: ${e instanceof Error ? e.message : String(e)}`);
68
76
  }
69
- } catch {
70
- return null;
71
77
  }
72
78
  }
73
79
 
@@ -3,11 +3,12 @@
3
3
  *
4
4
  * Use prime-power bits so combinations are unambiguous:
5
5
  * 1 — skip local LLM auto-detect (fetch to :1234/:11434)
6
- * 2 — (reserved for next scenario)
7
- * 3 — flags 1 + 2 combined
6
+ * 2 — skip cloud provider auto-detect (env var /models checks)
7
+ * 3 — flags 1 + 2 combined (skip all auto-detect)
8
8
  *
9
9
  * Production code should never set this. Tests pass it via env in test.use({ env: { EI_E2E_MODE: "1" } }).
10
10
  */
11
11
  const E2E_MODE = parseInt(process.env.EI_E2E_MODE ?? "0", 10);
12
12
 
13
- export const E2E_SKIP_LOCAL_DETECT = (E2E_MODE & 1) !== 0;
13
+ export const E2E_SKIP_LOCAL_DETECT = (E2E_MODE & 1) !== 0;
14
+ export const E2E_SKIP_CLOUD_DETECT = (E2E_MODE & 2) !== 0;
@@ -89,6 +89,26 @@ ROOM COMMANDS
89
89
  /capture topic <name> Re-scan all messages for a specific topic.
90
90
 
91
91
  EXTENDED COMMANDS
92
+ /reflect
93
+ Review a persona's pending reflection — a proposed identity update
94
+ generated by Ei after observing patterns in your conversations.
95
+ A badge appears on the persona pill when one is ready.
96
+ /reflect generate Write current + proposed YAML files to disk
97
+ /reflect update Read edited proposed.yaml back into Ei
98
+ /reflect apply Apply the proposed identity to the persona
99
+ /reflect dismiss Discard without changing anything
100
+
101
+ /import <path>
102
+ Import a document (txt, md, pdf, etc.) into Ei. Ei segments it,
103
+ extracts knowledge, and attributes it to the "Emmett" persona.
104
+ /import ~/notes/journal.md
105
+ /import /path/to/report.pdf
106
+
107
+ /unsource <source_tag>
108
+ Remove all facts, topics, etc. extracted from a previously imported
109
+ document. Use the source tag shown when the import completed.
110
+ /unsource my-journal-2024
111
+
92
112
  /tools
93
113
  Manage tool providers — enable or disable tools per persona.
94
114