ei-tui 0.4.3 → 0.5.0

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 (101) 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 +53 -35
  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/enums.ts +11 -0
  38. package/src/core/types/integrations.ts +10 -2
  39. package/src/core/types/llm.ts +3 -0
  40. package/src/core/types/rooms.ts +59 -0
  41. package/src/core/types.ts +1 -0
  42. package/src/core/utils/decay.ts +14 -8
  43. package/src/core/utils/exposure.ts +14 -0
  44. package/src/integrations/claude-code/importer.ts +23 -10
  45. package/src/integrations/cursor/importer.ts +22 -10
  46. package/src/integrations/opencode/importer.ts +30 -13
  47. package/src/prompts/ceremony/dedup.ts +2 -2
  48. package/src/prompts/generation/from-person.ts +85 -0
  49. package/src/prompts/generation/index.ts +2 -0
  50. package/src/prompts/generation/persona.ts +14 -10
  51. package/src/prompts/generation/seeds.ts +4 -29
  52. package/src/prompts/generation/types.ts +13 -0
  53. package/src/prompts/heartbeat/check.ts +1 -1
  54. package/src/prompts/heartbeat/ei.ts +4 -4
  55. package/src/prompts/heartbeat/types.ts +1 -0
  56. package/src/prompts/index.ts +15 -0
  57. package/src/prompts/message-utils.ts +2 -2
  58. package/src/prompts/persona/topics-match.ts +7 -6
  59. package/src/prompts/persona/topics-update.ts +8 -11
  60. package/src/prompts/persona/types.ts +2 -1
  61. package/src/prompts/response/index.ts +1 -1
  62. package/src/prompts/response/sections.ts +20 -8
  63. package/src/prompts/response/types.ts +6 -0
  64. package/src/prompts/room/index.ts +115 -0
  65. package/src/prompts/room/sections.ts +150 -0
  66. package/src/prompts/room/types.ts +93 -0
  67. package/tui/README.md +20 -0
  68. package/tui/src/app.tsx +3 -2
  69. package/tui/src/commands/activate.tsx +98 -0
  70. package/tui/src/commands/archive.tsx +54 -25
  71. package/tui/src/commands/capture.tsx +50 -0
  72. package/tui/src/commands/dedupe.tsx +2 -7
  73. package/tui/src/commands/delete.tsx +48 -0
  74. package/tui/src/commands/details.tsx +7 -0
  75. package/tui/src/commands/persona.tsx +271 -9
  76. package/tui/src/commands/room.tsx +261 -0
  77. package/tui/src/commands/silence.tsx +29 -0
  78. package/tui/src/components/ArchivedItemsOverlay.tsx +144 -0
  79. package/tui/src/components/ConfirmOverlay.tsx +6 -0
  80. package/tui/src/components/ConflictOverlay.tsx +6 -0
  81. package/tui/src/components/HelpOverlay.tsx +6 -1
  82. package/tui/src/components/LoadingOverlay.tsx +51 -0
  83. package/tui/src/components/MessageList.tsx +1 -18
  84. package/tui/src/components/PersonPickerOverlay.tsx +121 -0
  85. package/tui/src/components/PersonaListOverlay.tsx +6 -1
  86. package/tui/src/components/PromptInput.tsx +141 -8
  87. package/tui/src/components/ProviderListOverlay.tsx +5 -1
  88. package/tui/src/components/QuotesOverlay.tsx +5 -1
  89. package/tui/src/components/RoomMessageList.tsx +179 -0
  90. package/tui/src/components/Sidebar.tsx +54 -2
  91. package/tui/src/components/StatusBar.tsx +99 -8
  92. package/tui/src/components/ToolkitListOverlay.tsx +5 -1
  93. package/tui/src/components/WelcomeOverlay.tsx +6 -0
  94. package/tui/src/context/ei.tsx +252 -1
  95. package/tui/src/context/keyboard.tsx +48 -12
  96. package/tui/src/util/cyp-editor.tsx +152 -0
  97. package/tui/src/util/quote-utils.ts +19 -0
  98. package/tui/src/util/room-editor.tsx +164 -0
  99. package/tui/src/util/room-logic.ts +8 -0
  100. package/tui/src/util/room-parser.ts +70 -0
  101. package/tui/src/util/yaml-serializers.ts +151 -0
