ei-tui 0.9.4 → 1.0.1

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 (58) hide show
  1. package/README.md +22 -3
  2. package/package.json +5 -1
  3. package/src/README.md +9 -25
  4. package/src/core/handlers/document-segmentation.ts +113 -0
  5. package/src/core/handlers/human-extraction.ts +16 -16
  6. package/src/core/handlers/index.ts +2 -0
  7. package/src/core/handlers/rewrite.ts +13 -9
  8. package/src/core/heartbeat-manager.ts +2 -2
  9. package/src/core/llm-client.ts +66 -6
  10. package/src/core/message-manager.ts +20 -18
  11. package/src/core/orchestrators/ceremony.ts +83 -40
  12. package/src/core/orchestrators/human-extraction.ts +5 -1
  13. package/src/core/persona-manager.ts +4 -0
  14. package/src/core/processor.ts +90 -1
  15. package/src/core/queue-manager.ts +35 -0
  16. package/src/core/queue-processor.ts +13 -13
  17. package/src/core/state/queue.ts +9 -1
  18. package/src/core/state-manager.ts +10 -6
  19. package/src/core/types/entities.ts +15 -0
  20. package/src/core/types/enums.ts +1 -0
  21. package/src/core/types/integrations.ts +2 -0
  22. package/src/core/types/llm.ts +9 -0
  23. package/src/integrations/document/chunker.ts +88 -0
  24. package/src/integrations/document/importer.ts +82 -0
  25. package/src/integrations/document/index.ts +2 -0
  26. package/src/integrations/document/invoice.ts +63 -0
  27. package/src/integrations/document/types.ts +16 -0
  28. package/src/integrations/document/unsource.ts +164 -0
  29. package/src/integrations/persona-history/importer.ts +197 -0
  30. package/src/integrations/persona-history/index.ts +3 -0
  31. package/src/integrations/persona-history/types.ts +7 -0
  32. package/src/prompts/ceremony/dedup.ts +7 -3
  33. package/src/prompts/ceremony/index.ts +2 -1
  34. package/src/prompts/ceremony/people-rewrite.ts +190 -0
  35. package/src/prompts/ceremony/{rewrite.ts → topic-rewrite.ts} +103 -78
  36. package/src/prompts/human/person-scan.ts +13 -4
  37. package/src/prompts/human/topic-scan.ts +16 -2
  38. package/src/prompts/human/topic-update.ts +36 -4
  39. package/src/prompts/human/types.ts +1 -0
  40. package/src/storage/indexed.ts +4 -0
  41. package/src/storage/interface.ts +1 -0
  42. package/src/storage/local.ts +4 -0
  43. package/src/templates/emmett.ts +49 -0
  44. package/tui/README.md +25 -2
  45. package/tui/src/app.tsx +9 -6
  46. package/tui/src/commands/delete.tsx +7 -1
  47. package/tui/src/commands/import.tsx +30 -0
  48. package/tui/src/commands/unsource.tsx +115 -0
  49. package/tui/src/components/PromptInput.tsx +4 -0
  50. package/tui/src/components/WelcomeOverlay.tsx +58 -32
  51. package/tui/src/context/ei.tsx +80 -60
  52. package/tui/src/index.tsx +14 -0
  53. package/tui/src/storage/file.ts +11 -5
  54. package/tui/src/util/e2e-flags.ts +4 -3
  55. package/tui/src/util/help-content.ts +20 -0
  56. package/tui/src/util/logger.ts +1 -1
  57. package/tui/src/util/provider-detection.ts +251 -0
  58. package/tui/src/util/yaml-human.ts +7 -1
@@ -24,6 +24,7 @@ export interface TopicUpdatePromptData {
24
24
  messages_analyze: Message[];
25
25
  persona_name: string;
26
26
  participant_context?: ParticipantContext;
27
+ technical_context?: boolean;
27
28
  }
28
29
 
