ei-tui 0.9.4 → 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 (55) 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/index.ts +2 -0
  6. package/src/core/handlers/rewrite.ts +13 -9
  7. package/src/core/heartbeat-manager.ts +2 -2
  8. package/src/core/llm-client.ts +11 -1
  9. package/src/core/message-manager.ts +20 -18
  10. package/src/core/orchestrators/ceremony.ts +83 -40
  11. package/src/core/orchestrators/human-extraction.ts +5 -1
  12. package/src/core/persona-manager.ts +4 -0
  13. package/src/core/processor.ts +90 -1
  14. package/src/core/queue-manager.ts +35 -0
  15. package/src/core/state/queue.ts +9 -1
  16. package/src/core/state-manager.ts +4 -0
  17. package/src/core/types/entities.ts +15 -0
  18. package/src/core/types/enums.ts +1 -0
  19. package/src/core/types/integrations.ts +2 -0
  20. package/src/core/types/llm.ts +9 -0
  21. package/src/integrations/document/chunker.ts +88 -0
  22. package/src/integrations/document/importer.ts +82 -0
  23. package/src/integrations/document/index.ts +2 -0
  24. package/src/integrations/document/invoice.ts +63 -0
  25. package/src/integrations/document/types.ts +16 -0
  26. package/src/integrations/document/unsource.ts +164 -0
  27. package/src/integrations/persona-history/importer.ts +197 -0
  28. package/src/integrations/persona-history/index.ts +3 -0
  29. package/src/integrations/persona-history/types.ts +7 -0
  30. package/src/prompts/ceremony/dedup.ts +7 -3
  31. package/src/prompts/ceremony/index.ts +2 -1
  32. package/src/prompts/ceremony/people-rewrite.ts +190 -0
  33. package/src/prompts/ceremony/{rewrite.ts → topic-rewrite.ts} +103 -78
  34. package/src/prompts/human/person-scan.ts +13 -4
  35. package/src/prompts/human/topic-scan.ts +16 -2
  36. package/src/prompts/human/topic-update.ts +36 -4
  37. package/src/prompts/human/types.ts +1 -0
  38. package/src/storage/indexed.ts +4 -0
  39. package/src/storage/interface.ts +1 -0
  40. package/src/storage/local.ts +4 -0
  41. package/src/templates/emmett.ts +49 -0
  42. package/tui/README.md +22 -0
  43. package/tui/src/app.tsx +9 -6
  44. package/tui/src/commands/delete.tsx +7 -1
  45. package/tui/src/commands/import.tsx +30 -0
  46. package/tui/src/commands/unsource.tsx +115 -0
  47. package/tui/src/components/PromptInput.tsx +4 -0
  48. package/tui/src/components/WelcomeOverlay.tsx +58 -32
  49. package/tui/src/context/ei.tsx +80 -60
  50. package/tui/src/index.tsx +14 -0
  51. package/tui/src/storage/file.ts +11 -5
  52. package/tui/src/util/e2e-flags.ts +4 -3
  53. package/tui/src/util/help-content.ts +20 -0
  54. package/tui/src/util/provider-detection.ts +251 -0
  55. package/tui/src/util/yaml-human.ts +7 -1
@@ -1,23 +1,41 @@
1
1
  import type { RewriteScanPromptData, RewritePromptData } from "./types.js";
2
2
 
3
3
  // =============================================================================
4
- // PHASE 1: SCAN — Identify distinct subjects in a bloated item
4
+ // PHASE 1: SCAN — Identify subjects that don't belong in a Topic record
5
5
  // =============================================================================
6
6
 
