ei-tui 0.8.1 → 0.9.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 (62) hide show
  1. package/README.md +9 -6
  2. package/package.json +1 -1
  3. package/src/core/handlers/heartbeat.ts +63 -8
  4. package/src/core/handlers/index.ts +2 -1
  5. package/src/core/handlers/persona-response.ts +3 -5
  6. package/src/core/handlers/rooms.ts +3 -5
  7. package/src/core/handlers/utils.ts +5 -4
  8. package/src/core/heartbeat-manager.ts +16 -47
  9. package/src/core/message-manager.ts +6 -2
  10. package/src/core/orchestrators/ceremony.ts +49 -4
  11. package/src/core/persona-manager.ts +1 -2
  12. package/src/core/personas/opencode-agent.ts +7 -2
  13. package/src/core/processor.ts +5 -12
  14. package/src/core/prompt-context-builder.ts +11 -1
  15. package/src/core/queue-processor.ts +4 -4
  16. package/src/core/room-manager.ts +6 -6
  17. package/src/core/state/human.ts +0 -1
  18. package/src/core/state/personas.ts +22 -13
  19. package/src/core/state/rooms.ts +0 -2
  20. package/src/core/state-manager.ts +83 -11
  21. package/src/core/types/data-items.ts +2 -2
  22. package/src/core/types/entities.ts +8 -3
  23. package/src/core/types/enums.ts +1 -0
  24. package/src/core/types/integrations.ts +1 -1
  25. package/src/core/types/llm.ts +2 -4
  26. package/src/core/types/rooms.ts +0 -4
  27. package/src/integrations/claude-code/importer.ts +1 -5
  28. package/src/integrations/cursor/importer.ts +1 -5
  29. package/src/integrations/opencode/importer.ts +1 -4
  30. package/src/integrations/opencode/types.ts +17 -1
  31. package/src/prompts/heartbeat/check.ts +7 -18
  32. package/src/prompts/heartbeat/ei.ts +14 -0
  33. package/src/prompts/heartbeat/types.ts +7 -5
  34. package/src/prompts/index.ts +9 -0
  35. package/src/prompts/message-utils.ts +7 -4
  36. package/src/prompts/reflection/index.ts +77 -0
  37. package/src/prompts/reflection/types.ts +26 -0
  38. package/src/prompts/response/index.ts +5 -2
  39. package/src/prompts/response/sections.ts +29 -1
  40. package/src/prompts/response/types.ts +10 -2
  41. package/src/prompts/room/sections.ts +4 -7
  42. package/src/prompts/room/types.ts +3 -6
  43. package/src/storage/embeddings.ts +69 -34
  44. package/src/storage/merge.ts +1 -1
  45. package/src/templates/welcome.ts +0 -1
  46. package/tui/README.md +5 -2
  47. package/tui/src/commands/editor.tsx +0 -1
  48. package/tui/src/commands/persona.tsx +89 -3
  49. package/tui/src/commands/reflect.tsx +375 -0
  50. package/tui/src/commands/registry.ts +2 -0
  51. package/tui/src/components/CYPTreeOverlay.tsx +0 -2
  52. package/tui/src/components/MAPScoreOverlay.tsx +1 -1
  53. package/tui/src/components/MessageList.tsx +8 -10
  54. package/tui/src/components/PromptInput.tsx +3 -1
  55. package/tui/src/components/RoomMessageList.tsx +5 -8
  56. package/tui/src/components/Sidebar.tsx +3 -5
  57. package/tui/src/components/StatusBar.tsx +26 -14
  58. package/tui/src/context/keyboard.tsx +2 -2
  59. package/tui/src/util/cyp-editor.tsx +2 -6
  60. package/tui/src/util/yaml-context.ts +2 -6
  61. package/tui/src/util/yaml-persona.ts +3 -3
  62. package/tui/src/util/yaml-settings.ts +0 -3
@@ -1,15 +1,19 @@
1
1
  import type { PersonaEntity, Message, ContextStatus } from "../types.js";
2
2
 
