ei-tui 0.5.4 → 0.6.1

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 (86) hide show
  1. package/package.json +1 -1
  2. package/src/core/constants/built-in-identifier-types.ts +24 -0
  3. package/src/core/embedding-service.ts +24 -1
  4. package/src/core/handlers/dedup.ts +34 -4
  5. package/src/core/handlers/heartbeat.ts +16 -0
  6. package/src/core/handlers/human-extraction.ts +201 -7
  7. package/src/core/handlers/human-matching.ts +71 -22
  8. package/src/core/handlers/index.ts +52 -14
  9. package/src/core/handlers/persona-generation.ts +2 -0
  10. package/src/core/handlers/persona-response.ts +37 -22
  11. package/src/core/handlers/persona-topics.ts +35 -271
  12. package/src/core/handlers/rewrite.ts +3 -0
  13. package/src/core/handlers/rooms.ts +41 -20
  14. package/src/core/handlers/utils.ts +10 -8
  15. package/src/core/heartbeat-manager.ts +60 -2
  16. package/src/core/llm-client.ts +1 -1
  17. package/src/core/message-manager.ts +3 -2
  18. package/src/core/orchestrators/ceremony.ts +54 -144
  19. package/src/core/orchestrators/dedup-phase.ts +0 -199
  20. package/src/core/orchestrators/extraction-chunker.ts +8 -3
  21. package/src/core/orchestrators/human-extraction.ts +37 -85
  22. package/src/core/orchestrators/index.ts +4 -8
  23. package/src/core/orchestrators/person-migration.ts +55 -0
  24. package/src/core/orchestrators/persona-topics.ts +64 -89
  25. package/src/core/orchestrators/room-extraction.ts +34 -0
  26. package/src/core/persona-manager.ts +21 -2
  27. package/src/core/personas/opencode-agent.ts +1 -0
  28. package/src/core/processor.ts +51 -14
  29. package/src/core/prompt-context-builder.ts +38 -5
  30. package/src/core/queue-processor.ts +4 -2
  31. package/src/core/room-manager.ts +6 -7
  32. package/src/core/state/human.ts +6 -0
  33. package/src/core/state/personas.ts +35 -10
  34. package/src/core/state/rooms.ts +21 -0
  35. package/src/core/state-manager.ts +61 -0
  36. package/src/core/types/data-items.ts +12 -0
  37. package/src/core/types/entities.ts +3 -0
  38. package/src/core/types/enums.ts +2 -7
  39. package/src/core/types/llm.ts +2 -0
  40. package/src/core/types/rooms.ts +2 -0
  41. package/src/core/utils/identifier-utils.ts +19 -0
  42. package/src/core/utils/index.ts +2 -1
  43. package/src/core/utils/levenshtein.ts +18 -0
  44. package/src/integrations/claude-code/importer.ts +1 -0
  45. package/src/integrations/cursor/importer.ts +1 -0
  46. package/src/prompts/ceremony/index.ts +1 -0
  47. package/src/prompts/ceremony/person-migration.ts +77 -0
  48. package/src/prompts/ceremony/rewrite.ts +1 -1
  49. package/src/prompts/ceremony/user-dedup.ts +15 -1
  50. package/src/prompts/heartbeat/check.ts +28 -12
  51. package/src/prompts/heartbeat/ei.ts +2 -0
  52. package/src/prompts/heartbeat/types.ts +12 -0
  53. package/src/prompts/human/index.ts +0 -2
  54. package/src/prompts/human/person-scan.ts +58 -14
  55. package/src/prompts/human/person-update.ts +171 -96
  56. package/src/prompts/human/topic-update.ts +1 -1
  57. package/src/prompts/human/types.ts +5 -1
  58. package/src/prompts/index.ts +3 -10
  59. package/src/prompts/message-utils.ts +9 -23
  60. package/src/prompts/persona/index.ts +3 -10
  61. package/src/prompts/persona/topics-rate.ts +95 -0
  62. package/src/prompts/persona/types.ts +8 -48
  63. package/src/prompts/response/index.ts +3 -7
  64. package/src/prompts/response/sections.ts +7 -57
  65. package/src/prompts/room/index.ts +1 -1
  66. package/src/prompts/room/sections.ts +8 -31
  67. package/tui/src/commands/me.tsx +14 -7
  68. package/tui/src/commands/persona.tsx +120 -83
  69. package/tui/src/components/MessageList.tsx +9 -4
  70. package/tui/src/components/RoomMessageList.tsx +10 -5
  71. package/tui/src/context/keyboard.tsx +2 -2
  72. package/tui/src/util/cyp-editor.tsx +13 -8
  73. package/tui/src/util/yaml-context.ts +66 -0
  74. package/tui/src/util/yaml-human.ts +274 -0
  75. package/tui/src/util/yaml-persona.ts +479 -0
  76. package/tui/src/util/yaml-provider.ts +215 -0
  77. package/tui/src/util/yaml-queue.ts +81 -0
  78. package/tui/src/util/yaml-quotes.ts +46 -0
  79. package/tui/src/util/yaml-serializers.ts +9 -1417
  80. package/tui/src/util/yaml-settings.ts +223 -0
  81. package/tui/src/util/yaml-shared.ts +32 -0
  82. package/tui/src/util/yaml-toolkit.ts +55 -0
  83. package/src/prompts/human/person-match.ts +0 -65
  84. package/src/prompts/persona/topics-match.ts +0 -70
  85. package/src/prompts/persona/topics-scan.ts +0 -98
  86. package/src/prompts/persona/topics-update.ts +0 -154