7
- export function buildRewriteScanPrompt(data: RewriteScanPromptData): { system: string; user: string } {
8
- const typeLabel = data.itemType.charAt(0).toUpperCase() + data.itemType.slice(1);
7
+ function stripEmbedding<T extends { embedding?: unknown }>(item: T): Omit<T, "embedding"> {
8
+ const { embedding: _, ...rest } = item;
9
+ return rest as Omit<T, "embedding">;
10
+ }
9
11
 
10
- const system = `You are auditing a personal knowledge base. A single ${typeLabel} record has grown too large because unrelated information was repeatedly appended to it over time. The record's Name suggests its intended subject, but its Description now covers many additional, unrelated subjects.
12
+ export function buildTopicRewriteScanPrompt(data: RewriteScanPromptData): { system: string; user: string } {
13
+ const isTechnical = (data.item as { category?: string }).category === "Technical";
11
14
 
12
- Your job: identify the **additional** subjects buried in this record that do NOT belong under the record's Name.
15
+ const technicalGuidance = isTechnical
16
+ ? `
17
+ ## Technical Topic Guidance
13
18
 
14
- Rules:
15
- - Do NOT include the record's primary subject (what its Name describes) — only the extra, unrelated subjects.
16
- - Each subject should be a succinct phrase (2-8 words) that could serve as a search query.
17
- - Be specific. "Technical preferences" is too vague. "TypeScript coding conventions" is better.
18
- - If the record is actually cohesive and on-topic despite its length, return an empty array.
19
+ This is a Technical topic — a knowledge base for a specific technology, platform, or tool. Technical topics are ALLOWED to be dense and detailed.
20
+
21
+ Only flag subjects that are about a **different** technology or workflow than the one named in this record. For example:
22
+ - A Uniform topic containing Turborepo setup details flag "Turborepo monorepo setup"
23
+ - A Uniform topic containing Vercel preview gotchas do NOT flag (that's core Uniform knowledge)
24
+ - An AWS Bedrock topic containing Twilio integration details → flag "Twilio integration"
25
+ `
26
+ : "";
19
27
 
20
- Return a raw JSON array of strings. No markdown fencing, no commentary, no explanation. Just the array.
28
+ const system = `You are auditing a Topic record in a personal knowledge base. A single Topic record has grown too large because unrelated information was repeatedly added over time. The record's Name suggests its intended subject, but its Description now covers additional, unrelated subjects.
29
+
30
+ Your job: identify the **extra subjects** buried in this record that do NOT belong under the record's Name.
31
+
32
+ Rules:
33
+ - Do NOT include the record's primary subject (what its Name describes) — only the extra, unrelated subjects
34
+ - Each subject should be a succinct phrase (2-8 words) that could serve as a search query
35
+ - Be specific: "TypeScript coding conventions" beats "technical preferences"
36
+ - If the record is cohesive and on-topic despite its length, return an empty array
37
+ ${technicalGuidance}
38
+ Return a raw JSON array of strings. No markdown fencing, no commentary.
21
39
 
22
40
  Example — a Topic named "Software Engineering" whose description also discusses vim keybindings, git conventions, and AI tooling:
23
41
  ["vim keybindings and editor configuration", "git and GitHub workflow conventions", "AI coding assistant preferences"]`;
@@ -25,10 +43,10 @@ Example — a Topic named "Software Engineering" whose description also discusse
25
43
  const payload = JSON.stringify(stripEmbedding(data.item), null, 2);
26
44
 
27
45
  const schemaReminder = `**Return JSON:**
28
- \n\`\`\`json
46
+ \`\`\`json
29
47
  [
30
- "topic about vim keybindings",
31
- "git workflow conventions",
48
+ "vim keybindings and editor configuration",
49
+ "git and GitHub workflow conventions",
32
50
  "AI coding assistant preferences"
33
51
  ]
34
52
  \`\`\`
@@ -45,13 +63,70 @@ ${schemaReminder}`;
45
63
  }
46
64
 
47
65
  // =============================================================================
48
- // PHASE 2: REWRITEReorganize data across existing and new items
66
+ // PHASE 2: SPLITSlim the Topic, redistribute subjects to new/existing records
49
67
  // =============================================================================
50
68
 
51
- export function buildRewritePrompt(data: RewritePromptData): { system: string; user: string } {
52
- const typeLabel = data.itemType.charAt(0).toUpperCase() + data.itemType.slice(1);
69
+ function buildTopicExistingExample(): string {
70
+ return `Topic:
71
+ {
72
+ "id": "existing-uuid",
73
+ "type": "topic",
74
+ "name": "Topic Name",
75
+ "description": "Updated topic description",
76
+ "category": "Interest"
77
+ }
78
+
79
+ Person:
80
+ {
81
+ "id": "existing-uuid",
82
+ "type": "person",
83
+ "name": "Person Name",
84
+ "description": "Updated person description",
85
+ "relationship": "coworker"
86
+ }`;
87
+ }
88
+
89
+ function buildTopicNewExample(isTechnical: boolean): string {
90
+ const categoryHint = isTechnical
91
+ ? `"category": "Technical" // Split topics from a Technical record inherit Technical category unless clearly different`
92
+ : `"category": "Interest|Goal|Dream|Conflict|Concern|Fear|Hope|Plan|Project|Event|Technical"`;
93
+
94
+ return `Topic:
95
+ {
96
+ "type": "topic",
97
+ "name": "New Topic Name",
98
+ "description": "Concise topic description",
99
+ "sentiment": 0.0,
100
+ ${categoryHint}
101
+ }
102
+
103
+ Person:
104
+ {
105
+ "type": "person",
106
+ "name": "New Person Name",
107
+ "description": "Concise person description",
108
+ "sentiment": 0.0,
109
+ "relationship": "friend"
110
+ }`;
111
+ }
112
+
113
+ export function buildTopicRewriteSplitPrompt(data: RewritePromptData): { system: string; user: string } {
114
+ const isTechnical = (data.item as { category?: string }).category === "Technical";
115
+
116
+ const descriptionGuidance = isTechnical
117
+ ? `ideally under 600 characters, never over 900 — Technical topics are knowledge bases that preserve specific gotchas, decisions, and open questions`
118
+ : `ideally under 300 characters, never over 500`;
53
119
 
54
- const system = `You are reorganizing a personal knowledge base. A ${typeLabel} record has become a catch-all for several unrelated subjects. An earlier analysis identified the extra subjects, and we searched our knowledge base for potentially matching existing records.
120
+ const technicalCategoryNote = isTechnical
121
+ ? `\n- Topics split from a Technical record should inherit category "Technical" unless the subject is clearly a different type (e.g., a personal interest extracted from a technical topic)`
122
+ : "";
123
+
124
+ const noSubjectsGate = data.subjects.length === 0
125
+ ? `\n**IMPORTANT: No extra subjects were identified for this record. The correct response is to return the original record unchanged in "existing" with an empty "new" array. Do NOT create new records. Do NOT modify the description.**\n`
126
+ : "";
127
+
128
+ const system = `You are reorganizing a personal knowledge base. A Topic record has become a catch-all for several unrelated subjects. An earlier analysis identified the extra subjects, and we searched the knowledge base for potentially matching existing records.
129
+ ${noSubjectsGate}
55
130
 
56
131
  The search results under each subject are our **best guesses** — they may not be accurate matches. Only merge data into an existing record if the subject matter genuinely overlaps. Similar names with different meanings should produce a NEW record instead.
57
132
 
@@ -60,26 +135,26 @@ Your job:
60
135
  2. **Create new records**: For subjects with no appropriate match among the search results, create a new record.
61
136
  3. **Slim the original**: Remove all data from the original record that now lives elsewhere. The original should contain ONLY information directly relevant to its Name.
62
137
 
63
- Return raw JSON with exactly two keys. No markdown fencing, no commentary. Just the JSON object:
138
+ Return raw JSON with exactly two keys. No markdown fencing, no commentary:
64
139
  {
65
140
  "existing": [ /* updated records, including the slimmed-down original */ ],
66
141
  "new": [ /* brand-new records for subjects with no match */ ]
67
142
  }
68
143
 
69
144
  Record format for "existing" entries (MUST include "id" and "type"):
70
- ${buildExistingExamples()}
145
+ ${buildTopicExistingExample()}
71
146
 
72
147
  Record format for "new" entries (NO "id" field — the system assigns one):
73
- ${buildNewExamples()}
148
+ ${buildTopicNewExample(isTechnical)}
74
149
 
75
150
  Rules:
76
- - The original record (id: "${data.item.id}") MUST appear in "existing", slimmed down.
77
- - Descriptions should be concise: ${data.itemType === 'topic' ? 'ideally under 300 characters, never over 500' : 'ideally under 600 characters, never over 1000'}.
78
- - Preserve sentiment, strength, confidence, and other numeric values from the source record where applicable.
79
- - "type" must be one of: "topic", "person".
80
- - Topics MUST include "category" — one of: Interest, Goal, Dream, Conflict, Concern, Fear, Hope, Plan, Project, Event. For Event topics, the description should be a narrative account of a specific moment, not a general summary.
151
+ - The original record (id: "${data.item.id}") MUST appear in "existing", slimmed down
152
+ - Descriptions should be concise: ${descriptionGuidance}
153
+ - Preserve sentiment and other numeric values from the source record where applicable
154
+ - "type" must be one of: "topic", "person"
155
+ - Topics MUST include "category" — one of: Interest, Goal, Dream, Conflict, Concern, Fear, Hope, Plan, Project, Event, Technical. For Event topics, the description should be a narrative account of a specific moment, not a general summary. For Technical topics, split by distinct technical concept (e.g., "Uniform Composition Model" vs "Uniform Preview Setup") — preserve specificity over brevity
81
156
  - People MUST include "relationship" — a short label like "coworker", "friend", "mentor", etc.
82
- - Do NOT invent information. Only redistribute what exists in the original record.`;
157
+ - Do NOT invent information. Only redistribute what exists in the original record${technicalCategoryNote}`;
83
158
 
84
159
  const subjects = data.subjects.map(s => ({
85
160
  search_term: s.searchTerm,
@@ -93,7 +168,7 @@ Rules:
93
168
  };
94
169
 
95
170
  const schemaReminder = `**Return JSON:**
96
- \n\`\`\`json
171
+ \`\`\`json
97
172
  {
98
173
  "existing": [
99
174
  {
@@ -123,53 +198,3 @@ ${schemaReminder}`;
123
198
 
124
199
  return { system, user };
125
200
  }
126
-
127
- // =============================================================================
128
- // Helpers
129
- // =============================================================================
130
-
131
- /** Strip embedding arrays from items before putting them in prompts — they're huge and useless to the LLM. */
132
- function stripEmbedding<T extends { embedding?: unknown }>(item: T): Omit<T, "embedding"> {
133
- const { embedding: _, ...rest } = item;
134
- return rest as Omit<T, "embedding">;
135
- }
136
-
137
- function buildExistingExamples(): string {
138
- return `Topic:
139
- {
140
- "id": "existing-uuid",
141
- "type": "topic",
142
- "name": "Topic Name",
143
- "description": "Updated topic description",
144
- "category": "Interest"
145
- }
146
-
147
- Person:
148
- {
149
- "id": "existing-uuid",
150
- "type": "person",
151
- "name": "Person Name",
152
- "description": "Updated person description",
153
- "relationship": "coworker"
154
- }`;
155
- }
156
-
157
- function buildNewExamples(): string {
158
- return `Topic:
159
- {
160
- "type": "topic",
161
- "name": "New Topic Name",
162
- "description": "Concise topic description",
163
- "sentiment": 0.0,
164
- "category": "Interest|Goal|Dream|Conflict|Concern|Fear|Hope|Plan|Project|Event"
165
- }
166
-
167
- Person:
168
- {
169
- "type": "person",
170
- "name": "New Person Name",
171
- "description": "Concise person description",
172
- "sentiment": 0.0,
173
- "relationship": "friend"
174
- }`;
175
- }
@@ -43,6 +43,8 @@ Flag a PERSON when they were meaningfully discussed — not just mentioned in pa
43
43
 
44
44
  Be **conservative**: ignore one-off mentions, greetings, small talk, or jokes. Only flag people who matter to the human user's life.
45
45
 
46
+ A person is **not worth flagging** if they have no name AND appear only to attribute a single event ("a coworker showed me this band", "a friend told me about it", "some guy I know"). The human user having a contact who did one thing is not a meaningful discussion of that person.
47
+
46
48
  ## What a PERSON Is
47
49
 
48
50
  Someone in the human user's world.
@@ -68,11 +70,18 @@ Use the specific value where possible (e.g. "Father", "Brother", "Coworker"). Av
68
70
 
69
71
  ## When Identity Is Unclear
70
72
 
71
- If you can't identify which "Bob" or which "Brother" the user means, use "Unknown" and explain in the reason field. This triggers a later step to resolve ambiguity.
73
+ "Unknown" is ONLY for people who are **meaningfully and repeatedly discussed** but whose name isn't given. It is NOT a catch-all for any nameless mention.
74
+
75
+ ✓ USE "Unknown":
76
+ - name: "Unknown", relationship: "Brother", reason: "User talked at length about their brother across multiple messages without naming him"
77
+
78
+ ✗ DO NOT USE "Unknown" for one-off attributions:
79
+ - "a coworker showed me this band" → **skip entirely** — not a person, just attribution
80
+ - "a friend told me about it" → **skip entirely**
81
+ - "some guy I know" → **skip entirely**
82
+ - "a coworker at [company name]" with no personal name → **skip entirely** — a company name is NOT a person's name
72
83
 
73
- Examples:
74
- - name: "Alice from work", relationship: "Coworker", description: "Mentioned but not described further", reason: "User referenced a work colleague named Alice"
75
- - name: "Unknown", relationship: "Sibling", description: "User mentioned a sibling but did not give a name", reason: "User said 'my brother' without further context"
84
+ If someone has no personal name and appears only to explain how the user found something or heard about something, they are not a person in the user's life worth tracking. Do not extract them. A single interaction — even a meaningful one — does not make someone a contact.
76
85
 
77
86
  ## Identifiers (optional)
78
87
 
@@ -14,6 +14,17 @@ function participantContextSection(ctx: ParticipantContext | undefined): string
14
14
  return lines.join("\n");
15
15
  }
16
16
 
17
+ function technicalContextSection(technical_context: boolean | undefined): string {
18
+ if (!technical_context) return "";
19
+ return `## Technical Context
20
+
21
+ This conversation originates from a technical source (coding tool session, developer workflow). The human is likely a developer or technical user.
22
+
23
+ **Treat Technical as a priority category** for topics that are tools, platforms, frameworks, libraries, or technical concepts being actively learned, evaluated, or built with. Flag these even if they seem like passing mentions — technical knowledge compounds and is worth preserving.
24
+
25
+ `;
26
+ }
27
+
17
28
  export function buildHumanTopicScanPrompt(data: TopicScanPromptData): PromptOutput {
18
29
  if (!data.persona_name) {
19
30
  throw new Error("buildHumanTopicScanPrompt: persona_name is required");
@@ -56,9 +67,12 @@ Assign each TOPIC one category. Pick the closest fit:
56
67
  - **Plan** — concrete intentions with steps in mind
57
68
  - **Project** — active undertakings with real progress
58
69
  - **Event** — a specific, significant moment that either party might reference later ("remember when...")
70
+ - **Technical** — a tool, platform, framework, library, or technical concept being actively learned, evaluated, or built with
59
71
 
60
72
  When in doubt, pick the closest match. The update step will refine it.
61
73
 
74
+ ${technicalContextSection(data.technical_context)}
75
+
62
76
  ## Output Format
63
77
 
64
78
  \`\`\`json
@@ -67,7 +81,7 @@ When in doubt, pick the closest match. The update step will refine it.
67
81
  {
68
82
  "name": "Short label for the topic (10-75 characters)",
69
83
  "description": "1-2 sentences: what this topic is and why it matters to the user",
70
- "category": "One of the categories above",
84
+ "category": "One of the categories above (Interest|Goal|Dream|Conflict|Concern|Fear|Hope|Plan|Project|Event|Technical)",
71
85
  "reason": "Evidence from the conversation that justified flagging this topic"
72
86
  }
73
87
  ]
@@ -104,7 +118,7 @@ Scan the "Most Recent Messages" for TOPICS of interest to the human user.
104
118
  {
105
119
  "name": "Short label for the topic (10-75 characters)",
106
120
  "description": "1-2 sentences: what this topic is and why it matters to the user",
107
- "category": "Interest|Goal|Dream|Conflict|Concern|Fear|Hope|Plan|Project|Event",
121
+ "category": "Interest|Goal|Dream|Conflict|Concern|Fear|Hope|Plan|Project|Event|Technical",
108
122
  "reason": "Evidence from the conversation that justified flagging this topic"
109
123
  }
110
124
  ]
@@ -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) |
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
+ };