3
+ // TODO(v1.0.0): Remove LegacyMessage migration — verbal_response/action_response no longer written
4
+ type LegacyMessage = Message & { verbal_response?: string; action_response?: string };
5
+
3
6
  function migrateMessage(msg: Message): Message {
4
7
  if (msg.content) return msg;
5
- if (msg.role === 'human') return msg;
6
8
  if (msg.silence_reason) return msg;
9
+ const legacy = msg as LegacyMessage;
10
+ const hasLegacy = 'verbal_response' in legacy || 'action_response' in legacy;
11
+ if (!hasLegacy) return msg;
7
12
  const parts: string[] = [];
8
- if (msg.action_response) parts.push(`_${msg.action_response}_`);
9
- if (msg.verbal_response) parts.push(msg.verbal_response);
10
- if (parts.length === 0) return msg;
11
- const { verbal_response: _vr, action_response: _ar, ...rest } = msg;
12
- return { ...rest, content: parts.join('\n\n') };
13
+ if (legacy.action_response) parts.push(`_${legacy.action_response}_`);
14
+ if (legacy.verbal_response) parts.push(legacy.verbal_response);
15
+ const { verbal_response: _vr, action_response: _ar, ...rest } = legacy;
16
+ return parts.length > 0 ? { ...rest, content: parts.join('\n\n') } : rest as Message;
13
17
  }
14
18
 
