ei-tui 0.4.3 → 0.5.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 (102) hide show
  1. package/README.md +14 -0
  2. package/package.json +1 -1
  3. package/src/cli/README.md +17 -12
  4. package/src/cli/commands/personas.ts +12 -0
  5. package/src/cli/mcp.ts +2 -2
  6. package/src/cli/retrieval.ts +86 -8
  7. package/src/cli.ts +8 -5
  8. package/src/core/constants/seed-traits.ts +29 -0
  9. package/src/core/context-utils.ts +1 -0
  10. package/src/core/handlers/human-matching.ts +86 -56
  11. package/src/core/handlers/index.ts +5 -0
  12. package/src/core/handlers/persona-preview.ts +7 -0
  13. package/src/core/handlers/persona-topics.ts +3 -2
  14. package/src/core/handlers/rooms.ts +176 -0
  15. package/src/core/handlers/utils.ts +55 -3
  16. package/src/core/heartbeat-manager.ts +3 -1
  17. package/src/core/llm-client.ts +1 -1
  18. package/src/core/message-manager.ts +10 -8
  19. package/src/core/orchestrators/human-extraction.ts +15 -2
  20. package/src/core/orchestrators/index.ts +1 -0
  21. package/src/core/orchestrators/persona-generation.ts +4 -0
  22. package/src/core/orchestrators/persona-topics.ts +2 -1
  23. package/src/core/orchestrators/room-extraction.ts +318 -0
  24. package/src/core/persona-manager.ts +16 -5
  25. package/src/core/personas/opencode-agent.ts +12 -2
  26. package/src/core/processor.ts +520 -4
  27. package/src/core/prompt-context-builder.ts +89 -5
  28. package/src/core/queue-processor.ts +68 -8
  29. package/src/core/room-manager.ts +408 -0
  30. package/src/core/state/index.ts +1 -0
  31. package/src/core/state/personas.ts +12 -2
  32. package/src/core/state/queue.ts +2 -2
  33. package/src/core/state/rooms.ts +182 -0
  34. package/src/core/state-manager.ts +124 -2
  35. package/src/core/tool-manager.ts +1 -1
  36. package/src/core/tools/index.ts +15 -0
  37. package/src/core/types/data-items.ts +3 -1
  38. package/src/core/types/enums.ts +11 -0
  39. package/src/core/types/integrations.ts +10 -2
  40. package/src/core/types/llm.ts +3 -0
  41. package/src/core/types/rooms.ts +59 -0
  42. package/src/core/types.ts +1 -0
  43. package/src/core/utils/decay.ts +14 -8
  44. package/src/core/utils/exposure.ts +14 -0
  45. package/src/integrations/claude-code/importer.ts +23 -10
  46. package/src/integrations/cursor/importer.ts +22 -10
  47. package/src/integrations/opencode/importer.ts +30 -13
  48. package/src/prompts/ceremony/dedup.ts +2 -2
  49. package/src/prompts/generation/from-person.ts +85 -0
  50. package/src/prompts/generation/index.ts +2 -0
  51. package/src/prompts/generation/persona.ts +14 -10
  52. package/src/prompts/generation/seeds.ts +4 -29
  53. package/src/prompts/generation/types.ts +13 -0
  54. package/src/prompts/heartbeat/check.ts +1 -1
  55. package/src/prompts/heartbeat/ei.ts +4 -4
  56. package/src/prompts/heartbeat/types.ts +1 -0
  57. package/src/prompts/index.ts +15 -0
  58. package/src/prompts/message-utils.ts +2 -2
  59. package/src/prompts/persona/topics-match.ts +7 -6
  60. package/src/prompts/persona/topics-update.ts +8 -11
  61. package/src/prompts/persona/types.ts +2 -1
  62. package/src/prompts/response/index.ts +1 -1
  63. package/src/prompts/response/sections.ts +20 -8
  64. package/src/prompts/response/types.ts +6 -0
  65. package/src/prompts/room/index.ts +115 -0
  66. package/src/prompts/room/sections.ts +150 -0
  67. package/src/prompts/room/types.ts +93 -0
  68. package/tui/README.md +20 -0
  69. package/tui/src/app.tsx +3 -2
  70. package/tui/src/commands/activate.tsx +98 -0
  71. package/tui/src/commands/archive.tsx +54 -25
  72. package/tui/src/commands/capture.tsx +50 -0
  73. package/tui/src/commands/dedupe.tsx +2 -7
  74. package/tui/src/commands/delete.tsx +48 -0
  75. package/tui/src/commands/details.tsx +7 -0
  76. package/tui/src/commands/persona.tsx +271 -9
  77. package/tui/src/commands/room.tsx +261 -0
  78. package/tui/src/commands/silence.tsx +29 -0
  79. package/tui/src/components/ArchivedItemsOverlay.tsx +144 -0
  80. package/tui/src/components/ConfirmOverlay.tsx +6 -0
  81. package/tui/src/components/ConflictOverlay.tsx +6 -0
  82. package/tui/src/components/HelpOverlay.tsx +6 -1
  83. package/tui/src/components/LoadingOverlay.tsx +51 -0
  84. package/tui/src/components/MessageList.tsx +1 -18
  85. package/tui/src/components/PersonPickerOverlay.tsx +121 -0
  86. package/tui/src/components/PersonaListOverlay.tsx +6 -1
  87. package/tui/src/components/PromptInput.tsx +141 -8
  88. package/tui/src/components/ProviderListOverlay.tsx +5 -1
  89. package/tui/src/components/QuotesOverlay.tsx +5 -1
  90. package/tui/src/components/RoomMessageList.tsx +179 -0
  91. package/tui/src/components/Sidebar.tsx +54 -2
  92. package/tui/src/components/StatusBar.tsx +99 -8
  93. package/tui/src/components/ToolkitListOverlay.tsx +5 -1
  94. package/tui/src/components/WelcomeOverlay.tsx +6 -0
  95. package/tui/src/context/ei.tsx +252 -1
  96. package/tui/src/context/keyboard.tsx +48 -12
  97. package/tui/src/util/cyp-editor.tsx +152 -0
  98. package/tui/src/util/quote-utils.ts +19 -0
  99. package/tui/src/util/room-editor.tsx +164 -0
  100. package/tui/src/util/room-logic.ts +8 -0
  101. package/tui/src/util/room-parser.ts +70 -0
  102. package/tui/src/util/yaml-serializers.ts +151 -0