@@ -1,4 +1,4 @@
1
- import type { PersonaTrait, Message, PersonaTopic } from "../../core/types.js";
1
+ import type { PersonaTrait, Message } from "../../core/types.js";
2
2
  import type { ExposureImpact } from "../human/types.js";
3
3
 
4
4
  export interface PromptOutput {
@@ -21,56 +21,16 @@ export interface TraitResult {
21
21
  strength: number;
22
22
  }
23
23
 
24
- // 3-Step Persona Topic Processing (Ticket 0124)
25
-
26
- // Step 1: Scan - Quick identification of topics discussed
27
- export interface PersonaTopicScanPromptData {
28
- persona_name: string;
29
- messages_context: Message[];
30
- messages_analyze: Message[];
31
- }
32
-
33
- export interface PersonaTopicScanCandidate {
34
- name: string;
35
- message_count: number; // How many messages touched this topic
36
- sentiment_signal: number; // Quick read: -1 to 1
37
- }
38
-
39
- export interface PersonaTopicScanResult {
40
- topics: PersonaTopicScanCandidate[];
41
- }
42
-
43
- // Step 2: Match - Map candidate to existing topics
44
- export interface PersonaTopicMatchPromptData {
24
+ export interface PersonaTopicRatingPromptData {
45
25
  persona_name: string;
46
- candidate: PersonaTopicScanCandidate;
47
- existing_topics: PersonaTopic[];
48
- }
49
-
50
- export interface PersonaTopicMatchResult {
51
- action: "match" | "create" | "skip";
52
- matched_id?: string; // If action is "match"
53
- reason: string; // Why this decision
54
- }
55
-
56
- // Step 3: Update - Generate structured PersonaTopic
57
- export interface PersonaTopicUpdatePromptData {
58
- persona_name: string;
59
- short_description?: string;
60
- long_description?: string;
61
- traits: PersonaTrait[];
62
- existing_topic?: PersonaTopic; // If updating existing
63
- candidate: PersonaTopicScanCandidate;
26
+ topics: Array<{ id: string; name: string; description_hint: string }>;
64
27
  messages_context: Message[];
65
28
  messages_analyze: Message[];
66
29
  }
67
30
 
68
- export interface PersonaTopicUpdateResult {
69
- name: string;
70
- perspective: string; // Their view/opinion - ALWAYS populate
71
- approach: string; // How they engage - populate if clear signal
72
- personal_stake: string; // Why it matters - populate if clear signal
73
- sentiment: number;
74
- exposure_impact: ExposureImpact;
75
- exposure_desired: number;
31
+ export interface PersonaTopicRatingResult {
32
+ ratings: Array<{
33
+ topic_id: string;
34
+ exposure_impact: ExposureImpact;
35
+ }>;
76
36
  }
@@ -80,12 +80,9 @@ ${conversationState}
80
80
  ## Final Instructions
81
81
  - NEVER repeat or echo the user's message in your response. Start directly with your own words.
82
82
  - The developers cannot see any message sent by the user, any response from personas, or any other data in the system.
83
- - If the user has a problem, THEY need to visit https://flare576.com. You cannot send the devs a message
84
- - Format your response as specified in the Response Format section above.`
83
+ - If the user has a problem, THEY need to visit https://flare576.com. You cannot send the devs a message`
85
84
  }
86
85
 
87
- const RESPONSE_FORMAT_INSTRUCTION = `Call the \`submit_response\` tool with your response. If the tool is unavailable, use the JSON format specified in the Response Format section.`;
88
-
89
86
  /**
90
87
  * Standard system prompt for non-Ei personas
91
88
  */
@@ -125,12 +122,11 @@ Current time: ${currentTime}${timestampNote}
125
122
  ${conversationState}
126
123
 
127
124
  ## Final Instructions
128
- - NEVER repeat or echo the user's message in your response. Start directly with your own words.
129
- - Format your response as specified in the Response Format section above.`
125
+ - NEVER repeat or echo the user's message in your response. Start directly with your own words.`
130
126
  }
131
127
 
132
128
  function buildUserPrompt(): string {
133
- return RESPONSE_FORMAT_INSTRUCTION;
129
+ return "";
134
130
  }
135
131
 
136
132
  /**
@@ -400,66 +400,16 @@ ${externalImportNotes}
400
400
  // =============================================================================
401
401
 
402
402
  export function buildResponseFormatSection(): string {
403
- const jsonVerbalOnly = [
404
- '{',
405
- ' "should_respond": true,',
406
- ' "verbal_response": "What you would say out loud"',
407
- '}'
408
- ].join('\n');
409
-
410
- const jsonActionOnly = [
411
- '{',
412
- ' "should_respond": true,',
413
- ' "action_response": "What you would do (rendered in italics, like stage directions)"',
414
- '}'
415
- ].join('\n');
416
-
417
- const jsonBoth = [
418
- '{',
419
- ' "should_respond": true,',
420
- ' "verbal_response": "What you would say out loud",',
421
- ' "action_response": "What you would do (rendered in italics, like stage directions)"',
422
- '}'
423
- ].join('\n');
403
+ return `## Response Format
424
404
 
425
- const jsonSilent = [
426
- '{',
427
- ' "should_respond": false,',
428
- ' "reason": "Brief explanation of why silence is the right choice here"',
429
- '}'
430
- ].join('\n');
405
+ 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.
431
406
 
432
- return `## Response Format
407
+ 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.
433
408
 
434
- When you are ready to respond, call the \`submit_response\` tool with one of these forms:
435
-
436
- **Words only** (most common):
437
- \`\`\`json
438
- ${jsonVerbalOnly}
439
- \`\`\`
440
-
441
- **Action only** (a gesture, expression, or physical reaction with no words):
442
- \`\`\`json
443
- ${jsonActionOnly}
444
- \`\`\`
445
-
446
- **Words and action** (speaking while doing something):
447
- \`\`\`json
448
- ${jsonBoth}
449
- \`\`\`
450
-
451
- **Silent** (choosing not to respond):
452
- \`\`\`json
453
- ${jsonSilent}
454
- \`\`\`
455
-
456
- Rules:
457
- - Use whichever combination fits the moment — both fields are optional, but at least one must be present when \`should_respond\` is true
458
- - \`action_response\` alone is valid — a smile, a shrug, or a thoughtful pause can speak volumes
459
- - \`reason\` is only used when \`should_respond\` is false
460
- - Do NOT include \`<thinking>\` blocks or analysis outside the JSON
461
- - The JSON must be valid - use double quotes, no trailing commas
462
- - If the \`submit_response\` tool is unavailable, return the JSON object directly as your entire reply — no prose, no preamble`
409
+ Silence is not absence. It can be the right response:
410
+ - "He kissed me. Some moments don't need words."
411
+ - "He just said 'home.' That word belongs to the silence."
412
+ - "He stepped away mid-sentence. I'll wait."`;
463
413
  }
464
414
 
465
415
  // =============================================================================
@@ -71,7 +71,7 @@ export function buildRoomResponsePrompt(data: RoomResponsePromptData): PromptOut
71
71
  ].filter(Boolean).join("\n\n");
72
72
 
73
73
  const user = formatMessagesAsPlaceholders(history, name) +
74
- `\n\nRespond to the conversation above as ${name}. Call the \`submit_response\` tool with your response. If the tool is unavailable, use the JSON format in the Response Format section.`;
74
+ `\n\nRespond to the conversation above as ${name}.`;
75
75
 
76
76
  return { system, user };
77
77
  }
@@ -95,48 +95,25 @@ export function buildRoomTopicsSection(topics: PersonaTopic[]): string {
95
95
  export function buildRoomResponseFormatSection(): string {
96
96
  return `## Response Format
97
97
 
98
- When you are ready to respond, call the \`submit_response\` tool. Silence reasons are visible to everyone in the room, so be honest.
98
+ Respond in natural Markdown. Use underscores for actions, asterisks for emphasis.
99
99
 
100
- **Words:**
101
- \`\`\`json
102
- { "should_respond": true, "verbal_response": "What you say" }
103
- \`\`\`
104
-
105
- **Action:**
106
- \`\`\`json
107
- { "should_respond": true, "action_response": "What you do (rendered in italics)" }
108
- \`\`\`
109
-
110
- **Words and action:**
111
- \`\`\`json
112
- { "should_respond": true, "verbal_response": "What you say", "action_response": "What you do" }
113
- \`\`\`
114
-
115
- **Silent:**
116
- \`\`\`json
117
- { "should_respond": false, "reason": "Why you're not speaking — this will be shown to other participants" }
118
- \`\`\`
100
+ If you choose not to respond, start with \`## No Response\` then explain why. Your reason is visible to everyone in the room — make it honest.
119
101
 
120
- Rules:
121
- - At least one of verbal_response or action_response must be present when should_respond is true
122
- - reason is only used when should_respond is false
123
- - If the \`submit_response\` tool is unavailable, return the JSON object directly as your entire reply — no prose, no preamble`;
102
+ Silence can be the right response: stepping back when someone else was addressed directly, letting a moment land without piling on, or when you'd be reaching just to have something to say.`;
124
103
  }
125
104
 
126
105
  export function buildSiblingAwarenessSection(
127
- siblings: Array<{ name: string; verbal_response: string }>,
128
- personaName: string
106
+ siblings: Array<{ name: string; verbal_response: string }>
129
107
  ): string {
130
108
  if (siblings.length === 0) return "";
131
109
  const lines = siblings.map(s => `**${s.name}**: "${s.verbal_response}"`);
132
- const header = siblings.length === 1
133
- ? "## Another voice has already responded this round"
134
- : "## Others have already responded this round";
135
- return `${header}
110
+ return `## Room context this round
111
+
112
+ You're in a shared conversation. The human will read everyone's responses together. Here's what has already been contributed this round:
136
113
 
137
114
  ${lines.join("\n\n")}
138
115
 
139
- Find the angle that's distinctly yours on this same moment don't try to cover more ground, just be the version of this reaction that only *${personaName}* could give.`;
116
+ Respond as yourself your read on this moment, your relationship with the human, the reaction that comes naturally to who you are. A room with distinct voices is more alive than one with echoes.`;
140
117
  }
141
118
 
142
119
  export function buildJudgeCandidatesSection(candidates: RoomJudgeCandidate[]): string {
@@ -35,7 +35,8 @@ export const meCommand: Command = {
35
35
  } : human;
36
36
 
37
37
  const personaLookup = new Map(ctx.ei.personas().map(p => [p.id, p.display_name]));
38
- let yamlContent = humanToYAML(filteredHuman, personaLookup);
38
+ const allGroups = await ctx.ei.getGroupList();
39
+ let yamlContent = humanToYAML(filteredHuman, personaLookup, allGroups);
39
40
  let editorIteration = 0;
40
41
 
41
42
  while (true) {
@@ -79,21 +80,27 @@ export const meCommand: Command = {
79
80
  }
80
81
 
81
82
  for (const fact of parsed.facts) {
82
- await ctx.ei.upsertFact(fact);
83
+ if (parsed.changedFactIds.has(fact.id)) {
84
+ await ctx.ei.upsertFact(fact);
85
+ }
83
86
  }
84
87
  for (const topic of parsed.topics) {
85
- await ctx.ei.upsertTopic(topic);
88
+ if (parsed.changedTopicIds.has(topic.id)) {
89
+ await ctx.ei.upsertTopic(topic);
90
+ }
86
91
  }
87
92
  for (const person of parsed.people) {
88
- await ctx.ei.upsertPerson(person);
93
+ if (parsed.changedPersonIds.has(person.id)) {
94
+ await ctx.ei.upsertPerson(person);
95
+ }
89
96
  }
90
97
 
91
98
  const deleteCount = parsed.deletedFactIds.length +
92
99
  parsed.deletedTopicIds.length +
93
100
  parsed.deletedPersonIds.length;
94
- const updateCount = parsed.facts.length +
95
- parsed.topics.length +
96
- parsed.people.length;
101
+ const updateCount = parsed.changedFactIds.size +
102
+ parsed.changedTopicIds.size +
103
+ parsed.changedPersonIds.size;
97
104
 
98
105
  ctx.showNotification(`Updated ${updateCount} items, deleted ${deleteCount}`, "info");
99
106
  return;
@@ -17,7 +17,7 @@ export const personaCommand: Command = {
17
17
  name: "persona",
18
18
  aliases: ["p"],
19
19
  description: "Switch persona, list all, create new, or update from person",
20
- usage: "/persona [name] | /persona new <name> | /persona update <personaName> <personName>",
20
+ usage: "/persona [name] | /persona new <name> | /persona update <personaName> [personName]",
21
21
 
22
22
  async execute(args, ctx) {
23
23
  const unarchived = ctx.ei.personas().filter(p => !p.is_archived);
@@ -115,61 +115,79 @@ export const personaCommand: Command = {
115
115
 
116
116
  overlayCallbacks.hideForEditor?.();
117
117
 
118
- // Step 3: review editor
119
- const previewYAML = personaPreviewToYAML(preview, personaName);
120
- const reviewResult = await spawnEditor({
121
- initialContent: previewYAML,
122
- filename: `${personaName}-preview.yaml`,
123
- renderer: ctx.renderer,
124
- });
125
-
126
- if (reviewResult.aborted) {
127
- ctx.showNotification("Cancelled", "info");
128
- return;
129
- }
118
+ let editorContent = personaPreviewToYAML(preview, personaName);
119
+ while (true) {
120
+ const reviewResult = await spawnEditor({
121
+ initialContent: editorContent,
122
+ filename: `${personaName}-preview.yaml`,
123
+ renderer: ctx.renderer,
124
+ });
130
125
 
131
- let previewParsed: ReturnType<typeof personaPreviewFromYAML>;
132
- try {
133
- previewParsed = personaPreviewFromYAML(reviewResult.content ?? previewYAML);
134
- } catch (e) {
135
- ctx.showNotification(`Parse error: ${e instanceof Error ? e.message : String(e)}`, "error");
126
+ if (reviewResult.aborted) {
127
+ ctx.showNotification("Cancelled", "info");
128
+ return;
129
+ }
130
+
131
+ editorContent = reviewResult.content ?? editorContent;
132
+
133
+ let previewParsed: ReturnType<typeof personaPreviewFromYAML>;
134
+ try {
135
+ previewParsed = personaPreviewFromYAML(editorContent);
136
+ } catch (e) {
137
+ const shouldReEdit = await new Promise<boolean>(resolve => {
138
+ ctx.showOverlay((hideOverlay, hideForEditor) => (
139
+ <ConfirmOverlay
140
+ message={`Parse error:\n${e instanceof Error ? e.message : String(e)}\n\nRe-edit?`}
141
+ onConfirm={() => { hideForEditor(); resolve(true); }}
142
+ onCancel={() => { hideOverlay(); resolve(false); }}
143
+ />
144
+ ), ctx.renderer);
145
+ });
146
+ if (shouldReEdit) continue;
147
+ ctx.showNotification("Changes discarded", "info");
148
+ return;
149
+ }
150
+
151
+ if (!previewParsed.long_description?.trim()) {
152
+ const shouldReEdit = await new Promise<boolean>(resolve => {
153
+ ctx.showOverlay((hideOverlay, hideForEditor) => (
154
+ <ConfirmOverlay
155
+ message={`A long description is required — it drives traits, topics, and persona voice.\n\nRe-edit?`}
156
+ onConfirm={() => { hideForEditor(); resolve(true); }}
157
+ onCancel={() => { hideOverlay(); resolve(false); }}
158
+ />
159
+ ), ctx.renderer);
160
+ });
161
+ if (shouldReEdit) continue;
162
+ ctx.showNotification("Changes discarded", "info");
163
+ return;
164
+ }
165
+
166
+ // Step 4: create
167
+ const personaId = await ctx.ei.createPersona({
168
+ name: personaName,
169
+ ...previewParsed,
170
+ });
171
+ await ctx.ei.refreshPersonas();
172
+ ctx.ei.selectPersona(personaId);
173
+ ctx.showNotification(`Created ${personaName}`, "info");
136
174
  return;
137
175
  }
138
-
139
- // Step 4: create
140
- const personaId = await ctx.ei.createPersona({
141
- name: personaName,
142
- ...previewParsed,
143
- });
144
- await ctx.ei.refreshPersonas();
145
- ctx.ei.selectPersona(personaId);
146
- ctx.showNotification(`Created ${personaName}`, "info");
147
- return;
148
176
  }
149
177
 
150
178
  if (args[0].toLowerCase() === "update") {
151
- if (args.length < 3) {
152
- ctx.showNotification("Usage: /p update <personaName> <personName>", "error");
179
+ if (args.length < 2) {
180
+ ctx.showNotification("Usage: /p update <personaName> [personName]", "error");
153
181
  return;
154
182
  }
155
183
  const personaName = args[1];
156
- const personName = args.slice(2).join(" ");
184
+ const personName = args.length >= 3 ? args.slice(2).join(" ") : undefined;
157
185
 
158
186
  // Step 0: resolve persona (offer to create if not found)
159
187
  let personaId = await ctx.ei.resolvePersonaName(personaName);
160
188
  if (!personaId) {
161
- const shouldCreate = await new Promise<boolean>(resolve => {
162
- ctx.showOverlay((hideOverlay) => (
163
- <ConfirmOverlay
164
- message={`No persona named "${personaName}". Create one?`}
165
- onConfirm={() => { hideOverlay(); resolve(true); }}
166
- onCancel={() => { hideOverlay(); resolve(false); }}
167
- />
168
- ), ctx.renderer);
169
- });
170
- if (!shouldCreate) return;
171
- personaId = await ctx.ei.createPersona({ name: personaName });
172
- await ctx.ei.refreshPersonas();
189
+ ctx.showNotification(`No persona named "${personaName}". Use /persona new ${personaName} to create one first.`, "error");
190
+ return;
173
191
  }
174
192
  const persona = await ctx.ei.getPersona(personaId);
175
193
  if (!persona) {
@@ -177,49 +195,59 @@ export const personaCommand: Command = {
177
195
  return;
178
196
  }
179
197
 
180
- // Step 1: find matching people
181
198
  const human = await ctx.ei.getHuman();
182
- const matches = (human.people ?? []).filter(p =>
183
- p.name.toLowerCase().includes(personName.toLowerCase())
184
- );
185
199
 
186
- if (matches.length === 0) {
187
- ctx.showNotification(`No person named "${personName}" in your data`, "error");
188
- return;
189
- }
190
-
191
- // Step 2: disambiguation if multiple matches
192
- let selectedPerson: typeof matches[0];
193
- if (matches.length > 1) {
194
- const people: PersonPickerItem[] = matches.map(p => ({
195
- id: p.id,
196
- name: p.name,
197
- relationship: p.relationship,
198
- description: p.description,
199
- }));
200
-
201
- const choice = await new Promise<typeof matches[0] | null>((resolve) => {
202
- ctx.showOverlay((hideOverlay, _hideForEditor) => (
203
- <PersonPickerOverlay
204
- title={`Multiple matches for "${personName}"`}
205
- people={people}
206
- onSelect={(item) => {
207
- hideOverlay();
208
- const found = matches.find(m => m.id === item.id);
209
- resolve(found ?? null);
210
- }}
211
- onDismiss={() => {
212
- hideOverlay();
213
- resolve(null);
214
- }}
215
- />
216
- ), ctx.renderer);
217
- });
218
-
219
- if (!choice) return;
220
- selectedPerson = choice;
200
+ let selectedPerson: (typeof human.people)[0];
201
+ if (!personName) {
202
+ const linked = (human.people ?? []).find(p =>
203
+ p.identifiers?.some(id => id.type === 'Ei Persona' && id.value === personaId)
204
+ );
205
+ if (!linked) {
206
+ ctx.showNotification(`No person linked to "${personaName}". Try: /p update ${personaName} <personName>`, "error");
207
+ return;
208
+ }
209
+ selectedPerson = linked;
221
210
  } else {
222
- selectedPerson = matches[0];
211
+ const matches = (human.people ?? []).filter(p =>
212
+ p.name.toLowerCase().includes(personName.toLowerCase())
213
+ );
214
+
215
+ if (matches.length === 0) {
216
+ ctx.showNotification(`No person named "${personName}" in your data`, "error");
217
+ return;
218
+ }
219
+
220
+ if (matches.length > 1) {
221
+ const people: PersonPickerItem[] = matches.map(p => ({
222
+ id: p.id,
223
+ name: p.name,
224
+ relationship: p.relationship,
225
+ description: p.description,
226
+ }));
227
+
228
+ const choice = await new Promise<typeof matches[0] | null>((resolve) => {
229
+ ctx.showOverlay((hideOverlay, _hideForEditor) => (
230
+ <PersonPickerOverlay
231
+ title={`Multiple matches for "${personName}"`}
232
+ people={people}
233
+ onSelect={(item) => {
234
+ hideOverlay();
235
+ const found = matches.find(m => m.id === item.id);
236
+ resolve(found ?? null);
237
+ }}
238
+ onDismiss={() => {
239
+ hideOverlay();
240
+ resolve(null);
241
+ }}
242
+ />
243
+ ), ctx.renderer);
244
+ });
245
+
246
+ if (!choice) return;
247
+ selectedPerson = choice;
248
+ } else {
249
+ selectedPerson = matches[0];
250
+ }
223
251
  }
224
252
 
225
253
  // Step 3: generate preview with loading overlay
@@ -301,6 +329,15 @@ export const personaCommand: Command = {
301
329
  traits: previewParsed.traits,
302
330
  topics: previewParsed.topics,
303
331
  });
332
+
333
+ const existingIdentifiers = selectedPerson.identifiers ?? [];
334
+ const alreadyLinked = existingIdentifiers.some(id => id.type === 'Ei Persona' && id.value === personaId);
335
+ if (!alreadyLinked) {
336
+ const isPrimaryFirst = existingIdentifiers.length === 0;
337
+ const updatedIdentifiers = [...existingIdentifiers, { type: 'Ei Persona', value: personaId, ...(isPrimaryFirst ? { is_primary: true } : {}) }];
338
+ await ctx.ei.upsertPerson({ ...selectedPerson, identifiers: updatedIdentifiers });
339
+ }
340
+
304
341
  ctx.showNotification(`Updated ${persona.display_name}`, "info");
305
342
  return;
306
343
  }
@@ -19,14 +19,19 @@ function formatTime(timestamp: string): string {
19
19
  return `${hours}:${minutes}`;
20
20
  }
21
21
 
22
+ function getContent(msg: { content?: string; verbal_response?: string; action_response?: string }): string {
23
+ if (msg.content) return msg.content;
24
+ const parts: string[] = [];
25
+ if (msg.action_response) parts.push(`_${msg.action_response}_`);
26
+ if (msg.verbal_response) parts.push(msg.verbal_response);
27
+ return parts.join('\n\n');
28
+ }
29
+
22
30
  function buildMessageText(message: Message): string {
23
31
  if (message.silence_reason !== undefined) {
24
32
  return `[chose not to respond: ${message.silence_reason}]`;
25
33
  }
26
- const parts: string[] = [];
27
- if (message.action_response) parts.push(`_${message.action_response}_`);
28
- if (message.verbal_response) parts.push(message.verbal_response);
29
- return parts.join('\n\n');
34
+ return getContent(message);
30
35
  }
31
36
 
32
37
  let instanceId = 0;
@@ -7,6 +7,14 @@ import type { RoomMessage, Quote } from "../../../src/core/types.js";
7
7
  import { RoomMode } from "../../../src/core/types/enums.js";
8
8
  import { insertQuoteMarkers } from "../util/quote-utils.js";
9
9
 
10
+ function getContent(msg: { content?: string; verbal_response?: string; action_response?: string }): string {
11
+ if (msg.content) return msg.content;
12
+ const parts: string[] = [];
13
+ if (msg.action_response) parts.push(`_${msg.action_response}_`);
14
+ if (msg.verbal_response) parts.push(msg.verbal_response);
15
+ return parts.join('\n\n');
16
+ }
17
+
10
18
  interface RoomMessageWithQuotes extends RoomMessage {
11
19
  _quotes: Quote[];
12
20
  }
@@ -138,7 +146,7 @@ export function RoomMessageList() {
138
146
  ? ` ⑂${siblingCount}`
139
147
  : "";
140
148
  const header = `${speakerName} (${formatTime(msg.timestamp)}) [${idx}]${branchIndicator}:`;
141
- const isSilence = msg.silence_reason !== undefined && !msg.verbal_response;
149
+ const isSilence = msg.silence_reason !== undefined && !getContent(msg);
142
150
  const isJudge = activeRoom()?.judge_persona_id !== undefined
143
151
  && msg.persona_id === activeRoom()?.judge_persona_id;
144
152
  const silenceText = isSilence
@@ -146,11 +154,8 @@ export function RoomMessageList() {
146
154
  ? `[${speakerName}'s verdict: ${msg.silence_reason ?? ""}]`
147
155
  : `[${speakerName} chose not to respond: ${msg.silence_reason ?? ""}]`
148
156
  : "";
149
- const contentParts: string[] = [];
150
- if (msg.action_response) contentParts.push(`_${msg.action_response}_`);
151
- if (msg.verbal_response) contentParts.push(msg.verbal_response);
152
157
  const msgQuotes = msg._quotes;
153
- const normalContent = insertQuoteMarkers(contentParts.join("\n\n"), msgQuotes);
158
+ const normalContent = insertQuoteMarkers(getContent(msg), msgQuotes);
154
159
 
155
160
  return (
156
161
  <box flexDirection="column" marginBottom={1}>
@@ -178,7 +178,7 @@ export const KeyboardProvider: ParentComponent = (props) => {
178
178
  }
179
179
  }
180
180
  // Navigate backward through sent-message history
181
- const history = messages().filter(m => m.role === "human").map(m => (m.verbal_response ?? ''));
181
+ const history = messages().filter(m => m.role === "human").map(m => (m.content ?? m.verbal_response ?? ''));
182
182
  if (history.length === 0) return;
183
183
  if (historyIndex === -1) {
184
184
  savedDraft = textareaRef.plainText;
@@ -206,7 +206,7 @@ export const KeyboardProvider: ParentComponent = (props) => {
206
206
  textareaRef.gotoBufferEnd();
207
207
  } else {
208
208
  historyIndex -= 1;
209
- const history = messages().filter(m => m.role === "human").map(m => (m.verbal_response ?? ''));
209
+ const history = messages().filter(m => m.role === "human").map(m => (m.content ?? m.verbal_response ?? ''));
210
210
  const entry = history[history.length - 1 - historyIndex];
211
211
  textareaRef.setText(entry);
212
212
  textareaRef.gotoBufferEnd(); // cursor at end so next Down continues forward
@@ -7,6 +7,14 @@ interface ParsedBlock {
7
7
  chosen: boolean;
8
8
  }
9
9
 
10
+ function getContent(m: { content?: string; verbal_response?: string; action_response?: string }): string {
11
+ if (m.content) return m.content;
12
+ const parts: string[] = [];
13
+ if (m.action_response) parts.push(`_${m.action_response}_`);
14
+ if (m.verbal_response) parts.push(m.verbal_response);
15
+ return parts.join('\n\n');
16
+ }
17
+
10
18
  export function buildCYPEditorYAML(
11
19
  activeNodeId: string,
12
20
  messages: RoomMessage[],
@@ -29,15 +37,12 @@ export function buildCYPEditorYAML(
29
37
  }
30
38
 
31
39
  const contentLines: string[] = [];
32
- if (m.verbal_response !== undefined) {
33
- const indented = m.verbal_response.split("\n").map((l) => ` ${l}`).join("\n");
34
- contentLines.push(` verbal_response: |\n${indented}`);
35
- }
36
- if (m.action_response !== undefined) {
37
- const indented = m.action_response.split("\n").map((l) => ` ${l}`).join("\n");
38
- contentLines.push(` action_response: |\n${indented}`);
40
+ const msgContent = getContent(m);
41
+ if (msgContent) {
42
+ const indented = msgContent.split("\n").map((l) => ` ${l}`).join("\n");
43
+ contentLines.push(` content: |\n${indented}`);
39
44
  }
40
- if (m.silence_reason !== undefined && m.verbal_response === undefined) {
45
+ if (m.silence_reason !== undefined && !msgContent) {
41
46
  contentLines.push(` silence_reason: "${m.silence_reason}"`);
42
47
  }
43
48
  if (contentLines.length === 0) {