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
@@ -9,15 +9,10 @@ import type { StateManager } from "../state-manager.js";
9
9
  import type { ItemMatchResult, ExposureImpact, TopicUpdateResult, PersonUpdateResult } from "../../prompts/human/types.js";
10
10
  import { queueTopicUpdate, queuePersonUpdate, type ExtractionContext } from "../orchestrators/index.js";
11
11
  import { getEmbeddingService, getTopicEmbeddingText, getPersonEmbeddingText } from "../embedding-service.js";
12
+ import { calculateExposureCurrent } from "../utils/exposure.js";
12
13
 
13
- function mergeGroups(personaGroup: string | null, isNewItem: boolean, existing: string[] | undefined): string[] | undefined {
14
- if (!personaGroup) return existing;
15
- if (isNewItem) return [personaGroup];
16
- const groups = new Set(existing ?? []);
17
- groups.add(personaGroup);
18
- return Array.from(groups);
19
- }
20
- import { resolveMessageWindow, getMessageText } from "./utils.js";
14
+
15
+ import { resolveMessageWindow, getMessageText, normalizeRoomMessages } from "./utils.js";
21
16
 
22
17
  export function handleTopicMatch(response: LLMResponse, state: StateManager): void {
23
18
  const result = response.parsed as ItemMatchResult | undefined;
@@ -28,6 +23,7 @@ export function handleTopicMatch(response: LLMResponse, state: StateManager): vo
28
23
 
29
24
  const personaId = response.request.data.personaId as string;
30
25
  const personaDisplayName = response.request.data.personaDisplayName as string;
26
+ const roomId = response.request.data.roomId as string | undefined;
31
27
  const { messages_context, messages_analyze } = resolveMessageWindow(response, state);
32
28
 
33
29
  let matched_guid = result.matched_guid;
@@ -51,6 +47,7 @@ export function handleTopicMatch(response: LLMResponse, state: StateManager): vo
51
47
  } = {
52
48
  personaId,
53
49
  personaDisplayName,
50
+ roomId,
54
51
  messages_context,
55
52
  messages_analyze,
56
53
  candidateName: response.request.data.candidateName as string,
@@ -73,6 +70,7 @@ export function handlePersonMatch(response: LLMResponse, state: StateManager): v
73
70
 
74
71
  const personaId = response.request.data.personaId as string;
75
72
  const personaDisplayName = response.request.data.personaDisplayName as string;
73
+ const roomId = response.request.data.roomId as string | undefined;
76
74
  const { messages_context, messages_analyze } = resolveMessageWindow(response, state);
77
75
 
78
76
  let matched_guid = result.matched_guid;
@@ -96,6 +94,7 @@ export function handlePersonMatch(response: LLMResponse, state: StateManager): v
96
94
  } = {
97
95
  personaId,
98
96
  personaDisplayName,
97
+ roomId,
99
98
  messages_context,
100
99
  messages_analyze,
101
100
  candidateName: response.request.data.candidateName as string,
@@ -121,6 +120,7 @@ export async function handleTopicUpdate(response: LLMResponse, state: StateManag
121
120
  const existingItemId = response.request.data.existingItemId as string | undefined;
122
121
  const personaId = response.request.data.personaId as string;
123
122
  const personaDisplayName = response.request.data.personaDisplayName as string;
123
+ const roomId = response.request.data.roomId as string | undefined;
124
124
  const candidateCategory = response.request.data.candidateCategory as string | undefined;
125
125
 
126
126
  if (!result.name || !result.description || result.sentiment === undefined) {
@@ -128,6 +128,9 @@ export async function handleTopicUpdate(response: LLMResponse, state: StateManag
128
128
  return;
129
129
  }
130
130
 
131
+ const personaIds = personaId.split("|").filter(Boolean);
132
+ const primaryId = personaIds[0] ?? personaId;
133
+
131
134
  const now = new Date().toISOString();
132
135
  const human = state.getHuman();
133
136
 
@@ -137,8 +140,11 @@ export async function handleTopicUpdate(response: LLMResponse, state: StateManag
137
140
  };
138
141
  const itemId = resolveItemId();
139
142
 
140
- const persona = state.persona_getById(personaId);
143
+ const persona = state.persona_getById(primaryId);
141
144
  const personaGroup = persona?.group_primary ?? null;
145
+ const allPersonaGroups = personaIds
146
+ .map(id => state.persona_getById(id)?.group_primary)
147
+ .filter((g): g is string => g != null);
142
148
 
143
149
  const existingTopic = isNewItem ? undefined : human.topics.find(t => t.id === existingItemId);
144
150
 
@@ -153,27 +159,34 @@ export async function handleTopicUpdate(response: LLMResponse, state: StateManag
153
159
  }
154
160
 
155
161
  const exposureImpact = result.exposure_impact as ExposureImpact | undefined;
162
+ const interestedPersonas = isNewItem
163
+ ? personaIds
164
+ : [...new Set([...(existingTopic?.interested_personas ?? []), ...personaIds])];
165
+ const personaGroupsMerged = isNewItem
166
+ ? (allPersonaGroups.length > 0 ? allPersonaGroups : existingTopic?.persona_groups)
167
+ : [...new Set([...(existingTopic?.persona_groups ?? []), ...allPersonaGroups])];
168
+
156
169
  const topic: Topic = {
157
170
  id: itemId,
158
171
  name: result.name,
159
172
  description: result.description,
160
173
  sentiment: result.sentiment,
161
174
  category: result.category ?? candidateCategory ?? existingTopic?.category,
162
- exposure_current: calculateExposureCurrent(exposureImpact),
175
+ exposure_current: calculateExposureCurrent(exposureImpact, existingTopic?.exposure_current ?? 0),
163
176
  exposure_desired: result.exposure_desired ?? 0.5,
164
177
  last_updated: now,
165
178
  last_mentioned: now,
166
- learned_by: isNewItem ? personaId : existingTopic?.learned_by,
167
- last_changed_by: personaId,
168
- interested_personas: isNewItem
169
- ? [personaId]
170
- : [...new Set([...(existingTopic?.interested_personas ?? []), personaId])],
171
- persona_groups: mergeGroups(personaGroup, isNewItem, existingTopic?.persona_groups),
179
+ learned_by: isNewItem ? primaryId : existingTopic?.learned_by,
180
+ last_changed_by: primaryId,
181
+ interested_personas: interestedPersonas,
182
+ persona_groups: personaGroupsMerged,
172
183
  embedding,
173
184
  };
174
185
  state.human_topic_upsert(topic);
175
186
 
176
- const allMessages = state.messages_get(personaId);
187
+ const allMessages = roomId
188
+ ? normalizeRoomMessages(state.getRoomMessages(roomId), state)
189
+ : state.messages_get(personaId);
177
190
  await validateAndStoreQuotes(result.quotes, allMessages, itemId, personaDisplayName, personaGroup, state);
178
191
 
179
192
  console.log(`[handleTopicUpdate] ${isNewItem ? "Created" : "Updated"} topic "${result.name}"`);
@@ -191,6 +204,7 @@ export async function handlePersonUpdate(response: LLMResponse, state: StateMana
191
204
  const existingItemId = response.request.data.existingItemId as string | undefined;
192
205
  const personaId = response.request.data.personaId as string;
193
206
  const personaDisplayName = response.request.data.personaDisplayName as string;
207
+ const roomId = response.request.data.roomId as string | undefined;
194
208
  const candidateRelationship = response.request.data.candidateRelationship as string | undefined;
195
209
 
196
210
  if (!result.name || !result.description || result.sentiment === undefined) {
@@ -198,6 +212,9 @@ export async function handlePersonUpdate(response: LLMResponse, state: StateMana
198
212
  return;
199
213
  }
200
214
 
215
+ const personaIds = personaId.split("|").filter(Boolean);
216
+ const primaryId = personaIds[0] ?? personaId;
217
+
201
218
  const now = new Date().toISOString();
202
219
  const human = state.getHuman();
203
220
 
@@ -207,8 +224,11 @@ export async function handlePersonUpdate(response: LLMResponse, state: StateMana
207
224
  };
208
225
  const itemId = resolveItemId();
209
226
 
210
- const persona = state.persona_getById(personaId);
227
+ const persona = state.persona_getById(primaryId);
211
228
  const personaGroup = persona?.group_primary ?? null;
229
+ const allPersonaGroups = personaIds
230
+ .map(id => state.persona_getById(id)?.group_primary)
231
+ .filter((g): g is string => g != null);
212
232
 
213
233
  const existingPerson = isNewItem ? undefined : human.people.find(p => p.id === existingItemId);
214
234
 
@@ -223,27 +243,34 @@ export async function handlePersonUpdate(response: LLMResponse, state: StateMana
223
243
  }
224
244
 
225
245
  const exposureImpact = result.exposure_impact as ExposureImpact | undefined;
246
+ const interestedPersonas = isNewItem
247
+ ? personaIds
248
+ : [...new Set([...(existingPerson?.interested_personas ?? []), ...personaIds])];
249
+ const personaGroupsMerged = isNewItem
250
+ ? (allPersonaGroups.length > 0 ? allPersonaGroups : existingPerson?.persona_groups)
251
+ : [...new Set([...(existingPerson?.persona_groups ?? []), ...allPersonaGroups])];
252
+
226
253
  const person: Person = {
227
254
  id: itemId,
228
255
  name: result.name,
229
256
  description: result.description,
230
257
  sentiment: result.sentiment,
231
258
  relationship: result.relationship ?? candidateRelationship ?? existingPerson?.relationship ?? "Unknown",
232
- exposure_current: calculateExposureCurrent(exposureImpact),
259
+ exposure_current: calculateExposureCurrent(exposureImpact, existingPerson?.exposure_current ?? 0),
233
260
  exposure_desired: result.exposure_desired ?? 0.5,
234
261
  last_updated: now,
235
262
  last_mentioned: now,
236
- learned_by: isNewItem ? personaId : existingPerson?.learned_by,
237
- last_changed_by: personaId,
238
- interested_personas: isNewItem
239
- ? [personaId]
240
- : [...new Set([...(existingPerson?.interested_personas ?? []), personaId])],
241
- persona_groups: mergeGroups(personaGroup, isNewItem, existingPerson?.persona_groups),
263
+ learned_by: isNewItem ? primaryId : existingPerson?.learned_by,
264
+ last_changed_by: primaryId,
265
+ interested_personas: interestedPersonas,
266
+ persona_groups: personaGroupsMerged,
242
267
  embedding,
243
268
  };
244
269
  state.human_person_upsert(person);
245
270
 
246
- const allMessages = state.messages_get(personaId);
271
+ const allMessages = roomId
272
+ ? normalizeRoomMessages(state.getRoomMessages(roomId), state)
273
+ : state.messages_get(personaId);
247
274
  await validateAndStoreQuotes(result.quotes, allMessages, itemId, personaDisplayName, personaGroup, state);
248
275
 
249
276
  console.log(`[handlePersonUpdate] ${isNewItem ? "Created" : "Updated"} person "${result.name}"`);
@@ -255,8 +282,7 @@ function normalizeText(text: string): string {
255
282
  .replace(/[\u2018\u2019\u0060\u00B4]/g, "'") // curly single, backtick, acute accent
256
283
  .replace(/[\u2014\u2013\u2012]/g, '-') // em-dash, en-dash, figure dash
257
284
  .replace(/\u00A0/g, ' ') // non-breaking space
258
- .replace(/[\u2000-\u200F]/g, ' ') // unicode space variants
259
- .replace(/\u2026|\.\.\./g, '\u2026'); // normalize both ellipsis forms → unicode ellipsis (1:1)
285
+ .replace(/[\u2000-\u200F]/g, ' '); // unicode space variants
260
286
  }
261
287
 
262
288
  function stripPunctuation(text: string): string {
@@ -270,31 +296,46 @@ function stripPunctuation(text: string): string {
270
296
  .toLowerCase();
271
297
  }
272
298
 
273
- interface WordBoundaryMatch {
299
+ export interface WordBoundaryMatch {
274
300
  start: number;
275
301
  end: number;
276
302
  text: string;
277
303
  }
278
304
 
279
- function findQuoteByWords(quoteText: string, msgText: string): WordBoundaryMatch | null {
305
+ export function expandToWordBoundaries(text: string, start: number, end: number): WordBoundaryMatch {
306
+ // Only walk backward if start is mid-word (not already at a word boundary)
307
+ if (start > 0 && !/\s/.test(text[start]))
308
+ while (start > 0 && !/\s/.test(text[start - 1])) start--;
309
+ // Only walk forward if end is mid-word
310
+ if (end > 0 && !/\s/.test(text[end - 1]))
311
+ while (end < text.length && !/\s/.test(text[end])) end++;
312
+ return { start, end, text: text.slice(start, end) };
313
+ }
314
+
315
+ export function findQuoteByWords(quoteText: string, msgText: string): WordBoundaryMatch | null {
280
316
  const strippedQuote = stripPunctuation(quoteText);
281
317
  const quoteWords = strippedQuote.split(' ').filter(w => w.length > 0);
282
318
 
283
- if (quoteWords.length < 3) return null; // Too short to trust — require at least 3 words
319
+ if (quoteWords.length < 2) return null; // Too short to trust — require at least 2 words
284
320
 
285
- // Build word token list from original message with original positions
321
+ // Build word token list from original message with original positions.
322
+ // Each \S+ token is re-split into sub-tokens (sharing the parent's start/end)
323
+ // so that contractions stripped by stripPunctuation (e.g. don't → "don t")
324
+ // align correctly with quoteWords which is also split on spaces.
286
325
  const wordTokens: Array<{ word: string; start: number; end: number }> = [];
287
326
  const wordRegex = /\S+/g;
288
327
  let match: RegExpExecArray | null;
289
328
  while ((match = wordRegex.exec(msgText)) !== null) {
290
- wordTokens.push({
291
- word: stripPunctuation(match[0]),
292
- start: match.index,
293
- end: match.index + match[0].length,
294
- });
329
+ const tokenStart = match.index;
330
+ const tokenEnd = match.index + match[0].length;
331
+ const stripped = stripPunctuation(match[0]);
332
+ const subWords = stripped.split(' ').filter(w => w.length > 0);
333
+ for (const sub of subWords) {
334
+ wordTokens.push({ word: sub, start: tokenStart, end: tokenEnd });
335
+ }
295
336
  }
296
337
 
297
- // Find contiguous sequence of words matching the quote words
338
+ // Find contiguous sequence of word tokens matching the quote words
298
339
  for (let i = 0; i <= wordTokens.length - quoteWords.length; i++) {
299
340
  let allMatch = true;
300
341
  for (let j = 0; j < quoteWords.length; j++) {
@@ -306,11 +347,7 @@ function findQuoteByWords(quoteText: string, msgText: string): WordBoundaryMatch
306
347
  if (allMatch) {
307
348
  const startToken = wordTokens[i];
308
349
  const endToken = wordTokens[i + quoteWords.length - 1];
309
- return {
310
- start: startToken.start,
311
- end: endToken.end,
312
- text: msgText.slice(startToken.start, endToken.end),
313
- };
350
+ return expandToWordBoundaries(msgText, startToken.start, endToken.end);
314
351
  }
315
352
  }
316
353
 
@@ -343,9 +380,10 @@ async function validateAndStoreQuotes(
343
380
  let matchLevel: string;
344
381
 
345
382
  if (start !== -1) {
346
- matchStart = start;
347
- matchEnd = start + candidate.text.length;
348
- matchText = candidate.text;
383
+ const expanded = expandToWordBoundaries(msgText, start, start + candidate.text.length);
384
+ matchStart = expanded.start;
385
+ matchEnd = expanded.end;
386
+ matchText = expanded.text;
349
387
  matchLevel = "exact";
350
388
  } else {
351
389
  // Level 2: word-boundary fallback
@@ -413,7 +451,8 @@ async function validateAndStoreQuotes(
413
451
  data_item_ids: [dataItemId],
414
452
  persona_groups: [personaGroup || "General"],
415
453
  text: matchText,
416
- speaker: message.role === "human" ? "human" : personaName,
454
+ speaker: message.role === "human" ? "human" : (message.speaker_name ?? personaName),
455
+ channel: personaName,
417
456
  timestamp: message.timestamp,
418
457
  start: matchStart,
419
458
  end: matchEnd,
@@ -436,14 +475,5 @@ async function validateAndStoreQuotes(
436
475
  }
437
476
  }
438
477
 
439
- function calculateExposureCurrent(impact: ExposureImpact | undefined): number {
440
- switch (impact) {
441
- case "high": return 0.9;
442
- case "medium": return 0.6;
443
- case "low": return 0.3;
444
- case "none": return 0.1;
445
- default: return 0.5;
446
- }
447
- }
448
478
 
449
479
 
@@ -18,6 +18,8 @@ import { handleFactFind, handleHumanTopicScan, handleHumanPersonScan, handleEven
18
18
  import { handleTopicMatch, handleTopicUpdate, handlePersonMatch, handlePersonUpdate } from "./human-matching.js";
19
19
  import { handleRewriteScan, handleRewriteRewrite } from "./rewrite.js";
20
20
  import { handleDedupCurate } from "./dedup.js";
21
+ import { handleRoomResponse, handleRoomJudge } from "./rooms.js";
22
+ import { handlePersonaPreview } from "./persona-preview.js";
21
23
 
22
24
  export const handlers: Record<LLMNextStep, ResponseHandler> = {
23
25
  handlePersonaResponse,
@@ -45,4 +47,7 @@ export const handlers: Record<LLMNextStep, ResponseHandler> = {
45
47
  handleRewriteRewrite,
46
48
  handleDedupCurate,
47
49
  handleEventScan,
50
+ handleRoomResponse,
51
+ handleRoomJudge,
52
+ handlePersonaPreview,
48
53
  };
@@ -0,0 +1,7 @@
1
+ import type { LLMResponse } from "../types.js";
2
+ import type { StateManager } from "../state-manager.js";
3
+
4
+ export function handlePersonaPreview(_response: LLMResponse, _state: StateManager): void {
5
+ // Intentionally empty — state writes are not needed for preview generation.
6
+ // The Processor post-dispatch block handles: completeness validation, re-queue, and Promise resolution.
7
+ }
@@ -22,6 +22,7 @@ import {
22
22
  } from "../orchestrators/index.js";
23
23
  import { buildPersonaDescriptionsPrompt } from "../../prompts/generation/index.js";
24
24
  import { splitMessagesByTimestamp } from "./utils.js";
25
+ import { calculateExposureCurrent } from "../utils/exposure.js";
25
26
 
26
27
  export const MIN_MESSAGE_COUNT_FOR_CREATE = 2;
27
28
 
@@ -272,7 +273,7 @@ export function handlePersonaTopicUpdate(response: LLMResponse, state: StateMana
272
273
  approach: result.approach || "",
273
274
  personal_stake: result.personal_stake || "",
274
275
  sentiment: result.sentiment,
275
- exposure_current: result.exposure_current,
276
+ exposure_current: calculateExposureCurrent(result.exposure_impact, 0),
276
277
  exposure_desired: result.exposure_desired,
277
278
  last_updated: now,
278
279
  };
@@ -284,7 +285,7 @@ export function handlePersonaTopicUpdate(response: LLMResponse, state: StateMana
284
285
  const updatedTopics = persona.topics.map((t: PersonaTopic) => {
285
286
  if (t.id !== existingTopicId) return t;
286
287
 
287
- const newExposure = Math.min(1.0, t.exposure_current + (result.exposure_current - t.exposure_current));
288
+ const newExposure = Math.max(calculateExposureCurrent(result.exposure_impact, t.exposure_current), t.exposure_current);
288
289
 
289
290
  return {
290
291
  ...t,
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Room Response Handlers
3
+ */
4
+
5
+ import { ContextStatus, LLMNextStep, LLMPriority, LLMRequestType } from "../types.js";
6
+ import type { LLMResponse, RoomMessage } from "../types.js";
7
+ import type { StateManager } from "../state-manager.js";
8
+ import type { PersonaResponseResult } from "../../prompts/response/index.js";
9
+ import type { RoomJudgeResult } from "../../prompts/room/index.js";
10
+ import { buildRoomResponsePromptData } from "../prompt-context-builder.js";
11
+
12
+ export function handleRoomResponse(response: LLMResponse, state: StateManager): void {
13
+ const roomId = response.request.data.roomId as string;
14
+ const personaId = response.request.data.personaId as string;
15
+ const personaDisplayName = response.request.data.personaDisplayName as string;
16
+ const parentMessageId = response.request.data.parentMessageId as string | null ?? null;
17
+
18
+ if (!roomId || !personaId) {
19
+ console.error("[handleRoomResponse] Missing roomId or personaId in request data");
20
+ return;
21
+ }
22
+
23
+ const now = new Date().toISOString();
24
+
25
+ if (response.parsed !== undefined) {
26
+ const result = response.parsed as PersonaResponseResult;
27
+
28
+ if (!result.should_respond) {
29
+ const reason = result.reason;
30
+ console.log(`[handleRoomResponse] ${personaDisplayName} chose silence in room ${roomId}: ${reason ?? "(no reason)"}`);
31
+ if (reason) {
32
+ const msg: RoomMessage = {
33
+ id: crypto.randomUUID(),
34
+ parent_id: parentMessageId,
35
+ role: "persona",
36
+ persona_id: personaId,
37
+ silence_reason: reason,
38
+ timestamp: now,
39
+ read: false,
40
+ context_status: ContextStatus.Default,
41
+ };
42
+ state.appendRoomMessage(roomId, msg);
43
+ }
44
+ return;
45
+ }
46
+
47
+ const verbal = result.verbal_response || undefined;
48
+ const action = result.action_response || undefined;
49
+
50
+ if (!verbal && !action) {
51
+ console.log(`[handleRoomResponse] ${personaDisplayName} returned should_respond=true but no content`);
52
+ return;
53
+ }
54
+
55
+ const msg: RoomMessage = {
56
+ id: crypto.randomUUID(),
57
+ parent_id: parentMessageId,
58
+ role: "persona",
59
+ persona_id: personaId,
60
+ verbal_response: verbal,
61
+ action_response: action,
62
+ timestamp: now,
63
+ read: false,
64
+ context_status: ContextStatus.Default,
65
+ };
66
+ state.appendRoomMessage(roomId, msg);
67
+ console.log(`[handleRoomResponse] Appended response from ${personaDisplayName} to room ${roomId}`);
68
+ return;
69
+ }
70
+
71
+ if (!response.content) {
72
+ console.log(`[handleRoomResponse] ${personaDisplayName} no response (empty content)`);
73
+ return;
74
+ }
75
+
76
+ const msg: RoomMessage = {
77
+ id: crypto.randomUUID(),
78
+ parent_id: parentMessageId,
79
+ role: "persona",
80
+ persona_id: personaId,
81
+ verbal_response: response.content,
82
+ timestamp: now,
83
+ read: false,
84
+ context_status: ContextStatus.Default,
85
+ };
86
+ state.appendRoomMessage(roomId, msg);
87
+ console.log(`[handleRoomResponse] Appended plain-text response from ${personaDisplayName} to room ${roomId}`);
88
+ }
89
+
90
+ export async function handleRoomJudge(response: LLMResponse, state: StateManager): Promise<void> {
91
+ const roomId = response.request.data.roomId as string;
92
+ const judgeDisplayName = response.request.data.judgePersonaDisplayName as string;
93
+
94
+ if (!roomId) {
95
+ console.error("[handleRoomJudge] Missing roomId in request data");
96
+ return;
97
+ }
98
+
99
+ if (!response.parsed) {
100
+ console.error(`[handleRoomJudge] No parsed result from judge ${judgeDisplayName}`);
101
+ return;
102
+ }
103
+
104
+ const result = response.parsed as RoomJudgeResult;
105
+ if (!result.winner_message_id) {
106
+ console.error(`[handleRoomJudge] Judge ${judgeDisplayName} returned no winner_message_id`);
107
+ return;
108
+ }
109
+
110
+ const judgePersonaId = response.request.data.judgePersonaId as string;
111
+
112
+ const allMessages = state.getRoomMessages(roomId);
113
+ const winner = allMessages.find(m => m.id === result.winner_message_id);
114
+ if (!winner) {
115
+ console.error(`[handleRoomJudge] Winner message ${result.winner_message_id} not found in room ${roomId}`);
116
+ return;
117
+ }
118
+
119
+ const verdictParentId = winner.parent_id;
120
+
121
+ const ok = state.setRoomActiveNode(roomId, result.winner_message_id);
122
+ if (!ok) {
123
+ console.error(`[handleRoomJudge] Could not set active node ${result.winner_message_id} in room ${roomId}`);
124
+ return;
125
+ }
126
+
127
+ const losers = allMessages
128
+ .filter(m => m.parent_id === verdictParentId && m.id !== winner.id)
129
+ .map(m => m.id);
130
+ if (losers.length > 0) {
131
+ state.removeRoomMessages(roomId, losers);
132
+ }
133
+
134
+ if (result.reason) {
135
+ console.log(`[handleRoomJudge] ${judgeDisplayName} verdict: ${result.reason}`);
136
+ const verdictMsg = {
137
+ id: crypto.randomUUID(),
138
+ parent_id: verdictParentId,
139
+ role: "persona" as const,
140
+ persona_id: judgePersonaId,
141
+ silence_reason: result.reason,
142
+ timestamp: new Date().toISOString(),
143
+ read: false,
144
+ context_status: "default" as import("../types.js").ContextStatus,
145
+ };
146
+ state.appendRoomMessage(roomId, verdictMsg);
147
+ }
148
+
149
+ const room = state.getRoom(roomId);
150
+ if (!room) return;
151
+
152
+ for (const personaId of room.persona_ids) {
153
+ if (room.judge_persona_id === personaId) continue;
154
+ const persona = state.persona_getById(personaId);
155
+ if (!persona || persona.is_archived || persona.is_paused) continue;
156
+
157
+ const isTUI = false;
158
+ const promptData = await buildRoomResponsePromptData(state, room, persona, isTUI);
159
+ const model = persona.model ?? state.getHuman().settings?.default_model ?? "";
160
+
161
+ state.queue_enqueue({
162
+ type: LLMRequestType.JSON,
163
+ priority: LLMPriority.Room,
164
+ system: promptData.system,
165
+ user: promptData.user,
166
+ next_step: LLMNextStep.HandleRoomResponse,
167
+ model,
168
+ data: {
169
+ roomId,
170
+ personaId,
171
+ personaDisplayName: persona.display_name,
172
+ parentMessageId: result.winner_message_id,
173
+ },
174
+ });
175
+ }
176
+ }
@@ -1,12 +1,53 @@
1
- import type { Message, LLMResponse } from "../types.js";
1
+ import type { Message, RoomMessage, LLMResponse } from "../types.js";
2
2
  import type { StateManager } from "../state-manager.js";
3
3
 
4
+ export function normalizeRoomMessages(messages: RoomMessage[], state: StateManager): Message[] {
5
+ const human = state.getHuman();
6
+ const humanName = human.settings?.name_display ?? "Human";
7
+ return messages.map(m => {
8
+ const speakerName = m.role === "human"
9
+ ? humanName
10
+ : (state.persona_getById(m.persona_id ?? "")?.display_name ?? "Participant");
11
+ return {
12
+ id: m.id,
13
+ role: m.role === "human" ? "human" as const : "system" as const,
14
+ speaker_name: speakerName,
15
+ verbal_response: m.verbal_response,
16
+ action_response: m.action_response,
17
+ silence_reason: m.silence_reason,
18
+ timestamp: m.timestamp,
19
+ read: m.read,
20
+ context_status: m.context_status,
21
+ f: m.f,
22
+ t: m.t,
23
+ p: m.p,
24
+ e: m.e,
25
+ };
26
+ });
27
+ }
28
+
4
29
  export function resolveMessageWindow(
5
30
  response: LLMResponse,
6
31
  state: StateManager
7
32
  ): { messages_context: Message[]; messages_analyze: Message[] } {
8
- const personaId = response.request.data.personaId as string;
33
+ const roomId = response.request.data.roomId as string | undefined;
9
34
  const messageIdsToMark = response.request.data.message_ids_to_mark as string[] | undefined;
35
+
36
+ if (roomId) {
37
+ const allRoomMessages = normalizeRoomMessages(state.getRoomMessages(roomId), state);
38
+ if (messageIdsToMark && messageIdsToMark.length > 0) {
39
+ const idSet = new Set(messageIdsToMark);
40
+ const messages_analyze = allRoomMessages.filter(m => idSet.has(m.id));
41
+ const analyzeStartTime = messages_analyze[0]?.timestamp ?? '9999';
42
+ const messages_context = allRoomMessages.filter(m =>
43
+ !idSet.has(m.id) && new Date(m.timestamp).getTime() < new Date(analyzeStartTime).getTime()
44
+ );
45
+ return { messages_context, messages_analyze };
46
+ }
47
+ return { messages_context: [], messages_analyze: allRoomMessages };
48
+ }
49
+
50
+ const personaId = response.request.data.personaId as string;
10
51
  const allMessages = state.messages_get(personaId);
11
52
 
12
53
  if (messageIdsToMark && messageIdsToMark.length > 0) {
@@ -48,10 +89,21 @@ export function markMessagesExtracted(
48
89
  state: StateManager,
49
90
  flag: ExtractionFlag
50
91
  ): void {
92
+ const roomId = response.request.data.roomId as string | undefined;
51
93
  const personaId = response.request.data.personaId as string | undefined;
52
94
  const messageIds = response.request.data.message_ids_to_mark as string[] | undefined;
53
95
 
54
- if (!personaId || !messageIds?.length) return;
96
+ if (!messageIds?.length) return;
97
+
98
+ if (roomId) {
99
+ const count = state.markRoomMessagesExtracted(roomId, messageIds, flag);
100
+ if (count > 0) {
101
+ console.log(`[markMessagesExtracted] Marked ${count} room messages with flag '${flag}' for room ${roomId}`);
102
+ }
103
+ return;
104
+ }
105
+
106
+ if (!personaId) return;
55
107
 
56
108
  const count = state.messages_markExtracted(personaId, messageIds, flag);
57
109
  if (count > 0) {
@@ -158,9 +158,11 @@ export async function queueEiHeartbeat(
158
158
  return;
159
159
  }
160
160
 
161
+ const recentHistory = history.slice(-10);
161
162
  const promptData: EiHeartbeatPromptData = {
162
163
  items,
163
- recent_history: history.slice(-10),
164
+ recent_history: recentHistory,
165
+ system_messages: recentHistory.filter(m => m.role === "system"),
164
166
  };
165
167
 
166
168
  const prompt = buildEiHeartbeatPrompt(promptData);
@@ -159,7 +159,7 @@ export async function callLLMRaw(
159
159
  const chatMessages: ChatMessage[] = [
160
160
  { role: "system", content: systemPrompt },
161
161
  ...messages,
162
- { role: "user", content: userPrompt },
162
+ ...(userPrompt ? [{ role: "user" as const, content: userPrompt }] : []),
163
163
  ];
164
164
 
165
165
  const finalMessages = ensureUserFirst(chatMessages);