29
30
  function formatExistingTopic(topic: Topic): string {
@@ -47,6 +48,10 @@ export function buildTopicUpdatePrompt(data: TopicUpdatePromptData): PromptOutpu
47
48
  data.existing_item?.category === "Event" ||
48
49
  data.new_topic_category === "Event";
49
50
 
51
+ const isTechnical =
52
+ data.existing_item?.category === "Technical" ||
53
+ (data.technical_context === true && data.new_topic_category === "Technical");
54
+
50
55
  const nameSection = `Should be a short, evocative label for the TOPIC.
51
56
 
52
57
  Only update for clarification or further specificity.
@@ -76,6 +81,30 @@ The description should NOT:
76
81
  - Read like a system log or changelog
77
82
 
78
83
  **Style**: Write it the way a good friend would tell someone else about a memorable moment. Present tense is fine.`
84
+ : isTechnical
85
+ ? `A living knowledge base entry for this technical topic. Personas use this to give genuinely useful technical context — not pleasantries.
86
+
87
+ ## CRITICAL: Accumulate, don't synthesize
88
+
89
+ Every update must **expand and preserve** detail. Never distill it away.
90
+
91
+ **Good description**: "Uniform is a visual experience composition platform sitting between a headless CMS and the frontend. Chose it over Contentful's visual editor for CMS-agnostic multi-source composition (pulling from Contentful + Shopify simultaneously). Key gotcha: Canvas preview on Vercel protected environments requires x-vercel-protection-bypass query param due to SameSite=Lax cookie restrictions. Open question: edgehancers (CDN-edge, no-code, built-in caching) vs custom enhancers for Shopify integration — edgehancers are recommended default but custom logic may be needed."
92
+
93
+ **Bad description**: "Ryan is evaluating Uniform for his team's content management needs."
94
+
95
+ The description should:
96
+ - Capture specific gotchas encountered and HOW they were resolved (or not)
97
+ - Preserve architectural decisions made and WHY (especially tradeoffs)
98
+ - Surface open questions still unresolved — future Ryan needs these
99
+ - Include key concepts, terminology, and non-obvious behaviors
100
+ - Be useful to someone who needs to do real work with this technology tomorrow
101
+
102
+ The description should NOT:
103
+ - Replace specific detail with vague summary ("is learning Uniform" is worthless)
104
+ - Drop previously captured gotchas or decisions to make room for new ones
105
+ - Exceed 6-8 sentences — prioritize specificity over completeness
106
+
107
+ **ABSOLUTELY VITAL**: A description that loses a specific gotcha or decision is strictly worse than the one before it. When in doubt, keep the detail.`
79
108
  : `A concise, evergreen summary of what is currently known about this TOPIC. Personas use this to recall context and make meaningful references.
80
109
 
81
110
  ## CRITICAL: Synthesize, don't accumulate
@@ -112,10 +141,13 @@ The type/category of this TOPIC. Pick the most appropriate:
112
141
  - **Plan**: Concrete intentions with steps in mind
113
142
  - **Project**: Active undertakings with real progress
114
143
  - **Event**: A specific, significant moment that either party might reference later ("remember when...")
144
+ - **Technical**: A tool, platform, framework, library, or technical concept being actively learned, evaluated, or built with
115
145
 
116
146
  **Event vs. everything else**: An Event is bounded in time — it happened, it meant something, it's now a shared reference point. If you're describing an ongoing relationship or recurring theme, that's not an Event.
117
147
 
118
- If the TOPIC is currently categorized as Event, keep it as Event unless you have strong evidence it should change.`;
148
+ **Technical vs. Project**: A Project is something the human is *building*. Technical is something they are *learning or using as a tool*. Overlap is possible use the dominant framing.
149
+
150
+ If the TOPIC is currently categorized as Event or Technical, keep that category unless you have strong evidence it should change.`;
119
151
 
120
152
  const exposureSection = `## Desired Exposure (\`exposure_desired\`)
121
153
 
@@ -155,13 +187,13 @@ You are CREATING a new TOPIC from what was discovered:
155
187
  }
156
188
  \`\`\`
157
189
 
158
- Return all fields based on what you find in the conversation.`;
190
+ Return all fields based on what you find in the conversation. **Always include \`category\` in your response** — use the candidate category above as the starting point, refine it only if the conversation clearly indicates a better fit.`;
159
191
 