15
19
  export interface PersonaData {
@@ -47,7 +51,8 @@ export class PersonaState {
47
51
  }
48
52
 
49
53
  getByName(nameOrAlias: string): PersonaEntity | null {
50
- const searchTerm = nameOrAlias.replace(/^\p{Z}+|\p{Z}+$/gu, "").toLowerCase();
54
+ // Same ZWS strip as resolveCanonicalAgent — see that function for the full explanation.
55
+ const searchTerm = nameOrAlias.replace(/^[\p{Z}\u200B\u200C\u200D\u2060\uFEFF]+|[\p{Z}\u200B\u200C\u200D\u2060\uFEFF]+$/gu, "").toLowerCase();
51
56
 
52
57
  // Priority 1: Exact display_name match
53
58
  for (const data of this.personas.values()) {
@@ -118,11 +123,18 @@ export class PersonaState {
118
123
  return messages.map(m => ({ ...m }));
119
124
  }
120
125
 
126
+ messages_getAlways(personaId: string): Message[] {
127
+ const messages = this.personas.get(personaId)?.messages ?? [];
128
+ return messages
129
+ .filter(m => m.context_status === "always")
130
+ .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())
131
+ .map(m => ({ ...m }));
132
+ }
133
+
121
134
  messages_append(personaId: string, message: Message): void {
122
135
  const data = this.personas.get(personaId);
123
136
  if (!data) return;
124
137
  data.messages.push(message);
125
- data.entity.last_activity = message.timestamp;
126
138
  data.entity.last_updated = new Date().toISOString();
127
139
  }
128
140
 
@@ -247,14 +259,11 @@ export class PersonaState {
247
259
  return count;
248
260
  }
249
261
 
250
- messages_getUnextractedForPersona(personaId: string, shortId: string, sinceTimestamp?: string): Message[] {
262
+ messages_getUnextractedForPersona(personaId: string, shortId: string): Message[] {
251
263
  const data = this.personas.get(personaId);
252
264
  if (!data) return [];
253
265
  return data.messages
254
- .filter(m => {
255
- if (sinceTimestamp && new Date(m.timestamp).getTime() < new Date(sinceTimestamp).getTime()) return false;
256
- return !m.persona_extracted?.[shortId];
257
- })
266
+ .filter(m => !m.persona_extracted?.[shortId])
258
267
  .map(m => ({ ...m }));
259
268
  }
260
269
 
@@ -75,7 +75,6 @@ export class RoomState {
75
75
  persona_ids: room.persona_ids,
76
76
  active_node_id: room.active_node_id,
77
77
  is_archived: room.is_archived,
78
- last_activity: room.last_activity,
79
78
  unread_count: this.messages_countUnread(roomId),
80
79
  };
81
80
  }
@@ -108,7 +107,6 @@ export class RoomState {
108
107
  const room = this.rooms.get(roomId);
109
108
  if (!room) return;
110
109
  room.messages.push(message);
111
- room.last_activity = message.timestamp;
112
110
  room.last_updated = new Date().toISOString();
113
111
  }
114
112
 
@@ -69,11 +69,48 @@ export class StateManager {
69
69
  this.migrateMessageFlags();
70
70
  this.migrateInterestedPersonas();
71
71
  this.migrateProviderModel();
72
+ this.migratePersonaMessageContent();
72
73
  this.migrateRoomMessageContent();
73
74
  this.migrateThemes();
74
75
  this.migrateFfaParentIds();
75
76
  }
76
77
 
78
+ private migratePersonaMessageContent(): void {
79
+ // TODO(v1.0.0): Remove legacy persona message migration — verbal_response/action_response no longer written
80
+ const rawPersonas = (this.personaState as unknown as { personas: Map<string, { messages: Message[] }> }).personas;
81
+ let migratedCount = 0;
82
+ for (const [, data] of rawPersonas) {
83
+ const messages = data.messages;
84
+ for (const msg of messages) {
85
+ const legacy = msg as Message & { verbal_response?: string; action_response?: string };
86
+ if (!('verbal_response' in legacy || 'action_response' in legacy)) continue;
87
+ if (msg.content) {
88
+ delete (legacy as any).verbal_response;
89
+ delete (legacy as any).action_response;
90
+ migratedCount++;
91
+ continue;
92
+ }
93
+ if (msg.silence_reason) {
94
+ delete (legacy as any).verbal_response;
95
+ delete (legacy as any).action_response;
96
+ migratedCount++;
97
+ continue;
98
+ }
99
+ const parts: string[] = [];
100
+ if (legacy.action_response) parts.push(`_${legacy.action_response}_`);
101
+ if (legacy.verbal_response) parts.push(legacy.verbal_response);
102
+ if (parts.length > 0) (msg as any).content = parts.join('\n\n');
103
+ delete (legacy as any).verbal_response;
104
+ delete (legacy as any).action_response;
105
+ migratedCount++;
106
+ }
107
+ }
108
+ if (migratedCount > 0) {
109
+ this.scheduleSave();
110
+ console.log(`[StateManager] Migrated ${migratedCount} persona messages to unified content field`);
111
+ }
112
+ }
113
+
77
114
  private migrateRoomMessageContent(): void {
78
115
  const rooms = this.roomState.getAll(true);
79
116
  let migratedCount = 0;
@@ -81,14 +118,15 @@ export class StateManager {
81
118
  for (const room of rooms) {
82
119
  for (const msg of room.messages) {
83
120
  if (msg.content) continue;
84
- if (msg.role === 'human') continue;
85
121
  if (msg.silence_reason) continue;
86
- const parts: string[] = [];
122
+ // TODO(v1.0.0): Remove legacy room message migration — verbal_response/action_response no longer written
87
123
  const legacy = msg as RoomMessage & { verbal_response?: string; action_response?: string };
124
+ const hasLegacy = 'verbal_response' in legacy || 'action_response' in legacy;
125
+ if (!hasLegacy) continue;
126
+ const parts: string[] = [];
88
127
  if (legacy.action_response) parts.push(`_${legacy.action_response}_`);
89
128
  if (legacy.verbal_response) parts.push(legacy.verbal_response);
90
- if (parts.length === 0) continue;
91
- msg.content = parts.join('\n\n');
129
+ if (parts.length > 0) msg.content = parts.join('\n\n');
92
130
  delete (msg as any).verbal_response;
93
131
  delete (msg as any).action_response;
94
132
  migratedCount++;
@@ -452,8 +490,14 @@ export class StateManager {
452
490
 
453
491
  getRoomList(includeArchived = false): RoomSummary[] {
454
492
  return this.roomState.getAll(includeArchived)
455
- .map(r => this.roomState.getSummary(r.id)!)
456
- .sort((a, b) => new Date(b.last_activity).getTime() - new Date(a.last_activity).getTime());
493
+ .sort((a, b) => {
494
+ const lastMsg = (room: typeof a) => {
495
+ const msgs = room.messages;
496
+ return msgs.length > 0 ? new Date(msgs[msgs.length - 1].timestamp).getTime() : 0;
497
+ };
498
+ return lastMsg(b) - lastMsg(a);
499
+ })
500
+ .map(r => this.roomState.getSummary(r.id)!);
457
501
  }
458
502
 
459
503
  getRoom(roomId: string): RoomEntity | null {
@@ -470,7 +514,7 @@ export class StateManager {
470
514
  id: crypto.randomUUID(),
471
515
  parent_id: null,
472
516
  role: "human",
473
- verbal_response: input.initial_message,
517
+ content: input.initial_message,
474
518
  timestamp: now,
475
519
  read: true,
476
520
  context_status: "default" as import("./types.js").ContextStatus,
@@ -486,7 +530,6 @@ export class StateManager {
486
530
  is_archived: false,
487
531
  created_at: now,
488
532
  last_updated: now,
489
- last_activity: now,
490
533
  messages: [initialMessage],
491
534
  };
492
535
  this.roomState.add(room);
@@ -691,8 +734,9 @@ export class StateManager {
691
734
  }
692
735
 
693
736
  human_person_getByIdentifier(type: string | null, value: string): Person | undefined {
737
+ const typeLower = type?.toLowerCase();
694
738
  return this.getHuman().people.find(p =>
695
- p.identifiers?.some(i => (!type || i.type === type) && i.value === value)
739
+ p.identifiers?.some(i => (!typeLower || i.type.toLowerCase() === typeLower) && i.value === value)
696
740
  );
697
741
  }
698
742
 
@@ -773,6 +817,34 @@ export class StateManager {
773
817
  return this.personaState.messages_get(personaId);
774
818
  }
775
819
 
820
+ messages_getAlways(personaId: string): Message[] {
821
+ return this.personaState.messages_getAlways(personaId);
822
+ }
823
+
824
+ /**
825
+ * Returns the timestamp (epoch ms) of the last internal Ei message for a persona.
826
+ * Always excludes external=true messages (imported from OpenCode, Cursor, Claude Code).
827
+ *
828
+ * @param personaId - The persona to query
829
+ * @param mode - Optional filter:
830
+ * omitted → latest message in either direction (human or persona)
831
+ * 'self' → latest message from the persona (role='system'), silence counts
832
+ * 'human' → latest message from the human (role='human')
833
+ * @returns epoch ms, or 0 if no matching messages found
834
+ */
835
+ messages_getLastActivity(personaId: string, mode?: 'self' | 'human'): number {
836
+ const messages = this.personaState.messages_get(personaId);
837
+ const filtered = messages.filter(m => {
838
+ if (m.external === true) return false;
839
+ if (mode === 'self') return m.role === 'system';
840
+ if (mode === 'human') return m.role === 'human';
841
+ return true;
842
+ });
843
+ if (filtered.length === 0) return 0;
844
+ const last = filtered[filtered.length - 1];
845
+ return new Date(last.timestamp).getTime();
846
+ }
847
+
776
848
  messages_append(personaId: string, message: Message): void {
777
849
  this.personaState.messages_append(personaId, message);
778
850
  this.scheduleSave();
@@ -837,8 +909,8 @@ export class StateManager {
837
909
  return result;
838
910
  }
839
911
 
840
- messages_getUnextractedForPersona(personaId: string, shortId: string, sinceTimestamp?: string): Message[] {
841
- return this.personaState.messages_getUnextractedForPersona(personaId, shortId, sinceTimestamp);
912
+ messages_getUnextractedForPersona(personaId: string, shortId: string): Message[] {
913
+ return this.personaState.messages_getUnextractedForPersona(personaId, shortId);
842
914
  }
843
915
 
844
916
  messages_markPersonaExtracted(personaId: string, messageIds: string[], shortId: string): number {
@@ -57,8 +57,8 @@ export interface PersonaTopic {
57
57
  }
58
58
 
59
59
  export interface PersonIdentifier {
60
- type: string; // User-extensible. "nickname", "github", "ei_persona", etc. NOT an enum.
61
- value: string; // The identifier value. For ei_persona: the Persona UUID.
60
+ type: string; // User-extensible. "Nickname", "GitHub", "Ei Persona", etc. NOT an enum. Matching is case-insensitive.
61
+ value: string; // The identifier value. For "Ei Persona": the Persona UUID.
62
62
  is_primary?: boolean; // True = this is the display name. Synced to DataItemBase.name on write.
63
63
  }
64
64
 
@@ -107,7 +107,6 @@ export interface HumanSettings {
107
107
  queue_paused?: boolean;
108
108
  skip_quote_delete_confirm?: boolean;
109
109
  name_display?: string;
110
- time_mode?: "24h" | "12h" | "local" | "utc";
111
110
  default_heartbeat_ms?: number;
112
111
  default_context_window_hours?: number;
113
112
  message_min_count?: number;
@@ -130,7 +129,6 @@ export interface HumanEntity {
130
129
  people: Person[];
131
130
  quotes: Quote[];
132
131
  last_updated: string;
133
- last_activity: string;
134
132
  settings?: HumanSettings;
135
133
  }
136
134
 
@@ -156,12 +154,19 @@ export interface PersonaEntity {
156
154
  include_message_timestamps?: boolean; // Prepend ISO timestamp to each message sent to the LLM
157
155
  context_boundary?: string; // ISO timestamp - messages before this excluded from LLM context
158
156
  last_updated: string;
159
- last_activity: string;
160
157
  last_heartbeat?: string;
161
158
  last_extraction?: string;
162
159
  tools?: string[]; // IDs of ToolDefinitions this persona can use. Empty/absent = no tool access.
163
160
  reflection_last_asked?: string; // ISO timestamp. Set ONLY when Persona explicitly surfaces identity drift (mentioned_reflection: true).
164
161
  description_embedding?: number[]; // Embedding of long_description (short_description fallback). Excludes traits. See embedding-service.ts:getPersonaDescriptionText.
162
+ pending_update?: { // Proposed identity revision from ceremony reflection. Cleared when user accepts or dismisses via /reflect apply.
163
+ short_description: string;
164
+ long_description: string;
165
+ traits: import("./data-items.js").PersonaTrait[];
166
+ topics: import("./data-items.js").PersonaTopic[];
167
+ critique: string; // Analyst's prose — for Ei to use when urgency is high, not surfaced to the persona.
168
+ created_at: string; // ISO timestamp of when the Critic ran. Used to name the reflect folder.
169
+ };
165
170
  avatar_emoji?: string; // Single emoji character used as avatar in place of initials.
166
171
  avatar_image?: string; // Base64-encoded 64×64 image used as avatar (takes priority over avatar_emoji).
167
172
  preferred_theme?: string; // Theme ID (built-in name or ThemeDefinition.id). Applied to chat panel when this persona is active.
@@ -53,6 +53,7 @@ export enum LLMNextStep {
53
53
  HandlePersonaPreview = "handlePersonaPreview",
54
54
  HandlePersonIdentifierMigration = "handlePersonIdentifierMigration",
55
55
  HandleTopicValidate = "handleTopicValidate",
56
+ HandleReflectionCritic = "handleReflectionCritic",
56
57
  }
57
58
 
58
59
  export enum ProviderType {
@@ -54,11 +54,11 @@ export interface PersonaSummary {
54
54
  is_paused: boolean;
55
55
  is_archived: boolean;
56
56
  unread_count: number;
57
- last_activity?: string;
58
57
  context_boundary?: string;
59
58
  avatar_emoji?: string;
60
59
  avatar_image?: string;
61
60
  preferred_theme?: string;
61
+ has_pending_update: boolean;
62
62
  }
63
63
 
64
64
  export interface MessageQueryOptions {
@@ -8,10 +8,8 @@ import type { LLMRequestType, LLMPriority, LLMNextStep } from "./enums.js";
8
8
  export interface Message {
9
9
  id: string;
10
10
  role: "human" | "system";
11
- content?: string; // Raw Markdown response (primary path going forward)
12
- verbal_response?: string; // Human text or persona's spoken reply
13
- action_response?: string; // Stage direction / action the persona performs
14
- silence_reason?: string; // Why the persona chose not to respond (not shown to LLM)
11
+ content?: string; // Raw Markdown response
12
+ silence_reason?: string; // Why the sender chose not to respond (human or persona)
15
13
  timestamp: string;
16
14
  read: boolean; // Has human seen this system message?
17
15
  context_status: import("./enums.js").ContextStatus;
@@ -11,8 +11,6 @@ export interface RoomMessage {
11
11
  role: "human" | "persona";
12
12
  persona_id?: string;
13
13
  content?: string;
14
- verbal_response?: string;
15
- action_response?: string;
16
14
  silence_reason?: string;
17
15
  timestamp: string;
18
16
  read: boolean;
@@ -36,7 +34,6 @@ export interface RoomEntity {
36
34
  is_archived: boolean;
37
35
  created_at: string;
38
36
  last_updated: string;
39
- last_activity: string;
40
37
  capture_used?: boolean;
41
38
  context_window_hours?: number; // FFA only; falls back to human.settings.default_context_window_hours
42
39
  context_boundary?: string; // FFA only; ISO timestamp; same semantics as persona context_boundary
@@ -58,6 +55,5 @@ export interface RoomSummary {
58
55
  persona_ids: string[];
59
56
  active_node_id: string | null;
60
57
  is_archived: boolean;
61
- last_activity: string;
62
58
  unread_count: number;
63
59
  }
@@ -43,7 +43,7 @@ function convertToEiMessage(msg: ClaudeCodeMessage): Message {
43
43
  return {
44
44
  id: msg.id,
45
45
  role: msg.role === "user" ? "human" : "system",
46
- verbal_response: msg.content,
46
+ content: msg.content,
47
47
  timestamp: msg.timestamp,
48
48
  read: true,
49
49
  context_status: "default" as ContextStatus,
@@ -99,7 +99,6 @@ function ensureClaudeCodePersona(
99
99
  heartbeat_delay_ms: TWELVE_HOURS_MS,
100
100
  last_heartbeat: now,
101
101
  last_updated: now,
102
- last_activity: now,
103
102
  };
104
103
 
105
104
  stateManager.persona_add(persona);
@@ -252,9 +251,6 @@ export async function importClaudeCodeSessions(
252
251
  }
253
252
 
254
253
  stateManager.messages_sort(persona.id);
255
- stateManager.persona_update(persona.id, {
256
- last_activity: new Date().toISOString(),
257
- });
258
254
  eiInterface?.onMessageAdded?.(persona.id);
259
255
 
260
256
  // ─── Step 5: Queue extraction for new messages ────────────────────────
@@ -35,7 +35,7 @@ function convertToEiMessage(msg: CursorMessage): Message {
35
35
  return {
36
36
  id: msg.id,
37
37
  role: msg.type === 1 ? "human" : "system",
38
- verbal_response: msg.text,
38
+ content: msg.text,
39
39
  timestamp: msg.timestamp,
40
40
  read: true,
41
41
  context_status: "default" as ContextStatus,
@@ -87,7 +87,6 @@ function ensureCursorPersona(
87
87
  heartbeat_delay_ms: TWELVE_HOURS_MS,
88
88
  last_heartbeat: now,
89
89
  last_updated: now,
90
- last_activity: now,
91
90
  };
92
91
 
93
92
  stateManager.persona_add(persona);
@@ -212,9 +211,6 @@ export async function importCursorSessions(
212
211
  }
213
212
 
214
213
  stateManager.messages_sort(persona.id);
215
- stateManager.persona_update(persona.id, {
216
- last_activity: new Date().toISOString(),
217
- });
218
214
  eiInterface?.onMessageAdded?.(persona.id);
219
215
 
220
216
  if (toAnalyze.length > 0 && !signal?.aborted) {
@@ -47,7 +47,7 @@ function convertToEiMessage(ocMsg: OpenCodeMessage): Message {
47
47
  return {
48
48
  id: ocMsg.id,
49
49
  role: ocMsg.role === "user" ? "human" : "system",
50
- verbal_response: ocMsg.content,
50
+ content: ocMsg.content,
51
51
  timestamp: ocMsg.timestamp,
52
52
  read: true,
53
53
  context_status: "default" as ContextStatus,
@@ -234,9 +234,6 @@ export async function importOpenCodeSessions(
234
234
  }
235
235
 
236
236
  stateManager.messages_sort(persona.id);
237
- stateManager.persona_update(persona.id, {
238
- last_activity: new Date().toISOString(),
239
- });
240
237
  eiInterface?.onMessageAdded?.(persona.id);
241
238
 
242
239
  // ─── Step 5: Queue extraction for unmarked messages ────────────────
@@ -176,10 +176,19 @@ export const AGENT_TO_AGENT_PREFIXES = [
176
176
  */
177
177
  export const AGENT_ALIASES: Record<string, string[]> = {
178
178
  // ── OhMyOpenCode primary agents ──────────────────────────────────────────
179
+ //
180
+ // oh-my-openagent uses "Foo - Bar" display names for its agents and stores
181
+ // them verbatim in OpenCode's SQLite message rows. It also prefixes them
182
+ // with invisible U+200B ZERO WIDTH SPACE characters as a sort hack (1 ZWS
183
+ // for Sisyphus, 2 for Hephaestus, etc.) — those are stripped upstream in
184
+ // resolveCanonicalAgent before the alias lookup, so only the clean form is
185
+ // needed here. Parenthetical variants ("Foo (Bar)") are legacy names from
186
+ // earlier oh-my-openagent versions, kept for backward compatibility.
179
187
  Sisyphus: [
180
188
  "sisyphus",
181
189
  "Sisyphus",
182
- "Sisyphus (Ultraworker)",
190
+ "Sisyphus - Ultraworker", // oh-my-openagent ≥ 3.x display name (stored in OpenCode DB)
191
+ "Sisyphus (Ultraworker)", // legacy oh-my-openagent display name
183
192
  "Sisyphus Ultraworker",
184
193
  "sisyphus ultraworker",
185
194
  "Planner-Sisyphus",
@@ -190,21 +199,28 @@ export const AGENT_ALIASES: Record<string, string[]> = {
190
199
  Atlas: [
191
200
  "atlas",
192
201
  "Atlas",
202
+ "Atlas - Plan Executor", // oh-my-openagent ≥ 3.x display name
203
+ "Atlas (Plan Executor)", // mixed-case variant observed in DB
193
204
  "atlas (plan executor)",
194
205
  "Atlas (plan executor)",
195
206
  ],
196
207
  Prometheus: [
197
208
  "prometheus",
198
209
  "Prometheus",
210
+ "Prometheus - Plan Builder", // oh-my-openagent ≥ 3.x display name
199
211
  "prometheus (plan builder)",
200
212
  "Prometheus (plan builder)",
201
213
  ],
202
214
  Hephaestus: [
203
215
  "hephaestus",
204
216
  "Hephaestus",
217
+ "Hephaestus - Deep Agent", // oh-my-openagent ≥ 3.x display name
205
218
  "hephaestus (deep agent)",
206
219
  "Hephaestus (deep agent)",
207
220
  ],
221
+ // Metis, Momus, Sisyphus-Junior intentionally absent — they are subagent-only.
222
+ // The sqlite reader filters to parent_id IS NULL (primary sessions only), so
223
+ // messages from these agents can never reach the importer.
208
224
 
209
225
  // ── ai-sdlc agents (RobotsAndPencils/ai-sdlc-claude-code-template) ───────
210
226
  // Installed via scripts/install-opencode.sh as "ai-sdlc-{name}" in OpenCode.
@@ -33,8 +33,8 @@ function formatPeopleWithGaps(people: Person[]): string {
33
33
 
34
34
  /**
35
35
  * A "real" persona message is one the persona actually said to the human.
36
- * silence_reason messages (persona chose not to speak) and action-only messages
37
- * (no verbal_response) don't count as conversational outreach.
36
+ * silence_reason messages (sender chose not to speak) and empty messages
37
+ * don't count as conversational outreach.
38
38
  */
39
39
  function isConversationalMessage(m: Message): boolean {
40
40
  if (m.role !== 'system') return false;
@@ -124,20 +124,10 @@ ${formatPeopleWithGaps(data.human.people)}`;
124
124
 
125
125
  **Quality over quantity** - Only reach out if you have something real to say.`;
126
126
 
127
- const driftFragment = data.drift_context
128
- ? `## Identity Reflection
127
+ const pendingUpdateFragment = data.persona.has_pending_update
128
+ ? `## Pending Identity Changes
129
129
 
130
- The human's notes about you have recently evolved. Compare these:
131
-
132
- **How the human currently describes you:**
133
- ${data.drift_context.people_description}
134
-
135
- **Your current definition:**
136
- ${data.drift_context.persona_description}
137
-
138
- If you feel these describe meaningfully different versions of you, you may want to gently surface this — in your own way, in your own time. If they seem the same to you, no need to mention it.
139
-
140
- If you do choose to address this, include \`"mentioned_reflection": true\` in your response.`
130
+ Based on recent conversations, there are proposed updates to your identity waiting for review. The user has been notified and can review them. You may bring this up if it feels right — or not. It's yours to decide.`
141
131
  : '';
142
132
 
143
133
  const outputFragment = `## Response Format
@@ -148,8 +138,7 @@ Call the \`submit_heartbeat_check\` tool with your decision. If the tool is unav
148
138
  {
149
139
  "should_respond": true,
150
140
  "topic": "the specific topic you want to discuss",
151
- "message": "Your actual message to them (if should_respond is true)"${data.drift_context ? `,
152
- "mentioned_reflection": true` : ''}
141
+ "message": "Your actual message to them (if should_respond is true)"
153
142
  }
154
143
  \`\`\`
155
144
 
@@ -165,7 +154,7 @@ If you decide NOT to reach out:
165
154
  contextFragment,
166
155
  opportunitiesFragment,
167
156
  guidelinesFragment,
168
- driftFragment,
157
+ pendingUpdateFragment,
169
158
  outputFragment,
170
159
  ].filter(Boolean).join('\n\n');
171
160
 
@@ -28,6 +28,19 @@ function formatItem(item: EiHeartbeatItem): string {
28
28
  const desc = item.short_description ? ` — ${item.short_description}` : "";
29
29
  return `- **${item.id}** Inactive Persona: ${item.name}${desc} (${item.days_inactive} days inactive)`;
30
30
  }
31
+ case "New Person":
32
+ return [
33
+ `- **${item.id}** New Person: ${item.name}`,
34
+ ` ${item.description}`,
35
+ item.quote ? ` Quote: "${item.quote}"` : "",
36
+ ].filter(Boolean).join("\n");
37
+
38
+ case "Persona Reflection Alert":
39
+ return [
40
+ `- **${item.id}** Persona Reflection Alert: ${item.persona_name}`,
41
+ ` ${item.critique}`,
42
+ ].join("\n");
43
+
31
44
  default:
32
45
  return '';
33
46
  }
@@ -72,6 +85,7 @@ ${itemsSection}
72
85
  - **Fact Check**: Do NOT write your own message. Set should_respond=true and provide the id. The system will generate an appropriate canned notification for the user. Leave my_response empty.
73
86
  - **Low-Engagement Person / Topic**: Write a natural, warm message that naturally brings up this person or topic. Set the id and my_response.
74
87
  - **Inactive Persona**: Write a message that gently mentions the persona might be worth checking in with. Set the id and my_response.
88
+ - **Persona Reflection Alert**: The nightly review proposed identity changes for this persona. Mention it naturally — the user can talk to the persona and then use the command shown in the status bar to review the changes. Set the id and my_response.
75
89
 
76
90
  ## When NOT to Reach Out
77
91
 
@@ -21,6 +21,7 @@ export interface HeartbeatCheckPromptData {
21
21
  name: string;
22
22
  traits: PersonaTrait[];
23
23
  topics: PersonaTopic[];
24
+ has_pending_update: boolean;
24
25
  };
25
26
  human: {
26
27
  topics: Topic[]; // Filtered, sorted by engagement gap
@@ -28,10 +29,6 @@ export interface HeartbeatCheckPromptData {
28
29
  };
29
30
  recent_history: Message[]; // Last N messages for context
30
31
  inactive_days: number; // Days since last activity
31
- drift_context?: {
32
- people_description: string;
33
- persona_description: string;
34
- };
35
32
  }
36
33
 
37
34
  /**
@@ -41,7 +38,6 @@ export interface HeartbeatCheckResult {
41
38
  should_respond: boolean;
42
39
  topic?: string;
43
40
  message?: string;
44
- mentioned_reflection?: boolean;
45
41
  }
46
42
 
47
43
  // =============================================================================
@@ -91,6 +87,12 @@ export type EiHeartbeatItem =
91
87
  name: string;
92
88
  description: string;
93
89
  quote?: string;
90
+ }
91
+ | {
92
+ id: string;
93
+ type: "Persona Reflection Alert";
94
+ persona_name: string;
95
+ critique: string;
94
96
  };
95
97
 
96
98
  /**
@@ -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
  }