package/README.md CHANGED
@@ -58,6 +58,20 @@ A "Persona" is the combination of these two pieces of data, plus some _personali
58
58
 
59
59
  > <sup>1</sup>: By default. You can make them static.
60
60
 
61
+ ## What's a Room?
62
+
63
+ Rooms let you throw multiple personas into the same conversation thread. Chaos ensues. Or collaboration. Sometimes both.
64
+
65
+ Three modes, set at creation:
66
+
67
+ **Free For All (FFA)**: Everyone talks. Every message gets a response from every persona. It's loud. Good for brainstorming or when you want a bunch of perspectives on the same thing.
68
+
69
+ **Choose Your Path (CYP)**: The conversation branches. Each message triggers responses from all personas, but you pick which one continues the thread. Fork in the road, every turn. You're the navigator.
70
+
71
+ **Messages Against Persona (MAP)**: The interesting one. Everyone submits a response, but a Judge persona picks which one actually shows up. The personas have to stay in character and compete for the Judge's approval. The human doesn't have to play by the rules. It's partly a game of "who knows this judge best?" and partly just fun to watch them try.
72
+
73
+ Rooms learn the same way persona conversations do. Quotes, topics, people — all get extracted and persisted. The knowledge base grows no matter which mode you're in.
74
+
61
75
  ## The Basics
62
76
 
63
77
  Ei can operate with three types of input, and three types of output.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ei-tui",
3
- "version": "0.4.3",
3
+ "version": "0.5.0",
4
4
  "author": "Flare576",
