ei-tui 0.6.7 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/package.json +1 -1
  2. package/src/cli/mcp.ts +35 -10
  3. package/src/cli/persona-filter.ts +42 -0
  4. package/src/cli.ts +18 -6
  5. package/src/core/handlers/human-extraction.ts +1 -0
  6. package/src/core/handlers/human-matching.ts +10 -0
  7. package/src/core/handlers/index.ts +2 -1
  8. package/src/core/handlers/persona-response.ts +5 -0
  9. package/src/core/handlers/utils.ts +4 -1
  10. package/src/core/orchestrators/ceremony.ts +2 -2
  11. package/src/core/orchestrators/human-extraction.ts +5 -0
  12. package/src/core/personas/opencode-agent.ts +1 -0
  13. package/src/core/processor.ts +22 -2
  14. package/src/core/prompt-context-builder.ts +40 -10
  15. package/src/core/queue-manager.ts +18 -0
  16. package/src/core/room-manager.ts +21 -4
  17. package/src/core/state/personas.ts +2 -2
  18. package/src/core/state-manager.ts +26 -0
  19. package/src/core/types/data-items.ts +1 -0
  20. package/src/core/types/enums.ts +1 -0
  21. package/src/core/types/integrations.ts +1 -0
  22. package/src/core/types/rooms.ts +2 -0
  23. package/src/integrations/claude-code/importer.ts +3 -57
  24. package/src/integrations/cursor/importer.ts +2 -52
  25. package/src/integrations/opencode/importer.ts +1 -0
  26. package/src/prompts/response/sections.ts +1 -1
  27. package/src/prompts/response/types.ts +1 -0
  28. package/src/prompts/room/index.ts +2 -2
  29. package/src/prompts/room/sections.ts +4 -4
  30. package/src/prompts/room/types.ts +4 -0
  31. package/tui/src/commands/activate.tsx +7 -6
  32. package/tui/src/commands/context.tsx +188 -2
  33. package/tui/src/components/CYPTreeOverlay.tsx +357 -0
  34. package/tui/src/components/MAPScoreOverlay.tsx +300 -0
  35. package/tui/src/components/MessageList.tsx +14 -3
  36. package/tui/src/components/RoomMessageList.tsx +15 -3
  37. package/tui/src/context/ei.tsx +20 -0
  38. package/tui/src/util/cyp-tree.ts +62 -0
  39. package/tui/src/util/yaml-context.ts +87 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ei-tui",
3
- "version": "0.6.7",
3
+ "version": "0.7.1",
4
4
  "author": "Flare576",