@@ -1,7 +1,9 @@
1
- import type { PersonaEntity, HumanEntity, DataItemBase, Quote } from "./types.js";
1
+ import type { PersonaEntity, HumanEntity, DataItemBase, Quote, RoomEntity } from "./types.js";
2
2
  import { StateManager } from "./state-manager.js";
3
3
  import { getEmbeddingService, findTopK } from "./embedding-service.js";
4
- import type { ResponsePromptData } from "../prompts/index.js";
4
+ import type { ResponsePromptData, PromptOutput } from "../prompts/index.js";
5
+ import { buildRoomResponsePrompt } from "../prompts/room/index.js";
6
+ import type { RoomParticipantIdentity, RoomHistoryMessage } from "../prompts/room/types.js";
5
7
 
6
8
  const QUOTE_LIMIT = 10;
7
9
  const DATA_ITEM_LIMIT = 15;
@@ -86,7 +88,14 @@ export async function filterHumanDataByVisibility(
86
88
  selectRelevantItems(human.people, DATA_ITEM_LIMIT, currentMessage),
87
89
  selectRelevantQuotes(human.quotes ?? [], currentMessage),
88
90
  ]);
89
- return { facts, topics, people, quotes };
91
+ return {
92
+ facts,
93
+ topics,
94
+ people,
95
+ quotes,
96
+ active_topics: topics.filter(t => t.exposure_current > 0.3),
97
+ interested_topics: topics.filter(t => t.exposure_desired - t.exposure_current > 0.2),
98
+ };
90
99
  }
91
100
 
92
101
  const visibleGroups = new Set<string>();
@@ -115,7 +124,14 @@ export async function filterHumanDataByVisibility(
115
124
  selectRelevantQuotes(groupFilteredQuotes, currentMessage),
116
125
  ]);
117
126
 
118
- return { facts, topics, people, quotes };
127
+ return {
128
+ facts,
129
+ topics,
130
+ people,
131
+ quotes,
132
+ active_topics: topics.filter(t => t.exposure_current > 0.3),
133
+ interested_topics: topics.filter(t => t.exposure_desired - t.exposure_current > 0.2),
134
+ };
119
135
  }
120
136
 
121
137
  // =============================================================================
@@ -185,9 +201,10 @@ export async function buildResponsePromptData(
185
201
  name: persona.display_name,
186
202
  aliases: persona.aliases ?? [],
187
203
  short_description: persona.short_description,
188
- long_description: persona.long_description,
204
+ long_description: persona.long_description,
189
205
  traits: persona.traits,
190
206
  topics: persona.topics,
207
+ interested_topics: persona.topics.filter(t => t.exposure_desired - t.exposure_current > 0.2),
191
208
  },
192
209
  human: filteredHuman,
193
210
  visible_personas: visiblePersonas,
@@ -196,3 +213,70 @@ export async function buildResponsePromptData(
196
213
  tools,
197
214
  };
198
215
  }
