ei-tui 0.6.7 → 0.7.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 (39) hide show
  1. package/package.json +1 -1
  2. package/src/cli/mcp.ts +35 -10
  3. package/src/cli/persona-filter.ts +42 -0
  4. package/src/cli.ts +18 -6
  5. package/src/core/handlers/human-extraction.ts +1 -0
  6. package/src/core/handlers/human-matching.ts +10 -0
  7. package/src/core/handlers/index.ts +2 -1
  8. package/src/core/handlers/persona-response.ts +5 -0
  9. package/src/core/handlers/utils.ts +4 -1
  10. package/src/core/orchestrators/ceremony.ts +2 -2
  11. package/src/core/orchestrators/human-extraction.ts +5 -0
  12. package/src/core/personas/opencode-agent.ts +1 -0
  13. package/src/core/processor.ts +22 -2
  14. package/src/core/prompt-context-builder.ts +40 -10
  15. package/src/core/queue-manager.ts +18 -0
  16. package/src/core/room-manager.ts +21 -4
  17. package/src/core/state/personas.ts +2 -2
  18. package/src/core/state-manager.ts +26 -0
  19. package/src/core/types/data-items.ts +1 -0
  20. package/src/core/types/enums.ts +1 -0
  21. package/src/core/types/integrations.ts +1 -0
  22. package/src/core/types/rooms.ts +2 -0
  23. package/src/integrations/claude-code/importer.ts +3 -57
  24. package/src/integrations/cursor/importer.ts +2 -52
  25. package/src/integrations/opencode/importer.ts +1 -0
  26. package/src/prompts/response/sections.ts +1 -1
  27. package/src/prompts/response/types.ts +1 -0
  28. package/src/prompts/room/index.ts +2 -2
  29. package/src/prompts/room/sections.ts +4 -4
  30. package/src/prompts/room/types.ts +4 -0
  31. package/tui/src/commands/activate.tsx +7 -6
  32. package/tui/src/commands/context.tsx +188 -2
  33. package/tui/src/components/CYPTreeOverlay.tsx +357 -0
  34. package/tui/src/components/MAPScoreOverlay.tsx +300 -0
  35. package/tui/src/components/MessageList.tsx +14 -3
  36. package/tui/src/components/RoomMessageList.tsx +15 -3
  37. package/tui/src/context/ei.tsx +20 -0
  38. package/tui/src/util/cyp-tree.ts +62 -0
  39. package/tui/src/util/yaml-context.ts +87 -1
@@ -17,6 +17,7 @@ import type {
17
17
  RoomSummary,
18
18
  RoomCreationInput,
19
19
  } from "./types.js";
20
+ import { RoomMode } from "./types.js";
20
21
  import { BUILT_IN_FACT_NAMES } from './constants/built-in-facts.js';
21
22
  import type { ThemeDefinition } from './types/entities.js';
22
23
  import type { Storage } from "../storage/interface.js";
@@ -70,6 +71,7 @@ export class StateManager {
70
71
  this.migrateProviderModel();
71
72
  this.migrateRoomMessageContent();
72
73
  this.migrateThemes();
74
+ this.migrateFfaParentIds();
73
75
  }
74
76
 