5
5
  "repository": {
6
6
  "type": "git",
package/src/cli/mcp.ts CHANGED
@@ -3,7 +3,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
3
3
  import { z } from "zod";
4
4
  import { retrieveBalanced, lookupById, loadLatestState, type BalancedResult } from "./retrieval.js";
5
5
  import type { StorageState } from "../core/types/index.js";
6
- import { resolvePersonaId, filterByPersona, filterTypeSpecificByPersona } from "./persona-filter.js";
6
+ import { resolvePersonaId, filterByPersona, filterTypeSpecificByPersona, filterBySource, filterTypeSpecificBySource } from "./persona-filter.js";
7
7
 
8
8
  // Exported so tests can inject their own transport
9
9
  export function createMcpServer(): McpServer {
@@ -31,6 +31,12 @@ export function createMcpServer(): McpServer {
31
31
  .describe(
32
32
  "Filter to entities a specific persona has learned about. Use the persona display name."
33
33
  ),
34
+ source: z
35
+ .string()
36
+ .optional()
37
+ .describe(
38
+ "Filter to entities from a specific source. Prefix match against namespaced source identifiers (e.g. 'cursor', 'opencode', 'opencode:ses_abc123')."
39
+ ),
34
40
  limit: z
35
41
  .number()
36
42
  .optional()
@@ -42,16 +48,16 @@ export function createMcpServer(): McpServer {
42
48
  .describe("If true, sort by most recently mentioned."),
43
49
  },
44
50
  },
45
- async ({ query: rawQuery, type, persona, limit, recent }) => {
51
+ async ({ query: rawQuery, type, persona, source, limit, recent }) => {
46
52
  const query = rawQuery ?? "";
47
53
  const options = { recent: recent ?? false };
48
54
  const effectiveLimit = limit ?? 10;
49
55
 
50
56
  let state: StorageState | null = null;
51
57
  let personaId: string | undefined;
52
- if (persona) {
58
+ if (persona || source) {
53
59
  state = await loadLatestState();
54
- if (state) {
60
+ if (state && persona) {
55
61
  personaId = resolvePersonaId(state, persona) ?? undefined;
56
62
  if (!personaId) {
57
63
  return {
@@ -68,11 +74,17 @@ export function createMcpServer(): McpServer {
68
74
  if (personaId && state) {
69
75
  result = filterTypeSpecificByPersona(result as { id: string }[], state, personaId, type);
70
76
  }
77
+ if (source && state) {
78
+ result = filterTypeSpecificBySource(result as { id: string }[], state, source, type);
79
+ }
71
80
  } else {
72
81
  result = await retrieveBalanced(query, effectiveLimit, options);
73
82
  if (personaId && state) {
74
83
  result = filterByPersona(result as BalancedResult[], state, personaId);
75
84
  }
85
+ if (source && state) {
86
+ result = filterBySource(result as BalancedResult[], state, source);
87
+ }
76
88
  }
77
89
 
78
90
  return {
@@ -88,17 +100,30 @@ export function createMcpServer(): McpServer {
88
100
  "Look up a specific entity in the Ei knowledge base by ID. Returns the full entity record. Use IDs from ei_search results.",
89
101
  inputSchema: {
90
102
  id: z.string().describe("The entity ID to look up."),
103
+ source: z
104
+ .string()
105
+ .optional()
106
+ .describe(
107
+ "Filter to entities from a specific source. Prefix match against namespaced source identifiers (e.g. 'cursor', 'opencode', 'opencode:ses_abc123'). If the entity does not match, returns not found."
108
+ ),
91
109
  },
92
110
  },
93
- async ({ id }) => {
111
+ async ({ id, source }) => {
94
112
  const result = await lookupById(id);
95
- const text =
96
- result === null
97
- ? `No entity found with ID: ${id}`
98
- : JSON.stringify(result, null, 2);
113
+
114
+ if (result === null) {
115
+ return { content: [{ type: "text" as const, text: `No entity found with ID: ${id}` }] };
116
+ }
117
+
118
+ if (source) {
119
+ const sources = (result as { sources?: string[] }).sources;
120
+ if (!sources?.some((s) => s.startsWith(source))) {
121
+ return { content: [{ type: "text" as const, text: `No entity found with ID: ${id}` }] };
122
+ }
123
+ }
99
124
 
100
125
  return {
101
- content: [{ type: "text" as const, text }],
126
+ content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
102
127
  };
103
128
  }
104
129
  );
@@ -52,3 +52,45 @@ export function filterTypeSpecificByPersona<T extends { id: string }>(
52
52
  return original?.interested_personas?.includes(personaId) ?? false;
53
53
  });
54
54
  }
55
+
56
+ export function filterBySource(results: BalancedResult[], state: StorageState, sourcePrefix: string): BalancedResult[] {
57
+ return results.filter((result) => {
58
+ if (result.type === "quote") {
59
+ return false;
60
+ }
61
+ const { id } = result;
62
+ let original: { sources?: string[] } | undefined;
63
+ if (result.type === "fact") {
64
+ original = state.human.facts.find((f) => f.id === id);
65
+ } else if (result.type === "topic") {
66
+ original = state.human.topics.find((t) => t.id === id);
67
+ } else if (result.type === "person") {
68
+ original = state.human.people.find((p) => p.id === id);
69
+ }
70
+ return original?.sources?.some((s) => s.startsWith(sourcePrefix)) ?? false;
71
+ });
72
+ }
73
+
74
+ export function filterTypeSpecificBySource<T extends { id: string }>(
75
+ results: T[],
76
+ state: StorageState,
77
+ sourcePrefix: string,
78
+ targetType: string
79
+ ): T[] {
80
+ if (targetType === "quotes") {
81
+ return [];
82
+ }
83
+ const collection =
84
+ targetType === "facts"
85
+ ? state.human.facts
86
+ : targetType === "topics"
87
+ ? state.human.topics
88
+ : targetType === "people"
89
+ ? state.human.people
90
+ : null;
91
+ if (!collection) return results;
92
+ return results.filter((r) => {
93
+ const original = collection.find((item) => item.id === r.id) as { sources?: string[] } | undefined;
94
+ return original?.sources?.some((s) => s.startsWith(sourcePrefix)) ?? false;
95
+ });
96
+ }
package/src/cli.ts CHANGED
@@ -15,7 +15,7 @@ import { parseArgs } from "util";
15
15
  import { join } from "path";
16
16
  import { retrieveBalanced, lookupById, loadLatestState } from "./cli/retrieval";
17
17
  import type { StorageState } from "./core/types";
18
- import { resolvePersonaId, filterByPersona, filterTypeSpecificByPersona } from "./cli/persona-filter.js";
18
+ import { resolvePersonaId, filterByPersona, filterTypeSpecificByPersona, filterBySource, filterTypeSpecificBySource } from "./cli/persona-filter.js";
19
19
 
20
20
  const TYPE_ALIASES: Record<string, string> = {
21
21
  quote: "quotes",
@@ -59,6 +59,7 @@ Options:
59
59
  --number, -n Maximum number of results (default: 10)
60
60
  --recent, -r Sort by last_mentioned date (most recent first)
61
61
  --persona, -p Filter to entities a specific persona has learned about
62
+ --source, -s Filter to entities from a specific source (prefix match, e.g. "cursor", "opencode:ses_abc123")
62
63
  --id Look up entity by ID (accepts value or stdin)
63
64
  --install Register Ei with OpenCode, Claude Code, and Cursor
64
65
  --help, -h Show this help message
@@ -70,6 +71,7 @@ Examples:
70
71
  ei --recent # Most recently mentioned items
71
72
  ei topics --recent "work" # Recent work-related topics
72
73
  ei --persona "Architect" "work stuff" # What Architect knows about work
74
+ ei topics --source cursor "X" # Topics learned from Cursor sessions
73
75
  ei --id abc-123 # Look up entity by ID
74
76
  ei "memory leak" | jq .[0].id | ei --id # Pipe ID from search
75
77
  `);
@@ -305,6 +307,7 @@ async function main(): Promise<void> {
305
307
  number: { type: "string", short: "n" },
306
308
  recent: { type: "boolean", short: "r" },
307
309
  persona: { type: "string", short: "p" },
310
+ source: { type: "string", short: "s" },
308
311
  help: { type: "boolean", short: "h" },
309
312
  },
310
313
  allowPositionals: true,
@@ -325,6 +328,7 @@ async function main(): Promise<void> {
325
328
  // Default to recent mode when no query — allows `ei --persona Foo` and `ei` with no args
326
329
  const recent = parsed.values.recent === true || !query;
327
330
  const personaName = parsed.values.persona?.trim();
331
+ const sourcePrefix = parsed.values.source?.trim();
328
332
 
329
333
  if (isNaN(limit) || limit < 1) {
330
334
  console.error("--number must be a positive integer");
@@ -333,16 +337,18 @@ async function main(): Promise<void> {
333
337
 
334
338
  let state: StorageState | null = null;
335
339
  let personaId: string | undefined;
336
- if (personaName) {
340
+ if (personaName || sourcePrefix) {
337
341
  state = await loadLatestState();
338
342
  if (!state) {
339
343
  console.error("No saved state found. Is EI_DATA_PATH set correctly?");
340
344
  process.exit(1);
341
345
  }
342
- personaId = resolvePersonaId(state, personaName) ?? undefined;
343
- if (!personaId) {
344
- console.error(`Persona "${personaName}" not found.`);
345
- process.exit(1);
346
+ if (personaName) {
347
+ personaId = resolvePersonaId(state, personaName) ?? undefined;
348
+ if (!personaId) {
349
+ console.error(`Persona "${personaName}" not found.`);
350
+ process.exit(1);
351
+ }
346
352
  }
347
353
  }
348
354
 
@@ -355,11 +361,17 @@ async function main(): Promise<void> {
355
361
  if (personaId && state) {
356
362
  result = filterTypeSpecificByPersona(result, state, personaId, targetType);
357
363
  }
364
+ if (sourcePrefix && state) {
365
+ result = filterTypeSpecificBySource(result, state, sourcePrefix, targetType);
366
+ }
358
367
  } else {
359
368
  result = await retrieveBalanced(query, limit, options);
360
369
  if (personaId && state) {
361
370
  result = filterByPersona(result, state, personaId);
362
371
  }
372
+ if (sourcePrefix && state) {
373
+ result = filterBySource(result, state, sourcePrefix);
374
+ }
363
375
  }
364
376
 
365
377
  console.log(JSON.stringify(result, null, 2));
@@ -147,6 +147,7 @@ export async function handleFactFind(response: LLMResponse, state: StateManager)
147
147
  learned_by: existingFact.learned_by ?? context.personaId,
148
148
  last_changed_by: context.personaId,
149
149
  interested_personas: [...new Set([...(existingFact.interested_personas ?? []), context.personaId])],
150
+ sources: [...new Set([...(existingFact.sources ?? []), ...(context.sources ?? [])])],
150
151
  embedding,
151
152
  };
152
153
 
@@ -176,6 +176,10 @@ export async function handleTopicUpdate(response: LLMResponse, state: StateManag
176
176
  const personaGroupsMerged = isNewItem
177
177
  ? (allPersonaGroups.length > 0 ? allPersonaGroups : existingTopic?.persona_groups)
178
178
  : [...new Set([...(existingTopic?.persona_groups ?? []), ...allPersonaGroups])];
179
+ const incomingSources = (response.request.data.sources ?? []) as string[];
180
+ const sources = isNewItem
181
+ ? incomingSources
182
+ : [...new Set([...(existingTopic?.sources ?? []), ...incomingSources])];
179
183
 
180
184
  const topic: Topic = {
181
185
  id: itemId,
@@ -191,6 +195,7 @@ export async function handleTopicUpdate(response: LLMResponse, state: StateManag
191
195
  learned_by: isNewItem ? primaryId : existingTopic?.learned_by,
192
196
  last_changed_by: primaryId,
193
197
  interested_personas: interestedPersonas,
198
+ sources: sources.length > 0 ? sources : undefined,
194
199
  persona_groups: personaGroupsMerged,
195
200
  embedding,
196
201
  };
@@ -279,6 +284,10 @@ export async function handlePersonUpdate(response: LLMResponse, state: StateMana
279
284
  const personaGroupsMerged = isNewItem
280
285
  ? (allPersonaGroups.length > 0 ? allPersonaGroups : existingPerson?.persona_groups)
281
286
  : [...new Set([...(existingPerson?.persona_groups ?? []), ...allPersonaGroups])];
287
+ const incomingPersonSources = (response.request.data.sources ?? []) as string[];
288
+ const personSources = isNewItem
289
+ ? incomingPersonSources
290
+ : [...new Set([...(existingPerson?.sources ?? []), ...incomingPersonSources])];
282
291
 
283
292
  let resolvedIdentifiers: PersonIdentifier[];
284
293
  if (isNewItem) {
@@ -335,6 +344,7 @@ export async function handlePersonUpdate(response: LLMResponse, state: StateMana
335
344
  learned_by: isNewItem ? primaryId : existingPerson?.learned_by,
336
345
  last_changed_by: primaryId,
337
346
  interested_personas: interestedPersonas,
347
+ sources: personSources.length > 0 ? personSources : undefined,
338
348
  persona_groups: personaGroupsMerged,
339
349
  embedding,
340
350
  };
@@ -6,7 +6,7 @@ import type { PersonIdentifier } from "../types/data-items.js";
6
6
 
7
7
  export type { ResponseHandler } from "./persona-response.js";
8
8
 
9
- import { handlePersonaResponse, handleToolContinuation, handleOneShot } from "./persona-response.js";
9
+ import { handlePersonaResponse, handleToolContinuation, handleOneShot, handleOneShotJSON } from "./persona-response.js";
10
10
  import { handleHeartbeatCheck, handleEiHeartbeat } from "./heartbeat.js";
11
11
  import { handlePersonaGeneration, handlePersonaDescriptions, handlePersonaTraitExtraction } from "./persona-generation.js";
12
12
  import {
@@ -79,6 +79,7 @@ export const handlers: Record<LLMNextStep, ResponseHandler> = {
79
79
  handleHeartbeatCheck,
80
80
  handleEiHeartbeat,
81
81
  handleOneShot,
82
+ handleOneShotJSON,
82
83
  handleToolContinuation,
83
84
  handleRewriteScan,
84
85
  handleRewriteRewrite,
@@ -139,3 +139,8 @@ export function handleOneShot(_response: LLMResponse, _state: StateManager): voi
139
139
  // One-shot is handled specially in Processor to fire onOneShotReturned
140
140
  // This handler is a no-op placeholder
141
141
  }
142
+
143
+ export function handleOneShotJSON(_response: LLMResponse, _state: StateManager): void {
144
+ // One-shot JSON is handled specially in Processor to fire onOneShotJSONReturned
145
+ // This handler is a no-op placeholder
146
+ }
@@ -11,7 +11,10 @@ export function getMessageContent(msg: { content?: string; verbal_response?: str
11
11
 
12
12
  export function normalizeRoomMessages(messages: RoomMessage[], state: StateManager): Message[] {
13
13
  const human = state.getHuman();
14
- const humanName = human.settings?.name_display ?? "Human";
14
+ const humanName =
15
+ human.settings?.name_display ||
16
+ human.facts?.find(f => f.name === "Nickname/Preferred Name")?.description ||
17
+ "Human";
15
18
  return messages.map(m => {
16
19
  const speakerName = m.role === "human"
17
20
  ? humanName
@@ -1,4 +1,4 @@
1
- import { LLMRequestType, LLMPriority, LLMNextStep, RoomMode, type CeremonyConfig, type PersonaTopic, type Topic, type DataItemBase } from "../types.js";
1
+ import { LLMRequestType, LLMPriority, LLMNextStep, RoomMode, ContextStatus, type CeremonyConfig, type PersonaTopic, type Topic, type DataItemBase } from "../types.js";
2
2
  import type { StateManager } from "../state-manager.js";
3
3
  import { normalizeRoomMessages } from "../handlers/utils.js";
4
4
  import { applyDecayToValue } from "../utils/index.js";
@@ -347,7 +347,7 @@ export function prunePersonaMessages(personaId: string, state: StateManager): vo
347
347
  if (msgMs >= cutoffMs) break; // Sorted by time, no more old ones
348
348
 
349
349
  const fullyExtracted = m.t && m.p && m.f; // r intentionally excluded — trait extraction deprecated
350
- if (fullyExtracted) {
350
+ if (fullyExtracted && m.context_status !== ContextStatus.Always) {
351
351
  toRemove.push(m.id);
352
352
  }
353
353
  }
@@ -59,6 +59,7 @@ export interface ExtractionContext {
59
59
  messages_analyze: Message[];
60
60
  extraction_flag?: "f" | "t" | "p" | "e";
61
61
  roomId?: string;
62
+ sources?: string[];
62
63
  }
63
64
 
64
65
  export interface ExtractionOptions {
@@ -132,6 +133,7 @@ export function queueFactFind(context: ExtractionContext, state: StateManager, o
132
133
  analyze_from_timestamp: getAnalyzeFromTimestamp(chunk),
133
134
  extraction_flag: context.extraction_flag,
134
135
  message_ids_to_mark: chunk.messages_analyze.map(m => m.id),
136
+ sources: context.sources,
135
137
  },
136
138
  });
137
139
  }
@@ -173,6 +175,7 @@ export function queueTopicScan(context: ExtractionContext, state: StateManager,
173
175
  analyze_from_timestamp: getAnalyzeFromTimestamp(chunk),
174
176
  extraction_flag: context.extraction_flag,
175
177
  message_ids_to_mark: chunk.messages_analyze.map(m => m.id),
178
+ sources: context.sources,
176
179
  },
177
180
  });
178
181
  }
@@ -222,6 +225,7 @@ export function queuePersonScan(context: ExtractionContext, state: StateManager,
222
225
  analyze_from_timestamp: getAnalyzeFromTimestamp(chunk),
223
226
  extraction_flag: context.extraction_flag,
224
227
  message_ids_to_mark: chunk.messages_analyze.map(m => m.id),
228
+ sources: context.sources,
225
229
  },
226
230
  });
227
231
  }
@@ -610,6 +614,7 @@ export function queuePersonUpdate(
610
614
  candidateIdentifiers: isNewItem ? candidateIdentifiers : undefined,
611
615
  analyze_from_timestamp: getAnalyzeFromTimestamp(chunk),
612
616
  extraction_model: context.extraction_model,
617
+ sources: context.sources,
613
618
  },
614
619
  });
615
620
  }
@@ -16,6 +16,7 @@ export interface EnsureAgentPersonaOptions {
16
16
  }
17
17
 
18
18
  export function resolveCanonicalAgent(agentName: string): { canonical: string; aliases: string[] } {
19
+ agentName = agentName.replace(/^\p{Z}+|\p{Z}+$/gu, "");
19
20
  for (const [canonical, variants] of Object.entries(AGENT_ALIASES)) {
20
21
  if (variants.includes(agentName)) {
21
22
  return { canonical, aliases: variants };
@@ -112,6 +112,7 @@ import {
112
112
  deleteQueueItems,
113
113
  clearQueue,
114
114
  submitOneShot,
115
+ submitOneShotJSON,
115
116
  } from "./queue-manager.js";
116
117
  import {
117
118
  getRoomList,
@@ -1354,7 +1355,7 @@ const toolNextSteps = new Set([
1354
1355
  if (result.sessionsProcessed > 0) {
1355
1356
  console.log(
1356
1357
  `[Processor] Claude Code sync complete: ${result.sessionsProcessed} sessions, ` +
1357
- `${result.topicsCreated} topics created, ${result.messagesImported} messages imported, ` +
1358
+ `${result.messagesImported} messages imported, ` +
1358
1359
  `${result.extractionScansQueued} extraction scans queued`
1359
1360
  );
1360
1361
  }
@@ -1407,7 +1408,7 @@ const toolNextSteps = new Set([
1407
1408
  if (result.sessionsProcessed > 0) {
1408
1409
  console.log(
1409
1410
  `[Processor] Cursor sync complete: ${result.sessionsProcessed} sessions, ` +
1410
- `${result.topicsCreated} topics created, ${result.messagesImported} messages imported, ` +
1411
+ `${result.messagesImported} messages imported, ` +
1411
1412
  `${result.extractionScansQueued} extraction scans queued`
1412
1413
  );
1413
1414
  }
@@ -1484,6 +1485,10 @@ const toolNextSteps = new Set([
1484
1485
  const guid = response.request.data.guid as string;
1485
1486
  this.interface.onOneShotReturned?.(guid, "");
1486
1487
  }
1488
+ if (response.request.next_step === LLMNextStep.HandleOneShotJSON) {
1489
+ const guid = response.request.data.guid as string;
1490
+ this.interface.onOneShotJSONReturned?.(guid, null);
1491
+ }
1487
1492
  }
1488
1493
 
1489
1494
  this.interface.onError?.({ code, message });
@@ -1529,6 +1534,11 @@ const toolNextSteps = new Set([
1529
1534
  this.interface.onOneShotReturned?.(guid, content);
1530
1535
  }
1531
1536
 
1537
+ if (response.request.next_step === LLMNextStep.HandleOneShotJSON) {
1538
+ const guid = response.request.data.guid as string;
1539
+ this.interface.onOneShotJSONReturned?.(guid, response.parsed ?? null);
1540
+ }
1541
+
1532
1542
  if (response.request.next_step === LLMNextStep.HandlePersonaPreview) {
1533
1543
  const guid = response.request.data.guid as string;
1534
1544
  const loopCounter = (response.request.data.loop_counter as number) ?? 0;
@@ -1994,6 +2004,16 @@ const toolNextSteps = new Set([
1994
2004
  );
1995
2005
  }
1996
2006
 
2007
+ async submitOneShotJSON(guid: string, systemPrompt: string, userPrompt: string): Promise<void> {
2008
+ return submitOneShotJSON(
2009
+ this.stateManager,
2010
+ () => getOneshotModel(this.stateManager),
2011
+ guid,
2012
+ systemPrompt,
2013
+ userPrompt
2014
+ );
2015
+ }
2016
+
1997
2017
  async generatePersonaPreview(
1998
2018
  name: string,
1999
2019
  description: string,
@@ -1,4 +1,5 @@
1
- import type { PersonaEntity, HumanEntity, DataItemBase, Quote, RoomEntity } from "./types.js";
1
+ import type { PersonaEntity, HumanEntity, DataItemBase, Quote, RoomEntity, RoomMessage } from "./types.js";
2
+ import { RoomMode, ContextStatus } from "./types.js";
2
3
  import { StateManager } from "./state-manager.js";
3
4
  import { getEmbeddingService, findTopK } from "./embedding-service.js";
4
5
  import type { ResponsePromptData, PromptOutput } from "../prompts/index.js";
@@ -121,7 +122,12 @@ export async function filterHumanDataByVisibility(
121
122
  selectRelevantQuotes(human.quotes ?? [], currentMessage),
122
123
  ]);
123
124
  const { topics, people } = capTopicsAndPeople(rawTopics, rawPeople);
125
+ const humanName =
126
+ human.settings?.name_display ||
127
+ human.facts?.find(f => f.name === "Nickname/Preferred Name")?.description ||
128
+ "Human";
124
129
  return {
130
+ name: humanName,
125
131
  facts,
126
132
  topics,
127
133
  people,
@@ -158,7 +164,12 @@ export async function filterHumanDataByVisibility(
158
164
  ]);
159
165
  const { topics, people } = capTopicsAndPeople(rawTopics, rawPeople);
160
166
 
167
+ const humanName =
168
+ human.settings?.name_display ||
169
+ human.facts?.find(f => f.name === "Nickname/Preferred Name")?.description ||
170
+ "Human";
161
171
  return {
172
+ name: humanName,
162
173
  facts,
163
174
  topics,
164
175
  people,
@@ -264,14 +275,30 @@ export async function buildRoomResponsePromptData(
264
275
  ? [...sm.getRoomMessages(room.id)].sort((a, b) => a.timestamp.localeCompare(b.timestamp))
265
276
  : activePath;
266
277
 
267
- // Apply time window (same hours setting as 1:1 personas), but guarantee
268
- // at least MIN_ROOM_MESSAGES so rooms never feel like they're starting over.
269
- // Whichever anchor reaches further back wins.
270
- const contextWindowHours = human.settings?.default_context_window_hours ?? 8;
271
- const windowCutoff = new Date(Date.now() - contextWindowHours * 60 * 60 * 1000).toISOString();
272
- const byTime = allSourceMessages.filter(m => m.timestamp >= windowCutoff);
273
- const byCount = allSourceMessages.slice(-MIN_ROOM_MESSAGES);
274
- const sourceMessages = byTime.length >= byCount.length ? byTime : byCount;
278
+ let sourceMessages: RoomMessage[];
279
+ if (room.mode === RoomMode.FreeForAll) {
280
+ const contextWindowHours = room.context_window_hours
281
+ ?? human.settings?.default_context_window_hours
282
+ ?? 8;
283
+ const windowCutoff = new Date(Date.now() - contextWindowHours * 60 * 60 * 1000).toISOString();
284
+ const boundaryMs = room.context_boundary ? new Date(room.context_boundary).getTime() : 0;
285
+ sourceMessages = allSourceMessages.filter(m => {
286
+ const msgMs = new Date(m.timestamp).getTime();
287
+ if (m.context_status === ContextStatus.Always) return true;
288
+ if (m.context_status === ContextStatus.Never) return false;
289
+ if (msgMs < new Date(windowCutoff).getTime()) return false;
290
+ if (boundaryMs && msgMs < boundaryMs) return false;
291
+ return true;
292
+ });
293
+ const byCount = allSourceMessages.slice(-MIN_ROOM_MESSAGES);
294
+ if (byCount.length > sourceMessages.length) sourceMessages = byCount;
295
+ } else {
296
+ const contextWindowHours = human.settings?.default_context_window_hours ?? 8;
297
+ const windowCutoff = new Date(Date.now() - contextWindowHours * 60 * 60 * 1000).toISOString();
298
+ const byTime = allSourceMessages.filter(m => m.timestamp >= windowCutoff);
299
+ const byCount = allSourceMessages.slice(-MIN_ROOM_MESSAGES);
300
+ sourceMessages = byTime.length >= byCount.length ? byTime : byCount;
301
+ }
275
302
 
276
303
  const lastMessage = sourceMessages[sourceMessages.length - 1];
277
304
  const currentMessage = lastMessage ? getMessageContent(lastMessage) : undefined;
@@ -297,7 +324,10 @@ export async function buildRoomResponsePromptData(
297
324
  }
298
325
  otherParticipants.push({
299
326
  id: "human",
300
- name: human.settings?.name_display ?? "Human",
327
+ name:
328
+ human.settings?.name_display ||
329
+ human.facts?.find(f => f.name === "Nickname/Preferred Name")?.description ||
330
+ "Human",
301
331
  traits: [],
302
332
  is_human: true,
303
333
  });
@@ -77,3 +77,21 @@ export async function submitOneShot(
77
77
  data: { guid },
78
78
  });
79
79
  }
80
+
81
+ export async function submitOneShotJSON(
82
+ sm: StateManager,
83
+ getOneshotModel: () => string | undefined,
84
+ guid: string,
85
+ systemPrompt: string,
86
+ userPrompt: string
87
+ ): Promise<void> {
88
+ sm.queue_enqueue({
89
+ type: LLMRequestType.JSON,
90
+ priority: LLMPriority.High,
91
+ system: systemPrompt,
92
+ user: userPrompt,
93
+ next_step: LLMNextStep.HandleOneShotJSON,
94
+ model: getOneshotModel(),
95
+ data: { guid },
96
+ });
97
+ }
@@ -149,8 +149,19 @@ export async function sendFfaMessage(
149
149
  }
150
150
 
151
151
  const now = new Date().toISOString();
152
+
153
+ // FFA human messages always hang off the room root (the initial message with parent_id === null)
154
+ // so the tree is a flat star: root → every human turn, each human turn → persona responses.
155
+ // This gives the context window a bounded, predictable shape instead of a chain.
156
+ const ffaRootMsg = sm.getRoomMessages(roomId).find(m => m.parent_id === null);
157
+ if (!ffaRootMsg) {
158
+ onError({ code: "ROOM_NO_ROOT", message: "FFA room has no root message. Try archiving and recreating the room." });
159
+ return;
160
+ }
161
+ const ffaParentId = ffaRootMsg.id;
162
+
152
163
  const existing = sm.getRoomMessages(roomId).find(
153
- m => m.role === "human" && m.parent_id === room.active_node_id
164
+ m => m.role === "human" && m.id === room.active_node_id && m.parent_id === ffaParentId
154
165
  );
155
166
 
156
167
  let humanMsgId: string;
@@ -164,7 +175,7 @@ export async function sendFfaMessage(
164
175
  } else {
165
176
  const msg: RoomMessage = {
166
177
  id: crypto.randomUUID(),
167
- parent_id: room.active_node_id,
178
+ parent_id: ffaParentId,
168
179
  role: "human",
169
180
  verbal_response: content ?? undefined,
170
181
  silence_reason: content ? undefined : (silenceReason ?? "passed"),
@@ -278,9 +289,14 @@ export async function activateRoom(
278
289
 
279
290
  const currentRound = allMessages.filter(m => m.parent_id === room.active_node_id);
280
291
 
292
+ const humanDisplayName =
293
+ human.settings?.name_display ||
294
+ human.facts?.find(f => f.name === "Nickname/Preferred Name")?.description ||
295
+ "Human";
296
+
281
297
  const context: RoomHistoryMessage[] = sm.getRoomActivePath(roomId).map(m => ({
282
298
  speaker_name: m.role === "human"
283
- ? (human.settings?.name_display ?? "Human")
299
+ ? humanDisplayName
284
300
  : (sm.persona_getById(m.persona_id ?? "")?.display_name ?? "Unknown"),
285
301
  speaker_id: m.role === "human" ? "human" : (m.persona_id ?? ""),
286
302
  verbal_response: getMessageContent(m) || undefined,
@@ -290,7 +306,7 @@ export async function activateRoom(
290
306
  const candidates: RoomJudgeCandidate[] = currentRound.map(m => ({
291
307
  message_id: m.id,
292
308
  speaker_name: m.role === "human"
293
- ? (human.settings?.name_display ?? "Human")
309
+ ? humanDisplayName
294
310
  : (sm.persona_getById(m.persona_id ?? "")?.display_name ?? "Unknown"),
295
311
  speaker_id: m.role === "human" ? "human" : (m.persona_id ?? ""),
296
312
  verbal_response: getMessageContent(m) || undefined,
@@ -305,6 +321,7 @@ export async function activateRoom(
305
321
  long_description: judgePersona.long_description,
306
322
  traits: judgePersona.traits,
307
323
  },
324
+ human: { name: humanDisplayName },
308
325
  context,
309
326
  candidates,
310
327
  });
@@ -46,8 +46,8 @@ export class PersonaState {
46
46
  return this.personas.get(id)?.entity ?? null;
47
47
  }
48
48
 
49
- getByName(nameOrAlias: string): PersonaEntity | null {
50
- const searchTerm = nameOrAlias.toLowerCase();
49
+ getByName(nameOrAlias: string): PersonaEntity | null {
50
+ const searchTerm = nameOrAlias.replace(/^\p{Z}+|\p{Z}+$/gu, "").toLowerCase();
51
51
 
52
52
  // Priority 1: Exact display_name match
53
53
  for (const data of this.personas.values()) {