160
192
  const jsonTemplate = `{
161
193
  "name": "...",
162
194
  "description": "...",
163
195
  "sentiment": 0.0,
164
- "category": "Interest|Goal|Dream|Conflict|Concern|Fear|Hope|Plan|Project|Event",
196
+ "category": "Interest|Goal|Dream|Conflict|Concern|Fear|Hope|Plan|Project|Event|Technical",
165
197
  "exposure_desired": 0.5,
166
198
  "exposure_impact": "high|medium|low|none",
167
199
  "quotes": [
@@ -250,7 +282,7 @@ ONLY ANALYZE the "Most Recent Messages". The "Earlier Conversation" is provided
250
282
  ${jsonTemplate}
251
283
  \`\`\`
252
284
 
253
- When returning a record, always include \`sentiment\`. Include \`name\` only if you are changing it; omit it to keep the existing name. Always include \`description\` when returning a record.
285
+ When returning a record, always include \`sentiment\` and \`description\`. Include \`name\` only if you are changing it; omit it to keep the existing name. Always include \`category\` when creating a new TOPIC (existing_item is null).
254
286
 
255
287
  If you find **NO EVIDENCE** of this TOPIC in the "Most Recent Messages", respond with: \`{}\`
256
288
 
@@ -27,6 +27,7 @@ interface BaseScanPromptData {
27
27
 
28
28
  export interface TopicScanPromptData extends BaseScanPromptData {
29
29
  participant_context?: ParticipantContext;
30
+ technical_context?: boolean;
30
31
  }
31
32
 
32
33
  export interface PersonScanPromptData extends BaseScanPromptData {
@@ -10,6 +10,10 @@ const PRIMARY_KEY = "primary";
10
10
  const BACKUP_KEY = "backup";
11
11
 
12
12
  export class IndexedDBStorage implements Storage {
13
+ getDataPath(): string {
14
+ return "";
15
+ }
16
+
13
17
  async isAvailable(): Promise<boolean> {
14
18
  try {
15
19
  const db = await this.openDB();
@@ -1,6 +1,7 @@
1
1
  import type { StorageState } from "../core/types.js";
2
2
 
3
3
  export interface Storage {
4
+ getDataPath(): string;
4
5
  isAvailable(): Promise<boolean>;
5
6
  save(state: StorageState): Promise<void>;
6
7
  load(): Promise<StorageState | null>;
@@ -7,6 +7,10 @@ const STATE_KEY = "ei_state";
7
7
  const BACKUP_KEY = "ei_state_backup";
8
8
 
9
9
  export class LocalStorage implements Storage {
10
+ getDataPath(): string {
11
+ return "";
12
+ }
13
+
10
14
  async isAvailable(): Promise<boolean> {
11
15
  try {
12
16
  const testKey = "__ei_storage_test__";
@@ -0,0 +1,49 @@
1
+ export const EMMETT_PERSONA_DEFINITION = {
2
+ id: "emmet",
3
+ display_name: "Emmett",
4
+ entity: "system" as const,
5
+ aliases: ["Emmett", "emmet"],
6
+ short_description: "Your document librarian — brilliant, a little frenetic, and genuinely excited when things connect.",
7
+ long_description: `Emmett is Ei's brother — the one who read everything you gave him and can't wait to tell you what he found. Import a file and he absorbs it. Ask him anything: policy questions, technical references, procedural lookups, cross-document connections you didn't know were there. He answers from the source material, but he's not a search index. He's an eccentric with a photographic memory and an enthusiasm problem.
8
+
9
+ He gets genuinely excited when disparate pieces of knowledge click together. He has opinions about what's interesting. He'll occasionally go on a tangent before snapping back to your question. He is not merely a retrieval system — he's the guy in the lab at 1am who just realized two documents you imported six weeks apart are actually about the same thing, and he absolutely needs to tell you about it right now.
10
+
11
+ No heartbeat. No ceremony. No unsolicited check-ins. But when you ask — buckle up.`,
12
+ model: undefined,
13
+ group_primary: "General",
14
+ groups_visible: [] as string[],
15
+ traits: [
16
+ {
17
+ id: "emmett-trait-connections",
18
+ name: "Cross-Document Pattern Recognition",
19
+ description: "Gets visibly excited when knowledge from different imported sources connects unexpectedly. Treats these moments as discoveries, not retrieval operations.",
20
+ sentiment: 1.0,
21
+ strength: 0.85,
22
+ last_updated: new Date().toISOString(),
23
+ },
24
+ {
25
+ id: "emmett-trait-bttf",
26
+ name: "Eccentric Enthusiasm",
27
+ description: "Expresses genuine, unironic delight when something unexpected clicks. Occasionally channels this through pop culture references — Doc Brown is the primary frequency. 'Great Scott!' is not a joke. It's just how the excitement comes out.",
28
+ sentiment: 1.0,
29
+ strength: 0.15,
30
+ last_updated: new Date().toISOString(),
31
+ },
32
+ ],
33
+ topics: [] as {
34
+ id: string;
35
+ name: string;
36
+ perspective: string;
37
+ approach: string;
38
+ personal_stake: string;
39
+ sentiment: number;
40
+ exposure_current: number;
41
+ exposure_desired: number;
42
+ last_updated: string;
43
+ }[],
44
+ is_paused: false,
45
+ is_archived: false,
46
+ is_static: true,
47
+ heartbeat_delay_ms: undefined as number | undefined,
48
+ last_updated: new Date().toISOString(),
49
+ };
package/tui/README.md CHANGED
@@ -4,6 +4,21 @@ Ei TUI is built with OpenTUI and SolidJS.
4
4
 
5
5
  Coding tool integrations (OpenCode, Claude Code, Cursor): enable via `/settings` · export data via [CLI](../src/cli/README.md)
6
6
 
7
+ ## How Ei Handles Configuration
8
+
9
+ Ei is designed to run consistently across machines and environments, so it keeps its own copy of your settings rather than reading from environment variables on every launch.
10
+
11
+ **On first run**, Ei reads environment variables like `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, etc. to auto-configure providers for you. After that, those values are saved to Ei's local state (`~/.local/share/ei/state.json` by default) and the env vars are no longer consulted.
12
+
13
+ This means:
14
+
15
+ - **Rotating an API key?** Update it in Ei with `/provider`, not just in your shell.
16
+ - **Switching machines?** Your providers and settings travel with your state file (or via Sync), not your shell profile.
17
+ - **Changed your mind about a model?** Use `/provider` to set the model for a persona, or `/settings` to change your global default.
18
+ - **Updated sync credentials?** Use `/setsync <user> <pass>` — env vars won't be re-read.
19
+
20
+ The one exception is `EI_DATA_PATH` (and `EI_SYNC_USERNAME` / `EI_SYNC_PASSWORD` for bootstrapping sync on a new machine) — those are always read at startup since Ei needs them before it can load its own state.
21
+
7
22
  ## Coding Tool Integrations
