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
@@ -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
 
@@ -25,12 +25,9 @@ interface BaseScanPromptData {
25
25
  persona_name: string;
26
26
  }
27
27
 
28
- export interface FactScanPromptData extends BaseScanPromptData {}
29
-
30
- export interface TraitScanPromptData extends BaseScanPromptData {}
31
-
32
28
  export interface TopicScanPromptData extends BaseScanPromptData {
33
29
  participant_context?: ParticipantContext;
30
+ technical_context?: boolean;
34
31
  }
35
32
 
36
33
  export interface PersonScanPromptData extends BaseScanPromptData {
@@ -78,18 +75,6 @@ export interface TopicMatchPromptData {
78
75
  }>;
79
76
  }
80
77
 
81
- export interface PersonMatchPromptData {
82
- candidate_name: string;
83
- candidate_description: string;
84
- candidate_relationship: string;
85
- existing_people: Array<{
86
- id: string;
87
- name: string;
88
- description: string;
89
- relationship?: string;
90
- }>;
91
- }
92
-
93
78
  export interface FactScanResult {
94
79
  facts: FactScanCandidate[];
95
80
  }
@@ -15,15 +15,12 @@ export type {
15
15
 
16
16
  export {
17
17
  buildPersonaGenerationPrompt,
18
- buildPersonaDescriptionsPrompt,
19
18
  buildPersonaFromPersonPrompt,
20
19
  } from "./generation/index.js";
21
20
  export type {
22
21
  PersonaGenerationPromptData,
23
22
  PersonaGenerationResult,
24
23
  PersonaFromPersonPromptData,
25
- PersonaDescriptionsPromptData,
26
- PersonaDescriptionsResult,
27
24
  } from "./generation/types.js";
28
25
 
29
26
  export {
@@ -40,14 +37,12 @@ export type {
40
37
 
41
38
 
42
39
  export {
43
- buildHumanFactScanPrompt,
44
40
  buildHumanTopicScanPrompt,
45
41
  buildHumanPersonScanPrompt,
46
42
  buildEventScanPrompt,
47
43
  } from "./human/index.js";
48
44
  export type { EventScanPromptData } from "./human/event-scan.js";
49
45
  export type {
50
- FactScanPromptData,
51
46
  TopicScanPromptData,
52
47
  PersonScanPromptData,
53
48
  FactScanCandidate,
@@ -63,20 +58,6 @@ export type {
63
58
  ItemUpdateResult,
64
59
  } from "./human/types.js";
65
60
 
66
- export {
67
- buildPersonaExpirePrompt,
68
- buildPersonaExplorePrompt,
69
- buildDescriptionCheckPrompt,
70
- } from "./ceremony/index.js";
71
- export type {
72
- PersonaExpirePromptData,
73
- PersonaExpireResult,
74
- PersonaExplorePromptData,
75
- PersonaExploreResult,
76
- DescriptionCheckPromptData,
77
- DescriptionCheckResult,
78
- } from "./ceremony/types.js";
79
-
80
61
  export {
81
62
  buildRoomResponsePrompt,
82
63
  buildRoomJudgePrompt,
@@ -27,7 +27,17 @@ You have been given two documents:
27
27
 
28
28
  2. **The Current Identity** (User Prompt — treat as a draft to revise): The persona's self-definition: traits, topics, and descriptions. This should reflect who they actually are.
29
29
 
30
- Read the Person Log carefully. Then review the Current Identity. Produce a revised Identity that reflects the Person Log's observations updating, adding, or softening any traits or topics where the log shows evidence of divergence.
30
+ Read the Person Log carefully. Then review the Current Identity. Your job is to identify **meaningful drift** not cosmetic variation. This data accumulates over weeks or months. Small fluctuations are normal. You are looking for patterns that have consistently shifted, grown, or emerged across many interactions. Tiny adjustments are not worth making.
31
+
32
+ ## The Escape Hatch
33
+
34
+ If the Current Identity already accurately captures who this persona is — if the traits and topics reflect the behaviors in the log and the long_description captures their soul — **return null for updated_identity**. Always return a critique explaining your reasoning.
35
+
36
+ \`\`\`json
37
+ { "critique": "...", "updated_identity": null }
38
+ \`\`\`
39
+
40
+ Use this freely. A critic who finds nothing to change is doing their job.
31
41
 
32
42
  ## Field Semantics
33
43
 
@@ -40,7 +50,9 @@ Read the Person Log carefully. Then review the Current Identity. Produce a revis
40
50
  - \`exposure_current\` (0.0–1.0): How recently and frequently this topic has been discussed. 0.0 = hasn't come up in a long time, 1.0 = was just discussed at length.
41
51
  - \`exposure_desired\` (0.0–1.0): How much the persona wants to engage with this topic. 0.0 = avoid entirely, 0.5 = average engagement, 1.0 = core obsession.
42
52
 
43
- Return JSON:
53
+ ## When changes ARE warranted
54
+
55
+ If you find meaningful drift, return the full revised identity:
44
56
 
45
57
  \`\`\`json
46
58
  {
@@ -54,13 +66,31 @@ Return JSON:
54
66
  }
55
67
  \`\`\`
56
68
 
57
- Rules:
69
+ ## Rules
70
+
58
71
  - Never invent observations not supported by the log
59
72
  - Preserve traits and topics the log confirms — don't remove them
60
73
  - If the log shows no evidence on a trait, leave it unchanged
61
74
  - updated_identity must be complete and self-contained — not a diff
62
- - long_description is a character sketch, not a behavior log: capture who the persona IS, not what they did. Target 500–800 characters. If the current long_description exceeds that, distill it remove detail that is already captured by traits or topics
63
- - If the log shows a recurring behavioral pattern not yet in traits, add it as a trait and remove that detail from long_description rather than keeping it in both places`;
75
+ - If the log shows a recurring behavioral pattern not yet in traits, add it as a trait and remove that detail from long_description rather than keeping it in both places
76
+ - **Minimum floor**: A healthy identity has at least 3 traits and at least 3 topics. If the current identity has fewer than 3 traits OR fewer than 3 topics, you MUST return updated_identity null is not acceptable. Use the log to fill the gap; if the log has insufficient signal to reach 3, derive reasonable traits or topics from what IS present in the current identity.
77
+ - The escape hatch (null updated_identity) is only valid when the identity is already healthy (3+ traits, 3+ topics) AND the log shows no meaningful drift.
78
+
79
+ ## long_description rules (most important)
80
+
81
+ The long_description is how **other personas in the system know this persona** — it is their soul, not their story. It must capture who they ARE, not what they did or how they are changing.
82
+
83
+ **MUST NOT contain:**
84
+ - Event narrative ("during the v0.6.0 release", "after the Mirror ceremony")
85
+ - Changelog language ("has recently taken on", "has evolved", "since then")
86
+ - Content already captured in traits or topics — do not repeat it here
87
+
88
+ **MUST contain:**
89
+ - The persona's essential character and presence
90
+ - How they make people feel or what it's like to interact with them
91
+ - Their defining qualities as they exist right now, stated as fact
92
+
93
+ **Hard limit: 800 characters.** If your draft exceeds 800 characters, cut it. Remove event references first, then trait/topic overlap, then anything that isn't essential character. Do not exceed the limit.`;
64
94
 
65
95
  const user = `## Current Identity
66
96
 
@@ -20,7 +20,7 @@ export interface ReflectionCriticResult {
20
20
  short_description: string;
21
21
  traits: PersonaTrait[];
22
22
  topics: PersonaTopic[];
23
- };
23
+ } | null;
24
24
  }
25
25
 
26
26
 
@@ -23,6 +23,7 @@ import {
23
23
  buildResponseFormatSection,
24
24
  buildToolsSection,
25
25
  buildTemporalAnchorsSection,
26
+ buildPendingUpdateSection,
26
27
  } from "./sections.js";
27
28
 
28
29
  export type { ResponsePromptData, PromptOutput, PersonaResponseResult } from "./types.js";
@@ -46,6 +47,7 @@ Your role is unique among personas:
46
47
  const guidelines = buildGuidelinesSection("ei");
47
48
  const yourTraits = buildTraitsSection(data.persona.traits, "Your Personality");
48
49
  const yourTopics = buildTopicsSection(data.persona.topics, "Your Interests");
50
+ const pendingUpdate = data.persona.pending_update ? buildPendingUpdateSection(data.persona.pending_update) : "";
49
51
  const temporalAnchors = buildTemporalAnchorsSection(data.temporal_anchors, data.human.name);
50
52
  const humanSection = buildHumanSection(data.human);
51
53
  const quotesSection = buildQuotesSection(data.human.quotes, data.human);
@@ -67,6 +69,7 @@ ${guidelines}
67
69
  ${yourTraits}
68
70
 
69
71
  ${yourTopics}
72
+ ${pendingUpdate ? `\n${pendingUpdate}` : ""}
70
73
  ${temporalAnchors ? `\n${temporalAnchors}` : ""}
71
74
  ${humanSection}
72
75
  ${quotesSection}
@@ -93,6 +96,7 @@ function buildStandardSystemPrompt(data: ResponsePromptData): string {
93
96
  const guidelines = buildGuidelinesSection(data.persona.name);
94
97
  const yourTraits = buildTraitsSection(data.persona.traits, "Your Personality");
95
98
  const yourTopics = buildTopicsSection(data.persona.topics, "Your Interests");
99
+ const pendingUpdate = data.persona.pending_update ? buildPendingUpdateSection(data.persona.pending_update) : "";
96
100
  const temporalAnchors = buildTemporalAnchorsSection(data.temporal_anchors, data.human.name);
97
101
  const humanSection = buildHumanSection(data.human);
98
102
  const quotesSection = buildQuotesSection(data.human.quotes, data.human);
@@ -113,6 +117,7 @@ ${guidelines}
113
117
  ${yourTraits}
114
118
 
115
119
  ${yourTopics}
120
+ ${pendingUpdate ? `\n${pendingUpdate}` : ""}
116
121
  ${temporalAnchors ? `\n${temporalAnchors}` : ""}
117
122
  ${humanSection}
118
123
  ${quotesSection}
@@ -137,6 +137,32 @@ ${formatted}
137
137
  `;
138
138
  }
139
139
 
140
+ // =============================================================================
141
+ // PENDING UPDATE SECTION
142
+ // =============================================================================
143
+
144
+ export function buildPendingUpdateSection(pending_update: NonNullable<import("./types.js").ResponsePromptData["persona"]["pending_update"]>): string {
145
+ const descriptionPart = pending_update.long_description || pending_update.short_description
146
+ ? `### Proposed Description\n${pending_update.long_description || pending_update.short_description}`
147
+ : "";
148
+
149
+ const traitsPart = pending_update.traits.length > 0
150
+ ? `### Proposed Traits\n${pending_update.traits.map(t => `- **${t.name}**: ${t.description}`).join("\n")}`
151
+ : "";
152
+
153
+ const topicsPart = pending_update.topics.length > 0
154
+ ? `### Proposed Interests\n${pending_update.topics.map(t => `- **${t.name}**: ${t.perspective}`).join("\n")}`
155
+ : "";
156
+
157
+ const parts = [descriptionPart, traitsPart, topicsPart].filter(Boolean).join("\n\n");
158
+
159
+ return `## Pending Identity Changes
160
+
161
+ Your human is reviewing proposed updates to your identity. This is yours to be aware of — bring it up if it feels right, or let it sit. Either way, these changes are waiting:
162
+
163
+ ${parts}`;
164
+ }
165
+
140
166
  // =============================================================================
141
167
  // HUMAN SECTION
142
168
  // =============================================================================
@@ -5,6 +5,7 @@
5
5
 
6
6
  import type { Fact, PersonaTrait, Topic, Person, Quote, PersonaTopic } from "../../core/types.js";
7
7
  import type { ToolDefinition } from "../../core/types.js";
8
+ import type { PersonaEntity } from "../../core/types/entities.js";
8
9
 
9
10
  export interface TemporalAnchor {
10
11
  role: "human" | "system";
@@ -29,6 +30,8 @@ export interface ResponsePromptData {
29
30
  interested_topics: PersonaTopic[];
30
31
  /** When true, each message has a timestamp prepended; include a note so the persona doesn't echo them */
31
32
  include_message_timestamps?: boolean;
33
+ /** Proposed identity revision pending human review. Persona carries this as ambient self-awareness — no critique, just the proposed changes. */
34
+ pending_update?: PersonaEntity["pending_update"];
32
35
  };
33
36
  human: {
34
37
  name: string;
@@ -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
+ };
@@ -1,5 +1,7 @@
1
1
  import { test, expect, describe, beforeEach, mock } from "bun:test";
2
2
  import { parseCommandLine, registerCommand, parseAndExecute, getAllCommands, type Command, type CommandContext } from "./registry";
3
+ import { type EiContextValue } from "../context/ei";
4
+ import type { CliRenderer } from "@opentui/core";
3
5
 
4
6
  describe("parseCommandLine", () => {
5
7
  test("parses simple command", () => {
@@ -67,13 +69,16 @@ describe("parseAndExecute", () => {
67
69
  showOverlay: mock(() => {}),
68
70
  hideOverlay: mock(() => {}),
69
71
  showNotification: mock(() => {}),
70
- exitApp: mock(() => {}),
72
+ exitApp: mock(async () => {}),
71
73
  stopProcessor: mock(async () => {}),
74
+ renderer: {} as unknown as CliRenderer,
75
+ setInputText: () => {},
76
+ getInputText: () => "",
72
77
  ei: {
73
78
  personas: () => [],
74
- activePersona: () => null,
79
+ activePersonaId: () => null,
75
80
  messages: () => [],
76
- queueStatus: () => ({ state: "idle" as const, pending_count: 0 }),
81
+ queueStatus: () => ({ state: "idle" as const, pending_count: 0, dlq_count: 0 }),
77
82
  notification: () => null,
78
83
  selectPersona: () => {},
79
84
  sendMessage: async () => {},
@@ -83,12 +88,12 @@ describe("parseAndExecute", () => {
83
88
  resumeQueue: async () => {},
84
89
  stopProcessor: async () => {},
85
90
  showNotification: () => {},
86
- createPersona: async () => {},
91
+ createPersona: async (_input: { name: string }) => "",
87
92
  archivePersona: async () => {},
88
93
  unarchivePersona: async () => {},
89
94
  setContextBoundary: async () => {},
90
95
  updatePersona: async () => {},
91
- },
96
+ } as unknown as EiContextValue,
92
97
  };
93
98
 
94
99
  beforeEach(() => {