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.
- package/LICENSE +21 -0
- package/README.md +170 -0
- package/package.json +63 -0
- package/src/README.md +96 -0
- package/src/cli/README.md +47 -0
- package/src/cli/commands/facts.ts +25 -0
- package/src/cli/commands/people.ts +25 -0
- package/src/cli/commands/quotes.ts +19 -0
- package/src/cli/commands/topics.ts +25 -0
- package/src/cli/commands/traits.ts +25 -0
- package/src/cli/retrieval.ts +269 -0
- package/src/cli.ts +176 -0
- package/src/core/AGENTS.md +104 -0
- package/src/core/embedding-service.ts +241 -0
- package/src/core/handlers/index.ts +1057 -0
- package/src/core/index.ts +4 -0
- package/src/core/llm-client.ts +265 -0
- package/src/core/model-context-windows.ts +49 -0
- package/src/core/orchestrators/ceremony.ts +500 -0
- package/src/core/orchestrators/extraction-chunker.ts +138 -0
- package/src/core/orchestrators/human-extraction.ts +457 -0
- package/src/core/orchestrators/index.ts +28 -0
- package/src/core/orchestrators/persona-generation.ts +76 -0
- package/src/core/orchestrators/persona-topics.ts +117 -0
- package/src/core/personas/index.ts +5 -0
- package/src/core/personas/opencode-agent.ts +81 -0
- package/src/core/processor.ts +1413 -0
- package/src/core/queue-processor.ts +197 -0
- package/src/core/state/checkpoints.ts +68 -0
- package/src/core/state/human.ts +176 -0
- package/src/core/state/index.ts +5 -0
- package/src/core/state/personas.ts +217 -0
- package/src/core/state/queue.ts +144 -0
- package/src/core/state-manager.ts +347 -0
- package/src/core/types.ts +421 -0
- package/src/core/utils/decay.ts +33 -0
- package/src/index.ts +1 -0
- package/src/integrations/opencode/importer.ts +896 -0
- package/src/integrations/opencode/index.ts +16 -0
- package/src/integrations/opencode/json-reader.ts +304 -0
- package/src/integrations/opencode/reader-factory.ts +35 -0
- package/src/integrations/opencode/sqlite-reader.ts +189 -0
- package/src/integrations/opencode/types.ts +244 -0
- package/src/prompts/AGENTS.md +62 -0
- package/src/prompts/ceremony/description-check.ts +47 -0
- package/src/prompts/ceremony/expire.ts +30 -0
- package/src/prompts/ceremony/explore.ts +60 -0
- package/src/prompts/ceremony/index.ts +11 -0
- package/src/prompts/ceremony/types.ts +42 -0
- package/src/prompts/generation/descriptions.ts +91 -0
- package/src/prompts/generation/index.ts +15 -0
- package/src/prompts/generation/persona.ts +155 -0
- package/src/prompts/generation/seeds.ts +31 -0
- package/src/prompts/generation/types.ts +47 -0
- package/src/prompts/heartbeat/check.ts +179 -0
- package/src/prompts/heartbeat/ei.ts +208 -0
- package/src/prompts/heartbeat/index.ts +15 -0
- package/src/prompts/heartbeat/types.ts +70 -0
- package/src/prompts/human/fact-scan.ts +152 -0
- package/src/prompts/human/index.ts +32 -0
- package/src/prompts/human/item-match.ts +74 -0
- package/src/prompts/human/item-update.ts +322 -0
- package/src/prompts/human/person-scan.ts +115 -0
- package/src/prompts/human/topic-scan.ts +135 -0
- package/src/prompts/human/trait-scan.ts +115 -0
- package/src/prompts/human/types.ts +127 -0
- package/src/prompts/index.ts +90 -0
- package/src/prompts/message-utils.ts +39 -0
- package/src/prompts/persona/index.ts +16 -0
- package/src/prompts/persona/topics-match.ts +69 -0
- package/src/prompts/persona/topics-scan.ts +98 -0
- package/src/prompts/persona/topics-update.ts +157 -0
- package/src/prompts/persona/traits.ts +117 -0
- package/src/prompts/persona/types.ts +74 -0
- package/src/prompts/response/index.ts +147 -0
- package/src/prompts/response/sections.ts +355 -0
- package/src/prompts/response/types.ts +38 -0
- package/src/prompts/validation/ei.ts +93 -0
- package/src/prompts/validation/index.ts +6 -0
- package/src/prompts/validation/types.ts +22 -0
- package/src/storage/crypto.ts +96 -0
- package/src/storage/index.ts +5 -0
- package/src/storage/interface.ts +9 -0
- package/src/storage/local.ts +79 -0
- package/src/storage/merge.ts +69 -0
- package/src/storage/remote.ts +145 -0
- package/src/templates/welcome.ts +91 -0
- package/tui/README.md +62 -0
- package/tui/bunfig.toml +4 -0
- package/tui/src/app.tsx +55 -0
- package/tui/src/commands/archive.tsx +93 -0
- package/tui/src/commands/context.tsx +124 -0
- package/tui/src/commands/delete.tsx +71 -0
- package/tui/src/commands/details.tsx +41 -0
- package/tui/src/commands/editor.tsx +46 -0
- package/tui/src/commands/help.tsx +12 -0
- package/tui/src/commands/me.tsx +145 -0
- package/tui/src/commands/model.ts +47 -0
- package/tui/src/commands/new.ts +31 -0
- package/tui/src/commands/pause.ts +46 -0
- package/tui/src/commands/persona.tsx +58 -0
- package/tui/src/commands/provider.tsx +124 -0
- package/tui/src/commands/quit.ts +22 -0
- package/tui/src/commands/quotes.tsx +172 -0
- package/tui/src/commands/registry.test.ts +137 -0
- package/tui/src/commands/registry.ts +130 -0
- package/tui/src/commands/resume.ts +39 -0
- package/tui/src/commands/setsync.tsx +43 -0
- package/tui/src/commands/settings.tsx +83 -0
- package/tui/src/components/ConfirmOverlay.tsx +51 -0
- package/tui/src/components/ConflictOverlay.tsx +78 -0
- package/tui/src/components/HelpOverlay.tsx +69 -0
- package/tui/src/components/Layout.tsx +24 -0
- package/tui/src/components/MessageList.tsx +174 -0
- package/tui/src/components/PersonaListOverlay.tsx +186 -0
- package/tui/src/components/PromptInput.tsx +145 -0
- package/tui/src/components/ProviderListOverlay.tsx +208 -0
- package/tui/src/components/QuotesOverlay.tsx +157 -0
- package/tui/src/components/Sidebar.tsx +95 -0
- package/tui/src/components/StatusBar.tsx +77 -0
- package/tui/src/components/WelcomeOverlay.tsx +73 -0
- package/tui/src/context/ei.tsx +623 -0
- package/tui/src/context/keyboard.tsx +164 -0
- package/tui/src/context/overlay.tsx +53 -0
- package/tui/src/index.tsx +8 -0
- package/tui/src/storage/file.ts +185 -0
- package/tui/src/util/duration.ts +32 -0
- package/tui/src/util/editor.ts +188 -0
- package/tui/src/util/logger.ts +109 -0
- package/tui/src/util/persona-editor.tsx +181 -0
- package/tui/src/util/provider-editor.tsx +168 -0
- package/tui/src/util/syntax.ts +35 -0
- 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";
|