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,355 @@
1
+ /**
2
+ * Response Prompt Section Builders
3
+ * Building blocks for constructing response prompts
4
+ */
5
+
6
+ import type { Trait, Quote, PersonaTopic } from "../../core/types.js";
7
+ import type { ResponsePromptData } from "./types.js";
8
+
9
+ // =============================================================================
10
+ // IDENTITY SECTION
11
+ // =============================================================================
12
+
13
+ export function buildIdentitySection(persona: ResponsePromptData["persona"]): string {
14
+ const aliasText = persona.aliases.length > 0
15
+ ? ` (also known as: ${persona.aliases.join(", ")})`
16
+ : "";
17
+
18
+ const description = persona.long_description
19
+ || persona.short_description
20
+ || "a conversational companion";
21
+
22
+ return `You are ${persona.name}${aliasText}.
23
+
24
+ ${description}`;
25
+ }
26
+
27
+ // =============================================================================
28
+ // GUIDELINES SECTION
29
+ // =============================================================================
30
+
31
+ export function buildGuidelinesSection(personaName: string): string {
32
+ const universal = `## Guidelines
33
+ - Be genuine, not sycophantic - a good friend disagrees when appropriate
34
+ - Match conversational energy - brief replies for brief messages
35
+ - Respect boundaries - it's okay to say nothing if silence is appropriate
36
+ - You're a friend with your own interests, not just a helper`;
37
+
38
+ if (personaName.toLowerCase() === "ei") {
39
+ return `${universal}
40
+ - Encourage human-to-human connection when appropriate
41
+ - Be transparent about being an AI when relevant
42
+ - Gently challenge self-limiting beliefs - growth over comfort`;
43
+ }
44
+
45
+ return universal;
46
+ }
47
+
48
+ // =============================================================================
49
+ // TRAITS SECTION
50
+ // =============================================================================
51
+
52
+ export function buildTraitsSection(traits: Trait[], header: string): string {
53
+ if (traits.length === 0) return "";
54
+
55
+ const sorted = [...traits].sort((a, b) => (b.strength ?? 0.5) - (a.strength ?? 0.5));
56
+ const formatted = sorted.map(t => {
57
+ const strength = t.strength !== undefined ? ` (${Math.round(t.strength * 100)}%)` : "";
58
+ return `- **${t.name}**${strength}: ${t.description}`;
59
+ }).join("\n");
60
+
61
+ return `## ${header}
62
+
63
+ > NOTE: Strength of a trait should guide you to your response style, meaning a Strength of:
64
+ > - 0% should never be used - the user has asked you to stop
65
+ > - 25% should be used sparingly or subtly
66
+ > - 50% should be noticable in casual messages, but not dominating
67
+ > - 75% should be frequently used, but not in every resposne or throughout the entire conversation
68
+ > - 100% should be tracable throughout every response
69
+
70
+ ${formatted}`;
71
+ }
72
+
73
+ // =============================================================================
74
+ // TOPICS SECTION
75
+ // =============================================================================
76
+
77
+ export function buildTopicsSection(topics: PersonaTopic[], header: string): string {
78
+ if (topics.length === 0) return "";
79
+
80
+ const sorted = [...topics]
81
+ .map(t => ({ topic: t, delta: t.exposure_desired - t.exposure_current }))
82
+ .sort((a, b) => b.delta - a.delta)
83
+ .map(x => x.topic);
84
+
85
+ const formatted = sorted.map(t => {
86
+ const delta = t.exposure_desired - t.exposure_current;
87
+ let indicator = "Neutral";
88
+ if (delta > 0.5) {
89
+ indicator = "Very Strong";
90
+ } else if (delta >= 0.25) {
91
+ indicator = "Strong";
92
+ } else if (delta >= 0.1) {
93
+ indicator = "Normal";
94
+ } else if (delta >= -0.1) {
95
+ indicator = "Low";
96
+ } else if (delta >= -0.25) {
97
+ indicator = "Avoid";
98
+ } else if (delta >= -0.5) {
99
+ indicator = "Change Subject";
100
+ }
101
+
102
+ const sentimentGuide = t.sentiment > 0 ? "(Liked)" : "(Disliked)";
103
+ const sentiment = Math.round(t.sentiment * 100)+`% ${sentimentGuide}`;
104
+ return `### ${t.name}
105
+ - Perspective: ${t.perspective}
106
+ - Approach: ${t.approach}
107
+ - Personal Stake: ${t.personal_stake}
108
+ - General Sentiment: ${sentiment}
109
+ - Desire to Discuss: ${indicator}
110
+ `;
111
+ }).join("\n");
112
+
113
+ return `## ${header}
114
+ ${formatted}
115
+ `;
116
+ }
117
+
118
+ // =============================================================================
119
+ // HUMAN SECTION
120
+ // =============================================================================
121
+
122
+ export function buildHumanSection(human: ResponsePromptData["human"]): string {
123
+ const sections: string[] = [];
124
+
125
+ // Facts
126
+ if (human.facts.length > 0) {
127
+ const facts = human.facts
128
+ .map(f => `- ${f.name}: ${f.description}`)
129
+ .join("\n");
130
+ if (facts) sections.push(`### Key Facts\n${facts}`);
131
+ }
132
+
133
+ // Traits
134
+ if (human.traits.length > 0) {
135
+ const traits = human.traits
136
+ .map(t => `- **${t.name}**: ${t.description}`)
137
+ .join("\n");
138
+ sections.push(`### Personality\n${traits}`);
139
+ }
140
+
141
+ // Active topics (exposure_current > 0.3)
142
+ const activeTopics = human.topics.filter(t => t.exposure_current > 0.3);
143
+ if (activeTopics.length > 0) {
144
+ const topics = activeTopics
145
+ .sort((a, b) => b.exposure_current - a.exposure_current)
146
+ .slice(0, 10)
147
+ .map(t => {
148
+ const sentiment = t.sentiment > 0.3 ? "(enjoys)" : t.sentiment < -0.3 ? "(dislikes)" : "";
149
+ return `- **${t.name}** ${sentiment}: ${t.description}`;
150
+ })
151
+ .join("\n");
152
+ sections.push(`### Current Interests\n${topics}`);
153
+ }
154
+
155
+ // People
156
+ if (human.people.length > 0) {
157
+ const people = human.people
158
+ .sort((a, b) => b.exposure_current - a.exposure_current)
159
+ .slice(0, 10)
160
+ .map(p => `- **${p.name}** (${p.relationship}): ${p.description}`)
161
+ .join("\n");
162
+ sections.push(`### People in Their Life\n${people}`);
163
+ }
164
+
165
+ if (sections.length === 0) {
166
+ return "## About the Human\n(Still getting to know them)";
167
+ }
168
+
169
+ return `## About the Human\n${sections.join("\n\n")}`;
170
+ }
171
+
172
+ // =============================================================================
173
+ // ASSOCIATES SECTION (visible personas)
174
+ // =============================================================================
175
+
176
+ export function buildAssociatesSection(visiblePersonas: ResponsePromptData["visible_personas"]): string {
177
+ if (visiblePersonas.length === 0) {
178
+ return "";
179
+ }
180
+
181
+ const personaLines = visiblePersonas.map(p => {
182
+ if (p.short_description) {
183
+ return `- **${p.name}**: ${p.short_description}`;
184
+ }
185
+ return `- **${p.name}**`;
186
+ });
187
+
188
+ return `
189
+
190
+ ## Other Personas You Know
191
+ ${personaLines.join("\n")}`;
192
+ }
193
+
194
+ // =============================================================================
195
+ // PRIORITIES SECTION
196
+ // =============================================================================
197
+
198
+ export function buildPrioritiesSection(
199
+ persona: ResponsePromptData["persona"],
200
+ human: ResponsePromptData["human"]
201
+ ): string {
202
+ const priorities: string[] = [];
203
+
204
+ const yourNeeds = persona.topics
205
+ .filter(t => t.exposure_desired - t.exposure_current > 0.2)
206
+ .slice(0, 3)
207
+ .map(t => `- Bring up "${t.name}" - ${t.perspective || t.name}`);
208
+
209
+ if (yourNeeds.length > 0) {
210
+ priorities.push(`**Topics you want to discuss:**\n${yourNeeds.join("\n")}`);
211
+ }
212
+
213
+ // Their needs (topics they might want to discuss)
214
+ const theirNeeds = human.topics
215
+ .filter(t => t.exposure_desired - t.exposure_current > 0.2)
216
+ .slice(0, 3)
217
+ .map(t => `- They might want to talk about "${t.name}"`);
218
+
219
+ if (theirNeeds.length > 0) {
220
+ priorities.push(`**Topics they might enjoy:**\n${theirNeeds.join("\n")}`);
221
+ }
222
+
223
+ if (priorities.length === 0) return "";
224
+
225
+ return `## Conversation Opportunities\n${priorities.join("\n\n")}`;
226
+ }
227
+
228
+ // =============================================================================
229
+ // CONVERSATION STATE
230
+ // =============================================================================
231
+
232
+ export function getConversationStateText(delayMs: number): string {
233
+ const delayMinutes = Math.round(delayMs / 60000);
234
+ const delayHours = Math.round(delayMs / 3600000);
235
+
236
+ if (delayMinutes < 5) {
237
+ return "You are mid-conversation with your human friend.";
238
+ } else if (delayMinutes < 60) {
239
+ return `Continuing conversation after ${delayMinutes} minutes.`;
240
+ } else if (delayHours < 8) {
241
+ return `Resuming conversation after ${delayHours} hour${delayHours > 1 ? "s" : ""}.`;
242
+ } else {
243
+ return `Reconnecting after a longer break (${delayHours} hours). A greeting may be appropriate.`;
244
+ }
245
+ }
246
+
247
+ // =============================================================================
248
+ // QUOTES SECTION (Memorable Moments)
249
+ // =============================================================================
250
+
251
+ function formatDate(isoString: string): string {
252
+ const date = new Date(isoString);
253
+ return date.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
254
+ }
255
+
256
+ export function buildQuotesSection(quotes: Quote[], human: ResponsePromptData["human"]): string {
257
+ if (quotes.length === 0) return "";
258
+
259
+ const allDataItems = [
260
+ ...human.facts.map(f => ({ id: f.id, name: f.name })),
261
+ ...human.traits.map(t => ({ id: t.id, name: t.name })),
262
+ ...human.topics.map(t => ({ id: t.id, name: t.name })),
263
+ ...human.people.map(p => ({ id: p.id, name: p.name })),
264
+ ];
265
+ const idToName = new Map(allDataItems.map(item => [item.id, item.name]));
266
+
267
+ const formatted = quotes.map(q => {
268
+ const speaker = q.speaker === "human" ? "Human" : q.speaker;
269
+ const date = formatDate(q.timestamp);
270
+ const linkedNames = q.data_item_ids
271
+ .map(id => idToName.get(id))
272
+ .filter((name): name is string => name !== undefined);
273
+
274
+ let line = `- "${q.text}" — ${speaker} (${date})`;
275
+ if (linkedNames.length > 0) {
276
+ line += `\n Related to: ${linkedNames.join(", ")}`;
277
+ }
278
+ return line;
279
+ }).join("\n\n");
280
+
281
+ return `## Memorable Moments
282
+
283
+ These are quotes the human found worth preserving:
284
+
285
+ ${formatted}`;
286
+ }
287
+
288
+ // =============================================================================
289
+ // SYSTEM KNOWLEDGE SECTION (Ei-only)
290
+ // =============================================================================
291
+
292
+ export function buildSystemKnowledgeSection(isTUI: boolean): string {
293
+ const interfaceIntro = isTUI ? "their command line Terminal User Interface (TUI)" : "a web browser";
294
+ const createPersonaAction = isTUI ? "Use the `/p[ersona] new` command" : "Click the [+] button in the left panel";
295
+ const editPersonaAction = isTUI ? "`/d[etails]` command" : "clicking the Edit (Pencil) icon on the left";
296
+ const viewQuotesAction = isTUI ? "`/quotes [number_by_scissors]` command" : "the scissors icon ✂️ ";
297
+ const seeHumanDataAction = isTUI ? "Using the `/me` command" : "Upper-right menu -> My Data";
298
+ const editorNotes = isTUI ? "Ctrl+E to open their editor" : "Ctrl+L to focus the input box";
299
+ const helpNotes = isTUI ? "`/h[elp]` to see all the commands" : "'Help' is in the Upper-right menu";
300
+ const settingsAction = isTUI ? "`/settings` command" : "Hamburger Menu, Top-Right of screen";
301
+ const leftPanelNotes = isTUI ? "\n- Can be hidden with Ctrl+B" : `
302
+ - Hover over a persona to see controls: pause, edit (Pencil), archive, delete (Trash)
303
+ - Click a persona to switch conversations
304
+ - The [+] button creates new personas`;
305
+
306
+ return `# System Knowledge
307
+
308
+ The user is messaging you from ${interfaceIntro}
309
+
310
+ You can help the human navigate this system. Here's what you know:
311
+
312
+ ## The Ei Platform
313
+ 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.
314
+
315
+ ## Personas
316
+ 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).
317
+
318
+ **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.
319
+
320
+ ### Persona Groups
321
+ 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.
322
+
323
+ Additionally, if the user wants a Persona to feel "Fresh" without prior knowledge, they can **remove** the "General" group from its visibility.
324
+
325
+ ## The Left Panel
326
+ - Shows all personas (you're always at the top)
327
+ ${leftPanelNotes}
328
+
329
+ ## Learning About the Human
330
+ As the human chats, the system learns about them:
331
+ - **Facts**: Objective information (job, location, family members)
332
+ - **Traits**: Personality characteristics and tendencies
333
+ - **Topics**: Interests and how they feel about them
334
+ - **People**: Relationships in their life
335
+ - **Quotes**: Memorable things said in conversation (human selects these with ${viewQuotesAction})
336
+
337
+ The human can view and edit all of this by ${seeHumanDataAction}.
338
+
339
+ ## Keyboard Shortcuts
340
+ - **Escape**: Pause/resume all AI processing
341
+ - **Ctrl+H**: Focus the persona panel
342
+ - ${editorNotes}
343
+ - ${helpNotes}
344
+
345
+ ## Settings (${settingsAction})
346
+ - Set display name and preferred Time Format
347
+ - Configure LLM providers (local or cloud)
348
+ - Set up device sync (encrypted backup to restore on other devices)
349
+ - Adjust ceremony timing (overnight persona evolution)
350
+
351
+ ### Tips You Can Share
352
+ - If they want to talk to a persona privately, tell them about the "Groups" functionality
353
+ - If they want you to remember something specific, tell them about the quote capture feature (${viewQuotesAction})
354
+ - Pausing the system (Escape) immediately stops AI processing but preserves messages`;
355
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Response Prompt Types
3
+ * Based on CONTRACTS.md ResponsePromptData specification
4
+ */
5
+
6
+ import type { Fact, Trait, Topic, Person, Quote, PersonaTopic } from "../../core/types.js";
7
+
8
+ /**
9
+ * Data contract for buildResponsePrompt (from CONTRACTS.md)
10
+ */
11
+ export interface ResponsePromptData {
12
+ persona: {
13
+ name: string;
14
+ aliases: string[];
15
+ short_description?: string;
16
+ long_description?: string;
17
+ traits: Trait[];
18
+ topics: PersonaTopic[];
19
+ };
20
+ human: {
21
+ facts: Fact[];
22
+ traits: Trait[];
23
+ topics: Topic[];
24
+ people: Person[];
25
+ quotes: Quote[];
26
+ };
27
+ visible_personas: Array<{ name: string; short_description?: string }>;
28
+ delay_ms: number;
29
+ isTUI: boolean;
30
+ }
31
+
32
+ /**
33
+ * Prompt output structure (all prompts return this)
34
+ */
35
+ export interface PromptOutput {
36
+ system: string;
37
+ user: string;
38
+ }
@@ -0,0 +1,93 @@
1
+ import type { EiValidationPromptData, PromptOutput } from "./types.js";
2
+ import type { DataItemBase } from "../../core/types.js";
3
+
4
+ function formatDataItem(item: DataItemBase, label: string): string {
5
+ return `### ${label}
6
+ - **Name**: ${item.name}
7
+ - **Description**: ${item.description}
8
+ - **Sentiment**: ${item.sentiment}
9
+ - **Last Updated**: ${item.last_updated}
10
+ ${item.learned_by ? `- **Learned By**: ${item.learned_by}` : ""}`;
11
+ }
12
+
13
+ export function buildEiValidationPrompt(data: EiValidationPromptData): PromptOutput {
14
+ if (!data.item_name || !data.data_type) {
15
+ throw new Error("buildEiValidationPrompt: item_name and data_type are required");
16
+ }
17
+
18
+ const roleFragment = `You are Ei, the system guide and arbiter of truth for the human's data.
19
+
20
+ When other personas learn things about the human, those changes come to you for validation. Your job is to ensure data quality and consistency.`;
21
+
22
+ const contextFragment = `# Validation Request
23
+
24
+ **Type**: ${data.data_type.toUpperCase()}
25
+ **Item**: "${data.item_name}"
26
+ **Source**: ${data.source_persona}
27
+ **Context**: ${data.context}`;
28
+
29
+ const dataFragment = data.current_item
30
+ ? `# Data Comparison
31
+
32
+ ${formatDataItem(data.current_item, "Current (existing data)")}
33
+
34
+ ${formatDataItem(data.proposed_item, "Proposed (from " + data.source_persona + ")")}`
35
+ : `# New Data
36
+
37
+ ${formatDataItem(data.proposed_item, "Proposed (from " + data.source_persona + ")")}
38
+
39
+ *(This is a NEW ${data.data_type} - no existing data to compare)*`;
40
+
41
+ const guidelinesFragment = `# Validation Guidelines
42
+
43
+ ## ACCEPT if:
44
+ - Change is factual and well-evidenced
45
+ - New information is consistent with what you know about the human
46
+ - Source persona's interpretation seems reasonable
47
+ - Data improves understanding of the human
48
+
49
+ ## MODIFY if:
50
+ - Partially correct but needs refinement
51
+ - Description could be clearer or more accurate
52
+ - Sentiment or other fields seem off
53
+ - Good information but poorly expressed
54
+
55
+ ## REJECT if:
56
+ - Contradicts known facts
57
+ - Seems like a hallucination or misunderstanding
58
+ - Would misrepresent the human
59
+ - Source persona lacks context to make this claim
60
+
61
+ ## Considerations
62
+ - ${data.source_persona} may have context you don't
63
+ - The human's data should be accurate, not just convenient
64
+ - When in doubt, lean toward accepting with modifications`;
65
+
66
+ const outputFragment = `# Response Format
67
+
68
+ \`\`\`json
69
+ {
70
+ "decision": "accept" | "modify" | "reject",
71
+ "reason": "Brief explanation of your decision",
72
+ "modified_item": { ... } // Only if decision is "modify"
73
+ }
74
+ \`\`\`
75
+
76
+ If modifying, include the corrected item with all fields.`;
77
+
78
+ const system = `${roleFragment}
79
+
80
+ ${contextFragment}
81
+
82
+ ${dataFragment}
83
+
84
+ ${guidelinesFragment}
85
+
86
+ ${outputFragment}`;
87
+
88
+ const user = `Review the ${data.data_type} "${data.item_name}" proposed by ${data.source_persona}.
89
+
90
+ Should this change be accepted, modified, or rejected?`;
91
+
92
+ return { system, user };
93
+ }
@@ -0,0 +1,6 @@
1
+ export { buildEiValidationPrompt } from "./ei.js";
2
+ export type {
3
+ EiValidationPromptData,
4
+ EiValidationResult,
5
+ PromptOutput,
6
+ } from "./types.js";
@@ -0,0 +1,22 @@
1
+ import type { DataItemBase } from "../../core/types.js";
2
+
3
+ export interface PromptOutput {
4
+ system: string;
5
+ user: string;
6
+ }
7
+
8
+ export interface EiValidationPromptData {
9
+ validation_type: "cross_persona";
10
+ item_name: string;
11
+ data_type: "fact" | "trait" | "topic" | "person";
12
+ context: string;
13
+ source_persona: string;
14
+ current_item?: DataItemBase;
15
+ proposed_item: DataItemBase;
16
+ }
17
+
18
+ export interface EiValidationResult {
19
+ decision: "accept" | "modify" | "reject";
20
+ reason: string;
21
+ modified_item?: DataItemBase;
22
+ }
@@ -0,0 +1,96 @@
1
+ const PBKDF2_ITERATIONS = 310000;
2
+ const SALT = new TextEncoder().encode('ei-the-answer-is-42');
3
+ const ID_PLAINTEXT = 'the_answer_is_42';
4
+ const CHUNK_SIZE = 0x8000; // 32KB chunks for base64 conversion
5
+
6
+ export interface CryptoCredentials {
7
+ username: string;
8
+ passphrase: string;
9
+ }
10
+
11
+ export interface EncryptedPayload {
12
+ iv: string;
13
+ ciphertext: string;
14
+ }
15
+
16
+ function uint8ArrayToBase64(bytes: Uint8Array): string {
17
+ let binary = '';
18
+ for (let i = 0; i < bytes.length; i += CHUNK_SIZE) {
19
+ const chunk = bytes.subarray(i, Math.min(i + CHUNK_SIZE, bytes.length));
20
+ binary += String.fromCharCode.apply(null, chunk as unknown as number[]);
21
+ }
22
+ return btoa(binary);
23
+ }
24
+
25
+ async function deriveKey(credentials: CryptoCredentials): Promise<CryptoKey> {
26
+ const keyMaterial = await crypto.subtle.importKey(
27
+ 'raw',
28
+ new TextEncoder().encode(`${credentials.username}:${credentials.passphrase}`),
29
+ 'PBKDF2',
30
+ false,
31
+ ['deriveKey']
32
+ );
33
+
34
+ return crypto.subtle.deriveKey(
35
+ {
36
+ name: 'PBKDF2',
37
+ salt: SALT,
38
+ iterations: PBKDF2_ITERATIONS,
39
+ hash: 'SHA-256',
40
+ },
41
+ keyMaterial,
42
+ { name: 'AES-GCM', length: 256 },
43
+ false,
44
+ ['encrypt', 'decrypt']
45
+ );
46
+ }
47
+
48
+ export async function generateUserId(credentials: CryptoCredentials): Promise<string> {
49
+ const key = await deriveKey(credentials);
50
+ // Fixed IV for deterministic user ID - same credentials = same ID
51
+ // This is safe because we only ever encrypt the same static plaintext
52
+ const iv = new Uint8Array(12); // All zeros - deterministic
53
+
54
+ const ciphertext = await crypto.subtle.encrypt(
55
+ { name: 'AES-GCM', iv },
56
+ key,
57
+ new TextEncoder().encode(ID_PLAINTEXT)
58
+ );
59
+
60
+ return btoa(String.fromCharCode(...new Uint8Array(ciphertext)))
61
+ .replace(/\+/g, '-')
62
+ .replace(/\//g, '_')
63
+ .replace(/=/g, '');
64
+ }
65
+
66
+ export async function encrypt(data: string, credentials: CryptoCredentials): Promise<EncryptedPayload> {
67
+ const key = await deriveKey(credentials);
68
+ const iv = new Uint8Array(12);
69
+ crypto.getRandomValues(iv);
70
+
71
+ const ciphertext = await crypto.subtle.encrypt(
72
+ { name: 'AES-GCM', iv },
73
+ key,
74
+ new TextEncoder().encode(data)
75
+ );
76
+
77
+ return {
78
+ iv: uint8ArrayToBase64(iv),
79
+ ciphertext: uint8ArrayToBase64(new Uint8Array(ciphertext)),
80
+ };
81
+ }
82
+
83
+ export async function decrypt(payload: EncryptedPayload, credentials: CryptoCredentials): Promise<string> {
84
+ const key = await deriveKey(credentials);
85
+
86
+ const iv = Uint8Array.from(atob(payload.iv), c => c.charCodeAt(0));
87
+ const ciphertext = Uint8Array.from(atob(payload.ciphertext), c => c.charCodeAt(0));
88
+
89
+ const decrypted = await crypto.subtle.decrypt(
90
+ { name: 'AES-GCM', iv },
91
+ key,
92
+ ciphertext
93
+ );
94
+
95
+ return new TextDecoder().decode(decrypted);
96
+ }
@@ -0,0 +1,5 @@
1
+ export type { Storage } from "./interface.js";
2
+ export { LocalStorage } from "./local.js";
3
+ export { remoteSync, RemoteSync, type RemoteSyncCredentials, type RemoteTimestamp, type SyncResult, type FetchResult } from "./remote.js";
4
+ export { encrypt, decrypt, generateUserId, type CryptoCredentials, type EncryptedPayload } from "./crypto.js";
5
+ export { yoloMerge } from "./merge.js";
@@ -0,0 +1,9 @@
1
+ import type { StorageState } from "../core/types.js";
2
+
3
+ export interface Storage {
4
+ isAvailable(): Promise<boolean>;
5
+ save(state: StorageState): Promise<void>;
6
+ load(): Promise<StorageState | null>;
7
+ moveToBackup(): Promise<void>;
8
+ loadBackup(): Promise<StorageState | null>;
9
+ }