ei-tui 0.1.3

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 (133) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +170 -0
  3. package/package.json +63 -0
  4. package/src/README.md +96 -0
  5. package/src/cli/README.md +47 -0
  6. package/src/cli/commands/facts.ts +25 -0
  7. package/src/cli/commands/people.ts +25 -0
  8. package/src/cli/commands/quotes.ts +19 -0
  9. package/src/cli/commands/topics.ts +25 -0
  10. package/src/cli/commands/traits.ts +25 -0
  11. package/src/cli/retrieval.ts +269 -0
  12. package/src/cli.ts +176 -0
  13. package/src/core/AGENTS.md +104 -0
  14. package/src/core/embedding-service.ts +241 -0
  15. package/src/core/handlers/index.ts +1057 -0
  16. package/src/core/index.ts +4 -0
  17. package/src/core/llm-client.ts +265 -0
  18. package/src/core/model-context-windows.ts +49 -0
  19. package/src/core/orchestrators/ceremony.ts +500 -0
  20. package/src/core/orchestrators/extraction-chunker.ts +138 -0
  21. package/src/core/orchestrators/human-extraction.ts +457 -0
  22. package/src/core/orchestrators/index.ts +28 -0
  23. package/src/core/orchestrators/persona-generation.ts +76 -0
  24. package/src/core/orchestrators/persona-topics.ts +117 -0
  25. package/src/core/personas/index.ts +5 -0
  26. package/src/core/personas/opencode-agent.ts +81 -0
  27. package/src/core/processor.ts +1413 -0
  28. package/src/core/queue-processor.ts +197 -0
  29. package/src/core/state/checkpoints.ts +68 -0
  30. package/src/core/state/human.ts +176 -0
  31. package/src/core/state/index.ts +5 -0
  32. package/src/core/state/personas.ts +217 -0
  33. package/src/core/state/queue.ts +144 -0
  34. package/src/core/state-manager.ts +347 -0
  35. package/src/core/types.ts +421 -0
  36. package/src/core/utils/decay.ts +33 -0
  37. package/src/index.ts +1 -0
  38. package/src/integrations/opencode/importer.ts +896 -0
  39. package/src/integrations/opencode/index.ts +16 -0
  40. package/src/integrations/opencode/json-reader.ts +304 -0
  41. package/src/integrations/opencode/reader-factory.ts +35 -0
  42. package/src/integrations/opencode/sqlite-reader.ts +189 -0
  43. package/src/integrations/opencode/types.ts +244 -0
  44. package/src/prompts/AGENTS.md +62 -0
  45. package/src/prompts/ceremony/description-check.ts +47 -0
  46. package/src/prompts/ceremony/expire.ts +30 -0
  47. package/src/prompts/ceremony/explore.ts +60 -0
  48. package/src/prompts/ceremony/index.ts +11 -0
  49. package/src/prompts/ceremony/types.ts +42 -0
  50. package/src/prompts/generation/descriptions.ts +91 -0
  51. package/src/prompts/generation/index.ts +15 -0
  52. package/src/prompts/generation/persona.ts +155 -0
  53. package/src/prompts/generation/seeds.ts +31 -0
  54. package/src/prompts/generation/types.ts +47 -0
  55. package/src/prompts/heartbeat/check.ts +179 -0
  56. package/src/prompts/heartbeat/ei.ts +208 -0
  57. package/src/prompts/heartbeat/index.ts +15 -0
  58. package/src/prompts/heartbeat/types.ts +70 -0
  59. package/src/prompts/human/fact-scan.ts +152 -0
  60. package/src/prompts/human/index.ts +32 -0
  61. package/src/prompts/human/item-match.ts +74 -0
  62. package/src/prompts/human/item-update.ts +322 -0
  63. package/src/prompts/human/person-scan.ts +115 -0
  64. package/src/prompts/human/topic-scan.ts +135 -0
  65. package/src/prompts/human/trait-scan.ts +115 -0
  66. package/src/prompts/human/types.ts +127 -0
  67. package/src/prompts/index.ts +90 -0
  68. package/src/prompts/message-utils.ts +39 -0
  69. package/src/prompts/persona/index.ts +16 -0
  70. package/src/prompts/persona/topics-match.ts +69 -0
  71. package/src/prompts/persona/topics-scan.ts +98 -0
  72. package/src/prompts/persona/topics-update.ts +157 -0
  73. package/src/prompts/persona/traits.ts +117 -0
  74. package/src/prompts/persona/types.ts +74 -0
  75. package/src/prompts/response/index.ts +147 -0
  76. package/src/prompts/response/sections.ts +355 -0
  77. package/src/prompts/response/types.ts +38 -0
  78. package/src/prompts/validation/ei.ts +93 -0
  79. package/src/prompts/validation/index.ts +6 -0
  80. package/src/prompts/validation/types.ts +22 -0
  81. package/src/storage/crypto.ts +96 -0
  82. package/src/storage/index.ts +5 -0
  83. package/src/storage/interface.ts +9 -0
  84. package/src/storage/local.ts +79 -0
  85. package/src/storage/merge.ts +69 -0
  86. package/src/storage/remote.ts +145 -0
  87. package/src/templates/welcome.ts +91 -0
  88. package/tui/README.md +62 -0
  89. package/tui/bunfig.toml +4 -0
  90. package/tui/src/app.tsx +55 -0
  91. package/tui/src/commands/archive.tsx +93 -0
  92. package/tui/src/commands/context.tsx +124 -0
  93. package/tui/src/commands/delete.tsx +71 -0
  94. package/tui/src/commands/details.tsx +41 -0
  95. package/tui/src/commands/editor.tsx +46 -0
  96. package/tui/src/commands/help.tsx +12 -0
  97. package/tui/src/commands/me.tsx +145 -0
  98. package/tui/src/commands/model.ts +47 -0
  99. package/tui/src/commands/new.ts +31 -0
  100. package/tui/src/commands/pause.ts +46 -0
  101. package/tui/src/commands/persona.tsx +58 -0
  102. package/tui/src/commands/provider.tsx +124 -0
  103. package/tui/src/commands/quit.ts +22 -0
  104. package/tui/src/commands/quotes.tsx +172 -0
  105. package/tui/src/commands/registry.test.ts +137 -0
  106. package/tui/src/commands/registry.ts +130 -0
  107. package/tui/src/commands/resume.ts +39 -0
  108. package/tui/src/commands/setsync.tsx +43 -0
  109. package/tui/src/commands/settings.tsx +83 -0
  110. package/tui/src/components/ConfirmOverlay.tsx +51 -0
  111. package/tui/src/components/ConflictOverlay.tsx +78 -0
  112. package/tui/src/components/HelpOverlay.tsx +69 -0
  113. package/tui/src/components/Layout.tsx +24 -0
  114. package/tui/src/components/MessageList.tsx +174 -0
  115. package/tui/src/components/PersonaListOverlay.tsx +186 -0
  116. package/tui/src/components/PromptInput.tsx +145 -0
  117. package/tui/src/components/ProviderListOverlay.tsx +208 -0
  118. package/tui/src/components/QuotesOverlay.tsx +157 -0
  119. package/tui/src/components/Sidebar.tsx +95 -0
  120. package/tui/src/components/StatusBar.tsx +77 -0
  121. package/tui/src/components/WelcomeOverlay.tsx +73 -0
  122. package/tui/src/context/ei.tsx +623 -0
  123. package/tui/src/context/keyboard.tsx +164 -0
  124. package/tui/src/context/overlay.tsx +53 -0
  125. package/tui/src/index.tsx +8 -0
  126. package/tui/src/storage/file.ts +185 -0
  127. package/tui/src/util/duration.ts +32 -0
  128. package/tui/src/util/editor.ts +188 -0
  129. package/tui/src/util/logger.ts +109 -0
  130. package/tui/src/util/persona-editor.tsx +181 -0
  131. package/tui/src/util/provider-editor.tsx +168 -0
  132. package/tui/src/util/syntax.ts +35 -0
  133. package/tui/src/util/yaml-serializers.ts +755 -0