75
77
  private migrateRoomMessageContent(): void {
@@ -576,6 +578,30 @@ export class StateManager {
576
578
  this.humanState.set(human);
577
579
  }
578
580
 
581
+ private migrateFfaParentIds(): void {
582
+ const rooms = this.roomState.getAll(true);
583
+ let migratedCount = 0;
584
+
585
+ for (const room of rooms) {
586
+ if (room.mode !== RoomMode.FreeForAll) continue;
587
+ const rootMsg = room.messages.find(m => m.parent_id === null);
588
+ if (!rootMsg) continue;
589
+
590
+ for (const msg of room.messages) {
591
+ if (msg.role !== "human") continue;
592
+ if (msg.id === rootMsg.id) continue;
593
+ if (msg.parent_id === rootMsg.id) continue;
594
+ msg.parent_id = rootMsg.id;
595
+ migratedCount++;
596
+ }
597
+ }
598
+
599
+ if (migratedCount > 0) {
600
+ this.scheduleSave();
601
+ console.log(`[StateManager] Migrated ${migratedCount} FFA human messages to root parent_id`);
602
+ }
603
+ }
604
+
579
605
  getHuman(): HumanEntity {
580
606
  return this.humanState.get();
581
607
  }
@@ -15,6 +15,7 @@ export interface DataItemBase {
15
15
  learned_by?: string; // Persona ID that originally learned this item (stable UUID)
16
16
  last_changed_by?: string; // Persona ID that most recently updated this item (stable UUID)
17
17
  interested_personas?: string[]; // Persona IDs that have extracted/touched this item (accumulated)
18
+ sources?: string[]; // Namespaced source identifiers — where items were learned from. Format: "provider:id" (e.g., "opencode:ses_abc123", "cursor:composerId"). Grow-only union.
18
19
  persona_groups?: string[];
19
20
  embedding?: number[];
20
21
  rewrite_checked?: boolean; // True after rewrite scan finds no changes. Cleared automatically when extraction upserts a fresh item.
@@ -38,6 +38,7 @@ export enum LLMNextStep {
38
38
  HandleHeartbeatCheck = "handleHeartbeatCheck",
39
39
  HandleEiHeartbeat = "handleEiHeartbeat",
40
40
  HandleOneShot = "handleOneShot",
41
+ HandleOneShotJSON = "handleOneShotJSON",
41
42
  // Tool calling continuation (second LLM call after tool execution, may loop for more tool calls).
42
43
  // data.toolHistory: serialized LLMHistoryMessage[] (assistant + tool result messages)
43
44
  // data.toolCallCounts: serialized Map entries [[name, count], ...] carrying per-tool call counts
@@ -102,6 +102,7 @@ export interface Ei_Interface {
102
102
  onError?: (error: EiError) => void;
103
103
  onStateImported?: () => void;
104
104
  onOneShotReturned?: (guid: string, content: string) => void;
105
+ onOneShotJSONReturned?: (guid: string, parsed: unknown) => void;
105
106
  onContextBoundaryChanged?: (personaId: string) => void;
106
107
  onSaveAndExitStart?: () => void;
107
108
  onSaveAndExitFinish?: () => void;
@@ -38,6 +38,8 @@ export interface RoomEntity {
38
38
  last_updated: string;
39
39
  last_activity: string;
40
40
  capture_used?: boolean;
41
+ context_window_hours?: number; // FFA only; falls back to human.settings.default_context_window_hours
42
+ context_boundary?: string; // FFA only; ISO timestamp; same semantics as persona context_boundary
41
43
  messages: RoomMessage[];
42
44
  }
43
45
 
@@ -1,10 +1,9 @@
1
1
  import type { StateManager } from "../../core/state-manager.js";
2
- import type { Ei_Interface, Topic, Message, ContextStatus, PersonaEntity, PersonaTrait } from "../../core/types.js";
2
+ import type { Ei_Interface, Message, ContextStatus, PersonaEntity, PersonaTrait } from "../../core/types.js";
3
3
  import { DEFAULT_SEED_TRAITS } from "../../core/constants/seed-traits.js";
4
4
  import type { IClaudeCodeReader, ClaudeCodeSession, ClaudeCodeMessage } from "./types.js";
5
5
  import {
6
6
  CLAUDE_CODE_PERSONA_NAME,
7
- CLAUDE_CODE_TOPIC_GROUPS,
8
7
  MIN_SESSION_AGE_MS,
9
8
  } from "./types.js";
10
9
  import { ClaudeCodeReader } from "./reader.js";
@@ -20,8 +19,6 @@ import { isProcessRunning } from "../process-check.js";
20
19
 
21
20
  export interface ClaudeCodeImportResult {
22
21
  sessionsProcessed: number;
23
- topicsCreated: number;
24
- topicsUpdated: number;
25
22
  messagesImported: number;
26
23
  personaCreated: boolean;
27
24
  extractionScansQueued: number;
@@ -109,47 +106,6 @@ function ensureClaudeCodePersona(
109
106
  return persona;
110
107
  }
111
108
 
112
- // =============================================================================
113
- // Topic Management
114
- // =============================================================================
115
-
116
- function ensureSessionTopic(
117
- session: ClaudeCodeSession,
118
- stateManager: StateManager
119
- ): "created" | "updated" | "unchanged" {
120
- const human = stateManager.getHuman();
121
- const existingTopic = human.topics.find((t) => t.id === session.id);
122
-
123
- if (existingTopic) {
124
- if (existingTopic.name !== session.title) {
125
- const updatedTopic: Topic = {
126
- ...existingTopic,
127
- name: session.title,
128
- last_updated: new Date().toISOString(),
129
- };
130
- stateManager.human_topic_upsert(updatedTopic);
131
- return "updated";
132
- }
133
- return "unchanged";
134
- }
135
-
136
- const newTopic: Topic = {
137
- id: session.id,
138
- name: session.title,
139
- description: `Claude Code session in ${session.cwd}`,
140
- sentiment: 0,
141
- exposure_current: 0.5,
142
- exposure_desired: 0.3,
143
- persona_groups: CLAUDE_CODE_TOPIC_GROUPS,
144
- learned_by: stateManager.persona_getByName(CLAUDE_CODE_PERSONA_NAME)?.id ?? undefined,
145
- last_updated: new Date().toISOString(),
146
- learned_on: new Date().toISOString(),
147
- };
148
-
149
- stateManager.human_topic_upsert(newTopic);
150
- return "created";
151
- }
152
-
153
109
  // =============================================================================
154
110
  // State Helpers
155
111
  // =============================================================================
@@ -205,26 +161,15 @@ export async function importClaudeCodeSessions(
205
161
 
206
162
  const result: ClaudeCodeImportResult = {
207
163
  sessionsProcessed: 0,
208
- topicsCreated: 0,
209
- topicsUpdated: 0,
210
164
  messagesImported: 0,
211
165
  personaCreated: false,
212
166
  extractionScansQueued: 0,
213
167
  };
214
168
 
215
- // ─── Step 1: Ensure topics exist for ALL sessions ─────────────────────
169
+ // ─── Step 1: Get all sessions ─────────────────────────────────────────
216
170
  const allSessions = await reader.getSessions();
217
171
 
218
- for (const session of allSessions) {
219
- const topicResult = ensureSessionTopic(session, stateManager);
220
- if (topicResult === "created") result.topicsCreated++;
221
- else if (topicResult === "updated") result.topicsUpdated++;
222
- }
223
-
224
172
  if (signal?.aborted) return result;
225
- if (result.topicsCreated > 0 || result.topicsUpdated > 0) {
226
- eiInterface?.onHumanUpdated?.();
227
- }
228
173
 
229
174
  // ─── Step 2: Find next unprocessed session ────────────────────────────
230
175
  const human = stateManager.getHuman();
@@ -323,6 +268,7 @@ export async function importClaudeCodeSessions(
323
268
  personaDisplayName: persona.display_name,
324
269
  messages_context: contextMsgs,
325
270
  messages_analyze: toAnalyze,
271
+ sources: [`claudecode:${targetSession.id}`],
326
272
  };
327
273
 
328
274
  const ccSettings = stateManager.getHuman().settings?.claudeCode;
@@ -1,10 +1,9 @@
1
1
  import type { StateManager } from "../../core/state-manager.js";
2
- import type { Ei_Interface, Topic, Message, ContextStatus, PersonaEntity, PersonaTrait } from "../../core/types.js";
2
+ import type { Ei_Interface, Message, ContextStatus, PersonaEntity, PersonaTrait } from "../../core/types.js";
3
3
  import { DEFAULT_SEED_TRAITS } from "../../core/constants/seed-traits.js";
4
4
  import type { ICursorReader, CursorSession, CursorMessage } from "./types.js";
5
5
  import {
6
6
  CURSOR_PERSONA_NAME,
7
- CURSOR_TOPIC_GROUPS,
8
7
  MIN_SESSION_AGE_MS,
9
8
  } from "./types.js";
10
9
  import { CursorReader } from "./reader.js";
@@ -16,8 +15,6 @@ import {
16
15
 
17
16
  export interface CursorImportResult {
18
17
  sessionsProcessed: number;
19
- topicsCreated: number;
20
- topicsUpdated: number;
21
18
  messagesImported: number;
22
19
  personaCreated: boolean;
23
20
  extractionScansQueued: number;
@@ -97,43 +94,6 @@ function ensureCursorPersona(
97
94
  return persona;
98
95
  }
99
96
 
100
- function ensureSessionTopic(
101
- session: CursorSession,
102
- stateManager: StateManager
103
- ): "created" | "updated" | "unchanged" {
104
- const human = stateManager.getHuman();
105
- const existingTopic = human.topics.find((t) => t.id === session.id);
106
-
107
- if (existingTopic) {
108
- if (existingTopic.name !== session.name) {
109
- const updatedTopic: Topic = {
110
- ...existingTopic,
111
- name: session.name,
112
- last_updated: new Date().toISOString(),
113
- };
114
- stateManager.human_topic_upsert(updatedTopic);
115
- return "updated";
116
- }
117
- return "unchanged";
118
- }
119
-
120
- const newTopic: Topic = {
121
- id: session.id,
122
- name: session.name,
123
- description: `Cursor session in ${session.workspacePath}`,
124
- sentiment: 0,
125
- exposure_current: 0.5,
126
- exposure_desired: 0.3,
127
- persona_groups: CURSOR_TOPIC_GROUPS,
128
- learned_by: stateManager.persona_getByName(CURSOR_PERSONA_NAME)?.id ?? undefined,
129
- last_updated: new Date().toISOString(),
130
- learned_on: new Date().toISOString(),
131
- };
132
-
133
- stateManager.human_topic_upsert(newTopic);
134
- return "created";
135
- }
136
-
137
97
  function updateProcessedState(
138
98
  stateManager: StateManager,
139
99
  session: CursorSession
@@ -173,8 +133,6 @@ export async function importCursorSessions(
173
133
 
174
134
  const result: CursorImportResult = {
175
135
  sessionsProcessed: 0,
176
- topicsCreated: 0,
177
- topicsUpdated: 0,
178
136
  messagesImported: 0,
179
137
  personaCreated: false,
180
138
  extractionScansQueued: 0,
@@ -182,16 +140,7 @@ export async function importCursorSessions(
182
140
 
183
141
  const allSessions = await reader.getSessions();
184
142
 
185
- for (const session of allSessions) {
186
- const topicResult = ensureSessionTopic(session, stateManager);
187
- if (topicResult === "created") result.topicsCreated++;
188
- else if (topicResult === "updated") result.topicsUpdated++;
189
- }
190
-
191
143
  if (signal?.aborted) return result;
192
- if (result.topicsCreated > 0 || result.topicsUpdated > 0) {
193
- eiInterface?.onHumanUpdated?.();
194
- }
195
144
 
196
145
  const human = stateManager.getHuman();
197
146
  const processedSessions = human.settings?.cursor?.processed_sessions ?? {};
@@ -278,6 +227,7 @@ export async function importCursorSessions(
278
227
  personaDisplayName: persona.display_name,
279
228
  messages_context: contextMsgs,
280
229
  messages_analyze: toAnalyze,
230
+ sources: [`cursor:${targetSession.id}`],
281
231
  };
282
232
 
283
233
  queueAllScans(context, stateManager, { external_filter: "only" });
@@ -250,6 +250,7 @@ export async function importOpenCodeSessions(
250
250
  personaDisplayName: persona.display_name,
251
251
  messages_context: contextMsgs,
252
252
  messages_analyze: toAnalyze,
253
+ sources: [`opencode:${targetSession.id}`],
253
254
  };
254
255
 
255
256
  if (!signal?.aborted) {
@@ -277,7 +277,7 @@ export function buildQuotesSection(quotes: Quote[], human: ResponsePromptData["h
277
277
  const idToName = new Map(allDataItems.map(item => [item.id, item.name]));
278
278
 
279
279
  const formatted = quotes.map(q => {
280
- const speaker = q.speaker === "human" ? "Human" : q.speaker;
280
+ const speaker = q.speaker === "human" ? human.name : q.speaker;
281
281
  const date = formatDate(q.timestamp);
282
282
  const linkedNames = q.data_item_ids
283
283
  .map(id => idToName.get(id))
@@ -23,6 +23,7 @@ export interface ResponsePromptData {
23
23
  include_message_timestamps?: boolean;
24
24
  };
25
25
  human: {
26
+ name: string;
26
27
  facts: Fact[];
27
28
  topics: Topic[];
28
29
  people: Person[];
@@ -97,10 +97,10 @@ There is no objectively correct answer. Pick the response you find most interest
97
97
  **The MAP dynamic**: Every participant — personas and the Human alike — can see your description and traits. They have been crafting their responses specifically to appeal to your tastes. Personas are also constrained to stay true to their own identities; the Human is not. Factor that in if you choose to.`;
98
98
 
99
99
  const contextSection = context.length > 0
100
- ? buildRoomHistorySection(context)
100
+ ? buildRoomHistorySection(context, data.human.name)
101
101
  : "";
102
102
 
103
- const candidatesSection = buildJudgeCandidatesSection(candidates);
103
+ const candidatesSection = buildJudgeCandidatesSection(candidates, data.human.name);
104
104
  const decisionSection = buildJudgeDecisionFormatSection();
105
105
  const currentTime = formatCurrentTime();
106
106
 
@@ -32,11 +32,11 @@ export function buildRoomParticipantsSection(participants: RoomParticipantIdenti
32
32
  return `## Others in the Room\n\n${lines.join("\n\n")}`;
33
33
  }
34
34
 
35
- export function buildRoomHistorySection(history: RoomHistoryMessage[]): string {
35
+ export function buildRoomHistorySection(history: RoomHistoryMessage[], humanName: string): string {
36
36
  if (history.length === 0) return "";
37
37
 
38
38
  const lines = history.map(msg => {
39
- const speaker = msg.speaker_id === "human" ? "Human" : msg.speaker_name;
39
+ const speaker = msg.speaker_id === "human" ? humanName : msg.speaker_name;
40
40
  if (msg.silence_reason) {
41
41
  return `**${speaker}**: *[chose not to respond: ${msg.silence_reason}]*`;
42
42
  }
@@ -116,9 +116,9 @@ ${lines.join("\n\n")}
116
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.`;
117
117
  }
118
118
 
119
- export function buildJudgeCandidatesSection(candidates: RoomJudgeCandidate[]): string {
119
+ export function buildJudgeCandidatesSection(candidates: RoomJudgeCandidate[], humanName: string): string {
120
120
  const lines = candidates.map((c, i) => {
121
- const speaker = c.speaker_id === "human" ? "Human" : c.speaker_name;
121
+ const speaker = c.speaker_id === "human" ? humanName : c.speaker_name;
122
122
  const content = c.silence_reason
123
123
  ? `*[chose not to respond: ${c.silence_reason}]*`
124
124
  : [c.verbal_response, c.action_response ? `*${c.action_response}*` : ""].filter(Boolean).join(" ");
@@ -39,6 +39,7 @@ export interface RoomResponsePromptData {
39
39
  };
40
40
  other_participants: RoomParticipantIdentity[];
41
41
  human: {
42
+ name: string;
42
43
  facts: Fact[];
43
44
  topics: Topic[];
44
45
  people: Person[];
@@ -72,6 +73,9 @@ export interface RoomJudgePromptData {
72
73
  long_description?: string;
73
74
  traits: PersonaTrait[];
74
75
  };
76
+ human: {
77
+ name: string;
78
+ };
75
79
  context: RoomHistoryMessage[];
76
80
  candidates: RoomJudgeCandidate[];
77
81
  }
@@ -1,6 +1,7 @@
1
1
  import type { Command } from "./registry";
2
2
  import { RoomMode } from "../../../src/core/types/enums.js";
3
3
  import { openCYPEditor } from "../util/cyp-editor.js";
4
+ import { buildCYPTree } from "../util/cyp-tree.js";
4
5
 
5
6
  export const activateCommand: Command = {
6
7
  name: "activate",
@@ -55,18 +56,18 @@ export const activateCommand: Command = {
55
56
 
56
57
  const num = parseInt(args[0], 10);
57
58
  if (isNaN(num) || num < 1) {
58
- ctx.showNotification("Usage: /activate <num> (1-based message index)", "error");
59
+ ctx.showNotification("Usage: /activate <num> (1-based BFS tree number)", "error");
59
60
  return;
60
61
  }
61
62
 
62
63
  const messages = ctx.ei.roomMessages();
63
- const target = messages[num - 1];
64
- if (!target) {
65
- ctx.showNotification(`No message at index ${num} (room has ${messages.length} messages)`, "error");
64
+ const { numToId } = buildCYPTree(messages);
65
+ const targetId = numToId.get(num);
66
+ if (!targetId) {
67
+ ctx.showNotification(`No message at tree position ${num} (tree has ${numToId.size} nodes)`, "error");
66
68
  return;
67
69
  }
68
-
69
- await ctx.ei.selectCYPBranch(target.id);
70
+ await ctx.ei.selectCYPBranch(targetId);
70
71
 
71
72
  const freshRoom = ctx.ei.getRoom(roomId);
72
73
  const newActiveNodeId = freshRoom?.active_node_id;
@@ -1,8 +1,11 @@
1
1
  import type { Command } from "./registry.js";
2
2
  import { spawnEditor } from "../util/editor.js";
3
- import { contextToYAML, contextFromYAML } from "../util/yaml-serializers.js";
3
+ import { contextToYAML, contextFromYAML, ffaContextToYAML, ffaContextFromYAML } from "../util/yaml-serializers.js";
4
4
  import { logger } from "../util/logger.js";
5
5
  import { ConfirmOverlay } from "../components/ConfirmOverlay.js";
6
+ import { CYPTreeOverlay } from "../components/CYPTreeOverlay.js";
7
+ import { MAPScoreOverlay } from "../components/MAPScoreOverlay.js";
8
+ import { RoomMode } from "../../../src/core/types/enums.js";
6
9
 
7
10
  export const contextCommand: Command = {
8
11
  name: "context",
@@ -12,8 +15,191 @@ export const contextCommand: Command = {
12
15
 
13
16
  async execute(_args, ctx) {
14
17
  const personaId = ctx.ei.activePersonaId();
18
+ const roomId = ctx.ei.activeRoomId();
19
+
20
+ if (roomId) {
21
+ const room = ctx.ei.getRoom(roomId);
22
+ if (!room) {
23
+ ctx.showNotification("Room not found", "warn");
24
+ return;
25
+ }
26
+
27
+ if (room.mode === RoomMode.ChooseYourPath) {
28
+ const activeNodeId = room.active_node_id;
29
+ if (!activeNodeId) {
30
+ ctx.showNotification("No active node in room", "warn");
31
+ return;
32
+ }
33
+ ctx.showOverlay((hideOverlay) => (
34
+ <CYPTreeOverlay
35
+ roomId={roomId}
36
+ roomName={room.display_name}
37
+ messages={ctx.ei.roomMessages()}
38
+ activeNodeId={activeNodeId}
39
+ activeRoomPath={ctx.ei.roomActivePath()}
40
+ personas={ctx.ei.personas()}
41
+ onSelectBranch={(msgId) => ctx.ei.selectCYPBranch(msgId)}
42
+ onDismiss={hideOverlay}
43
+ />
44
+ ), ctx.renderer);
45
+ return;
46
+ }
47
+
48
+ if (room.mode === RoomMode.FreeForAll) {
49
+ const allMessages = ctx.ei.roomMessages();
50
+ if (allMessages.length === 0) {
51
+ ctx.showNotification("No messages to edit", "info");
52
+ return;
53
+ }
54
+
55
+ const personas = ctx.ei.personas();
56
+ const speakerMap = new Map(personas.map((p) => [p.id, p.display_name]));
57
+
58
+ const originalStatus = new Map(allMessages.map((m) => [m.id, m.context_status]));
59
+
60
+ let yamlContent = ffaContextToYAML(allMessages, speakerMap);
61
+ let editorIteration = 0;
62
+
63
+ while (true) {
64
+ editorIteration++;
65
+ logger.debug("[context] ffa starting editor iteration", { iteration: editorIteration });
66
+
67
+ const result = await spawnEditor({
68
+ initialContent: yamlContent,
69
+ filename: "ffa-context.yaml",
70
+ renderer: ctx.renderer,
71
+ });
72
+
73
+ logger.debug("[context] ffa editor returned", {
74
+ iteration: editorIteration,
75
+ aborted: result.aborted,
76
+ success: result.success,
77
+ hasContent: result.content !== null,
78
+ });
79
+
80
+ if (result.aborted) {
81
+ ctx.showNotification("Editor cancelled", "info");
82
+ return;
83
+ }
84
+
85
+ if (!result.success) {
86
+ ctx.showNotification("Editor failed to open", "error");
87
+ return;
88
+ }
89
+
90
+ if (result.content === null) {
91
+ ctx.showNotification("No changes made", "info");
92
+ return;
93
+ }
94
+
95
+ try {
96
+ const parsed = ffaContextFromYAML(result.content);
97
+
98
+ if (parsed.deletedMessageIds.length > 0) {
99
+ const count = parsed.deletedMessageIds.length;
100
+ const hasImplicit = parsed.implicitDeleteCount > 0;
101
+ const confirmed = await new Promise<boolean>((resolve) => {
102
+ ctx.showOverlay((hideOverlay) => (
103
+ <ConfirmOverlay
104
+ message={`Delete ${count} message${count === 1 ? "" : "s"}?${hasImplicit ? `\n(includes ${parsed.implicitDeleteCount} persona response${parsed.implicitDeleteCount === 1 ? "" : "s"})` : ""}`}
105
+ onConfirm={() => { hideOverlay(); resolve(true); }}
106
+ onCancel={() => { hideOverlay(); resolve(false); }}
107
+ />
108
+ ), ctx.renderer);
109
+ });
110
+ if (!confirmed) {
111
+ ctx.showNotification("Delete cancelled", "info");
112
+ return;
113
+ }
114
+ await ctx.ei.deleteRoomMessages(roomId, parsed.deletedMessageIds);
115
+ }
116
+
117
+ for (const msg of parsed.messages) {
118
+ const orig = originalStatus.get(msg.id);
119
+ if (orig !== undefined && orig !== msg.context_status) {
120
+ await ctx.ei.setRoomMessageContextStatus(roomId, msg.id, msg.context_status);
121
+ }
122
+ }
123
+
124
+ const deleteCount = parsed.deletedMessageIds.length;
125
+ const notification =
126
+ deleteCount > 0
127
+ ? `Context updated (${deleteCount} message${deleteCount === 1 ? "" : "s"} deleted)`
128
+ : "Context updated";
129
+
130
+ ctx.showNotification(notification, "info");
131
+ return;
132
+ } catch (parseError) {
133
+ const errorMsg = parseError instanceof Error ? parseError.message : String(parseError);
134
+ logger.debug("[context] ffa YAML parse error, prompting for re-edit", {
135
+ iteration: editorIteration,
136
+ error: errorMsg,
137
+ });
138
+
139
+ const shouldReEdit = await new Promise<boolean>((resolve) => {
140
+ ctx.showOverlay((hideOverlay, hideForEditor) => (
141
+ <ConfirmOverlay
142
+ message={`YAML parse error:\n${errorMsg}\n\nRe-edit?`}
143
+ onConfirm={() => {
144
+ logger.debug("[context] ffa user confirmed re-edit");
145
+ hideForEditor();
146
+ resolve(true);
147
+ }}
148
+ onCancel={() => {
149
+ logger.debug("[context] ffa user cancelled re-edit");
150
+ hideOverlay();
151
+ resolve(false);
152
+ }}
153
+ />
154
+ ), ctx.renderer);
155
+ });
156
+
157
+ logger.debug("[context] ffa shouldReEdit", { shouldReEdit, iteration: editorIteration });
158
+
159
+ if (shouldReEdit) {
160
+ yamlContent = result.content;
161
+ logger.debug("[context] ffa continuing to next iteration");
162
+ continue;
163
+ } else {
164
+ ctx.showNotification("Changes discarded", "info");
165
+ return;
166
+ }
167
+ }
168
+ }
169
+ }
170
+
171
+ if (room.mode === RoomMode.MessagesAgainstPersona) {
172
+ if (!room.judge_persona_id) {
173
+ ctx.showNotification("No judge configured for this room", "warn");
174
+ return;
175
+ }
176
+ const human = await ctx.ei.getHuman();
177
+ const humanName =
178
+ human.settings?.name_display ||
179
+ human.facts?.find((f) => f.name === "Nickname/Preferred Name")?.description ||
180
+ "You";
181
+ ctx.showOverlay((hideOverlay) => (
182
+ <MAPScoreOverlay
183
+ roomId={roomId}
184
+ roomName={room.display_name}
185
+ messages={ctx.ei.roomMessages()}
186
+ activeNodeId={room.active_node_id ?? ""}
187
+ activeRoomPath={ctx.ei.roomActivePath()}
188
+ personas={ctx.ei.personas()}
189
+ judgePersonaId={room.judge_persona_id!}
190
+ humanName={humanName}
191
+ onDismiss={hideOverlay}
192
+ />
193
+ ), ctx.renderer);
194
+ return;
195
+ }
196
+
197
+ ctx.showNotification("Unknown room mode", "warn");
198
+ return;
199
+ }
200
+
15
201
  if (!personaId) {
16
- ctx.showNotification("No active persona", "error");
202
+ ctx.showNotification("No active chat", "warn");
17
203
  return;
18
204
  }
19
205