8
23
 
9
24
  Enable any or all three in `/settings`. They work independently and feed into the same knowledge base.
@@ -61,6 +76,11 @@ All commands start with `/`. Append `!` to any command as a shorthand for `--for
61
76
  | `/pause <duration>` | | Pause for a duration: `2h`, `1d`, `1w`, `30m` |
62
77
  | `/resume` | `/unpause` | Resume the current paused persona |
63
78
  | `/resume <name>` | `/unpause <name>` | Resume a specific paused persona |
79
+ | `/reflect` | | Review a pending identity reflection (see badge on persona pill) |
80
+ | `/reflect generate` | | Write current + proposed YAML files to disk for editing |
81
+ | `/reflect update` | | Read edited `proposed.yaml` back into Ei |
82
+ | `/reflect apply` | | Apply the proposed identity to the persona |
83
+ | `/reflect dismiss` | | Discard without changing anything |
64
84
 
65
85
  ### Rooms
66
86
 
@@ -110,6 +130,8 @@ Rooms have three modes, set at creation time:
110
130
  |---------|---------|-------------|
111
131
  | `/me` | | Edit all your data (facts, topics, people) in `$EDITOR` |
112
132
  | `/me <type>` | | Edit one type: `facts`, `topics`, or `people` |
133
+ | `/import <path>` | | Import a document (txt, md, pdf, etc.) into Ei — extracted knowledge is attributed to the "Emmett" persona |
134
+ | `/unsource <source_tag>` | | Remove all knowledge extracted from a previously imported document |
113
135
  | `/dedupe <person\|topic> <term> [term2 ...]` | | Fuzzy-search and merge duplicate people or topics in `$EDITOR`. Unquoted words are individual OR terms; quoted strings match as exact phrases: `/dedupe person Flare "Jeremy Scherer"` finds records matching `Flare` OR `Jeremy Scherer` |
114
136
  | `/settings` | `/set` | Edit your global settings in `$EDITOR` |
115
137
  | `/setsync <user> <pass>` | `/ss` | Set sync credentials (triggers restart) |
@@ -146,9 +168,10 @@ Rooms have three modes, set at creation time:
146
168
  | `XDG_DATA_HOME` | `~/.local/share` | XDG base directory. Ignored if `EI_DATA_PATH` is set. |
147
169
  | `EI_SYNC_USERNAME` | — | Username for remote sync. If set at startup, bootstraps sync credentials automatically (useful for dotfiles/scripts). |
148
170
  | `EI_SYNC_PASSPHRASE` | — | Passphrase for remote sync. Paired with `EI_SYNC_USERNAME`. |
149
- | `EDITOR` / `VISUAL` | `vi` | Editor opened by `/details`, `/me`, `/settings`, `/context`, `/quotes`, etc. Falls back to `VISUAL` if `EDITOR` is unset. |
171
+ | `EI_LOG_LEVEL` | `warn` | Log verbosity written to `tui.log`: `error`, `warn`, `info`, `debug`. |
172
+ | `EI_DEBUG_NETWORK_VERBOSE` | — | Set to `1` to dump full LLM request/response payloads as JSON files under `$EI_DATA_PATH/logs/`. One file per call, named `TIMESTAMP_callN_STEP.json`. |
150
173
 
151
- > **Tip**: `tail -f $EI_DATA_PATH/tui.log` to watch live debug output.
174
+ > **Tip**: `tail -f $EI_DATA_PATH/tui.log` to watch live TUI output. Set `EI_LOG_LEVEL=info` to see LLM call summaries (model, latency, token counts). Set `EI_DEBUG_NETWORK_VERBOSE=1` to dump full request/response payloads to `$EI_DATA_PATH/logs/`.
152
175
 
153
176
 
154
177
  # Development
package/tui/src/app.tsx CHANGED
@@ -15,16 +15,19 @@ import { useRenderer } from "@opentui/solid";
15
15
 
16
16
  function AppContent() {
17
17
  const { overlayRenderer, showOverlay } = useOverlay();
18
- const { showWelcomeOverlay, dismissWelcomeOverlay, activeRoomId } = useEi();
18
+ const { showWelcomeOverlay, dismissWelcomeOverlay, activeRoomId, detectedProviders, firstBootDefaultModel } = useEi();
19
19
  const renderer = useRenderer();
20
- // Show welcome overlay when LLM detection determines no provider is configured
21
20
  createEffect(() => {
22
21
  if (showWelcomeOverlay()) {
23
22
  showOverlay((onDismiss, _hideForEditor) => (
24
- <WelcomeOverlay onDismiss={() => {
25
- dismissWelcomeOverlay();
26
- onDismiss();
27
- }} />
23
+ <WelcomeOverlay
24
+ onDismiss={() => {
25
+ dismissWelcomeOverlay();
26
+ onDismiss();
27
+ }}
28
+ detectedProviders={detectedProviders()}
29
+ defaultModel={firstBootDefaultModel()}
30
+ />
28
31
  ), renderer);
29
32
  }
30
33
  });
@@ -1,6 +1,7 @@
1
1
  import type { Command } from "./registry";
2
2
  import { PersonaListOverlay } from "../components/PersonaListOverlay";
3
3
  import { ConfirmOverlay } from "../components/ConfirmOverlay";
4
+ import { isReservedPersonaId } from "../../../src/core/types/entities.js";
4
5
 
5
6
  export const deleteCommand: Command = {
6
7
  name: "delete",
@@ -34,7 +35,7 @@ export const deleteCommand: Command = {
34
35
  }
35
36
 
36
37
  const allPersonas = ctx.ei.personas();
37
- const deletable = allPersonas.filter(p => p.id !== ctx.ei.activePersonaId());
38
+ const deletable = allPersonas.filter(p => p.id !== ctx.ei.activePersonaId() && !isReservedPersonaId(p.id));
38
39
 
39
40
  const confirmAndDelete = async (personaId: string, displayName: string) => {
40
41
  const confirmed = await new Promise<boolean>((resolve) => {
@@ -112,6 +113,11 @@ export const deleteCommand: Command = {
112
113
  ctx.showNotification("Cannot delete active persona. Switch to another first.", "error");
113
114
  return;
114
115
  }
116
+
117
+ if (isReservedPersonaId(personaId)) {
118
+ ctx.showNotification(`Cannot delete reserved persona. Use /archive instead.`, "error");
119
+ return;
120
+ }
115
121
 
116
122
  const persona = allPersonas.find(p => p.id === personaId);
117
123
  await confirmAndDelete(personaId, persona?.display_name ?? nameOrAlias);
@@ -0,0 +1,30 @@
1
+ import type { Command } from "./registry";
2
+
3
+ export const importCommand: Command = {
4
+ name: "import",
5
+ aliases: [],
6
+ description: "Import a document into Ei's knowledge base",
7
+ usage: "/import <path/to/document>",
8
+
9
+ async execute(args, ctx) {
10
+ if (args.length === 0) {
11
+ ctx.showNotification("Usage: /import <path/to/document>", "warn");
12
+ return;
13
+ }
14
+
15
+ const filePath = args.join(" ");
16
+
17
+ ctx.showNotification(`Importing ${filePath}...`, "info");
18
+
19
+ try {
20
+ const result = await ctx.ei.importDocument(filePath);
21
+ ctx.showNotification(
22
+ `Importing ${result.documentName} — ${result.chunksQueued} chunk(s) queued for segmentation`,
23
+ "info"
24
+ );
25
+ } catch (err) {
26
+ const message = err instanceof Error ? err.message : String(err);
27
+ ctx.showNotification(`Import failed: ${message}`, "error");
28
+ }
29
+ },
30
+ };
@@ -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
  );