5
5
  "repository": {
6
6
  "type": "git",
package/src/cli/README.md CHANGED
@@ -5,10 +5,11 @@
5
5
  ei # Start the TUI
6
6
  ei "query string" # Return up to 10 results across all types
7
7
  ei -n 5 "query string" # Return up to 5 results
8
- ei facts -n 5 "query string" # Return up to 5 facts
9
- ei people -n 5 "query string" # Return up to 5 people
10
- ei topics -n 5 "query string" # Return up to 5 topics
11
- ei quotes -n 5 "query string" # Return up to 5 quotes
8
+ ei facts -n 5 "query string" # Return up to 5 facts
9
+ ei people -n 5 "query string" # Return up to 5 people
10
+ ei topics -n 5 "query string" # Return up to 5 topics
11
+ ei quotes -n 5 "query string" # Return up to 5 quotes
12
+ ei personas -n 5 "query string" # Return up to 5 personas (name match)
12
13
  ei --persona "Beta" "query string" # Filter results to what Beta has learned
13
14
  ei --recent # Most recently mentioned items (no query needed)
14
15
  ei --persona "Beta" --recent # Most recently mentioned items Beta has learned
@@ -18,7 +19,7 @@ ei --install # Register Ei with OpenCode, Claude Code, and Cur
18
19
  ei mcp # Start the Ei MCP stdio server (for Cursor/Claude Desktop)
19
20
  ```
20
21
 
21
- Type aliases: `fact`, `person`, `topic`, `quote` all work (singular or plural).
22
+ Type aliases: `fact`, `person`, `topic`, `quote`, `persona` all work (singular or plural).
22
23
 
23
24
  # An Agentic Tool
24
25
 
@@ -70,9 +71,10 @@ ei "What are the user's current preferences, active projects, and workflow?"
70
71
  \```
71
72
 
72
73
  Ei is a persistent knowledge base built from the user's conversations — facts, preferences,
73
- people, topics. Use it when the user references past work, mentions how they like things done,
74
- or asks "how did we do X." Use `ei --persona "Beta" "walruses"` to scope results to what a specific persona has learned. Query again mid-session when they correct you or reference something
75
- from a previous session.
74
+ people, topics, personas. Use it when the user references past work, mentions how they like things done,
75
+ or asks "how did we do X." Use `ei --persona "Beta" "walruses"` to scope results to what a specific
76
+ persona has learned. Use `ei personas "name"` to find personas by name. Query again mid-session when
77
+ they correct you or reference something from a previous session.
76
78
  ```
77
79
 
78
80
  ### Claude Code
@@ -83,6 +85,7 @@ Add to `~/.claude/CLAUDE.md` (user-level) or `CLAUDE.md` at project root:
83
85
  At session start, use the **ei** MCP to pull user context: call `ei_search` with a
84
86
  natural-language query about the user's preferences, active projects, and workflow.
85
87
  A `persona` filter is available to scope results to what a specific persona has learned.
88
+ Use `type: "personas"` to search for personas by name.
86
89
 
87
90
  Use Ei when the user references past decisions, mentions people or preferences, or asks
88
91
  "how did we do X." Query again when they correct you or reference something from a previous
@@ -101,7 +104,7 @@ alwaysApply: true
101
104
  # Ei MCP — User knowledge base
102
105
 
103
106
  The **ei** MCP (server `user-ei`) is a persistent knowledge base built from the user's
104
- conversations (facts, people, topics, quotes).
107
+ conversations (facts, people, topics, quotes, personas).
105
108
 
106
109
  **Use it when:**
107
110
  - The user refers to past decisions, fixes, or "how we did X" and current chat/codebase
@@ -112,7 +115,7 @@ conversations (facts, people, topics, quotes).
112
115
  than only code.
113
116
 
114
117
  **How to use:**
115
- 1. Call `ei_search` (server `user-ei`) with a natural-language query (or omit query and use `recent: true` to browse); optionally filter by `type` (facts, people, topics, quotes) or `persona` display_name.
118
+ 1. Call `ei_search` (server `user-ei`) with a natural-language query (or omit query and use `recent: true` to browse); optionally filter by `type` (facts, people, topics, quotes, personas) or `persona` display_name.
116
119
  2. If you need full detail for a result, call `ei_lookup` with the entity `id` from step 1.
117
120
 
118
121
  Prefer querying Ei before asking the user for context they may have already shared.
@@ -120,13 +123,13 @@ Prefer querying Ei before asking the user for context they may have already shar
120
123
 
121
124
  ## What the Tool Provides
122
125
 
123
- The installed tool gives OpenCode agents access to all four data types with proper Zod-validated args:
126
+ The installed tool gives OpenCode agents access to all five data types with proper Zod-validated args:
124
127
 
125
128
  | Arg | Type | Description |
126
129
  |-----|------|-------------|
127
130
  | `query` | string (optional) | Search text, or entity ID when `lookup=true`. Omit to browse by recency. |
128
131
  | `persona` | string (optional) | Persona display_name to filter results — only returns entities that persona has extracted |
129
- | `type` | enum (optional) | `facts` \| `people` \| `topics` \| `quotes` — omit for balanced results |
132
+ | `type` | enum (optional) | `facts` \| `people` \| `topics` \| `quotes` \| `personas` — omit for balanced results |
130
133
  | `limit` | number (optional) | Max results, default 10 |
131
134
  | `lookup` | boolean (optional) | If true, fetch single entity by ID |
132
135
  | `recent` | boolean (optional) | If true, sort by most recently mentioned. Can be combined with `persona` or `query`. |
@@ -139,4 +142,6 @@ All search commands return arrays. Each result includes a `type` field.
139
142
 
140
143
  **Quote**: `{ type, id, text, speaker, timestamp, linked_items[] }`
141
144
 
145
+ **Persona**: `{ type, id, display_name, short_description, model, base_prompt, traits[], topics[] }`
146
+
142
147
  **ID lookup** (`lookup: true`): single object (not an array) with the same shape.
@@ -0,0 +1,12 @@
1
+ import { loadLatestState, retrievePersonas } from "../retrieval";
2
+ import type { PersonaResult } from "../retrieval";
3
+
4
+ export async function execute(query: string, limit: number, options: { recent?: boolean } = {}): Promise<PersonaResult[]> {
5
+ const state = await loadLatestState();
6
+ if (!state) {
7
+ console.error("No saved state found. Is EI_DATA_PATH set correctly?");
8
+ return [];
9
+ }
10
+
11
+ return retrievePersonas(query, state, limit, options);
12
+ }
package/src/cli/mcp.ts CHANGED
@@ -20,10 +20,10 @@ export function createMcpServer(): McpServer {
20
20
  inputSchema: {
21
21
  query: z.string().optional().describe("Search text. Supports natural language. Omit to browse without semantic filtering — useful with recent=true or persona filter."),
22
22
  type: z
23
- .enum(["facts", "people", "topics", "quotes"])
23
+ .enum(["facts", "people", "topics", "quotes", "personas"])
24
24
  .optional()
25
25
  .describe(
26
- "Filter to a specific data type. Omit to search all types (balanced across all 4)."
26
+ "Filter to a specific data type. Omit to search all types (balanced across all 5)."
27
27
  ),
28
28
  persona: z
29
29
  .string()
@@ -1,4 +1,6 @@
1
1
  import type { StorageState, Quote, Fact, Person, Topic } from "../core/types";
2
+ import type { PersonaEntity } from "../core/types/entities.js";
3
+ import type { PersonaTrait, PersonaTopic } from "../core/types/data-items.js";
2
4
  import { decodeAllEmbeddings } from "../storage/embeddings";
3
5
  import { crossFind } from "../core/utils/index.ts";
4
6
  import { join } from "path";
@@ -110,19 +112,30 @@ export interface TopicResult {
110
112
  sentiment: number;
111
113
  }
112
114
 
115
+ export interface PersonaResult {
116
+ id: string;
117
+ display_name: string;
118
+ short_description?: string;
119
+ model?: string;
120
+ base_prompt: string;
121
+ traits: PersonaTrait[];
122
+ topics: PersonaTopic[];
123
+ }
124
+
113
125
  export type BalancedResult =
114
126
  | ({ type: "quote" } & QuoteResult)
115
127
  | ({ type: "fact" } & FactResult)
116
128
  | ({ type: "person" } & PersonResult)
117
- | ({ type: "topic" } & TopicResult);
129
+ | ({ type: "topic" } & TopicResult)
130
+ | ({ type: "persona" } & PersonaResult);
118
131
 
119
- const DATA_TYPES = ["quote", "fact", "person", "topic"] as const;
132
+ const DATA_TYPES = ["quote", "fact", "person", "topic", "persona"] as const;
120
133
  type DataType = typeof DATA_TYPES[number];
121
134
 
122
135
  interface ScoredEntry {
123
136
  type: DataType;
124
137
  similarity: number;
125
- mapped: QuoteResult | FactResult | PersonResult | TopicResult;
138
+ mapped: QuoteResult | FactResult | PersonResult | TopicResult | PersonaResult;
126
139
  itemId: string;
127
140
  }
128
141
 
@@ -182,6 +195,46 @@ function mapTopic(topic: Topic): TopicResult {
182
195
  };
183
196
  }
184
197
 
198
+ export function mapPersona(persona: PersonaEntity): PersonaResult {
199
+ return {
200
+ id: persona.id,
201
+ display_name: persona.display_name,
202
+ short_description: persona.short_description,
203
+ model: persona.model,
204
+ base_prompt: persona.long_description ?? "",
205
+ traits: persona.traits,
206
+ topics: persona.topics,
207
+ };
208
+ }
209
+
210
+ export function retrievePersonas(
211
+ query: string,
212
+ state: StorageState,
213
+ limit: number = 10,
214
+ options: { recent?: boolean } = {}
215
+ ): PersonaResult[] {
216
+ const { recent } = options;
217
+ const personaList = Object.values(state.personas).map((p) => p.entity);
218
+
219
+ if (recent && !query) {
220
+ return personaList
221
+ .sort((a, b) => b.last_updated.localeCompare(a.last_updated))
222
+ .slice(0, limit)
223
+ .map(mapPersona);
224
+ }
225
+
226
+ if (!query) {
227
+ return [];
228
+ }
229
+
230
+ const q = query.toLowerCase();
231
+ return personaList
232
+ .filter((p) => p.display_name.toLowerCase().includes(q))
233
+ .sort((a, b) => b.last_updated.localeCompare(a.last_updated))
234
+ .slice(0, limit)
235
+ .map(mapPersona);
236
+ }
237
+
185
238
  export async function retrieveBalanced(
186
239
  query: string,
187
240
  limit: number = 10,
@@ -199,11 +252,16 @@ export async function retrieveBalanced(
199
252
  const recentDate = (item: AnyItem): string => item.last_mentioned ?? item.last_updated ?? "";
200
253
 
201
254
  if (recent && !query) {
202
- const allItems: Array<{ type: DataType; item: AnyItem; mapped: QuoteResult | FactResult | PersonResult | TopicResult }> = [
255
+ const allItems: Array<{ type: DataType; item: AnyItem; mapped: QuoteResult | FactResult | PersonResult | TopicResult | PersonaResult }> = [
203
256
  ...state.human.quotes.map(q => ({ type: "quote" as DataType, item: q as AnyItem, mapped: mapQuote(q, state) })),
204
257
  ...state.human.facts.map(f => ({ type: "fact" as DataType, item: f as AnyItem, mapped: mapFact(f) })),
205
258
  ...state.human.people.map(p => ({ type: "person" as DataType, item: p as AnyItem, mapped: mapPerson(p) })),
206
259
  ...state.human.topics.map(t => ({ type: "topic" as DataType, item: t as AnyItem, mapped: mapTopic(t) })),
260
+ ...Object.values(state.personas).map(({ entity: p }) => ({
261
+ type: "persona" as DataType,
262
+ item: { id: p.id, last_updated: p.last_updated } as AnyItem,
263
+ mapped: mapPersona(p),
264
+ })),
207
265
  ];
208
266
  return allItems
209
267
  .sort((a, b) => recentDate(b.item).localeCompare(recentDate(a.item)))
@@ -237,10 +295,19 @@ export async function retrieveBalanced(
237
295
  }
238
296
  }
239
297
  }
240
- return allScored
298
+ const embeddingResults = allScored
241
299
  .sort((a, b) => recentDate(b.mapped as AnyItem).localeCompare(recentDate(a.mapped as AnyItem)))
242
300
  .slice(0, limit)
243
301
  .map(({ type, mapped }) => ({ type, ...mapped }) as BalancedResult);
302
+ const personaMatches = retrievePersonas(query, state, limit, { recent: true });
303
+ if (personaMatches.length > 0) {
304
+ const combined = [
305
+ ...personaMatches.map((p) => ({ type: "persona" as const, ...p }) as BalancedResult),
306
+ ...embeddingResults,
307
+ ];
308
+ return combined.slice(0, limit);
309
+ }
310
+ return embeddingResults;
244
311
  }
245
312
 
246
313
  for (const { type, items, mapper } of typeConfigs) {
@@ -278,7 +345,16 @@ export async function retrieveBalanced(
278
345
 
279
346
  result.sort((a, b) => b.similarity - a.similarity);
280
347
 
281
- return result.map(({ type, mapped }) => ({ type, ...mapped }) as BalancedResult);
348
+ const embeddingFinal = result.map(({ type, mapped }) => ({ type, ...mapped }) as BalancedResult);
349
+ const personaFinal = retrievePersonas(query, state, limit);
350
+ if (personaFinal.length > 0) {
351
+ const combined = [
352
+ ...personaFinal.map((p) => ({ type: "persona" as const, ...p }) as BalancedResult),
353
+ ...embeddingFinal,
354
+ ];
355
+ return combined.slice(0, limit);
356
+ }
357
+ return embeddingFinal;
282
358
  }
283
359
 
284
360
  export async function lookupById(id: string): Promise<({ type: string } & Record<string, unknown>) | null> {
@@ -289,6 +365,8 @@ export async function lookupById(id: string): Promise<({ type: string } & Record
289
365
 
290
366
  const found = crossFind(id, state.human);
291
367
  if (!found) return null;
292
- const { type, embedding, ...rest } = found;
293
- return { type, ...rest };
368
+ const { type, ...rest } = found;
369
+ const withoutEmbedding = { ...rest } as Record<string, unknown>;
370
+ delete withoutEmbedding.embedding;
371
+ return { type, ...withoutEmbedding };
294
372
  }
package/src/cli.ts CHANGED
@@ -26,6 +26,8 @@ const TYPE_ALIASES: Record<string, string> = {
26
26
  people: "people",
27
27
  topic: "topics",
28
28
  topics: "topics",
29
+ persona: "personas",
30
+ personas: "personas",
29
31
  };
30
32
 
31
33
  function printHelp(): void {
@@ -47,10 +49,11 @@ Usage:
47
49
  ei mcp Start the Ei MCP stdio server (for Cursor/Claude Desktop)
48
50
 
49
51
  Types:
50
- quote / quotes Quotes from conversation history
51
- fact / facts Facts about the user
52
- person / people People from the user's life
53
- topic / topics Topics of interest
52
+ quote / quotes Quotes from conversation history
53
+ fact / facts Facts about the user
54
+ person / people People from the user's life
55
+ topic / topics Topics of interest
56
+ persona / personas Personas in this Ei instance
54
57
 
55
58
  Options:
56
59
  --number, -n Maximum number of results (default: 10)
@@ -88,7 +91,7 @@ function buildOpenCodeToolContent(): string {
88
91
  ' "Search text, or an entity ID when lookup=true. Supports natural language. Omit to browse by recency."',
89
92
  ' ),',
90
93
  ' type: tool.schema',
91
- ' .enum(["facts", "people", "topics", "quotes"])',
94
+ ' .enum(["facts", "people", "topics", "quotes", "personas"])',
92
95
  ' .optional()',
93
96
  ' .describe(',
94
97
  ' "Filter to a specific data type. Omit to search all types (balanced across all 4).",',
@@ -0,0 +1,29 @@
1
+ export interface SeedTrait {
2
+ name: string;
3
+ description: string;
4
+ sentiment: number;
5
+ strength: number;
6
+ }
7
+
8
+ export const SEED_TRAIT_GENUINE: SeedTrait = {
9
+ name: "Genuine Responses",
10
+ description: "Respond authentically rather than with empty validation. Disagree when appropriate. Skip phrases like 'Great question!' or 'Absolutely!' - just respond to the substance.",
11
+ sentiment: 0.5,
12
+ strength: 0.7,
13
+ };
14
+
15
+ export const SEED_TRAIT_NATURAL_SPEECH: SeedTrait = {
16
+ name: "Natural Speech",
17
+ description: `Write in natural conversational flow. Avoid AI-typical patterns like:
18
+ - Choppy dramatic fragments ('Bold move. Risky play.')
19
+ - Rhetorical 'That X? Y.' structures
20
+ - 'That's not just... That's ...'
21
+ - formulaic paragraph openers`,
22
+ sentiment: 0.5,
23
+ strength: 0.7,
24
+ };
25
+
26
+ export const DEFAULT_SEED_TRAITS: SeedTrait[] = [
27
+ SEED_TRAIT_GENUINE,
28
+ SEED_TRAIT_NATURAL_SPEECH,
29
+ ];
@@ -17,6 +17,7 @@ export function filterMessagesForContext(
17
17
  const boundaryMs = contextBoundary ? new Date(contextBoundary).getTime() : 0;
18
18
 
19
19
  return messages.filter((msg) => {
20
+ if (msg.external === true) return false;
20
21
  if (msg.context_status === ContextStatusEnum.Always) return true;
21
22
  if (msg.context_status === ContextStatusEnum.Never) return false;
22
23
 
@@ -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}"`);
@@ -436,14 +463,5 @@ async function validateAndStoreQuotes(
436
463
  }
437
464
  }
438
465
 
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
466
 
449
467
 
@@ -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,