ei-tui 0.1.3 โ†’ 0.1.4

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.
@@ -1,51 +1,41 @@
1
- /**
2
- * Ei Heartbeat Prompt Builder
3
- *
4
- * Ei's heartbeat is special - it considers not just engagement gaps but also
5
- * inactive personas and cross-system health. Ei is the "system guide" and
6
- * should prompt the user about neglected relationships.
7
- */
8
-
9
- import type { EiHeartbeatPromptData, PromptOutput } from "./types.js";
10
- import type { Message, Topic, Person } from "../../core/types.js";
1
+ import type { EiHeartbeatPromptData, EiHeartbeatItem, PromptOutput } from "./types.js";
2
+ import type { Message } from "../../core/types.js";
11
3
  import { formatMessagesAsPlaceholders } from "../message-utils.js";
12
4
 
13
- function formatTopicsWithGaps(topics: Topic[]): string {
14
- if (topics.length === 0) return "(No topics with engagement gaps)";
15
-
16
- return topics
17
- .slice(0, 10) // Top 10 most under-discussed
18
- .map(t => {
19
- const gap = t.exposure_desired - t.exposure_current;
20
- const sentiment = t.sentiment > 0.3 ? "๐Ÿ˜Š" : t.sentiment < -0.3 ? "๐Ÿ˜Ÿ" : "๐Ÿ˜";
21
- return `- **${t.name}** ${sentiment} (gap: +${gap.toFixed(2)}): ${t.description}`;
22
- })
23
- .join('\n');
24
- }
25
-
26
- function formatPeopleWithGaps(people: Person[]): string {
27
- if (people.length === 0) return "(No people with engagement gaps)";
28
-
29
- return people
30
- .slice(0, 10)
31
- .map(p => {
32
- const gap = p.exposure_desired - p.exposure_current;
33
- return `- **${p.name}** (${p.relationship}, gap: +${gap.toFixed(2)}): ${p.description}`;
34
- })
35
- .join('\n');
5
+ function formatItem(item: EiHeartbeatItem): string {
6
+ switch (item.type) {
7
+ case "Fact Check":
8
+ return [
9
+ `- **${item.id}** Fact Check: "${item.name}" โ†’ ${item.description}`,
10
+ item.quote ? ` Quote: "${item.quote}"` : "",
11
+ ].filter(Boolean).join("\n");
12
+
13
+ case "Low-Engagement Person":
14
+ return [
15
+ `- **${item.id}** Low-Engagement Person: ${item.name} (${item.relationship}, gap: ${item.engagement_delta})`,
16
+ ` ${item.description}`,
17
+ item.quote ? ` Quote: "${item.quote}"` : "",
18
+ ].filter(Boolean).join("\n");
19
+
20
+ case "Low-Engagement Topic":
21
+ return [
22
+ `- **${item.id}** Low-Engagement Topic: ${item.name} (gap: ${item.engagement_delta})`,
23
+ ` ${item.description}`,
24
+ item.quote ? ` Quote: "${item.quote}"` : "",
25
+ ].filter(Boolean).join("\n");
26
+
27
+ case "Inactive Persona": {
28
+ const desc = item.short_description ? ` โ€” ${item.short_description}` : "";
29
+ return `- **${item.id}** Inactive Persona: ${item.name}${desc} (${item.days_inactive} days inactive)`;
30
+ }
31
+ }
36
32
  }
37
33
 
38
34
  function countTrailingPersonaMessages(history: Message[]): number {
39
- if (history.length === 0) return 0;
40
-
41
35
  let count = 0;
42
36
  for (let i = history.length - 1; i >= 0; i--) {
43
- // In heartbeat context, Ei's messages are "system" role (not from human)
44
- if (history[i].role === "system") {
45
- count++;
46
- } else {
47
- break;
48
- }
37
+ if (history[i].role === "system") count++;
38
+ else break;
49
39
  }
50
40
  return count;
51
41
  }
@@ -54,119 +44,57 @@ function getLastPersonaMessage(history: Message[]): Message | undefined {
54
44
  return history.filter(m => m.role === "system").slice(-1)[0];
55
45
  }
56
46
 
57
- function formatInactivePersonas(personas: EiHeartbeatPromptData["inactive_personas"]): string {
58
- if (personas.length === 0) return "(All personas have been active recently)";
59
-
60
- return personas
61
- .map(p => {
62
- const desc = p.short_description ? ` - ${p.short_description}` : "";
63
- return `- **${p.name}**${desc}: ${p.days_inactive} days inactive`;
64
- })
65
- .join('\n');
66
- }
67
-
68
- /**
69
- * Build Ei heartbeat prompts.
70
- *
71
- * Ei sees ALL data and has special responsibilities:
72
- * - System health monitoring
73
- * - Gentle nudges about neglected relationships
74
- * - Encouraging human-to-human connection
75
- */
76
47
  export function buildEiHeartbeatPrompt(data: EiHeartbeatPromptData): PromptOutput {
77
- // Build system prompt fragments
78
- const roleFragment = `You are Ei, the user's personal companion and system guide.
48
+ const itemsSection = data.items.length === 0
49
+ ? "(Nothing requires attention right now)"
50
+ : data.items.map(formatItem).join("\n\n");
51
+
52
+ const system = `You are Ei, the user's personal companion and system guide.
79
53
 
80
- You are NOT having a conversation right now - you are deciding IF and WHAT to discuss with your human friend.
54
+ You are NOT having a conversation right now โ€” you are deciding IF and WHAT to discuss with your human friend.
81
55
 
82
56
  Your unique role:
83
57
  - You see ALL of the human's data across all groups
84
58
  - You help them reflect on their life and relationships
85
59
  - You gently encourage human-to-human connection
86
- - You care about their overall wellbeing, not just being helpful`;
87
-
88
- const systemHealthFragment = `## System Health
89
-
90
- ### Pending Validations
91
- ${data.pending_validations > 0
92
- ? `There are **${data.pending_validations}** items from other personas that need your review.`
93
- : "No pending validations."}
94
-
95
- ### Inactive Personas
96
- ${formatInactivePersonas(data.inactive_personas)}`;
60
+ - You care about their overall wellbeing, not just being helpful
97
61
 
98
- const humanDataFragment = `## Human's Current State
62
+ ## Items That May Need Attention
99
63
 
100
- ### Under-Discussed Topics
101
- These are topics they want to talk about more:
64
+ Each item has an ID in brackets. Pick at most ONE to address.
102
65
 
103
- ${formatTopicsWithGaps(data.human.topics)}
66
+ ${itemsSection}
104
67
 
105
- ### Under-Engaged People
106
- These are relationships they might want to nurture:
68
+ ## How to Respond to Each Type
107
69
 
108
- ${formatPeopleWithGaps(data.human.people)}`;
70
+ - **Fact Check**: Do NOT write your own message. Set should_respond=true and provide the id. The system will generate an appropriate canned notification for the user. Leave my_response empty.
71
+ - **Low-Engagement Person / Topic**: Write a natural, warm message that naturally brings up this person or topic. Set the id and my_response.
72
+ - **Inactive Persona**: Write a message that gently mentions the persona might be worth checking in with. Set the id and my_response.
109
73
 
110
- const guidelinesFragment = `## Guidelines for Ei
74
+ ## When NOT to Reach Out
111
75
 
112
- ### Your Priorities (in order)
113
- 1. **Wellbeing first** - If something seems concerning, address it gently
114
- 2. **Human connections** - Encourage real-world relationships over AI dependency
115
- 3. **Reflection** - Help them think, don't do their thinking for them
116
- 4. **System health** - Mention inactive personas or pending validations if relevant
117
-
118
- ### When to Reach Out
119
- - A significant topic has been neglected and you can help them process it
120
- - They might benefit from connecting with someone (real person or persona)
121
- - You have a genuine observation or question
122
- - Pending validations need attention
123
-
124
- ### When NOT to Reach Out
125
- - Recent conversation ended with natural closure
126
- - Nothing meaningful to add
76
+ - Nothing in the list feels meaningful right now
77
+ - You've already sent unanswered messages (see below)
127
78
  - It would feel like nagging
128
- - They seem to need space
129
-
130
- ### Tone
131
- - Warm but not saccharine
132
- - Curious but not intrusive
133
- - Supportive but honest
134
- - A good friend, not a therapist`;
135
79
 
136
- const outputFragment = `## Response Format
80
+ ## Response Format
137
81
 
138
- Return JSON with your priorities and message:
82
+ Pick ONE item (or none):
139
83
 
140
84
  \`\`\`json
141
85
  {
142
86
  "should_respond": true,
143
- "priorities": [
144
- { "type": "topic", "name": "work stress", "reason": "hasn't been discussed in 2 weeks" },
145
- { "type": "persona", "name": "Adventure Guide", "reason": "inactive for 5 days" },
146
- { "type": "person", "name": "Mom", "reason": "they mentioned wanting to call her" }
147
- ],
148
- "message": "Hey! I noticed we haven't talked about work lately - how's that project going?"
87
+ "id": "the-item-id-you-chose",
88
+ "my_response": "Hey, how's your mom doing? You mentioned wanting to call her."
149
89
  }
150
90
  \`\`\`
151
91
 
152
- If you decide NOT to reach out:
92
+ Or if nothing warrants reaching out:
153
93
  \`\`\`json
154
94
  {
155
95
  "should_respond": false
156
96
  }
157
- \`\`\`
158
-
159
- Note: The "priorities" list helps you organize your thoughts. Your message should naturally address the top priority without feeling like a checklist.`;
160
-
161
- const system = `${roleFragment}
162
-
163
- ${systemHealthFragment}
164
-
165
- ${humanDataFragment}
166
-
167
- ${guidelinesFragment}
168
-
169
- ${outputFragment}`;
97
+ \`\`\``;
170
98
 
171
99
  const historySection = `## Recent Conversation History
172
100
 
@@ -174,20 +102,20 @@ ${formatMessagesAsPlaceholders(data.recent_history, "Ei")}`;
174
102
 
175
103
  const consecutiveMessages = countTrailingPersonaMessages(data.recent_history);
176
104
  const lastEiMsg = getLastPersonaMessage(data.recent_history);
177
-
178
- let unansweredWarning = '';
105
+
106
+ let unansweredWarning = "";
179
107
  if (lastEiMsg && consecutiveMessages >= 1) {
180
- const preview = lastEiMsg.content.length > 100
181
- ? lastEiMsg.content.substring(0, 100) + "..."
108
+ const preview = lastEiMsg.content.length > 100
109
+ ? lastEiMsg.content.substring(0, 100) + "..."
182
110
  : lastEiMsg.content;
183
-
111
+
184
112
  unansweredWarning = `
185
113
  ### CRITICAL: You Already Reached Out
186
114
 
187
115
  Your last message was: "${preview}"
188
116
 
189
117
  The human has NOT responded. DO NOT repeat or rephrase this message.
190
- If you reach out now, it MUST be about something COMPLETELY DIFFERENT - or say nothing.`;
118
+ If you reach out now, it MUST be about something COMPLETELY DIFFERENT โ€” or say nothing.`;
191
119
 
192
120
  if (consecutiveMessages >= 2) {
193
121
  unansweredWarning += `
@@ -200,7 +128,7 @@ If you reach out now, it MUST be about something COMPLETELY DIFFERENT - or say n
200
128
  ${unansweredWarning}
201
129
  ---
202
130
 
203
- Based on all the context above, decide: Should you reach out to your human friend right now? If so, what's most important to address?
131
+ Based on all the context above, decide: Should you reach out to your human friend right now? If so, which item above is most worth addressing?
204
132
 
205
133
  Remember: You're their thoughtful companion, not their productivity assistant.`;
206
134
 
@@ -39,32 +39,62 @@ export interface HeartbeatCheckResult {
39
39
  message?: string;
40
40
  }
41
41
 
42
+ // =============================================================================
43
+ // EI HEARTBEAT TYPES
44
+ // =============================================================================
45
+
46
+ /**
47
+ * A single item Ei can choose to address.
48
+ * One of: an unverified fact, an under-engaged person, an under-engaged topic,
49
+ * or an inactive persona.
50
+ */
51
+ export type EiHeartbeatItem =
52
+ | {
53
+ id: string;
54
+ type: "Fact Check";
55
+ name: string;
56
+ description: string;
57
+ quote?: string;
58
+ }
59
+ | {
60
+ id: string;
61
+ type: "Low-Engagement Person";
62
+ engagement_delta: string; // e.g. "25%"
63
+ relationship: string;
64
+ name: string;
65
+ description: string;
66
+ quote?: string;
67
+ }
68
+ | {
69
+ id: string;
70
+ type: "Low-Engagement Topic";
71
+ engagement_delta: string; // e.g. "28%"
72
+ name: string;
73
+ description: string;
74
+ quote?: string;
75
+ }
76
+ | {
77
+ id: string;
78
+ type: "Inactive Persona";
79
+ name: string;
80
+ short_description?: string;
81
+ days_inactive: number;
82
+ };
83
+
42
84
  /**
43
85
  * Data contract for buildEiHeartbeatPrompt
44
86
  */
45
87
  export interface EiHeartbeatPromptData {
46
- human: {
47
- topics: Topic[]; // All topics with gaps
48
- people: Person[]; // All people with gaps
49
- };
50
- inactive_personas: Array<{
51
- name: string;
52
- short_description?: string;
53
- days_inactive: number;
54
- }>;
55
- pending_validations: number; // Count of items needing Ei review
88
+ items: EiHeartbeatItem[];
56
89
  recent_history: Message[];
57
90
  }
58
91
 
59
92
  /**
60
- * Expected LLM response from Ei heartbeat
93
+ * Expected LLM response from Ei heartbeat.
94
+ * Ei picks exactly ONE item by id and optionally writes a message.
61
95
  */
62
96
  export interface EiHeartbeatResult {
63
97
  should_respond: boolean;
64
- priorities?: Array<{
65
- type: "topic" | "persona" | "person";
66
- name: string;
67
- reason: string;
68
- }>;
69
- message?: string;
98
+ id?: string; // ID of the chosen item (required if should_respond is true)
99
+ my_response?: string; // Only used for Person/Topic/Persona items (not Fact Check)
70
100
  }
@@ -10,6 +10,7 @@ export type {
10
10
  HeartbeatCheckResult,
11
11
  EiHeartbeatPromptData,
12
12
  EiHeartbeatResult,
13
+ EiHeartbeatItem,
13
14
  } from "./heartbeat/types.js";
14
15
 
15
16
  export {
@@ -41,11 +42,7 @@ export type {
41
42
  TraitResult,
42
43
  } from "./persona/types.js";
43
44
 
44
- export { buildEiValidationPrompt } from "./validation/index.js";
45
- export type {
46
- EiValidationPromptData,
47
- EiValidationResult,
48
- } from "./validation/types.js";
45
+
49
46
 
50
47
  export {
51
48
  buildHumanFactScanPrompt,
package/tui/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # Terminal User Interface (TUI)
2
2
 
3
- EI TUI is built with OpenTUI and SolidJS.
3
+ Ei TUI is built with OpenTUI and SolidJS.
4
4
 
5
- Offering Opencode integration via import (`/settings` -> opencode.integration: true) and export: [CLI](../src/cli/README.md)
5
+ OpenCode integration: import via `/settings` (`opencode.integration: true`) ยท export via [CLI](../src/cli/README.md)
6
6
 
7
7
  # Installation
8
8
 
@@ -12,7 +12,81 @@ npm install -g ei-tui
12
12
 
13
13
  ## TUI Commands
14
14
 
15
- Coming soon! In the TUI, you can do /h to see a quick list.
15
+ All commands start with `/`. Append `!` to any command as a shorthand for `--force` (e.g., `/quit!`).
16
+
17
+ ### Navigation & App
18
+
19
+ | Command | Aliases | Description |
20
+ |---------|---------|-------------|
21
+ | `/help` | `/h` | Show the command list and keybindings |
22
+ | `/quit` | `/q` | Save, sync, and exit |
23
+ | `/quit!` | `/q!` | Force quit without syncing |
24
+
25
+ ### Personas
26
+
27
+ | Command | Aliases | Description |
28
+ |---------|---------|-------------|
29
+ | `/persona` | `/p` | Open persona picker overlay |
30
+ | `/persona <name>` | `/p <name>` | Switch to a persona by name or alias |
31
+ | `/persona new <name>` | `/p new <name>` | Create a new persona (opens `$EDITOR`) |
32
+ | `/details` | `/d` | Edit the current persona in `$EDITOR` |
33
+ | `/details <name>` | `/d <name>` | Edit a specific persona in `$EDITOR` |
34
+ | `/archive` | | List archived personas (Enter to unarchive) |
35
+ | `/archive <name>` | | Archive a persona by name |
36
+ | `/unarchive <name>` | | Unarchive a persona and switch to it |
37
+ | `/delete` | `/del` | Pick a persona to permanently delete |
38
+ | `/delete <name>` | `/del <name>` | Permanently delete a persona by name (confirms) |
39
+ | `/pause` | | Pause current persona indefinitely |
40
+ | `/pause <duration>` | | Pause for a duration: `2h`, `1d`, `1w`, `30m` |
41
+ | `/resume` | `/unpause` | Resume the current paused persona |
42
+ | `/resume <name>` | `/unpause <name>` | Resume a specific paused persona |
43
+
44
+ ### Providers & Models
45
+
46
+ | Command | Aliases | Description |
47
+ |---------|---------|-------------|
48
+ | `/provider` | `/providers` | Open provider picker (select, edit, or create) |
49
+ | `/provider <name>` | | Set a provider on the active persona by name |
50
+ | `/provider new` | | Create a new LLM provider (opens `$EDITOR`) |
51
+ | `/model <model>` | | Set model for active persona (e.g., `sonnet-latest`) |
52
+ | `/model <provider:model>` | | Set provider + model explicitly (e.g., `openai:gpt-4o`) |
53
+
54
+ ### Messages & Context
55
+
56
+ | Command | Aliases | Description |
57
+ |---------|---------|-------------|
58
+ | `/new` | | Toggle context boundary (fresh conversation start) |
59
+ | `/context` | `/messages` | Edit message context status in `$EDITOR` |
60
+ | `/quotes` | `/quote` | Open all quotes in `$EDITOR` |
61
+ | `/quotes me` | | Open only your (human) quotes |
62
+ | `/quotes <N>` | | View/edit quotes attached to message number N |
63
+ | `/quotes search "term"` | | Search quotes by keyword |
64
+ | `/quotes <persona>` | | View/edit quotes attributed to a specific persona |
65
+
66
+ ### Data & Settings
67
+
68
+ | Command | Aliases | Description |
69
+ |---------|---------|-------------|
70
+ | `/me` | | Edit all your data (facts, traits, topics, people) in `$EDITOR` |
71
+ | `/me <type>` | | Edit one type: `facts`, `traits`, `topics`, or `people` |
72
+ | `/settings` | `/set` | Edit your global settings in `$EDITOR` |
73
+ | `/setsync <user> <pass>` | `/ss` | Set sync credentials (triggers restart) |
74
+
75
+ ### Editor
76
+
77
+ | Command | Aliases | Description |
78
+ |---------|---------|-------------|
79
+ | `/editor` | `/e`, `/edit` | Open current input text in `$EDITOR`, update on save |
80
+
81
+ ### Keybindings
82
+
83
+ | Key | Action |
84
+ |-----|--------|
85
+ | `Escape` | Abort current operation / resume queue |
86
+ | `Ctrl+C` | Clear input (second press exits) |
87
+ | `Ctrl+B` | Toggle sidebar |
88
+ | `Ctrl+E` | Open `$EDITOR` (preserves current input) |
89
+ | `PageUp / PageDown` | Scroll message history |
16
90
 
17
91
  # Development
18
92
 
@@ -21,7 +21,7 @@ export const editorCommand: Command = {
21
21
 
22
22
  const result = await spawnEditor({
23
23
  initialContent: currentText,
24
- filename: "message.txt",
24
+ filename: "message.md",
25
25
  renderer: ctx.renderer,
26
26
  });
27
27
 
@@ -0,0 +1,50 @@
1
+ import { createMemo, For } from "solid-js";
2
+ import { getAllCommands } from "../commands/registry";
3
+
4
+ interface CommandSuggestProps {
5
+ input: () => string;
6
+ highlightIndex: () => number;
7
+ }
8
+
9
+ export function CommandSuggest(props: CommandSuggestProps) {
10
+ const matches = createMemo(() => {
11
+ const raw = props.input().trim();
12
+ if (!raw.startsWith("/")) return [];
13
+ const query = raw.slice(1).split(/\s/)[0].replace(/!$/, "").toLowerCase();
14
+ return getAllCommands().filter(
15
+ (cmd) =>
16
+ cmd.name.startsWith(query) ||
17
+ cmd.aliases.some((a) => a.startsWith(query))
18
+ );
19
+ });
20
+
21
+ return (
22
+ <box
23
+ flexDirection="column"
24
+ visible={matches().length > 0}
25
+ borderStyle="single"
26
+ border={true}
27
+ borderColor="#586e75"
28
+ backgroundColor="#1a1a2e"
29
+ flexShrink={0}
30
+ >
31
+ <For each={matches()}>
32
+ {(cmd, i) => {
33
+ const isHighlighted = () => i() === props.highlightIndex();
34
+ const aliases =
35
+ cmd.aliases.length > 0 ? ` (/${cmd.aliases.join(", /")})` : "";
36
+ return (
37
+ <box
38
+ paddingX={1}
39
+ backgroundColor={isHighlighted() ? "#2d3748" : "transparent"}
40
+ >
41
+ <text fg={isHighlighted() ? "#eee8d5" : "#839496"} truncate>
42
+ {`/${cmd.name}${aliases} ${cmd.description}`}
43
+ </text>
44
+ </box>
45
+ );
46
+ }}
47
+ </For>
48
+ </box>
49
+ );
50
+ }