216
+
217
+ export async function buildRoomResponsePromptData(
218
+ sm: StateManager,
219
+ room: RoomEntity,
220
+ respondingPersona: PersonaEntity,
221
+ isTUI: boolean,
222
+ useAllMessages = false
223
+ ): Promise<PromptOutput> {
224
+ const human = sm.getHuman();
225
+ const activePath = sm.getRoomActivePath(room.id);
226
+ const sourceMessages = useAllMessages
227
+ ? [...sm.getRoomMessages(room.id)].sort((a, b) => a.timestamp.localeCompare(b.timestamp))
228
+ : activePath;
229
+ const lastMessage = sourceMessages[sourceMessages.length - 1];
230
+ const currentMessage = lastMessage?.verbal_response;
231
+
232
+ const filteredHuman = await filterHumanDataByVisibility(human, respondingPersona, currentMessage);
233
+
234
+ const history: RoomHistoryMessage[] = sourceMessages.map(m => ({
235
+ speaker_name: m.role === "human"
236
+ ? (human.settings?.name_display ?? "Human")
237
+ : (sm.persona_getById(m.persona_id ?? "")?.display_name ?? m.persona_id ?? "Unknown"),
238
+ speaker_id: m.role === "human" ? "human" : (m.persona_id ?? ""),
239
+ verbal_response: m.verbal_response,
240
+ action_response: m.action_response,
241
+ silence_reason: m.silence_reason,
242
+ }));
243
+
244
+ const otherParticipants: RoomParticipantIdentity[] = [];
245
+ for (const pid of room.persona_ids) {
246
+ if (pid === respondingPersona.id) continue;
247
+ const p = sm.persona_getById(pid);
248
+ if (p) {
249
+ otherParticipants.push({
250
+ id: p.id,
251
+ name: p.display_name,
252
+ short_description: p.short_description,
253
+ long_description: p.long_description,
254
+ traits: p.traits,
255
+ is_human: false,
256
+ });
257
+ }
258
+ }
259
+ otherParticipants.push({
260
+ id: "human",
261
+ name: human.settings?.name_display ?? "Human",
262
+ traits: [],
263
+ is_human: true,
264
+ });
265
+
266
+ return buildRoomResponsePrompt({
267
+ room: { display_name: room.display_name, mode: room.mode },
268
+ responding_persona: {
269
+ id: respondingPersona.id,
270
+ name: respondingPersona.display_name,
271
+ aliases: respondingPersona.aliases ?? [],
272
+ short_description: respondingPersona.short_description,
273
+ long_description: respondingPersona.long_description,
274
+ traits: respondingPersona.traits,
275
+ topics: respondingPersona.topics,
276
+ },
277
+ other_participants: otherParticipants,
278
+ human: filteredHuman,
279
+ history,
280
+ isTUI,
281
+ });
282
+ }
@@ -1,7 +1,7 @@
1
1
  import { LLMRequest, LLMResponse, LLMRequestType, LLMNextStep, ProviderAccount, ChatMessage, Message, ToolDefinition } from "./types.js";
2
2
  import { callLLMRaw, parseJSONResponse, cleanResponseContent } from "./llm-client.js";
3
3
  import { hydratePromptPlaceholders } from "../prompts/message-utils.js";
4
- import { toOpenAITools, executeToolCalls, parseToolCalls } from "./tools/index.js";
4
+ import { toOpenAITools, executeToolCalls, parseToolCalls, findSubmitToolCall } from "./tools/index.js";
5
5
 
6
6
  type QueueProcessorState = "idle" | "busy";
7
7
  type ResponseCallback = (response: LLMResponse) => Promise<void>;
