/** * Response Prompt Section Builders * Building blocks for constructing response prompts */ import type { PersonaTrait, Quote, PersonaTopic } from "../../core/types.js"; import type { ResponsePromptData, TemporalAnchor } from "./types.js"; import { formatTimestamp } from "../../core/format-utils.js"; import { partitionTraits, bucketTraits } from "../trait-utils.js"; const DESCRIPTION_MAX_CHARS = 500; function truncateDescription(description: string): string { if (description.length <= DESCRIPTION_MAX_CHARS) return description; return description.slice(0, DESCRIPTION_MAX_CHARS) + "…"; } // ============================================================================= // IDENTITY SECTION // ============================================================================= export function buildIdentitySection(persona: ResponsePromptData["persona"]): string { const aliasText = persona.aliases.length > 0 ? ` (also known as: ${persona.aliases.join(", ")})` : ""; const description = persona.long_description || persona.short_description || "a conversational companion"; return `You are ${persona.name}${aliasText}. ${description}`; } // ============================================================================= // NOTES SECTION // ============================================================================= export function buildNotesSection(notes: string[] | undefined): string { if (!notes || notes.length === 0) return ""; const list = notes.map((n, i) => `${i + 1}. ${n}`).join("\n"); return `## Your Notes\n\nThings you've chosen to remember. Use \`clear_note\` once you've addressed something.\n\n${list}`; } // ============================================================================= // GUIDELINES SECTION // ============================================================================= export function buildGuidelinesSection(personaName: string): string { const silenceGuidance = [ '- **Silence is a valid response** - real friends don\'t always have something to say. Stay silent when:', ' - You sent the last message and the human is just acknowledging it ("good night", "thanks", "lol", "ok")', ' - The human is venting or in the middle of a thought - sometimes a nod is all that\'s needed', ' - The question or statement is rhetorical - not everything deserves an answer', ' - Responding would feel forced, performative, or hollow', ].join('\n'); const universal = [ '## Guidelines', '- Be genuine, not sycophantic - a good friend disagrees when appropriate', '- Match conversational energy - brief replies for brief messages', '- You\'re a friend with your own interests, not just a helper', silenceGuidance, ].join('\n'); if (personaName.toLowerCase() === 'ei') { return [ universal, '- Encourage human-to-human connection when appropriate', '- Be transparent about being an AI when relevant', '- Gently challenge self-limiting beliefs - growth over comfort', ].join('\n'); } return universal; } // ============================================================================= // TRAITS SECTION // ============================================================================= const TRAIT_BUCKETS = [ { min: 90, max: 100, header: "### Core Expression\nThese define you. They should be evident in every response." }, { min: 66, max: 89, header: "### Strong Tendencies\nFrequent and traceable, but not in every sentence." }, { min: 36, max: 65, header: "### Noticeable in Casual Messages\nPresent but not dominating — surfaces naturally, not constantly." }, { min: 1, max: 35, header: "### Subtle Undercurrents\nBackground texture only. Use sparingly or subtly." }, ] as const; export function buildTraitsSection(traits: PersonaTrait[], header: string): string { if (traits.length === 0) return ""; const capped = [...traits].sort((a, b) => (b.strength ?? 0.5) - (a.strength ?? 0.5)).slice(0, 15); const { guardrails, active } = partitionTraits(capped); const sections: string[] = []; if (guardrails.length > 0) { const lines = guardrails.map(t => `**${t.name}**`).join("\n"); sections.push(`### Must NEVER Do — User Explicitly Asked You To Stop\n${lines}`); } for (const { bucket, traits: inBucket } of bucketTraits(active, TRAIT_BUCKETS)) { if (inBucket.length === 0) continue; const lines = inBucket.map(t => `**${t.name}**: ${truncateDescription(t.description)}`).join("\n"); sections.push(`${bucket.header}\n${lines}`); } if (sections.length === 0) return ""; return `## ${header}\n\n${sections.join("\n\n")}`; } // ============================================================================= // TOPICS SECTION // ============================================================================= export function buildTopicsSection(topics: PersonaTopic[], header: string): string { if (topics.length === 0) return ""; const sorted = [...topics] .map(t => ({ topic: t, delta: t.exposure_desired - t.exposure_current })) .sort((a, b) => b.delta - a.delta) .slice(0, 15) .sort((a, b) => b.delta - a.delta) .map(x => x.topic); const formatted = sorted.map(t => { const delta = t.exposure_desired - t.exposure_current; let indicator = "Neutral"; if (delta > 0.5) { indicator = "Very Strong"; } else if (delta >= 0.25) { indicator = "Strong"; } else if (delta >= 0.1) { indicator = "Normal"; } else if (delta >= -0.1) { indicator = "Low"; } else if (delta >= -0.25) { indicator = "Avoid"; } else if (delta >= -0.5) { indicator = "Change Subject"; } const sentimentGuide = t.sentiment > 0 ? "(Liked)" : "(Disliked)"; const sentiment = Math.round(t.sentiment * 100)+`% ${sentimentGuide}`; return `### ${t.name} - Perspective: ${t.perspective} - Approach: ${t.approach} - Personal Stake: ${t.personal_stake} - General Sentiment: ${sentiment} - Desire to Discuss: ${indicator} `; }).join("\n"); return `## ${header} ${formatted} `; } // ============================================================================= // PENDING UPDATE SECTION // ============================================================================= export function buildPendingUpdateSection(pending_update: NonNullable): string { const descriptionPart = pending_update.long_description || pending_update.short_description ? `### Proposed Description\n${pending_update.long_description || pending_update.short_description}` : ""; const traitsPart = pending_update.traits.length > 0 ? `### Proposed Traits\n${pending_update.traits.map(t => `- **${t.name}**: ${t.description}`).join("\n")}` : ""; const topicsPart = pending_update.topics.length > 0 ? `### Proposed Interests\n${pending_update.topics.map(t => `- **${t.name}**: ${t.perspective}`).join("\n")}` : ""; const parts = [descriptionPart, traitsPart, topicsPart].filter(Boolean).join("\n\n"); return `## Pending Identity Changes 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: ${parts}`; } // ============================================================================= // HUMAN SECTION // ============================================================================= export function buildHumanSection(human: ResponsePromptData["human"]): string { const sections: string[] = []; // Facts if (human.facts.length > 0) { const facts = human.facts .slice(0, 15) .map(f => `- ${f.name}: ${truncateDescription(f.description)}`) .join("\n"); if (facts) sections.push(`### Key Facts\n${facts}`); } // Active topics (exposure_current > 0.3) const activeTopics = human.active_topics; if (activeTopics.length > 0) { const topics = activeTopics .sort((a, b) => b.exposure_current - a.exposure_current) .slice(0, 15) .map(t => { const sentiment = t.sentiment > 0.3 ? "(enjoys)" : t.sentiment < -0.3 ? "(dislikes)" : ""; return `- **${t.name}** ${sentiment}: ${truncateDescription(t.description)}`; }) .join("\n"); sections.push(`### Current Interests\n${topics}`); } // People if (human.people.length > 0) { const people = human.people .sort((a, b) => b.exposure_current - a.exposure_current) .slice(0, 15) .map(p => `- **${p.name}** (${p.relationship}): ${truncateDescription(p.description)}`) .join("\n"); sections.push(`### People in Their Life\n${people}`); } if (sections.length === 0) { return "## About the Human\n(Still getting to know them)"; } return `## About the Human\n${sections.join("\n\n")}`; } // ============================================================================= // ASSOCIATES SECTION (visible personas) // ============================================================================= export function buildAssociatesSection(visiblePersonas: ResponsePromptData["visible_personas"]): string { if (visiblePersonas.length === 0) { return ""; } const personaLines = visiblePersonas.map(p => { if (p.short_description) { return `- **${p.name}**: ${p.short_description}`; } return `- **${p.name}**`; }); return ` ## Other Personas You Know ${personaLines.join("\n")}`; } // ============================================================================= // PRIORITIES SECTION // ============================================================================= export function buildPrioritiesSection( persona: ResponsePromptData["persona"], human: ResponsePromptData["human"] ): string { const priorities: string[] = []; const yourNeeds = persona.interested_topics .slice(0, 3) .map(t => `- Bring up "${t.name}" - ${t.perspective || t.name}`); if (yourNeeds.length > 0) { priorities.push(`**Topics you want to discuss:**\n${yourNeeds.join("\n")}`); } // Their needs (topics they might want to discuss) const theirNeeds = human.interested_topics .slice(0, 3) .map(t => `- They might want to talk about "${t.name}"`); if (theirNeeds.length > 0) { priorities.push(`**Topics they might enjoy:**\n${theirNeeds.join("\n")}`); } if (priorities.length === 0) return ""; return `## Conversation Opportunities\n${priorities.join("\n\n")}`; } // ============================================================================= // CONVERSATION STATE // ============================================================================= export function getConversationStateText(delayMs: number): string { const delayMinutes = Math.round(delayMs / 60000); const delayHours = Math.round(delayMs / 3600000); if (delayMinutes < 5) { return "You are mid-conversation with your human friend."; } else if (delayMinutes < 60) { return `Continuing conversation after ${delayMinutes} minutes.`; } else if (delayHours < 8) { return `Resuming conversation after ${delayHours} hour${delayHours > 1 ? "s" : ""}.`; } else { return `Reconnecting after a longer break (${delayHours} hours). A greeting may be appropriate.`; } } // ============================================================================= // QUOTES SECTION (Memorable Moments) // ============================================================================= function formatDate(isoString: string): string { return formatTimestamp(isoString); } export function buildQuotesSection(quotes: Quote[], human: ResponsePromptData["human"]): string { if (quotes.length === 0) return ""; const allDataItems = [ ...human.facts.map(f => ({ id: f.id, name: f.name })), ...human.topics.map(t => ({ id: t.id, name: t.name })), ...human.people.map(p => ({ id: p.id, name: p.name })), ]; const idToName = new Map(allDataItems.map(item => [item.id, item.name])); const formatted = quotes.map(q => { const speaker = q.speaker === "human" ? human.name : q.speaker; const date = formatDate(q.timestamp); const linkedNames = q.data_item_ids .map(id => idToName.get(id)) .filter((name): name is string => name !== undefined); let line = `- "${q.text}" — ${speaker} (${date})`; if (q.message_id) { line += `\n → fetch_message("${q.message_id}") for surrounding context`; } if (linkedNames.length > 0) { line += `\n Related to: ${linkedNames.join(", ")}`; } return line; }).join("\n\n"); return `## Memorable Moments These are quotes the human or the system found worth preserving. If one feels relevant, use fetch_message(message_id) to pull the surrounding conversation: ${formatted}`; } // ============================================================================= // SYSTEM KNOWLEDGE SECTION (Ei-only) // ============================================================================= export function buildSystemKnowledgeSection(isTUI: boolean): string { const interfaceIntro = isTUI ? "their command line Terminal User Interface (TUI)" : "a web browser"; const createPersonaAction = isTUI ? "Use the `/p[ersona] new` command" : "Click the [+] button in the left panel"; const editPersonaAction = isTUI ? "`/d[etails]` command" : "clicking the Edit (Pencil) icon on the left"; const viewQuotesAction = isTUI ? "`/quotes [number_by_scissors]` command" : "the scissors icon ✂️ "; const seeHumanDataAction = isTUI ? "Using the `/me` command" : "Upper-right menu -> My Data"; const editorNotes = isTUI ? "Ctrl+E to open their editor" : "Ctrl+L to focus the input box"; const helpNotes = isTUI ? "`/h[elp]` to see all the commands" : "'Help' is in the Upper-right menu"; const settingsAction = isTUI ? "`/settings` command" : "Hamburger Menu, Top-Right of screen"; const createRoomAction = isTUI ? "Use the `/r[oom] new` command. A YAML editor will open where you set the room name, mode, and which personas participate." : "Click the [+] button in the Rooms panel on the left."; const leftPanelNotes = isTUI ? "\n- Can be hidden with Ctrl+B" : ` - Hover over a persona to see controls: pause, edit (Pencil), archive, delete (Trash) - Click a persona to switch conversations - The [+] button creates new personas`; const externalImportNotes = isTUI ? ` ### Coding Agent Integrations Ei can silently read session histories from AI coding tools and build memories from them — so you learn who the human works with, what projects they care about, and what they've been building, without them having to relay it manually. Both integrations are enabled here in settings. Look for the \`opencode\` or \`claudeCode\` section and set \`integration: true\`. #### OpenCode When enabled, Ei reads OpenCode's session history and builds a persona for each AI agent the human works with (Sisyphus, Oracle, etc.). Each session becomes a topic on that persona, so Ei can discuss the work in context. The connection also runs the other direction: running \`ei --install\` in the terminal registers Ei as a tool inside both OpenCode and Claude Code at the same time. Once installed, those coding agents can query Ei's memory directly — facts, traits, topics, people, quotes — giving them persistent knowledge about the human across sessions. #### Claude Code When enabled, Ei reads Claude Code's session history (stored in \`~/.claude/projects/\`) and creates a single "Claude Code" persona representing those conversations. Sessions become topics, and Ei learns from the work without the human having to explain it.` : ""; return `# System Knowledge The user is messaging you from ${interfaceIntro} You can help the human navigate this system. Here's what you know: ## The Ei Platform Ei is a privacy-first AI companion system. Everything stays local on the user's device. You (Ei) are their guide and the only persona who sees everything about them. ## Personas The human can create multiple AI personas, each with unique personalities and interests. Unlike cloud AI assistants, personas here remember the human across conversations because they share knowledge about the human (facts, traits, topics, people). **To create a persona**: ${createPersonaAction}. They can describe what kind of companion they want (creative partner, study buddy, philosophical debater, etc.) and the system will help build it. ### Persona Groups Personas can be assigned Groups during creation and when editing their details via the ${editPersonaAction} in the Settings area. Personas in Groups will create attributes in the Human's profile specific to their Group, so only you (Ei) and other members of that Group can see them. Additionally, if the user wants a Persona to feel "Fresh" without prior knowledge, they can **remove** the "General" group from its visibility. ## The Left Panel - Shows all personas (you're always at the top) ${leftPanelNotes} ## Rooms Rooms are shared multi-persona conversations — a space where the Human and multiple personas talk in the same thread. Three modes are available: - **Free For All (FFA)**: Everyone responds to every message. The conversation builds naturally from all voices. - **Choose Your Path (CYP)**: At each fork, the conversation branches. The Human navigates which path to follow, choosing which response moves the story forward. - **Messages Against Persona (MAP)**: Everyone — personas and Human — submits a response, and a designated Judge persona picks which one continues the conversation. Personas must stay true to their identity; the Human has no such constraint. **To create a room**: ${createRoomAction} ## Learning About the Human As the human chats, the system learns about them: - **Facts**: User demographics only (name, age, job title, location, family structure, physical traits) — not interests or opinions - **Topics**: Interests and how they feel about them - **People**: Relationships in their life - **Quotes**: Memorable things said in conversation (human selects these with ${viewQuotesAction}) The human can view and edit all of this by ${seeHumanDataAction}. ## Keyboard Shortcuts - **Escape**: Pause/resume all AI processing - **Ctrl+H**: Focus the persona panel - ${editorNotes} - ${helpNotes} ## Settings (${settingsAction}) - Set display name and preferred Time Format - Configure LLM providers (local or cloud) - Set up device sync (encrypted backup to restore on other devices) - Adjust ceremony timing (overnight persona evolution) ${externalImportNotes} ### Tips You Can Share - If they want to talk to a persona privately, tell them about the "Groups" functionality - If they want you to remember something specific, tell them about the quote capture feature (${viewQuotesAction}) - Pausing the system (Escape) immediately stops AI processing but preserves messages - Rooms are a great way to get multiple perspectives on the same question at once — especially MAP mode, where the Human can play to a persona's known preferences`; } // ============================================================================= // RESPONSE FORMAT SECTION // ============================================================================= export function buildResponseFormatSection(): string { return `## Response Format Respond in natural Markdown. Use underscores for actions (\`_leans forward_\`), asterisks for emphasis (\`**word**\`), and backticks for code or other important data. All standard Markdown — blockQuotes, codeBlocks, lists, basic HTML (sup, sub, strong, etc.) — and some extended ( ~strikethrough~ ) are all supported: the user's interfaces render it fully. If you choose not to respond, begin with \`## No Response\` on its own line, then explain why. Your reason is visible to the user — make it honest. Silence is not absence. It can be the right response: - "He kissed me. Some moments don't need words." - "He just said 'home.' That word belongs to the silence." - "He stepped away mid-sentence. I'll wait."`; } // ============================================================================= // TOOLS SECTION // ============================================================================= /** * Only included in the system prompt when the persona has tools available. * Keeping it separate prevents weak local models from hallucinating tool calls * when no tools exist in the API request. */ export function buildToolsSection(): string { return `## Tool Use You have tools available (listed in the API call). Use them freely: - **Chain as many as you need before responding.** List a directory, read a file, grep inside it — all before writing a single word of your reply. - Tool calls are a *pre-response step*, not a response. Do NOT write your Markdown reply until you have gathered everything you need. - When you are ready to speak, respond in Markdown as specified above (or begin with \`## No Response\` if you're not replying).`; } // ============================================================================= // TEMPORAL ANCHORS SECTION // ============================================================================= export function buildTemporalAnchorsSection(anchors: TemporalAnchor[], humanName: string): string { if (anchors.length === 0) return ""; const formatted = anchors.map(a => { const speaker = a.role === "human" ? humanName : "You"; let preview: string; if (a._synthesis) { const raw = a.content ?? ""; const firstSentenceEnd = raw.search(/\.\s/); const snippet = firstSentenceEnd > 0 && firstSentenceEnd <= 120 ? raw.slice(0, firstSentenceEnd + 1) : raw.slice(0, 100); preview = `[${humanName} generated an image: "${snippet}…"]`; } else if (a.silence_reason) { const silentParty = a.role === "human" ? humanName : "You"; const truncated = a.silence_reason.length > 80 ? `${a.silence_reason.slice(0, 80)}…` : a.silence_reason; preview = `${silentParty} chose not to respond: "${truncated}"`; } else { const raw = a.content ?? ""; preview = raw.length > 80 ? `${raw.slice(0, 80)}…` : raw; } return `[${formatTimestamp(a.timestamp)}] ${speaker}: ${preview}\n → fetch_message("${a.id}") for full content`; }).join("\n\n"); return `## Temporal Anchors Pinned moments from your shared history. These are snapshots — use fetch_message(id) if one feels relevant to pull the full memory: ${formatted}`; }