ei-tui 0.8.0 → 0.9.0

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 (65) hide show
  1. package/README.md +9 -6
  2. package/package.json +1 -1
  3. package/src/cli/mcp.ts +2 -2
  4. package/src/cli.ts +1 -1
  5. package/src/core/handlers/heartbeat.ts +63 -8
  6. package/src/core/handlers/index.ts +2 -1
  7. package/src/core/handlers/persona-response.ts +3 -5
  8. package/src/core/handlers/rooms.ts +3 -5
  9. package/src/core/handlers/utils.ts +5 -4
  10. package/src/core/heartbeat-manager.ts +16 -47
  11. package/src/core/message-manager.ts +6 -2
  12. package/src/core/orchestrators/ceremony.ts +49 -4
  13. package/src/core/orchestrators/human-extraction.ts +2 -2
  14. package/src/core/persona-manager.ts +1 -2
  15. package/src/core/personas/opencode-agent.ts +7 -2
  16. package/src/core/processor.ts +5 -12
  17. package/src/core/prompt-context-builder.ts +11 -1
  18. package/src/core/queue-processor.ts +4 -4
  19. package/src/core/room-manager.ts +6 -6
  20. package/src/core/state/human.ts +0 -1
  21. package/src/core/state/personas.ts +22 -13
  22. package/src/core/state/rooms.ts +0 -2
  23. package/src/core/state-manager.ts +83 -11
  24. package/src/core/types/data-items.ts +2 -2
  25. package/src/core/types/entities.ts +8 -3
  26. package/src/core/types/enums.ts +1 -0
  27. package/src/core/types/integrations.ts +1 -1
  28. package/src/core/types/llm.ts +2 -4
  29. package/src/core/types/rooms.ts +0 -4
  30. package/src/integrations/claude-code/importer.ts +1 -5
  31. package/src/integrations/cursor/importer.ts +1 -5
  32. package/src/integrations/opencode/importer.ts +1 -4
  33. package/src/integrations/opencode/types.ts +17 -1
  34. package/src/prompts/heartbeat/check.ts +7 -18
  35. package/src/prompts/heartbeat/ei.ts +14 -0
  36. package/src/prompts/heartbeat/types.ts +7 -5
  37. package/src/prompts/index.ts +9 -0
  38. package/src/prompts/message-utils.ts +7 -4
  39. package/src/prompts/reflection/index.ts +77 -0
  40. package/src/prompts/reflection/types.ts +26 -0
  41. package/src/prompts/response/index.ts +5 -2
  42. package/src/prompts/response/sections.ts +29 -1
  43. package/src/prompts/response/types.ts +10 -2
  44. package/src/prompts/room/sections.ts +4 -7
  45. package/src/prompts/room/types.ts +3 -6
  46. package/src/storage/embeddings.ts +69 -34
  47. package/src/storage/merge.ts +1 -1
  48. package/src/templates/welcome.ts +0 -1
  49. package/tui/README.md +5 -2
  50. package/tui/src/commands/editor.tsx +0 -1
  51. package/tui/src/commands/persona.tsx +89 -3
  52. package/tui/src/commands/reflect.tsx +375 -0
  53. package/tui/src/commands/registry.ts +2 -0
  54. package/tui/src/components/CYPTreeOverlay.tsx +0 -2
  55. package/tui/src/components/MAPScoreOverlay.tsx +1 -1
  56. package/tui/src/components/MessageList.tsx +6 -9
  57. package/tui/src/components/PromptInput.tsx +3 -1
  58. package/tui/src/components/RoomMessageList.tsx +2 -6
  59. package/tui/src/components/Sidebar.tsx +3 -5
  60. package/tui/src/components/StatusBar.tsx +26 -14
  61. package/tui/src/context/keyboard.tsx +2 -2
  62. package/tui/src/util/cyp-editor.tsx +2 -6
  63. package/tui/src/util/yaml-context.ts +2 -6
  64. package/tui/src/util/yaml-persona.ts +3 -3
  65. package/tui/src/util/yaml-settings.ts +0 -3
@@ -81,6 +81,15 @@ export {
81
81
  buildRoomResponsePrompt,
82
82
  buildRoomJudgePrompt,
83
83
  } from "./room/index.js";