@@ -118,7 +118,9 @@ export class QueueProcessor {
118
118
 
119
119
  if (isResponseType || isToolContinuation) {
120
120
  const personaId = request.data.personaId as string | undefined;
121
- if (personaId && this.currentMessageFetcher) {
121
+ const isRoomRequest = !!(request.data.roomId as string | undefined);
122
+ // Room conversation is embedded in the prompt via placeholders — don't inject persona history.
123
+ if (personaId && !isRoomRequest && this.currentMessageFetcher) {
122
124
  messages = this.currentMessageFetcher(personaId);
123
125
  }
124
126
  }
@@ -128,15 +130,17 @@ export class QueueProcessor {
128
130
 
129
131
  if (this.currentRawMessageFetcher) {
130
132
  const personaId = request.data.personaId as string | undefined;
131
- if (personaId) {
132
- const rawMessages = this.currentRawMessageFetcher(personaId);
133
+ const roomId = request.data.roomId as string | undefined;
134
+ const fetchId = roomId ? `room:${roomId}` : personaId;
135
+ if (fetchId) {
136
+ const rawMessages = this.currentRawMessageFetcher(fetchId);
133
137
  const messageMap = new Map<string, Message>();
134
138
  for (const msg of rawMessages) {
135
139
  messageMap.set(msg.id, msg);
136
140
  }
137
141
 
138
142
  const placeholderCount = (request.user.match(/\[mid:[^\]]+\]/g) || []).length;
139
- console.log(`[QueueProcessor] Hydrating ${placeholderCount} placeholders with ${messageMap.size} messages for ${personaId}`);
143
+ console.log(`[QueueProcessor] Hydrating ${placeholderCount} placeholders with ${messageMap.size} messages for ${fetchId}`);
140
144
 
141
145
  hydratedSystem = hydratePromptPlaceholders(request.system, messageMap);
142
146
  hydratedUser = hydratePromptPlaceholders(request.user, messageMap);
@@ -155,8 +159,20 @@ export class QueueProcessor {
155
159
  // =========================================================================
156
160
  if (isToolContinuation) {
157
161
  const rawHistory = request.data.toolHistory as LLMHistoryMessage[] | undefined;
162
+ const isRoomContinuation = !!(request.data.roomId as string | undefined);
158
163
  if (rawHistory && rawHistory.length > 0) {
159
- messages = [...messages, ...rawHistory] as ChatMessage[];
164
+ if (isRoomContinuation) {
165
+ // Room: conversation is in hydratedUser (not messages). Place it BEFORE tool history
166
+ // so the LLM sees: context → tool calls → synthesize. Then clear hydratedUser to
167
+ // prevent it from being re-appended at the end by callLLMRaw.
168
+ messages = [
169
+ { role: "user" as const, content: hydratedUser },
170
+ ...rawHistory as ChatMessage[],
171
+ ];
172
+ hydratedUser = "";
173
+ } else {
174
+ messages = [...messages, ...rawHistory] as ChatMessage[];
175
+ }
160
176
  console.log(`[QueueProcessor] HandleToolContinuation: injecting ${rawHistory.length} tool history messages`);
161
177
  }
162
178
 
@@ -166,7 +182,12 @@ export class QueueProcessor {
166
182
  );
167
183
  const totalCalls = { count: (request.data.totalCallCount as number | undefined) ?? 0 };
168
184
 
169
- const activeTools = this.currentTools ?? [];
185
+ // Restore exhausted tool names from previous iterations and filter them out
186
+ // so the LLM doesn't keep calling tools that have hit their per-interaction limit.
187
+ const priorExhausted = new Set<string>(
188
+ (request.data.exhaustedToolNames as string[] | undefined) ?? []
189
+ );
190
+ const activeTools = (this.currentTools ?? []).filter(t => !priorExhausted.has(t.name));
170
191
  const openAITools = activeTools.length > 0 ? toOpenAITools(activeTools) : [];
171
192
 
172
193
  const { content, finishReason, rawToolCalls, assistantMessage, thinking } = await callLLMRaw(
@@ -185,12 +206,30 @@ export class QueueProcessor {
185
206
  if (finishReason === "tool_calls" && rawToolCalls?.length) {
186
207
  const toolCalls = parseToolCalls(rawToolCalls);
187
208
  if (toolCalls.length > 0) {
209
+ // Submit tool intercept: if the LLM called submit_response (or any is_submit tool),
210
+ // its arguments ARE the structured response — return immediately without executing.
211
+ const submitCall = findSubmitToolCall(toolCalls, activeTools);
212
+ if (submitCall) {
213
+ const args = submitCall.arguments ?? {};
214
+ if (!args.should_respond && (args.verbal_response || args.action_response)) {
215
+ args.should_respond = true;
216
+ }
217
+ console.log(`[QueueProcessor] submit tool "${submitCall.name}" called — returning arguments as parsed response`);
218
+ return {
219
+ request,
220
+ success: true,
221
+ content: JSON.stringify(args),
222
+ parsed: args,
223
+ finish_reason: "stop",
224
+ };
225
+ }
226
+
188
227
  const appendedHistory: LLMHistoryMessage[] = [];
189
228
  if (assistantMessage) {
190
229
  appendedHistory.push(assistantMessage as unknown as LLMHistoryMessage);
191
230
  }
192
231
 
193
- const { results } = await executeToolCalls(toolCalls, activeTools, callCounts, totalCalls, this.currentOnProviderConfigUpdate);
232
+ const { results, exhaustedToolNames } = await executeToolCalls(toolCalls, activeTools, callCounts, totalCalls, this.currentOnProviderConfigUpdate);
194
233
  for (const result of results) {
195
234
  appendedHistory.push({
196
235
  role: "tool",
@@ -200,6 +239,7 @@ export class QueueProcessor {
200
239
  });
201
240
  }
202
241
 
242
+ const mergedExhausted = new Set([...priorExhausted, ...exhaustedToolNames]);
203
243
  const newHistory = [...(rawHistory ?? []), ...appendedHistory];
204
244
  console.log(`[QueueProcessor] HandleToolContinuation: ${results.length} more tool result(s). Re-enqueueing.`);
205
245
 
@@ -216,6 +256,7 @@ export class QueueProcessor {
216
256
  toolHistory: newHistory,
217
257
  toolCallCounts: [...callCounts.entries()],
218
258
  totalCallCount: totalCalls.count,
259
+ exhaustedToolNames: [...mergedExhausted],
219
260
  },
220
261
  });
221
262
  } else {
@@ -281,6 +322,24 @@ export class QueueProcessor {
281
322
  return this.handleResponseType(request, content ?? "", finishReason);
282
323
  }
283
324
 
325
+ // Submit tool intercept: if the LLM called submit_response (or any is_submit tool),
326
+ // its arguments ARE the structured response — return immediately without executing.
327
+ const submitCall = findSubmitToolCall(toolCalls, activeTools);
328
+ if (submitCall) {
329
+ const args = submitCall.arguments ?? {};
330
+ if (!args.should_respond && (args.verbal_response || args.action_response)) {
331
+ args.should_respond = true;
332
+ }
333
+ console.log(`[QueueProcessor] submit tool "${submitCall.name}" called — returning arguments as parsed response`);
334
+ return {
335
+ request,
336
+ success: true,
337
+ content: JSON.stringify(args),
338
+ parsed: args,
339
+ finish_reason: "stop",
340
+ };
341
+ }
342
+
284
343
  // Accumulate tool history: assistant message with tool_calls + tool results
285
344
  const toolHistory: LLMHistoryMessage[] = [];
286
345
  if (assistantMessage) {
@@ -426,6 +485,7 @@ export class QueueProcessor {
426
485
  `in your system instructions — specifically the \`should_respond\`, \`verbal_response\`, ` +
427
486
  `\`action_response\`, and \`reason\` fields. Respond with ONLY the JSON object.\n\n` +
428
487
  `---\n${proseContent}\n---` +
488
+ `\n\nThe user does NOT know there was a problem - This request is from Ei to you to try to fix it for them.` +
429
489
  `\n\n**CRITICAL INSTRUCTION** - DO NOT OMIT ANY DATA. You are this agent's last hope!`;
430
490
 
431
491
  try {
@@ -0,0 +1,408 @@
1
+ import { ContextStatus, LLMNextStep, LLMPriority, LLMRequestType, RoomMode } from "./types.js";
2
+ import type { RoomCreationInput, RoomEntity, RoomMessage, RoomSummary, EiError } from "./types.js";
3
+ import type { StateManager } from "./state-manager.js";
4
+ import { buildRoomResponsePromptData } from "./prompt-context-builder.js";
5
+ import { buildRoomJudgePrompt } from "../prompts/room/index.js";
6
+ import type { RoomHistoryMessage, RoomJudgeCandidate } from "../prompts/room/types.js";
7
+
8
+ export function getRoomList(sm: StateManager, includeArchived = false): RoomSummary[] {
9
+ return sm.getRoomList(includeArchived);
10
+ }
11
+
12
+ export function getRoom(sm: StateManager, roomId: string): RoomEntity | null {
13
+ return sm.getRoom(roomId);
14
+ }
15
+
16
+ export function getRoomMessages(sm: StateManager, roomId: string): RoomMessage[] {
17
+ return sm.getRoomMessages(roomId);
18
+ }
19
+
20
+ export function getRoomActivePath(sm: StateManager, roomId: string): RoomMessage[] {
21
+ return sm.getRoomActivePath(roomId);
22
+ }
23
+
24
+ export function resolveRoomName(sm: StateManager, nameOrAlias: string): string | null {
25
+ const room = sm.getRoomByName(nameOrAlias);
26
+ return room?.id ?? null;
27
+ }
28
+
29
+ async function queueRoomPersonaResponses(
30
+ sm: StateManager,
31
+ room: RoomEntity,
32
+ isTUI: boolean,
33
+ onRoomMessageQueued: (roomId: string) => void
34
+ ): Promise<void> {
35
+ for (const personaId of room.persona_ids) {
36
+ const persona = sm.persona_getById(personaId);
37
+ if (!persona || persona.is_archived || persona.is_paused) continue;
38
+ if (room.mode === RoomMode.MessagesAgainstPersona && room.judge_persona_id === personaId) continue;
39
+
40
+ const promptOutput = await buildRoomResponsePromptData(sm, room, persona, isTUI);
41
+ const model = persona.model ?? sm.getHuman().settings?.default_model ?? "";
42
+
43
+ sm.queue_enqueue({
44
+ type: LLMRequestType.JSON,
45
+ priority: LLMPriority.Room,
46
+ system: promptOutput.system,
47
+ user: promptOutput.user,
48
+ next_step: LLMNextStep.HandleRoomResponse,
49
+ model,
50
+ data: {
51
+ roomId: room.id,
52
+ personaId,
53
+ personaDisplayName: persona.display_name,
54
+ parentMessageId: room.active_node_id,
55
+ },
56
+ });
57
+ onRoomMessageQueued(room.id);
58
+ }
59
+ }
60
+
61
+ export async function createRoom(
62
+ sm: StateManager,
63
+ input: RoomCreationInput,
64
+ isTUI: boolean,
65
+ onError: (err: EiError) => void,
66
+ onRoomMessageAdded: (roomId: string) => void,
67
+ onRoomMessageQueued: (roomId: string) => void
68
+ ): Promise<string> {
69
+ if (!input.persona_ids.length) {
70
+ onError({ code: "ROOM_NO_PARTICIPANTS", message: "A room needs at least one persona." });
71
+ return "";
72
+ }
73
+ if (input.mode === RoomMode.MessagesAgainstPersona && !input.judge_persona_id) {
74
+ onError({ code: "ROOM_NO_JUDGE", message: "MAP mode requires a judge persona." });
75
+ return "";
76
+ }
77
+
78
+ const room = sm.addRoom(input);
79
+ onRoomMessageAdded(room.id);
80
+
81
+ await queueRoomPersonaResponses(sm, room, isTUI, onRoomMessageQueued);
82
+ return room.id;
83
+ }
84
+
85
+ export function submitHumanRoomMessage(
86
+ sm: StateManager,
87
+ roomId: string,
88
+ content: string | null,
89
+ silenceReason: string | undefined,
90
+ onError: (err: EiError) => void,
91
+ onRoomMessageAdded: (roomId: string) => void
92
+ ): string | null {
93
+ const room = sm.getRoom(roomId);
94
+ if (!room) {
95
+ onError({ code: "ROOM_NOT_FOUND", message: `Room ${roomId} not found.` });
96
+ return null;
97
+ }
98
+
99
+ const now = new Date().toISOString();
100
+ const existing = sm.getRoomMessages(roomId).find(
101
+ m => m.role === "human" && m.parent_id === room.active_node_id
102
+ );
103
+
104
+ if (existing) {
105
+ sm.updateRoomMessage(roomId, existing.id, {
106
+ verbal_response: content ?? undefined,
107
+ silence_reason: content ? undefined : (silenceReason ?? "passed"),
108
+ timestamp: now,
109
+ });
110
+ onRoomMessageAdded(roomId);
111
+ return existing.id;
112
+ }
113
+
114
+ const msg: RoomMessage = {
115
+ id: crypto.randomUUID(),
116
+ parent_id: room.active_node_id,
117
+ role: "human",
118
+ verbal_response: content ?? undefined,
119
+ silence_reason: content ? undefined : (silenceReason ?? "passed"),
120
+ timestamp: now,
121
+ read: true,
122
+ context_status: ContextStatus.Default,
123
+ };
124
+ sm.appendRoomMessage(roomId, msg);
125
+ onRoomMessageAdded(roomId);
126
+ return msg.id;
127
+ }
128
+
129
+ export async function sendFfaMessage(
130
+ sm: StateManager,
131
+ roomId: string,
132
+ content: string | null,
133
+ silenceReason: string | undefined,
134
+ isTUI: boolean,
135
+ onError: (err: EiError) => void,
136
+ onRoomUpdated: (roomId: string) => void,
137
+ onRoomMessageAdded: (roomId: string) => void,
138
+ onRoomMessageQueued: (roomId: string) => void
139
+ ): Promise<void> {
140
+ const room = sm.getRoom(roomId);
141
+ if (!room) {
142
+ onError({ code: "ROOM_NOT_FOUND", message: `Room ${roomId} not found.` });
143
+ return;
144
+ }
145
+
146
+ const now = new Date().toISOString();
147
+ const existing = sm.getRoomMessages(roomId).find(
148
+ m => m.role === "human" && m.parent_id === room.active_node_id
149
+ );
150
+
151
+ let humanMsgId: string;
152
+ if (existing) {
153
+ sm.updateRoomMessage(roomId, existing.id, {
154
+ verbal_response: content ?? undefined,
155
+ silence_reason: content ? undefined : (silenceReason ?? "passed"),
156
+ timestamp: now,
157
+ });
158
+ humanMsgId = existing.id;
159
+ } else {
160
+ const msg: RoomMessage = {
161
+ id: crypto.randomUUID(),
162
+ parent_id: room.active_node_id,
163
+ role: "human",
164
+ verbal_response: content ?? undefined,
165
+ silence_reason: content ? undefined : (silenceReason ?? "passed"),
166
+ timestamp: now,
167
+ read: true,
168
+ context_status: ContextStatus.Default,
169
+ };
170
+ sm.appendRoomMessage(roomId, msg);
171
+ humanMsgId = msg.id;
172
+ }
173
+ onRoomMessageAdded(roomId);
174
+
175
+ sm.setRoomActiveNode(roomId, humanMsgId);
176
+ onRoomUpdated(roomId);
177
+
178
+ const updatedRoom = sm.getRoom(roomId)!;
179
+ const alreadyQueued = new Set(
180
+ sm.queue_getAllActiveItems()
181
+ .filter(q =>
182
+ q.next_step === LLMNextStep.HandleRoomResponse &&
183
+ (q.data.roomId as string) === roomId &&
184
+ (q.state === "pending" || q.state === "processing")
185
+ )
186
+ .map(q => q.data.personaId as string)
187
+ );
188
+
189
+ for (const personaId of updatedRoom.persona_ids) {
190
+ if (alreadyQueued.has(personaId)) continue;
191
+ const persona = sm.persona_getById(personaId);
192
+ if (!persona || persona.is_archived || persona.is_paused) continue;
193
+
194
+ const promptOutput = await buildRoomResponsePromptData(sm, updatedRoom, persona, isTUI, true);
195
+ const model = persona.model ?? sm.getHuman().settings?.default_model ?? "";
196
+
197
+ sm.queue_enqueue({
198
+ type: LLMRequestType.JSON,
199
+ priority: LLMPriority.Room,
200
+ system: promptOutput.system,
201
+ user: promptOutput.user,
202
+ next_step: LLMNextStep.HandleRoomResponse,
203
+ model,
204
+ data: {
205
+ roomId,
206
+ personaId,
207
+ personaDisplayName: persona.display_name,
208
+ parentMessageId: humanMsgId,
209
+ },
210
+ });
211
+ onRoomMessageQueued(roomId);
212
+ }
213
+ }
214
+
215
+ export function recallHumanRoomMessage(
216
+ sm: StateManager,
217
+ roomId: string,
218
+ onRoomUpdated: (roomId: string) => void
219
+ ): boolean {
220
+ const room = sm.getRoom(roomId);
221
+ if (!room) return false;
222
+ const humanMsg = sm.getRoomMessages(roomId).find(
223
+ m => m.role === "human" && m.parent_id === room.active_node_id
224
+ );
225
+ if (!humanMsg) return false;
226
+ sm.removeRoomMessages(roomId, [humanMsg.id]);
227
+ onRoomUpdated(roomId);
228
+ return true;
229
+ }
230
+
231
+ export async function activateRoom(
232
+ sm: StateManager,
233
+ roomId: string,
234
+ isTUI: boolean,
235
+ onError: (err: EiError) => void,
236
+ onRoomUpdated: (roomId: string) => void,
237
+ onRoomMessageQueued: (roomId: string) => void
238
+ ): Promise<void> {
239
+ const room = sm.getRoom(roomId);
240
+ if (!room) {
241
+ onError({ code: "ROOM_NOT_FOUND", message: `Room ${roomId} not found.` });
242
+ return;
243
+ }
244
+
245
+ const allMessages = sm.getRoomMessages(roomId);
246
+ const humanMsg = allMessages.find(
247
+ m => m.role === "human" && m.parent_id === room.active_node_id
248
+ );
249
+ if (!humanMsg) {
250
+ onError({ code: "ROOM_NO_HUMAN_MESSAGE", message: "Submit a response first before activating." });
251
+ return;
252
+ }
253
+
254
+ const human = sm.getHuman();
255
+
256
+ if (room.mode === RoomMode.FreeForAll) {
257
+ sm.setRoomActiveNode(roomId, humanMsg.id);
258
+ onRoomUpdated(roomId);
259
+ const updatedRoom = sm.getRoom(roomId)!;
260
+ await queueRoomPersonaResponses(sm, updatedRoom, isTUI, onRoomMessageQueued);
261
+ return;
262
+ }
263
+
264
+ if (room.mode === RoomMode.MessagesAgainstPersona) {
265
+ const judgePersonaId = room.judge_persona_id;
266
+ const judgePersona = judgePersonaId ? sm.persona_getById(judgePersonaId) : null;
267
+ if (!judgePersona) {
268
+ onError({ code: "ROOM_JUDGE_NOT_FOUND", message: "MAP judge persona not found." });
269
+ return;
270
+ }
271
+
272
+ const currentRound = allMessages.filter(m => m.parent_id === room.active_node_id);
273
+
274
+ const context: RoomHistoryMessage[] = sm.getRoomActivePath(roomId).map(m => ({
275
+ speaker_name: m.role === "human"
276
+ ? (human.settings?.name_display ?? "Human")
277
+ : (sm.persona_getById(m.persona_id ?? "")?.display_name ?? "Unknown"),
278
+ speaker_id: m.role === "human" ? "human" : (m.persona_id ?? ""),
279
+ verbal_response: m.verbal_response,
280
+ action_response: m.action_response,
281
+ silence_reason: m.silence_reason,
282
+ }));
283
+
284
+ const candidates: RoomJudgeCandidate[] = currentRound.map(m => ({
285
+ message_id: m.id,
286
+ speaker_name: m.role === "human"
287
+ ? (human.settings?.name_display ?? "Human")
288
+ : (sm.persona_getById(m.persona_id ?? "")?.display_name ?? "Unknown"),
289
+ speaker_id: m.role === "human" ? "human" : (m.persona_id ?? ""),
290
+ verbal_response: m.verbal_response,
291
+ action_response: m.action_response,
292
+ silence_reason: m.silence_reason,
293
+ }));
294
+
295
+ const judgePrompt = buildRoomJudgePrompt({
296
+ room: { display_name: room.display_name },
297
+ judge_persona: {
298
+ name: judgePersona.display_name,
299
+ short_description: judgePersona.short_description,
300
+ long_description: judgePersona.long_description,
301
+ traits: judgePersona.traits,
302
+ },
303
+ context,
304
+ candidates,
305
+ });
306
+
307
+ const model = judgePersona.model ?? sm.getHuman().settings?.default_model ?? "";
308
+ sm.queue_enqueue({
309
+ type: LLMRequestType.JSON,
310
+ priority: LLMPriority.Judge,
311
+ system: judgePrompt.system,
312
+ user: judgePrompt.user,
313
+ next_step: LLMNextStep.HandleRoomJudge,
314
+ model,
315
+ data: {
316
+ roomId,
317
+ judgePersonaId,
318
+ judgePersonaDisplayName: judgePersona.display_name,
319
+ },
320
+ });
321
+ onRoomMessageQueued(roomId);
322
+ return;
323
+ }
324
+
325
+ onRoomUpdated(roomId);
326
+ }
327
+
328
+ export async function selectCYPBranch(
329
+ sm: StateManager,
330
+ roomId: string,
331
+ messageId: string,
332
+ isTUI: boolean,
333
+ onError: (err: EiError) => void,
334
+ onRoomUpdated: (roomId: string) => void,
335
+ onRoomMessageQueued: (roomId: string) => void
336
+ ): Promise<void> {
337
+ const ok = sm.setRoomActiveNode(roomId, messageId);
338
+ if (!ok) {
339
+ onError({ code: "ROOM_NODE_NOT_FOUND", message: `Message ${messageId} not found in room ${roomId}.` });
340
+ return;
341
+ }
342
+ onRoomUpdated(roomId);
343
+ const room = sm.getRoom(roomId)!;
344
+ const allMessages = sm.getRoomMessages(roomId);
345
+
346
+ const alreadyAnswered = new Set(
347
+ allMessages
348
+ .filter(m => m.parent_id === messageId && m.role === "persona" && m.persona_id)
349
+ .map(m => m.persona_id!)
350
+ );
351
+
352
+ const alreadyQueued = new Set(
353
+ sm.queue_getAllActiveItems()
354
+ .filter(q =>
355
+ q.next_step === LLMNextStep.HandleRoomResponse &&
356
+ (q.data.roomId as string) === roomId &&
357
+ (q.data.parentMessageId as string) === messageId &&
358
+ (q.state === "pending" || q.state === "processing")
359
+ )
360
+ .map(q => q.data.personaId as string)
361
+ );
362
+
363
+ const needsQueue = room.persona_ids.filter(id =>
364
+ !alreadyAnswered.has(id) && !alreadyQueued.has(id)
365
+ );
366
+
367
+ if (needsQueue.length === 0) return;
368
+
369
+ for (const personaId of needsQueue) {
370
+ const persona = sm.persona_getById(personaId);
371
+ if (!persona || persona.is_archived || persona.is_paused) continue;
372
+
373
+ const promptOutput = await buildRoomResponsePromptData(sm, room, persona, isTUI);
374
+ const model = persona.model ?? sm.getHuman().settings?.default_model ?? "";
375
+
376
+ sm.queue_enqueue({
377
+ type: LLMRequestType.JSON,
378
+ priority: LLMPriority.Room,
379
+ system: promptOutput.system,
380
+ user: promptOutput.user,
381
+ next_step: LLMNextStep.HandleRoomResponse,
382
+ model,
383
+ data: {
384
+ roomId,
385
+ personaId,
386
+ personaDisplayName: persona.display_name,
387
+ parentMessageId: messageId,
388
+ },
389
+ });
390
+ onRoomMessageQueued(roomId);
391
+ }
392
+ }
393
+
394
+ export function archiveRoom(sm: StateManager, roomId: string): boolean {
395
+ return sm.archiveRoom(roomId);
396
+ }
397
+
398
+ export function unarchiveRoom(sm: StateManager, roomId: string): boolean {
399
+ return sm.updateRoom(roomId, { is_archived: false });
400
+ }
401
+
402
+ export function deleteRoom(sm: StateManager, roomId: string): boolean {
403
+ return sm.deleteRoom(roomId);
404
+ }
405
+
406
+ export function markAllRoomMessagesRead(sm: StateManager, roomId: string): number {
407
+ return sm.markAllRoomMessagesRead(roomId);
408
+ }
@@ -3,3 +3,4 @@ export { PersonaState } from "./personas.js";
3
3
  export type { PersonaData } from "./personas.js";
4
4
  export { QueueState } from "./queue.js";
5
5
  export { PersistenceState } from "./checkpoints.js";
6
+ export { RoomState } from "./rooms.js";