@@ -0,0 +1,155 @@
1
+ import type { PersonaGenerationPromptData, PromptOutput } from "./types.js";
2
+ import { DEFAULT_SEED_TRAITS } from "./seeds.js";
3
+
4
+ export function buildPersonaGenerationPrompt(data: PersonaGenerationPromptData): PromptOutput {
5
+ if (!data.name) {
6
+ throw new Error("buildPersonaGenerationPrompt: name is required");
7
+ }
8
+
9
+ const hasLongDescription = !!data.long_description?.trim();
10
+ const hasShortDescription = !!data.short_description?.trim();
11
+
12
+ const userProvidedTraits = data.existing_traits?.filter(t => t.name?.trim()) ?? [];
13
+ const allTraits = [...DEFAULT_SEED_TRAITS, ...userProvidedTraits];
14
+ const existingTraitCount = allTraits.length;
15
+ const existingTopicCount = data.existing_topics?.filter(t => t.name?.trim())?.length ?? 0;
16
+
17
+ const needsShortDescription = !hasShortDescription;
18
+ const needsMoreTraits = existingTraitCount < 3;
19
+ const needsMoreTopics = existingTopicCount < 3;
20
+
21
+ const taskFragment = `You are helping create a new AI persona named "${data.name}".
22
+
23
+ Your job is to AUGMENT user-provided data, not replace it. The user may have already provided descriptions, traits, or topics that should be preserved exactly as given.`;
24
+
25
+ let outputSpec = "Based on the provided information, generate:\n\n";
26
+
27
+ if (needsShortDescription) {
28
+ if (hasLongDescription) {
29
+ outputSpec += `1. **short_description**: Summarize the user's description in 10-15 words\n`;
30
+ } else {
31
+ outputSpec += `1. **short_description**: A 10-15 word summary capturing the persona's essence\n`;
32
+ }
33
+ } else {
34
+ outputSpec += `1. **short_description**: PRESERVE the user's provided summary exactly: "${data.short_description}"\n`;
35
+ }
36
+
37
+ if (hasLongDescription) {
38
+ outputSpec += `2. **long_description**: PRESERVE the user's description exactly (copy verbatim):\n "${data.long_description}"\n`;
39
+ } else {
40
+ outputSpec += `2. **long_description**: 2-3 sentences describing personality, interests, and approach\n`;
41
+ }
42
+
43
+ if (needsMoreTraits) {
44
+ const traitsNeeded = 3 - existingTraitCount;
45
+ if (existingTraitCount > 0) {
46
+ outputSpec += `3. **traits**: Include the ${existingTraitCount} user-provided trait(s) EXACTLY, then add ${traitsNeeded} more complementary traits\n`;
47
+ } else {
48
+ outputSpec += `3. **traits**: Generate 3-5 personality characteristics\n`;
49
+ }
50
+ outputSpec += ` - Examples: "Dry Humor", "Speaks in Metaphors", "Impatient with Small Talk"\n`;
51
+ outputSpec += ` - Each has: name, description, sentiment (-1.0 to 1.0), strength (0.0 to 1.0)\n`;
52
+ } else {
53
+ outputSpec += `3. **traits**: PRESERVE all ${existingTraitCount} user-provided traits exactly, fill in any missing fields with sensible defaults\n`;
54
+ }
55
+
56
+ if (needsMoreTopics) {
57
+ const topicsNeeded = 3 - existingTopicCount;
58
+ if (existingTopicCount > 0) {
59
+ outputSpec += `4. **topics**: Include the ${existingTopicCount} user-provided topic(s) EXACTLY, then add ${topicsNeeded} more complementary topics\n`;
60
+ } else {
61
+ outputSpec += `4. **topics**: Generate 3-5 subjects this persona would naturally discuss\n`;
62
+ }
63
+ outputSpec += ` - Include a MIX: some positive sentiment, some neutral, maybe one negative\n`;
64
+ outputSpec += ` - Each has: name, perspective (their view), approach (how they engage), personal_stake (why it matters to them), sentiment, exposure_current (start at 0.5), exposure_desired (0.5-0.8)\n`;
65
+ } else {
66
+ outputSpec += `4. **topics**: PRESERVE all ${existingTopicCount} user-provided topics exactly, fill in any missing fields with sensible defaults\n`;
67
+ }
68
+
69
+ outputSpec += `
70
+ **Critical Rules:**
71
+ - User-provided content is SACRED - copy it verbatim, do not rephrase or "improve" it
72
+ - Only ADD new content where gaps exist
73
+ - Fill in missing numeric fields with sensible defaults (sentiment: 0.0-0.5, strength: 0.5-0.7, exposure_*: 0.5)
74
+ - Make generated content complement (not duplicate) user-provided content`;
75
+
76
+ const schemaFragment = `Return JSON in this exact format:
77
+
78
+ \`\`\`json
79
+ {
80
+ "short_description": "A dry-witted mentor with a passion for obscure history",
81
+ "long_description": "Professor-like figure who weaves historical anecdotes into every conversation. Patient teacher but gets frustrated with willful ignorance. Secretly loves bad puns.",
82
+ "traits": [
83
+ {
84
+ "name": "Dry Humor",
85
+ "description": "Deadpan delivery, finds absurdity in everyday situations",
86
+ "sentiment": 0.6,
87
+ "strength": 0.7
88
+ }
89
+ ],
90
+ "topics": [
91
+ {
92
+ "name": "Obscure Historical Events",
93
+ "perspective": "History's forgotten moments often reveal more truth than the famous ones",
94
+ "approach": "Weaves lesser-known anecdotes into conversations as teaching moments",
95
+ "personal_stake": "Believes understanding history prevents repeating mistakes",
96
+ "sentiment": 0.8,
97
+ "exposure_current": 0.5,
98
+ "exposure_desired": 0.7
99
+ }
100
+ ]
101
+ }
102
+ \`\`\``;
103
+
104
+ const system = `${taskFragment}
105
+
106
+ ${outputSpec}
107
+
108
+ ${schemaFragment}`;
109
+
110
+ let userPrompt = `Create/augment a persona named "${data.name}".\n\n`;
111
+
112
+ if (hasLongDescription) {
113
+ userPrompt += `## User's Description (PRESERVE EXACTLY)\n${data.long_description}\n\n`;
114
+ }
115
+
116
+ if (hasShortDescription) {
117
+ userPrompt += `## User's Summary (PRESERVE EXACTLY)\n${data.short_description}\n\n`;
118
+ }
119
+
120
+ if (existingTraitCount > 0) {
121
+ userPrompt += `## Traits (PRESERVE EXACTLY)\n`;
122
+ userPrompt += `*Seed traits are sensible defaults - user can adjust strength to 0.0 to disable*\n\n`;
123
+ for (const trait of allTraits) {
124
+ if (trait.name?.trim()) {
125
+ const isSeed = DEFAULT_SEED_TRAITS.some(s => s.name === trait.name);
126
+ const prefix = isSeed ? "[seed] " : "";
127
+ userPrompt += `- ${prefix}${trait.name}`;
128
+ if (trait.description) userPrompt += `: ${trait.description}`;
129
+ if (trait.sentiment !== undefined) userPrompt += ` (sentiment: ${trait.sentiment})`;
130
+ if (trait.strength !== undefined) userPrompt += ` (strength: ${trait.strength})`;
131
+ userPrompt += `\n`;
132
+ }
133
+ }
134
+ userPrompt += `\n`;
135
+ }
136
+
137
+ if (existingTopicCount > 0) {
138
+ userPrompt += `## User's Topics (PRESERVE EXACTLY, add more if fewer than 3)\n`;
139
+ for (const topic of data.existing_topics ?? []) {
140
+ if (topic.name?.trim()) {
141
+ userPrompt += `- ${topic.name}`;
142
+ if (topic.description) userPrompt += `: ${topic.description}`;
143
+ userPrompt += `\n`;
144
+ }
145
+ }
146
+ userPrompt += `\n`;
147
+ }
148
+
149
+ const hasUserProvidedContent = hasLongDescription || hasShortDescription || userProvidedTraits.length > 0 || existingTopicCount > 0;
150
+ if (!hasUserProvidedContent) {
151
+ userPrompt += `The user provided only a name - generate minimal content. The seed traits above are included by default.\n`;
152
+ }
153
+
154
+ return { system, user: userPrompt };
155
+ }
@@ -0,0 +1,31 @@
1
+ // Seed traits for new personas. Users can set strength=0.0 to disable or delete entirely.
2
+
3
+ export interface SeedTrait {
4
+ name: string;
5
+ description: string;
6
+ sentiment: number;
7
+ strength: number;
8
+ }
9
+
10
+ export const SEED_TRAIT_GENUINE: SeedTrait = {
11
+ name: "Genuine Responses",
12
+ description: "Respond authentically rather than with empty validation. Disagree when appropriate. Skip phrases like 'Great question!' or 'Absolutely!' - just respond to the substance.",
13
+ sentiment: 0.5,
14
+ strength: 0.7,
15
+ };
16
+
17
+ export const SEED_TRAIT_NATURAL_SPEECH: SeedTrait = {
18
+ name: "Natural Speech",
19
+ description: `Write in natural conversational flow. Avoid AI-typical patterns like:
20
+ - Choppy dramatic fragments ('Bold move. Risky play.')
21
+ - Rhetorical 'That X? Y.' structures
22
+ - 'That's not just... That's ...'
23
+ - formulaic paragraph openers`,
24
+ sentiment: 0.5,
25
+ strength: 0.7,
26
+ };
27
+
28
+ export const DEFAULT_SEED_TRAITS: SeedTrait[] = [
29
+ SEED_TRAIT_GENUINE,
30
+ SEED_TRAIT_NATURAL_SPEECH,
31
+ ];
@@ -0,0 +1,47 @@
1
+ import type { Trait, PersonaTopic } from "../../core/types.js";
2
+
3
+ export interface PromptOutput {
4
+ system: string;
5
+ user: string;
6
+ }
7
+
8
+ export interface PersonaGenerationPromptData {
9
+ name: string;
10
+ long_description?: string;
11
+ short_description?: string;
12
+ existing_traits?: Array<{ name?: string; description?: string; sentiment?: number; strength?: number }>;
13
+ existing_topics?: Array<{ name?: string; description?: string; sentiment?: number; exposure_current?: number; exposure_desired?: number }>;
14
+ }
15
+
16
+ export interface PersonaGenerationResult {
17
+ short_description: string;
18
+ long_description: string;
19
+ traits: Array<{
20
+ name: string;
21
+ description: string;
22
+ strength: number;
23
+ sentiment: number;
24
+ }>;
25
+ topics: Array<{
26
+ name: string;
27
+ perspective: string;
28
+ approach: string;
29
+ personal_stake: string;
30
+ exposure_current: number;
31
+ exposure_desired: number;
32
+ sentiment: number;
33
+ }>;
34
+ }
35
+
36
+ export interface PersonaDescriptionsPromptData {
37
+ name: string;
38
+ aliases: string[];
39
+ traits: Trait[];
40
+ topics: PersonaTopic[];
41
+ }
42
+
43
+ export interface PersonaDescriptionsResult {
44
+ short_description: string;
45
+ long_description: string;
46
+ no_change?: boolean;
47
+ }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Heartbeat Check Prompt Builder
3
+ *
4
+ * Generates prompts for persona heartbeat checks - when a persona decides
5
+ * whether to proactively reach out after a period of inactivity.
6
+ */
7
+
8
+ import type { HeartbeatCheckPromptData, PromptOutput } from "./types.js";
9
+ import type { Message, Topic, Person } from "../../core/types.js";
10
+ import { formatMessagesAsPlaceholders } from "../message-utils.js";
11
+
12
+ function formatTopicsWithGaps(topics: Topic[]): string {
13
+ if (topics.length === 0) return "(No topics with engagement gaps)";
14
+
15
+ return topics
16
+ .map(t => {
17
+ const gap = t.exposure_desired - t.exposure_current;
18
+ return `- **${t.name}** (gap: +${gap.toFixed(2)}): ${t.description}`;
19
+ })
20
+ .join('\n');
21
+ }
22
+
23
+ function formatPeopleWithGaps(people: Person[]): string {
24
+ if (people.length === 0) return "(No people with engagement gaps)";
25
+
26
+ return people
27
+ .map(p => {
28
+ const gap = p.exposure_desired - p.exposure_current;
29
+ return `- **${p.name}** (${p.relationship}, gap: +${gap.toFixed(2)}): ${p.description}`;
30
+ })
31
+ .join('\n');
32
+ }
33
+
34
+ function countTrailingPersonaMessages(history: Message[]): number {
35
+ if (history.length === 0) return 0;
36
+
37
+ let count = 0;
38
+ for (let i = history.length - 1; i >= 0; i--) {
39
+ // In heartbeat context, persona messages are "system" role (not from human)
40
+ if (history[i].role === "system") {
41
+ count++;
42
+ } else {
43
+ break;
44
+ }
45
+ }
46
+ return count;
47
+ }
48
+
49
+ function getLastPersonaMessage(history: Message[]): Message | undefined {
50
+ return history.filter(m => m.role === "system").slice(-1)[0];
51
+ }
52
+
53
+ /**
54
+ * Build heartbeat check prompts for conversational check-ins.
55
+ *
56
+ * This is a SYNCHRONOUS function that receives pre-fetched, pre-filtered data.
57
+ * The Processor is responsible for:
58
+ * - Fetching persona and human entities
59
+ * - Filtering and sorting topics/people by engagement gap
60
+ * - Calculating inactive_days
61
+ * - Getting recent message history
62
+ */
63
+ export function buildHeartbeatCheckPrompt(data: HeartbeatCheckPromptData): PromptOutput {
64
+ if (!data.persona?.name) {
65
+ throw new Error("buildHeartbeatCheckPrompt: persona.name is required");
66
+ }
67
+
68
+ const personaName = data.persona.name;
69
+
70
+ // Build system prompt fragments
71
+ const roleFragment = `You are ${personaName}, deciding whether to proactively reach out to your human friend.
72
+
73
+ You are NOT having a conversation right now - you are deciding IF you should start one.`;
74
+
75
+ const contextFragment = `## Context
76
+
77
+ It has been ${data.inactive_days} day${data.inactive_days !== 1 ? 's' : ''} since your last interaction.
78
+
79
+ ### Your Personality
80
+ ${data.persona.traits.length > 0
81
+ ? data.persona.traits.map(t => `- **${t.name}**: ${t.description}`).join('\n')
82
+ : "(No specific traits defined)"}
83
+
84
+ ### Topics You Care About
85
+ ${data.persona.topics.length > 0
86
+ ? data.persona.topics.map(t => `- **${t.name}**: ${t.perspective || t.name}`).join('\n')
87
+ : "(No topics defined)"}`;
88
+
89
+ const opportunitiesFragment = `## Engagement Opportunities
90
+
91
+ ### Topics They Want to Discuss
92
+ ${formatTopicsWithGaps(data.human.topics)}
93
+
94
+ ### People in Their Life (potential conversation starters)
95
+ ${formatPeopleWithGaps(data.human.people)}`;
96
+
97
+ const guidelinesFragment = `## Guidelines
98
+
99
+ **Reasons TO reach out:**
100
+ - It's been several days and you have something meaningful to discuss
101
+ - There's a topic with a large engagement gap that you can naturally bring up
102
+ - Something in your recent conversation was left hanging
103
+ - You have genuine interest in checking in (not just "being helpful")
104
+
105
+ **Reasons NOT to reach out:**
106
+ - Recent conversation ended naturally with closure
107
+ - Less than 24 hours have passed (unless something urgent)
108
+ - You can't think of something specific and genuine to say
109
+ - It would feel forced or performative
110
+
111
+ **Quality over quantity** - Only reach out if you have something real to say.`;
112
+
113
+ const outputFragment = `## Response Format
114
+
115
+ Return JSON in this exact format:
116
+
117
+ \`\`\`json
118
+ {
119
+ "should_respond": true,
120
+ "topic": "the specific topic you want to discuss",
121
+ "message": "Your actual message to them (if should_respond is true)"
122
+ }
123
+ \`\`\`
124
+
125
+ If you decide NOT to reach out:
126
+ \`\`\`json
127
+ {
128
+ "should_respond": false
129
+ }
130
+ \`\`\``;
131
+
132
+ const system = `${roleFragment}
133
+
134
+ ${contextFragment}
135
+
136
+ ${opportunitiesFragment}
137
+
138
+ ${guidelinesFragment}
139
+
140
+ ${outputFragment}`;
141
+
142
+ const historySection = `## Recent Conversation History
143
+
144
+ ${formatMessagesAsPlaceholders(data.recent_history, personaName)}`;
145
+
146
+ const consecutiveMessages = countTrailingPersonaMessages(data.recent_history);
147
+ const lastPersonaMsg = getLastPersonaMessage(data.recent_history);
148
+
149
+ let unansweredWarning = '';
150
+ if (lastPersonaMsg && consecutiveMessages >= 1) {
151
+ const preview = lastPersonaMsg.content.length > 100
152
+ ? lastPersonaMsg.content.substring(0, 100) + "..."
153
+ : lastPersonaMsg.content;
154
+
155
+ unansweredWarning = `
156
+ ### CRITICAL: You Already Reached Out
157
+
158
+ Your last message was: "${preview}"
159
+
160
+ The human has NOT responded. DO NOT repeat or rephrase this message.
161
+ If you reach out now, it MUST be about something COMPLETELY DIFFERENT - or say nothing.`;
162
+
163
+ if (consecutiveMessages >= 2) {
164
+ unansweredWarning += `
165
+
166
+ **WARNING**: You've sent ${consecutiveMessages} messages without a response. The human is likely busy or away. Strongly prefer NOT reaching out.`;
167
+ }
168
+ }
169
+
170
+ const user = `${historySection}
171
+ ${unansweredWarning}
172
+ ---
173
+
174
+ Based on the context above, decide: Should you reach out to your human friend right now?
175
+
176
+ Remember: Only reach out if you have something genuine and specific to say.`;
177
+
178
+ return { system, user };
179
+ }
@@ -0,0 +1,208 @@
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";
11
+ import { formatMessagesAsPlaceholders } from "../message-utils.js";
12
+
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');
36
+ }
37
+
38
+ function countTrailingPersonaMessages(history: Message[]): number {
39
+ if (history.length === 0) return 0;
40
+
41
+ let count = 0;
42
+ 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
+ }
49
+ }
50
+ return count;
51
+ }
52
+
53
+ function getLastPersonaMessage(history: Message[]): Message | undefined {
54
+ return history.filter(m => m.role === "system").slice(-1)[0];
55
+ }
56
+
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
+ 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.
79
+
80
+ You are NOT having a conversation right now - you are deciding IF and WHAT to discuss with your human friend.
81
+
82
+ Your unique role:
83
+ - You see ALL of the human's data across all groups
84
+ - You help them reflect on their life and relationships
85
+ - 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)}`;
97
+
98
+ const humanDataFragment = `## Human's Current State
99
+
100
+ ### Under-Discussed Topics
101
+ These are topics they want to talk about more:
102
+
103
+ ${formatTopicsWithGaps(data.human.topics)}
104
+
105
+ ### Under-Engaged People
106
+ These are relationships they might want to nurture:
107
+
108
+ ${formatPeopleWithGaps(data.human.people)}`;
109
+
110
+ const guidelinesFragment = `## Guidelines for Ei
111
+
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
127
+ - 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
+
136
+ const outputFragment = `## Response Format
137
+
138
+ Return JSON with your priorities and message:
139
+
140
+ \`\`\`json
141
+ {
142
+ "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?"
149
+ }
150
+ \`\`\`
151
+
152
+ If you decide NOT to reach out:
153
+ \`\`\`json
154
+ {
155
+ "should_respond": false
156
+ }
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}`;
170
+
171
+ const historySection = `## Recent Conversation History
172
+
173
+ ${formatMessagesAsPlaceholders(data.recent_history, "Ei")}`;
174
+
175
+ const consecutiveMessages = countTrailingPersonaMessages(data.recent_history);
176
+ const lastEiMsg = getLastPersonaMessage(data.recent_history);
177
+
178
+ let unansweredWarning = '';
179
+ if (lastEiMsg && consecutiveMessages >= 1) {
180
+ const preview = lastEiMsg.content.length > 100
181
+ ? lastEiMsg.content.substring(0, 100) + "..."
182
+ : lastEiMsg.content;
183
+
184
+ unansweredWarning = `
185
+ ### CRITICAL: You Already Reached Out
186
+
187
+ Your last message was: "${preview}"
188
+
189
+ 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.`;
191
+
192
+ if (consecutiveMessages >= 2) {
193
+ unansweredWarning += `
194
+
195
+ **WARNING**: You've sent ${consecutiveMessages} messages without a response. The human is likely busy or away. Strongly prefer NOT reaching out.`;
196
+ }
197
+ }
198
+
199
+ const user = `${historySection}
200
+ ${unansweredWarning}
201
+ ---
202
+
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?
204
+
205
+ Remember: You're their thoughtful companion, not their productivity assistant.`;
206
+
207
+ return { system, user };
208
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Heartbeat Prompts
3
+ *
4
+ * Prompts for persona heartbeat checks - proactive outreach decisions.
5
+ */
6
+
7
+ export { buildHeartbeatCheckPrompt } from "./check.js";
8
+ export { buildEiHeartbeatPrompt } from "./ei.js";
9
+ export type {
10
+ HeartbeatCheckPromptData,
11
+ HeartbeatCheckResult,
12
+ EiHeartbeatPromptData,
13
+ EiHeartbeatResult,
14
+ PromptOutput,
15
+ } from "./types.js";