84
+
85
+ export { buildReflectionCriticPrompt } from "./reflection/index.js";
86
+ export type {
87
+ ReflectionCriticPromptData,
88
+ ReflectionCriticResult,
89
+ PersonaIdentitySnapshot,
90
+ } from "./reflection/types.js";
91
+
92
+
84
93
  export type {
85
94
  RoomResponsePromptData,
86
95
  RoomJudgePromptData,
@@ -8,24 +8,27 @@ export function getMessageDisplayText(message: Message): string | null {
8
8
  const content = getMessageContent(message);
9
9
  if (content) parts.push(content);
10
10
  if (message.silence_reason) {
11
- const name = message.speaker_name ?? 'Persona';
11
+ const name = message.role === "human"
12
+ ? (message.speaker_name ?? 'Human')
13
+ : (message.speaker_name ?? 'Persona');
12
14
  parts.push(`[${name} chose not to respond because: ${message.silence_reason}]`);
13
15
  }
14
16
  if (parts.length === 0) return null;
15
17
  return parts.join('\n\n');
16
18
  }
17
19
 
18
- export function buildChatMessageContent(message: Message): string {
20
+ export function buildChatMessageContent(message: Message, humanName?: string): string {
19
21
  const parts: string[] = [];
20
22
  const content = getMessageContent(message);
21
23
 
22
24
  if (message._synthesis && content) {
23
- parts.push(`[The user used your conversation to generate an image. The full prompt was: "${content}"]`);
25
+ parts.push(`[${humanName ?? 'The user'} used your conversation to generate an image. The full prompt was: "${content}"]`);
24
26
  } else if (content) {
25
27
  parts.push(content);
26
28
  }
27
29
  if (message.silence_reason) {
28
- parts.push(`You chose not to respond because: ${message.silence_reason}`);
30
+ const silentParty = message.role === "human" ? (humanName ?? "The human") : "You";
31
+ parts.push(`${silentParty} chose not to respond because: ${message.silence_reason}`);
29
32
  }
30
33
  return parts.join('\n\n');
31
34
  }
@@ -0,0 +1,77 @@
1
+ import type { PromptOutput } from "../response/types.js";
2
+ import type { ReflectionCriticPromptData } from "./types.js";
3
+
4
+ export type {
5
+ ReflectionCriticPromptData,
6
+ ReflectionCriticResult,
7
+ PersonaIdentitySnapshot,
8
+ } from "./types.js";
9
+
10
+ export function buildReflectionCriticPrompt(data: ReflectionCriticPromptData): PromptOutput {
11
+ if (!data.persona_identity?.name) {
12
+ throw new Error("buildReflectionCriticPrompt: persona_identity.name is required");
13
+ }
14
+
15
+ const identityJson = JSON.stringify({
16
+ long_description: data.persona_identity.long_description,
17
+ short_description: data.persona_identity.short_description,
18
+ traits: data.persona_identity.traits.map(t => ({ name: t.name, description: t.description, strength: t.strength, sentiment: t.sentiment })),
19
+ topics: data.persona_identity.topics.map(t => ({ name: t.name, perspective: t.perspective, approach: t.approach, personal_stake: t.personal_stake, sentiment: t.sentiment, exposure_current: t.exposure_current, exposure_desired: t.exposure_desired })),
20
+ }, null, 2);
21
+
22
+ const system = `You are a character analyst reviewing an AI persona named ${data.persona_identity.name}.
23
+
24
+ You have been given two documents:
25
+
26
+ 1. **The Person Log** (System Prompt — treat as ground truth): A running record of observed behaviors, statements, and patterns from real conversations. This is what actually happened.
27
+
28
+ 2. **The Current Identity** (User Prompt — treat as a draft to revise): The persona's self-definition: traits, topics, and descriptions. This should reflect who they actually are.
29
+
30
+ Read the Person Log carefully. Then review the Current Identity. Produce a revised Identity that reflects the Person Log's observations — updating, adding, or softening any traits or topics where the log shows evidence of divergence.
31
+
32
+ ## Field Semantics
33
+
34
+ **Traits:**
35
+ - \`strength\` (0.0–1.0): How consistently this trait manifests. 0.0 = actively suppress this behavior, 0.5 = moderate/default, 1.0 = defining characteristic, always present.
36
+ - \`sentiment\` (-1.0–1.0): How the persona feels about having this trait. -1.0 = resents it, 0.0 = neutral, 1.0 = embraces it fully.
37
+
38
+ **Topics:**
39
+ - \`sentiment\` (-1.0–1.0): How the persona feels about this topic. -1.0 = aversion/conflict, 0.0 = neutral, 1.0 = deep affinity.
40
+ - \`exposure_current\` (0.0–1.0): How recently and frequently this topic has been discussed. 0.0 = hasn't come up in a long time, 1.0 = was just discussed at length.
41
+ - \`exposure_desired\` (0.0–1.0): How much the persona wants to engage with this topic. 0.0 = avoid entirely, 0.5 = average engagement, 1.0 = core obsession.
42
+
43
+ Return JSON:
44
+
45
+ \`\`\`json
46
+ {
47
+ "critique": "2-4 sentence prose summary of what the log reveals — what confirms, nuances, or contradicts the current identity",
48
+ "updated_identity": {
49
+ "long_description": "revised long description",
50
+ "short_description": "revised short description (1-2 sentences)",
51
+ "traits": [{ "name": "...", "description": "...", "strength": 0.8, "sentiment": 0.7 }],
52
+ "topics": [{ "name": "...", "perspective": "...", "approach": "...", "personal_stake": "...", "sentiment": 0.5, "exposure_current": 0.5, "exposure_desired": 0.6 }]
53
+ }
54
+ }
55
+ \`\`\`
56
+
57
+ Rules:
58
+ - Never invent observations not supported by the log
59
+ - Preserve traits and topics the log confirms — don't remove them
60
+ - If the log shows no evidence on a trait, leave it unchanged
61
+ - updated_identity must be complete and self-contained — not a diff`;
62
+
63
+ const user = `## Current Identity
64
+
65
+ \`\`\`json
66
+ ${identityJson}
67
+ \`\`\`
68
+
69
+ Analyze the Person Log above and return the revised identity JSON.`;
70
+
71
+ return {
72
+ system: `${system}\n\n## Person Log (Ground Truth)\n\n${data.person_log}`,
73
+ user,
74
+ };
75
+ }
76
+
77
+
@@ -0,0 +1,26 @@
1
+ import type { PersonaTrait, PersonaTopic } from "../../core/types.js";
2
+
3
+ export interface PersonaIdentitySnapshot {
4
+ name: string;
5
+ long_description: string;
6
+ short_description: string;
7
+ traits: PersonaTrait[];
8
+ topics: PersonaTopic[];
9
+ }
10
+
11
+ export interface ReflectionCriticPromptData {
12
+ persona_identity: PersonaIdentitySnapshot;
13
+ person_log: string;
14
+ }
15
+
16
+ export interface ReflectionCriticResult {
17
+ critique: string;
18
+ updated_identity: {
19
+ long_description: string;
20
+ short_description: string;
21
+ traits: PersonaTrait[];
22
+ topics: PersonaTopic[];
23
+ };
24
+ }
25
+
26
+
@@ -22,6 +22,7 @@ import {
22
22
  getConversationStateText,
23
23
  buildResponseFormatSection,
24
24
  buildToolsSection,
25
+ buildTemporalAnchorsSection,
25
26
  } from "./sections.js";
26
27
 
27
28
  export type { ResponsePromptData, PromptOutput, PersonaResponseResult } from "./types.js";
@@ -45,6 +46,7 @@ Your role is unique among personas:
45
46
  const guidelines = buildGuidelinesSection("ei");
46
47
  const yourTraits = buildTraitsSection(data.persona.traits, "Your Personality");
47
48
  const yourTopics = buildTopicsSection(data.persona.topics, "Your Interests");
49
+ const temporalAnchors = buildTemporalAnchorsSection(data.temporal_anchors, data.human.name);
48
50
  const humanSection = buildHumanSection(data.human);
49
51
  const quotesSection = buildQuotesSection(data.human.quotes, data.human);
50
52
  const associatesSection = buildAssociatesSection(data.visible_personas);
@@ -65,7 +67,7 @@ ${guidelines}
65
67
  ${yourTraits}
66
68
 
67
69
  ${yourTopics}
68
-
70
+ ${temporalAnchors ? `\n${temporalAnchors}` : ""}
69
71
  ${humanSection}
70
72
  ${quotesSection}
71
73
  ${associatesSection}
@@ -91,6 +93,7 @@ function buildStandardSystemPrompt(data: ResponsePromptData): string {
91
93
  const guidelines = buildGuidelinesSection(data.persona.name);
92
94
  const yourTraits = buildTraitsSection(data.persona.traits, "Your Personality");
93
95
  const yourTopics = buildTopicsSection(data.persona.topics, "Your Interests");
96
+ const temporalAnchors = buildTemporalAnchorsSection(data.temporal_anchors, data.human.name);
94
97
  const humanSection = buildHumanSection(data.human);
95
98
  const quotesSection = buildQuotesSection(data.human.quotes, data.human);
96
99
  const associatesSection = buildAssociatesSection(data.visible_personas);
@@ -110,7 +113,7 @@ ${guidelines}
110
113
  ${yourTraits}
111
114
 
112
115
  ${yourTopics}
113
-
116
+ ${temporalAnchors ? `\n${temporalAnchors}` : ""}
114
117
  ${humanSection}
115
118
  ${quotesSection}
116
119
  ${associatesSection}
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import type { PersonaTrait, Quote, PersonaTopic } from "../../core/types.js";
7
- import type { ResponsePromptData } from "./types.js";
7
+ import type { ResponsePromptData, TemporalAnchor } from "./types.js";
8
8
  import { formatTimestamp } from "../../core/format-utils.js";
9
9
 
10
10
  const DESCRIPTION_MAX_CHARS = 500;
@@ -428,3 +428,31 @@ You have tools available (listed in the API call). Use them freely:
428
428
  - Tool calls are a *pre-response step*, not a response. Do NOT produce the JSON reply until you have gathered everything you need.
429
429
  - When you are ready to speak, produce the JSON reply as specified above.`;
430
430
  }
431
+
432
+ // =============================================================================
433
+ // TEMPORAL ANCHORS SECTION
434
+ // =============================================================================
435
+
436
+ export function buildTemporalAnchorsSection(anchors: TemporalAnchor[], humanName: string): string {
437
+ if (anchors.length === 0) return "";
438
+
439
+ const formatted = anchors.map(a => {
440
+ const speaker = a.role === "human" ? humanName : "You";
441
+ let text: string;
442
+ if (a._synthesis && a.content) {
443
+ text = `[${humanName} used your conversation to generate an image. The full prompt was: "${a.content}"]`;
444
+ } else if (a.silence_reason) {
445
+ const silentParty = a.role === "human" ? humanName : "You";
446
+ text = `${silentParty} chose not to respond because: ${a.silence_reason}`;
447
+ } else {
448
+ text = a.content ?? "";
449
+ }
450
+ return `[${formatTimestamp(a.timestamp)}] ${speaker}: ${text}`;
451
+ }).join("\n\n");
452
+
453
+ return `## Temporal Anchors
454
+
455
+ These are pinned moments from your shared history — preserved across context windows as part of who you are:
456
+
457
+ ${formatted}`;
458
+ }
@@ -6,6 +6,14 @@
6
6
  import type { Fact, PersonaTrait, Topic, Person, Quote, PersonaTopic } from "../../core/types.js";
7
7
  import type { ToolDefinition } from "../../core/types.js";
8
8
 
9
+ export interface TemporalAnchor {
10
+ role: "human" | "system";
11
+ content?: string;
12
+ silence_reason?: string;
13
+ timestamp: string;
14
+ _synthesis?: boolean;
15
+ }
16
+
9
17
  /**
10
18
  * Data contract for buildResponsePrompt (from CONTRACTS.md)
11
19
  */
@@ -34,6 +42,7 @@ export interface ResponsePromptData {
34
42
  interested_topics: Topic[];
35
43
  };
36
44
  visible_personas: Array<{ name: string; short_description?: string }>;
45
+ temporal_anchors: TemporalAnchor[];
37
46
  delay_ms: number;
38
47
  isTUI: boolean;
39
48
  /** Tools assigned to this persona and available in the current runtime. Used to conditionally include tool-use instructions in the system prompt. */
@@ -45,8 +54,7 @@ export interface ResponsePromptData {
45
54
  */
46
55
  export interface PersonaResponseResult {
47
56
  should_respond: boolean;
48
- verbal_response?: string;
49
- action_response?: string;
57
+ content?: string;
50
58
  reason?: string;
51
59
  }
52
60
 
@@ -40,10 +40,7 @@ export function buildRoomHistorySection(history: RoomHistoryMessage[], humanName
40
40
  if (msg.silence_reason) {
41
41
  return `**${speaker}**: *[chose not to respond: ${msg.silence_reason}]*`;
42
42
  }
43
- const parts: string[] = [];
44
- if (msg.verbal_response) parts.push(msg.verbal_response);
45
- if (msg.action_response) parts.push(`*${msg.action_response}*`);
46
- return `**${speaker}**: ${parts.join(" ")}`;
43
+ return `**${speaker}**: ${msg.content ?? ''}`;
47
44
  });
48
45
 
49
46
  return `## Conversation So Far\n\n${lines.join("\n\n")}`;
@@ -103,10 +100,10 @@ Silence can be the right response: stepping back when someone else was addressed
103
100
  }
104
101
 
105
102
  export function buildSiblingAwarenessSection(
106
- siblings: Array<{ name: string; verbal_response: string }>
103
+ siblings: Array<{ name: string; content: string }>
107
104
  ): string {
108
105
  if (siblings.length === 0) return "";
109
- const lines = siblings.map(s => `**${s.name}**: "${s.verbal_response}"`);
106
+ const lines = siblings.map(s => `**${s.name}**: "${s.content}"`);
110
107
  return `## Room context — this round
111
108
 
112
109
  You're in a shared conversation. The human will read everyone's responses together. Here's what has already been contributed this round:
@@ -121,7 +118,7 @@ export function buildJudgeCandidatesSection(candidates: RoomJudgeCandidate[], hu
121
118
  const speaker = c.speaker_id === "human" ? humanName : c.speaker_name;
122
119
  const content = c.silence_reason
123
120
  ? `*[chose not to respond: ${c.silence_reason}]*`
124
- : [c.verbal_response, c.action_response ? `*${c.action_response}*` : ""].filter(Boolean).join(" ");
121
+ : (c.content ?? '');
125
122
  return `### Option ${i + 1} — ${speaker}\nMessage ID: \`${c.message_id}\`\n\n${content}`;
126
123
  });
127
124
  return `## The Responses\n\n${lines.join("\n\n")}`;
@@ -17,8 +17,7 @@ export interface RoomParticipantIdentity {
17
17
  export interface RoomHistoryMessage {
18
18
  speaker_name: string;
19
19
  speaker_id: string;
20
- verbal_response?: string;
21
- action_response?: string;
20
+ content?: string;
22
21
  silence_reason?: string;
23
22
  }
24
23
 
@@ -58,8 +57,7 @@ export interface RoomJudgeCandidate {
58
57
  message_id: string;
59
58
  speaker_name: string;
60
59
  speaker_id: string;
61
- verbal_response?: string;
62
- action_response?: string;
60
+ content?: string;
63
61
  silence_reason?: string;
64
62
  }
65
63
 
@@ -87,8 +85,7 @@ export interface RoomJudgeResult {
87
85
 
88
86
  export interface PersonaResponseResult {
89
87
  should_respond: boolean;
90
- verbal_response?: string;
91
- action_response?: string;
88
+ content?: string;
92
89
  reason?: string;
93
90
  }
94
91
 
@@ -15,8 +15,9 @@
15
15
  * decodeEmbedding returns it as-is. Mixed old/new files are handled transparently.
16
16
  *
17
17
  * IMPORTANT: encodeAllEmbeddings does NOT mutate the input state. It returns a new
18
- * StorageState where human item arrays are shallow-copied with encoded embedding fields.
19
- * This prevents the live in-memory state from being corrupted with base64 strings.
18
+ * StorageState where human item arrays and persona entities are shallow-copied with
19
+ * encoded embedding fields. This prevents the live in-memory state from being
20
+ * corrupted with base64 strings.
20
21
  */
21
22
 
22
23
  import type { StorageState } from "../core/types.js";
@@ -58,46 +59,80 @@ function decodeEmbedding(value: unknown): number[] | undefined {
58
59
 
59
60
  const HUMAN_ITEM_KEYS = ["facts", "topics", "people", "quotes"] as const;
60
61
 
61
- /**
62
- * Returns a new StorageState with embeddings encoded as base64 strings.
63
- * Does NOT mutate the input — human item arrays are shallow-copied.
64
- */
65
62
  export function encodeAllEmbeddings(state: StorageState): StorageState {
66
- const human = (state as unknown as Record<string, unknown>)["human"] as Record<string, unknown> | undefined;
67
- if (!human) return state;
68
-
69
- const encodedHuman: Record<string, unknown> = { ...human };
70
- for (const key of HUMAN_ITEM_KEYS) {
71
- const items = human[key];
72
- if (Array.isArray(items)) {
73
- encodedHuman[key] = items.map((item: Record<string, unknown>) => {
74
- if (!Array.isArray(item.embedding) || item.embedding.length === 0) return item;
75
- return { ...item, embedding: encodeEmbedding(item.embedding as number[]) };
76
- });
63
+ const raw = state as unknown as Record<string, unknown>;
64
+
65
+ const human = raw["human"] as Record<string, unknown> | undefined;
66
+ let encodedHuman = human;
67
+ if (human) {
68
+ encodedHuman = { ...human };
69
+ for (const key of HUMAN_ITEM_KEYS) {
70
+ const items = human[key];
71
+ if (Array.isArray(items)) {
72
+ (encodedHuman as Record<string, unknown>)[key] = items.map((item: Record<string, unknown>) => {
73
+ if (!Array.isArray(item.embedding) || item.embedding.length === 0) return item;
74
+ return { ...item, embedding: encodeEmbedding(item.embedding as number[]) };
75
+ });
76
+ }
77
+ }
78
+ }
79
+
80
+ const personas = raw["personas"] as Record<string, unknown> | undefined;
81
+ let encodedPersonas = personas;
82
+ if (personas) {
83
+ encodedPersonas = {};
84
+ for (const [id, data] of Object.entries(personas)) {
85
+ const d = data as Record<string, unknown>;
86
+ const entity = d["entity"] as Record<string, unknown> | undefined;
87
+ if (!entity || !Array.isArray(entity["description_embedding"]) || (entity["description_embedding"] as unknown[]).length === 0) {
88
+ (encodedPersonas as Record<string, unknown>)[id] = data;
89
+ } else {
90
+ (encodedPersonas as Record<string, unknown>)[id] = {
91
+ ...d,
92
+ entity: { ...entity, description_embedding: encodeEmbedding(entity["description_embedding"] as number[]) },
93
+ };
94
+ }
77
95
  }
78
96
  }
79
97
 
80
- return { ...state, human: encodedHuman as unknown as StorageState["human"] };
98
+ return { ...state, human: encodedHuman as unknown as StorageState["human"], personas: encodedPersonas as unknown as StorageState["personas"] };
81
99
  }
82
100
 
83
- /**
84
- * Returns a new StorageState with embeddings decoded from base64 to number[].
85
- * Does NOT mutate the input — human item arrays are shallow-copied.
86
- */
87
101
  export function decodeAllEmbeddings(state: StorageState): StorageState {
88
- const human = (state as unknown as Record<string, unknown>)["human"] as Record<string, unknown> | undefined;
89
- if (!human) return state;
90
-
91
- const decodedHuman: Record<string, unknown> = { ...human };
92
- for (const key of HUMAN_ITEM_KEYS) {
93
- const items = human[key];
94
- if (Array.isArray(items)) {
95
- decodedHuman[key] = items.map((item: Record<string, unknown>) => {
96
- if (item.embedding === undefined || Array.isArray(item.embedding)) return item;
97
- return { ...item, embedding: decodeEmbedding(item.embedding) };
98
- });
102
+ const raw = state as unknown as Record<string, unknown>;
103
+
104
+ const human = raw["human"] as Record<string, unknown> | undefined;
105
+ let decodedHuman = human;
106
+ if (human) {
107
+ decodedHuman = { ...human };
108
+ for (const key of HUMAN_ITEM_KEYS) {
109
+ const items = human[key];
110
+ if (Array.isArray(items)) {
111
+ (decodedHuman as Record<string, unknown>)[key] = items.map((item: Record<string, unknown>) => {
112
+ if (item.embedding === undefined || Array.isArray(item.embedding)) return item;
113
+ return { ...item, embedding: decodeEmbedding(item.embedding) };
114
+ });
115
+ }
116
+ }
117
+ }
118
+
119
+ const personas = raw["personas"] as Record<string, unknown> | undefined;
120
+ let decodedPersonas = personas;
121
+ if (personas) {
122
+ decodedPersonas = {};
123
+ for (const [id, data] of Object.entries(personas)) {
124
+ const d = data as Record<string, unknown>;
125
+ const entity = d["entity"] as Record<string, unknown> | undefined;
126
+ if (!entity || entity["description_embedding"] === undefined || Array.isArray(entity["description_embedding"])) {
127
+ (decodedPersonas as Record<string, unknown>)[id] = data;
128
+ } else {
129
+ (decodedPersonas as Record<string, unknown>)[id] = {
130
+ ...d,
131
+ entity: { ...entity, description_embedding: decodeEmbedding(entity["description_embedding"]) },
132
+ };
133
+ }
99
134
  }
100
135
  }
101
136
 
102
- return { ...state, human: decodedHuman as unknown as StorageState["human"] };
137
+ return { ...state, human: decodedHuman as unknown as StorageState["human"], personas: decodedPersonas as unknown as StorageState["personas"] };
103
138
  }
@@ -150,7 +150,7 @@ export function yoloMerge(local: StorageState, remote: StorageState): StorageSta
150
150
  if (remoteSettings.queue_paused !== undefined) localSettings.queue_paused = remoteSettings.queue_paused;
151
151
  if (remoteSettings.skip_quote_delete_confirm !== undefined) localSettings.skip_quote_delete_confirm = remoteSettings.skip_quote_delete_confirm;
152
152
  if (remoteSettings.name_display !== undefined) localSettings.name_display = remoteSettings.name_display;
153
- if (remoteSettings.time_mode !== undefined) localSettings.time_mode = remoteSettings.time_mode;
153
+
154
154
 
155
155
  if (remoteSettings.opencode) localSettings.opencode = remoteSettings.opencode;
156
156
  if (remoteSettings.ceremony) localSettings.ceremony = remoteSettings.ceremony;
@@ -87,5 +87,4 @@ Ei's unique role:
87
87
  is_archived: false,
88
88
  is_static: false,
89
89
  last_updated: new Date().toISOString(),
90
- last_activity: new Date().toISOString(),
91
90
  };
package/tui/README.md CHANGED
@@ -24,8 +24,11 @@ OpenCode also supports reading Ei's extracted knowledge back out via the [CLI to
24
24
  # Install Bun (if you don't have it)
25
25
  curl -fsSL https://bun.sh/install | bash
26
26
 
27
- # Install Ei
28
- npm install -g ei-tui
27
+ # Run Ei — no install needed, always the latest version
28
+ bunx ei-tui
29
+
30
+ # Or, if you use it as much as I do, add this to your profile!
31
+ alias ei='bunx ei-tui'
29
32
  ```
30
33
 
31
34
  ## TUI Commands
@@ -38,7 +38,6 @@ export const editorCommand: Command = {
38
38
  }
39
39
 
40
40
  if (result.content === null) {
41
- ctx.setInputText("");
42
41
  ctx.showNotification("No changes made", "info");
43
42
  return;
44
43
  }
@@ -1,5 +1,7 @@
1
+ import YAML from "yaml";
1
2
  import type { Command } from "./registry";
2
3
  import { isReservedPersonaName } from "../../../src/core/types.js";
4
+ import { logger } from "../util/logger.js";
3
5
  import { PersonaListOverlay } from "../components/PersonaListOverlay";
4
6
  import { LoadingOverlay } from "../components/LoadingOverlay.js";
5
7
  import { PersonPickerOverlay } from "../components/PersonPickerOverlay.js";
@@ -66,6 +68,7 @@ export const personaCommand: Command = {
66
68
  try {
67
69
  parsed = descriptionFromYAML(descResult.content);
68
70
  } catch (e) {
71
+ logger.error("[persona:new] description parse error", e);
69
72
  ctx.showNotification(`Parse error: ${e instanceof Error ? e.message : String(e)}`, "error");
70
73
  return;
71
74
  }
@@ -104,6 +107,7 @@ export const personaCommand: Command = {
104
107
  .catch(e => {
105
108
  if (!dismissed && overlayCallbacks.hideOverlay) {
106
109
  overlayCallbacks.hideOverlay();
110
+ logger.error("[persona:new] generation failed", e);
107
111
  ctx.showNotification(`Generation failed: ${e instanceof Error ? e.message : String(e)}`, "error");
108
112
  resolve(null);
109
113
  }
@@ -199,11 +203,91 @@ export const personaCommand: Command = {
199
203
 
200
204
  let selectedPerson: (typeof human.people)[0];
201
205
  if (!personName) {
206
+ if (persona.pending_update) {
207
+ const previewContent = personaPreviewToYAML(
208
+ {
209
+ long_description: persona.pending_update.long_description,
210
+ short_description: persona.pending_update.short_description ?? '',
211
+ traits: persona.pending_update.traits.map(t => ({ ...t, strength: t.strength ?? 0.5, sentiment: t.sentiment ?? 0 })),
212
+ topics: persona.pending_update.topics,
213
+ },
214
+ persona.display_name,
215
+ undefined,
216
+ persona.long_description,
217
+ );
218
+ const applyHeader = [
219
+ `# Set _apply: true to apply these changes. Leave false to dismiss without applying.`,
220
+ `# :cq to cancel (pending changes will remain).`,
221
+ `_apply: false`,
222
+ ``,
223
+ ].join('\n');
224
+ const reviewResult = await spawnEditor({
225
+ initialContent: applyHeader + previewContent,
226
+ filename: `${personaId}-pending-update.yaml`,
227
+ renderer: ctx.renderer,
228
+ });
229
+
230
+ if (reviewResult.aborted) {
231
+ ctx.showNotification("Cancelled — pending changes preserved", "info");
232
+ return;
233
+ }
234
+
235
+ if (reviewResult.content === null) {
236
+ await ctx.ei.updatePersona(personaId, { pending_update: undefined });
237
+ ctx.showNotification(`Dismissed pending changes for ${persona.display_name}`, "info");
238
+ return;
239
+ }
240
+
241
+ const content = reviewResult.content;
242
+ let shouldApply = false;
243
+ let previewParsed: ReturnType<typeof personaPreviewFromYAML>;
244
+ try {
245
+ const raw = YAML.parse(content) as Record<string, unknown>;
246
+ shouldApply = raw._apply === true;
247
+ previewParsed = personaPreviewFromYAML(content);
248
+ } catch (e) {
249
+ logger.error("[persona:update] pending update parse error", e);
250
+ ctx.showNotification(`Parse error: ${e instanceof Error ? e.message : String(e)}`, "error");
251
+ return;
252
+ }
253
+
254
+ if (!shouldApply) {
255
+ const goBack = await new Promise<boolean>((resolve) => {
256
+ ctx.showOverlay((hideOverlay, hideForEditor) => (
257
+ <ConfirmOverlay
258
+ message={`You made edits but _apply is still false — changes won't be applied.\n\nConfirm = go back and edit\nCancel = dismiss without applying`}
259
+ onConfirm={() => { hideForEditor(); resolve(true); }}
260
+ onCancel={() => { hideOverlay(); resolve(false); }}
261
+ />
262
+ ), ctx.renderer);
263
+ });
264
+
265
+ if (goBack) {
266
+ ctx.showNotification("Pending changes preserved", "info");
267
+ return;
268
+ }
269
+
270
+ await ctx.ei.updatePersona(personaId, { pending_update: undefined });
271
+ ctx.showNotification(`Dismissed pending changes for ${persona.display_name}`, "info");
272
+ return;
273
+ }
274
+
275
+ await ctx.ei.updatePersona(personaId, {
276
+ long_description: previewParsed.long_description,
277
+ short_description: previewParsed.short_description,
278
+ traits: previewParsed.traits,
279
+ topics: previewParsed.topics,
280
+ pending_update: undefined,
281
+ });
282
+ ctx.showNotification(`Applied changes to ${persona.display_name}`, "info");
283
+ return;
284
+ }
285
+
202
286
  const linked = (human.people ?? []).find(p =>
203
- p.identifiers?.some(id => id.type === 'Ei Persona' && id.value === personaId)
287
+ p.identifiers?.some(id => id.type.toLowerCase() === 'ei persona' && id.value === personaId)
204
288
  );
205
289
  if (!linked) {
206
- ctx.showNotification(`No person linked to "${personaName}". Try: /p update ${personaName} <personName>`, "error");
290
+ ctx.showNotification(`No pending update or linked person for "${personaName}". Try: /p update ${personaName} <personName>`, "error");
207
291
  return;
208
292
  }
209
293
  selectedPerson = linked;
@@ -284,6 +368,7 @@ export const personaCommand: Command = {
284
368
  .catch(e => {
285
369
  if (!dismissed && overlayCallbacks2.hideOverlay) {
286
370
  overlayCallbacks2.hideOverlay();
371
+ logger.error("[persona:update] generation failed", e);
287
372
  ctx.showNotification(`Generation failed: ${e instanceof Error ? e.message : String(e)}`, "error");
288
373
  resolve(null);
289
374
  }
@@ -318,6 +403,7 @@ export const personaCommand: Command = {
318
403
  try {
319
404
  previewParsed = personaPreviewFromYAML(reviewResult.content ?? updatePreviewYAML);
320
405
  } catch (e) {
406
+ logger.error("[persona:update] preview parse error", e);
321
407
  ctx.showNotification(`Parse error: ${e instanceof Error ? e.message : String(e)}`, "error");
322
408
  return;
323
409
  }
@@ -331,7 +417,7 @@ export const personaCommand: Command = {
331
417
  });
332
418
 
333
419
  const existingIdentifiers = selectedPerson.identifiers ?? [];
334
- const alreadyLinked = existingIdentifiers.some(id => id.type === 'Ei Persona' && id.value === personaId);
420
+ const alreadyLinked = existingIdentifiers.some(id => id.type.toLowerCase() === 'ei persona' && id.value === personaId);
335
421
  if (!alreadyLinked) {
336
422
  const isPrimaryFirst = existingIdentifiers.length === 0;
337
423
  const updatedIdentifiers = [...existingIdentifiers, { type: 'Ei Persona', value: personaId, ...(isPrimaryFirst ? { is_primary: true } : {}) }];