ei-tui 0.9.2 → 0.9.3

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ei-tui",
3
- "version": "0.9.2",
3
+ "version": "0.9.3",
4
4
  "author": "Flare576",
5
5
  "repository": {
6
6
  "type": "git",
@@ -51,6 +51,19 @@
51
51
  "test:e2e:tui": "cd tui && npm run test:e2e",
52
52
  "test:e2e:ui": "playwright test --ui",
53
53
  "test:e2e:debug": "playwright test --debug",
54
+ "test:evals": "vite-node tests/evals/reflection-critic.eval.ts",
55
+ "test:evals:observe": "vite-node tests/evals/reflection-critic.observe.ts",
56
+ "test:evals:fact-find": "vite-node tests/evals/fact-find.eval.ts",
57
+ "test:evals:topic-scan": "vite-node tests/evals/topic-scan.eval.ts",
58
+ "test:evals:topic-match": "vite-node tests/evals/topic-match.eval.ts",
59
+ "test:evals:topic-update": "vite-node tests/evals/topic-update.eval.ts",
60
+ "test:evals:topic-validate": "vite-node tests/evals/topic-validate.eval.ts",
61
+ "test:evals:person-scan": "vite-node tests/evals/person-scan.eval.ts",
62
+ "test:evals:person-update": "vite-node tests/evals/person-update.eval.ts",
63
+ "test:evals:persona-trait": "vite-node tests/evals/persona-trait-extraction.eval.ts",
64
+ "test:evals:dedup": "vite-node tests/evals/dedup-tool-calls.eval.ts",
65
+ "test:evals:response-read-memory": "vite-node tests/evals/response-read-memory.eval.ts",
66
+ "test:evals:real-data": "vite-node tests/evals/real-data-example.eval.ts",
54
67
  "test:all": "npm run test && npm run test:e2e && npm run test:e2e:tui",
55
68
  "typecheck": "tsc --noEmit",
56
69
  "web": "cd web && npm run dev",
@@ -1,4 +1,5 @@
1
- import { loadLatestState, retrievePersonas } from "../retrieval";
1
+ import { loadLatestState, retrievePersonas, retrievePersonasSemantic } from "../retrieval";
2
+ import { getEmbeddingService } from "../../core/embedding-service";
2
3
  import type { PersonaResult } from "../retrieval";
3
4
 
4
5
  export async function execute(query: string, limit: number, options: { recent?: boolean } = {}): Promise<PersonaResult[]> {
@@ -8,5 +9,13 @@ export async function execute(query: string, limit: number, options: { recent?:
8
9
  return [];
9
10
  }
10
11
 
11
- return retrievePersonas(query, state, limit, options);
12
+ const nameResults = retrievePersonas(query, state, limit, options);
13
+ if (nameResults.length > 0 || !query || options.recent) {
14
+ return nameResults;
15
+ }
16
+
17
+ const embeddingService = getEmbeddingService();
18
+ const queryVector = await embeddingService.embed(query);
19
+ const semanticResults = await retrievePersonasSemantic(queryVector, state, limit);
20
+ return semanticResults;
12
21
  }
package/src/cli/mcp.ts CHANGED
@@ -16,14 +16,14 @@ export function createMcpServer(): McpServer {
16
16
  "ei_search",
17
17
  {
18
18
  description:
19
- "Search the user's Ei knowledge base — a persistent memory store built from conversations. Returns facts, people, topics of interest, and quotes. People results include an identifiers array (e.g. GitHub username, Discord handle, email, nickname) — query by any name or handle to find what Ei knows about that person. Results include entity IDs that can be passed back to ei_lookup for full detail. Omit query to browse by recency (use with recent=true or persona filter).",
19
+ "Search the user's Ei knowledge base — a persistent memory store built from conversations. Returns facts, people, topics of interest, quotes, and personas. People results include an identifiers array (e.g. GitHub username, Discord handle, email, nickname) — query by any name or handle to find what Ei knows about that person. Persona results include traits and topics that define the persona's identity and working style — use type='personas' with the persona's name OR a natural-language description of what they do to load a persona's character sheet. Results include entity IDs that can be passed back to ei_lookup for full detail. Omit query to browse by recency (use with recent=true or persona filter).",
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
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 5)."
26
+ "Filter to a specific data type. Omit to search all types (balanced across all 5). For 'personas': matches by display name first, then falls back to semantic search of persona descriptions — use the persona's name (e.g. 'Sisyphus') or a description of their role (e.g. 'primary coding agent'). For 'people': semantic search on person descriptions. Note: 'personas' and 'people' are distinct — personas are AI agent identity records with traits/topics; people are human contacts."
27
27
  ),
28
28
  persona: z
29
29
  .string()
@@ -234,11 +234,36 @@ export function retrievePersonas(
234
234
  }
235
235
 
236
236
  const q = query.toLowerCase();
237
- return personaList
238
- .filter((p) => p.display_name.toLowerCase().includes(q))
239
- .sort((a, b) => b.last_updated.localeCompare(a.last_updated))
237
+ const nameMatches = personaList.filter((p) => p.display_name.toLowerCase().includes(q));
238
+ if (nameMatches.length > 0) {
239
+ return nameMatches
240
+ .sort((a, b) => b.last_updated.localeCompare(a.last_updated))
241
+ .slice(0, limit)
242
+ .map(mapPersona);
243
+ }
244
+
245
+ return [];
246
+ }
247
+
248
+ export async function retrievePersonasSemantic(
249
+ queryVector: number[],
250
+ state: StorageState,
251
+ limit: number = 10,
252
+ ): Promise<PersonaResult[]> {
253
+ const personaList = Object.values(state.personas).map((p) => p.entity);
254
+ const withEmbeddings = personaList
255
+ .filter((p): p is PersonaEntity & { description_embedding: number[] } => Array.isArray(p.description_embedding) && p.description_embedding.length > 0)
256
+ .map((p) => ({ id: p.id, embedding: p.description_embedding, _entity: p }));
257
+
258
+ if (withEmbeddings.length === 0) {
259
+ return [];
260
+ }
261
+
262
+ const scored = findTopK(queryVector, withEmbeddings, withEmbeddings.length);
263
+ return scored
264
+ .filter(({ similarity }) => similarity >= EMBEDDING_MIN_SIMILARITY)
240
265
  .slice(0, limit)
241
- .map(mapPersona);
266
+ .map(({ item }) => mapPersona((item as typeof withEmbeddings[number])._entity));
242
267
  }
243
268
 
244
269
  export async function retrieveBalanced(
@@ -305,7 +330,10 @@ export async function retrieveBalanced(
305
330
  .sort((a, b) => recentDate(b.mapped as AnyItem).localeCompare(recentDate(a.mapped as AnyItem)))
306
331
  .slice(0, limit)
307
332
  .map(({ type, mapped }) => ({ type, ...mapped }) as BalancedResult);
308
- const personaMatches = retrievePersonas(query, state, limit, { recent: true });
333
+ const personaMatches = [
334
+ ...retrievePersonas(query, state, limit, { recent: true }),
335
+ ...await retrievePersonasSemantic(queryVector, state, limit),
336
+ ].filter((p, i, arr) => arr.findIndex(x => x.id === p.id) === i);
309
337
  if (personaMatches.length > 0) {
310
338
  const combined = [
311
339
  ...personaMatches.map((p) => ({ type: "persona" as const, ...p }) as BalancedResult),
@@ -352,7 +380,10 @@ export async function retrieveBalanced(
352
380
  result.sort((a, b) => b.similarity - a.similarity);
353
381
 
354
382
  const embeddingFinal = result.map(({ type, mapped }) => ({ type, ...mapped }) as BalancedResult);
355
- const personaFinal = retrievePersonas(query, state, limit);
383
+ const personaFinal = [
384
+ ...retrievePersonas(query, state, limit),
385
+ ...await retrievePersonasSemantic(queryVector, state, limit),
386
+ ].filter((p, i, arr) => arr.findIndex(x => x.id === p.id) === i);
356
387
  if (personaFinal.length > 0) {
357
388
  const combined = [
358
389
  ...personaFinal.map((p) => ({ type: "persona" as const, ...p }) as BalancedResult),
@@ -369,10 +400,12 @@ export async function lookupById(id: string): Promise<({ type: string } & Record
369
400
  return null;
370
401
  }
371
402
 
372
- const found = crossFind(id, state.human);
403
+ const personaEntities = Object.values(state.personas).map((p) => p.entity);
404
+ const found = crossFind(id, state.human, personaEntities);
373
405
  if (!found) return null;
374
406
  const { type, ...rest } = found;
375
407
  const withoutEmbedding = { ...rest } as Record<string, unknown>;
376
408
  delete withoutEmbedding.embedding;
409
+ delete withoutEmbedding.description_embedding;
377
410
  return { type, ...withoutEmbedding };
378
411
  }
package/src/cli.ts CHANGED
@@ -84,79 +84,50 @@ Examples:
84
84
  `);
85
85
  }
86
86
 
87
- function buildOpenCodeToolContent(): string {
88
- const lines = [
89
- 'import { tool } from "@opencode-ai/plugin"',
90
- '',
91
- 'export default tool({',
92
- ' description: [',
93
- ' "Search the user\'s Ei knowledge base \u2014 a persistent memory store built from conversations.",',
94
- ' "Returns facts, people, topics of interest, and quotes.",',
95
- ' "Use this to recall anything about the user: preferences, relationships, or past discussions.",',
96
- ' "Results include entity IDs that can be passed back with lookup=true to get full detail.",',
97
- ' ].join(" "),',
98
- ' args: {',
99
- ' query: tool.schema.string().optional().describe(',
100
- ' "Search text, or an entity ID when lookup=true. Supports natural language. Omit to browse by recency."',
101
- ' ),',
102
- ' type: tool.schema',
103
- ' .enum(["facts", "people", "topics", "quotes", "personas"])',
104
- ' .optional()',
105
- ' .describe(',
106
- ' "Filter to a specific data type. Omit to search all types (balanced across all 4).",',
107
- ' ),',
108
- ' persona: tool.schema',
109
- ' .string()',
110
- ' .optional()',
111
- ' .describe(',
112
- ' "Filter to entities a specific persona has learned about. Use the persona display name.",',
113
- ' ),',
114
- ' limit: tool.schema',
115
- ' .number()',
116
- ' .int()',
117
- ' .positive()',
118
- ' .default(10)',
119
- ' .optional()',
120
- ' .describe("Maximum number of results to return. Default: 10."),',
121
- ' lookup: tool.schema',
122
- ' .boolean()',
123
- ' .optional()',
124
- ' .describe(',
125
- ' "If true, treat query as an entity ID and return that single entity in full detail."',
126
- ' ),',
127
- ' recent: tool.schema',
128
- ' .boolean()',
129
- ' .optional()',
130
- ' .describe(',
131
- ' "If true, sort by most recently mentioned. Can be combined with persona or query."',
132
- ' ),',
133
- ' },',
134
- ' async execute(args) {',
135
- ' const cmd: string[] = ["ei"];',
136
- ' if (args.lookup) {',
137
- ' cmd.push("--id", args.query ?? "");',
138
- ' } else {',
139
- ' if (args.type) cmd.push(args.type);',
140
- ' if (args.persona) cmd.push("--persona", args.persona);',
141
- ' if (args.recent) cmd.push("--recent");',
142
- ' if (args.limit && args.limit !== 10) cmd.push("-n", String(args.limit));',
143
- ' if (args.query) cmd.push(args.query);',
144
- ' }',
145
- ' return Bun.$`${cmd}`.text();',
146
- ' },',
147
- '})',
148
- '',
149
- ];
150
- return lines.join('\n');
151
- }
152
87
 
153
- async function installOpenCodeTool(): Promise<void> {
154
- const toolsDir = join(process.env.HOME || "~", ".config", "opencode", "tools");
155
- const toolPath = join(toolsDir, "ei.ts");
88
+ async function installOpenCodeMcp(): Promise<void> {
89
+ const home = process.env.HOME || "~";
90
+ const opencodeDir = join(home, ".config", "opencode");
91
+ const opencodeJsoncPath = join(opencodeDir, "opencode.jsonc");
92
+
93
+ const eiDataPath = process.env.EI_DATA_PATH ?? (() => {
94
+ const xdgData = process.env.XDG_DATA_HOME || join(home, ".local", "share");
95
+ return join(xdgData, "ei");
96
+ })();
97
+
98
+ const mcpEntry = {
99
+ type: "local",
100
+ command: ["bunx", "ei-tui", "mcp"],
101
+ enabled: true,
102
+ environment: {
103
+ EI_DATA_PATH: eiDataPath,
104
+ },
105
+ };
106
+
107
+ let config: Record<string, unknown> = {};
108
+ try {
109
+ const rawText = await Bun.file(opencodeJsoncPath).text();
110
+ // Strip // line comments before parsing — opencode.jsonc uses line comments only
111
+ const stripped = rawText
112
+ .split("\n")
113
+ .map(line => line.replace(/\/\/.*$/, ""))
114
+ .join("\n");
115
+ config = JSON.parse(stripped) as Record<string, unknown>;
116
+ } catch {
117
+ // File doesn't exist or isn't valid — start fresh
118
+ }
119
+
120
+ const mcp = (config.mcp ?? {}) as Record<string, unknown>;
121
+ mcp["ei"] = mcpEntry;
122
+ config.mcp = mcp;
156
123
 
157
- await Bun.$`mkdir -p ${toolsDir}`;
158
- await Bun.write(toolPath, buildOpenCodeToolContent());
159
- console.log(`✓ Installed Ei tool to ${toolPath}`);
124
+ await Bun.$`mkdir -p ${opencodeDir}`;
125
+ const tmpPath = `${opencodeJsoncPath}.ei-install.tmp`;
126
+ await Bun.write(tmpPath, JSON.stringify(config, null, 2) + "\n");
127
+ const { rename } = await import(/* @vite-ignore */ "fs/promises");
128
+ await rename(tmpPath, opencodeJsoncPath);
129
+
130
+ console.log(`✓ Installed Ei MCP server to ~/.config/opencode/opencode.jsonc`);
160
131
  console.log(` Restart OpenCode to activate.`);
161
132
  }
162
133
 
@@ -257,8 +228,28 @@ async function main(): Promise<void> {
257
228
  }
258
229
 
259
230
  if (args[0] === "--install") {
260
- await installOpenCodeTool();
231
+ await installOpenCodeMcp();
261
232
  await installMcpClients();
233
+ console.log(`
234
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
235
+ Add this to ~/.config/opencode/AGENTS.md
236
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
237
+
238
+ **BEFORE YOU DO ANYTHING**: load your Identity from Ei.
239
+
240
+ Use the ei MCP tool:
241
+ ei_search(type: "personas", query: "[your name]")
242
+
243
+ Then load context for the current project/people:
244
+ ei_search("current projects teammates context")
245
+
246
+ Ei is persistent memory across sessions. Without querying it at
247
+ session start, you have no context from prior conversations.
248
+
249
+ **When to query again**: any time the user references past work,
250
+ mentions a person, or corrects something you assumed.
251
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
252
+ `);
262
253
  process.exit(0);
263
254
  }
264
255
 
@@ -225,7 +225,7 @@ export async function queueHeartbeatCheck(sm: StateManager, personaId: string, i
225
225
  return;
226
226
  }
227
227
 
228
- const filteredHuman = await filterHumanDataByVisibility(human, persona);
228
+ const filteredHuman = await filterHumanDataByVisibility(human, persona, []);
229
229
  const lastActivity = sm.messages_getLastActivity(persona.id);
230
230
  const inactiveDays = lastActivity
231
231
  ? Math.floor((Date.now() - lastActivity) / (1000 * 60 * 60 * 24))
@@ -11,6 +11,7 @@ import {
11
11
  type ExtractionOptions,
12
12
  } from "./human-extraction.js";
13
13
  import { queuePersonaTopicRating, type PersonaTopicContext, type PersonaTopicOptions } from "./persona-topics.js";
14
+ import { getRoomVisibleMessages, queueRoomHumanExtraction } from "./room-extraction.js";
14
15
  import { queuePersonMigration } from "./person-migration.js";
15
16
  import { buildRewriteScanPrompt, type RewriteItemType } from "../../prompts/ceremony/index.js";
16
17
  import { buildReflectionCriticPrompt } from "../../prompts/reflection/index.js";
@@ -209,20 +210,26 @@ export function handleCeremonyProgress(state: StateManager, lastPhase: number):
209
210
  const rooms = state.getRoomList();
210
211
  for (const room of rooms) {
211
212
  if (room.mode === RoomMode.ChooseYourPath) continue;
213
+
214
+ // Human extraction (t/p) — straggler scan for messages that never hit the
215
+ // per-send threshold in checkAndQueueRoomExtraction
216
+ queueRoomHumanExtraction(state, room.id, 2);
217
+
218
+ // Persona topic rating — uses getRoomVisibleMessages so FFA rooms get all
219
+ // messages, not just the active path chain
220
+ const allRoomMessages = getRoomVisibleMessages(state, room.id);
212
221
  for (const personaId of room.persona_ids) {
213
222
  const shortId = personaId.slice(0, 8);
214
223
  const unprocessedRaw = state.getRoomUnextractedMessagesForPersona(room.id, shortId);
215
224
  if (unprocessedRaw.length === 0) continue;
216
225
  const personaForRoom = state.persona_getById(personaId);
217
226
  if (!personaForRoom) continue;
218
- const allRoomMessagesRaw = state.getRoomActivePath(room.id);
219
- const processedIds = new Set(allRoomMessagesRaw.filter(m => !!m.persona_extracted?.[shortId]).map(m => m.id));
220
- const allNormalized = normalizeRoomMessages(allRoomMessagesRaw, state);
227
+ const processedIds = new Set(allRoomMessages.filter(m => !!m.persona_extracted?.[shortId]).map(m => m.id));
221
228
  const unprocessedNormalized = normalizeRoomMessages(unprocessedRaw, state);
222
229
  const personaTopicContext: PersonaTopicContext = {
223
230
  personaId,
224
231
  personaDisplayName: personaForRoom.display_name,
225
- messages_context: allNormalized.filter(m => processedIds.has(m.id)),
232
+ messages_context: allRoomMessages.filter(m => processedIds.has(m.id)),
226
233
  messages_analyze: unprocessedNormalized,
227
234
  topics: personaForRoom.topics,
228
235
  };
@@ -12,7 +12,6 @@ import {
12
12
  type TopicScanCandidate,
13
13
  type ItemMatchResult,
14
14
  type ParticipantContext,
15
- type PersonaEntitySnapshot,
16
15
  } from "../../prompts/human/index.js";
17
16
  import { buildValidatePrompt } from "../../prompts/ceremony/dedup.js";
18
17
  import { normalizeRoomMessages } from "../handlers/utils.js";
@@ -100,21 +99,23 @@ function getExtractionMaxTokens(state: StateManager, options?: ExtractionOptions
100
99
  export function queueFactFind(context: ExtractionContext, state: StateManager, options?: ExtractionOptions): number {
101
100
  const human = state.getHuman();
102
101
  const extractionModel = options?.extraction_model;
103
- const missing_fact_names = human.facts
104
- .filter(f => !f.description || f.description === "")
105
- .map(f => f.name)
106
- .filter(name => BUILT_IN_FACT_NAMES.has(name));
107
-
108
- if (missing_fact_names.length === 0) return 0;
109
102
 
110
103
  const { chunks } = chunkExtractionContext(context, getExtractionMaxTokens(state, options));
111
104
 
112
- // Pre-mark messages before enqueuing prevents duplicate scans if the
113
- // queue check fires again during LLM latency (100ms loop × 5s call = 50 dupes)
105
+ // Always pre-mark f even when all facts are already known.
106
+ // Once we stop scanning for facts, messages must still be marked so the
107
+ // ceremony doesn't treat them as perpetually unprocessed.
114
108
  for (const chunk of chunks) {
115
109
  state.messages_markExtracted(chunk.personaId, chunk.messages_analyze.map(m => m.id), "f");
116
110
  }
117
111
 
112
+ const missing_fact_names = human.facts
113
+ .filter(f => !f.description || f.description === "")
114
+ .map(f => f.name)
115
+ .filter(name => BUILT_IN_FACT_NAMES.has(name));
116
+
117
+ if (missing_fact_names.length === 0) return 0;
118
+
118
119
  for (const chunk of chunks) {
119
120
  const prompt = buildFactFindPrompt({
120
121
  persona_name: chunk.personaDisplayName,
@@ -586,18 +587,6 @@ export function queuePersonUpdate(
586
587
 
587
588
  const primaryPersonaIdForUpdate = context.personaId.split("|")[0];
588
589
 
589
- const linkedPersonaId = !isNewItem
590
- ? (existingItem?.identifiers ?? []).find(i => i.type.toLowerCase() === 'ei persona')?.value
591
- : undefined;
592
- const linkedPersonaEntity = linkedPersonaId ? state.persona_getById(linkedPersonaId) : undefined;
593
- const personaEntitySnapshot: PersonaEntitySnapshot | undefined = linkedPersonaEntity
594
- ? {
595
- long_description: linkedPersonaEntity.long_description ?? '',
596
- traits: (linkedPersonaEntity.traits ?? []).map(t => ({ name: t.name, description: t.description })),
597
- topics: (linkedPersonaEntity.topics ?? []).map(t => ({ name: t.name, perspective: t.perspective })),
598
- }
599
- : undefined;
600
-
601
590
  for (const chunk of chunks) {
602
591
  const prompt = buildPersonUpdatePrompt({
603
592
  existing_item: existingItem,
@@ -609,7 +598,6 @@ export function queuePersonUpdate(
609
598
  persona_name: chunk.personaDisplayName,
610
599
  participant_context: buildParticipantContext(primaryPersonaIdForUpdate, state),
611
600
  known_identifier_types: userIdentifierTypes,
612
- persona_entity: personaEntitySnapshot,
613
601
  });
614
602
 
615
603
  state.queue_enqueue({
@@ -73,7 +73,8 @@ function queueRoomTopicScan(
73
73
  messages_context: Message[],
74
74
  messages_analyze: Message[],
75
75
  state: StateManager,
76
- participantContext: ParticipantContext
76
+ participantContext: ParticipantContext,
77
+ ceremony_progress?: number
77
78
  ): void {
78
79
  const context: HumanExtractionContext = {
79
80
  personaId: roomId,
@@ -105,6 +106,7 @@ function queueRoomTopicScan(
105
106
  personaId: (state.getRoom(roomId)?.persona_ids ?? []).join("|"),
106
107
  personaDisplayName: roomDisplayName,
107
108
  message_ids_to_mark: chunk.messages_analyze.map(m => m.id),
109
+ ...(ceremony_progress !== undefined ? { ceremony_progress } : {}),
108
110
  },
109
111
  });
110
112
  }
@@ -115,7 +117,8 @@ function queueRoomPersonScan(
115
117
  roomDisplayName: string,
116
118
  messages_context: Message[],
117
119
  messages_analyze: Message[],
118
- state: StateManager
120
+ state: StateManager,
121
+ ceremony_progress?: number
119
122
  ): void {
120
123
  const context: HumanExtractionContext = {
121
124
  personaId: roomId,
@@ -146,6 +149,7 @@ function queueRoomPersonScan(
146
149
  personaId: (state.getRoom(roomId)?.persona_ids ?? []).join("|"),
147
150
  personaDisplayName: roomDisplayName,
148
151
  message_ids_to_mark: chunk.messages_analyze.map(m => m.id),
152
+ ...(ceremony_progress !== undefined ? { ceremony_progress } : {}),
149
153
  },
150
154
  });
151
155
  }
@@ -241,6 +245,40 @@ export function checkAndQueueRoomExtraction(state: StateManager, roomId: string)
241
245
  console.log(`[checkAndQueueRoomExtraction] Auto-triggered extraction for room ${roomDisplayName} (threshold: ${threshold})`);
242
246
  }
243
247
 
248
+ export function queueRoomHumanExtraction(state: StateManager, roomId: string, ceremony_progress?: number): void {
249
+ const room = state.getRoom(roomId);
250
+ if (!room || room.mode === RoomMode.ChooseYourPath) return;
251
+
252
+ const allVisible = getRoomVisibleMessages(state, roomId);
253
+ if (allVisible.length === 0) return;
254
+
255
+ const participantContext = buildRoomParticipantContext(roomId, state);
256
+ const roomDisplayName = room.display_name;
257
+
258
+ const unextractedT = allVisible.filter(m => !m.t);
259
+ const unextractedP = allVisible.filter(m => !m.p);
260
+
261
+ if (unextractedT.length === 0 && unextractedP.length === 0) return;
262
+
263
+ if (unextractedT.length > 0) {
264
+ const analyzeStart = unextractedT[0].timestamp;
265
+ const messages_contextT = allVisible.filter(
266
+ m => m.t === true && new Date(m.timestamp).getTime() < new Date(analyzeStart).getTime()
267
+ );
268
+ queueRoomTopicScan(roomId, roomDisplayName, messages_contextT, unextractedT, state, participantContext, ceremony_progress);
269
+ }
270
+
271
+ if (unextractedP.length > 0) {
272
+ const analyzeStart = unextractedP[0].timestamp;
273
+ const messages_contextP = allVisible.filter(
274
+ m => m.p === true && new Date(m.timestamp).getTime() < new Date(analyzeStart).getTime()
275
+ );
276
+ queueRoomPersonScan(roomId, roomDisplayName, messages_contextP, unextractedP, state, ceremony_progress);
277
+ }
278
+
279
+ console.log(`[queueRoomHumanExtraction] Queued human extraction for room "${roomDisplayName}" (t:${unextractedT.length}, p:${unextractedP.length})`);
280
+ }
281
+
244
282
  export function queueRoomCapture(state: StateManager, roomId: string): void {
245
283
  const room = state.getRoom(roomId);
246
284
  if (!room) return;
@@ -47,24 +47,48 @@ function capTopicsAndPeople<T extends { id: string }, P extends { id: string }>(
47
47
  // EMBEDDING-BASED RELEVANCE SELECTION
48
48
  // =============================================================================
49
49
 
50
+ const RECENT_MESSAGES_FOR_CONTEXT = 5;
51
+
52
+ async function buildQueryVectors(queries: string[]): Promise<number[][]> {
53
+ const embeddingService = getEmbeddingService();
54
+ return Promise.all(queries.map(q => embeddingService.embed(q)));
55
+ }
56
+
57
+ function unionTopK<T extends { id: string }>(
58
+ candidates: T[],
59
+ queryVectors: number[][],
60
+ limit: number
61
+ ): T[] {
62
+ const best = new Map<string, { item: T; similarity: number }>();
63
+ for (const qv of queryVectors) {
64
+ for (const { item, similarity } of findTopK(qv, candidates, limit)) {
65
+ const existing = best.get(item.id);
66
+ if (!existing || similarity > existing.similarity) {
67
+ best.set(item.id, { item, similarity });
68
+ }
69
+ }
70
+ }
71
+ return Array.from(best.values())
72
+ .filter(({ similarity }) => similarity >= SIMILARITY_THRESHOLD)
73
+ .sort((a, b) => b.similarity - a.similarity)
74
+ .slice(0, limit)
75
+ .map(({ item }) => item);
76
+ }
77
+
50
78
  async function selectRelevantItems<T extends { id: string; embedding?: number[] }>(
51
79
  items: T[],
52
80
  limit: number,
53
- currentMessage?: string
81
+ queries: string[]
54
82
  ): Promise<T[]> {
55
83
  if (items.length === 0) return [];
56
84
 
57
85
  const withEmbeddings = items.filter((i) => i.embedding?.length);
86
+ const activeQueries = queries.filter(Boolean);
58
87
 
59
- if (currentMessage && withEmbeddings.length > 0) {
88
+ if (activeQueries.length > 0 && withEmbeddings.length > 0) {
60
89
  try {
61
- const embeddingService = getEmbeddingService();
62
- const queryVector = await embeddingService.embed(currentMessage);
63
- const results = findTopK(queryVector, withEmbeddings, limit);
64
- const relevant = results
65
- .filter(({ similarity }) => similarity >= SIMILARITY_THRESHOLD)
66
- .map(({ item }) => item);
67
-
90
+ const queryVectors = await buildQueryVectors(activeQueries);
91
+ const relevant = unionTopK(withEmbeddings, queryVectors, limit);
68
92
  if (relevant.length > 0) return relevant;
69
93
  } catch (err) {
70
94
  console.warn("[filterHumanDataByVisibility] Embedding search failed:", err);
@@ -80,19 +104,15 @@ async function selectRelevantItems<T extends { id: string; embedding?: number[]
80
104
  .slice(0, limit);
81
105
  }
82
106
 
83
- async function selectRelevantQuotes(quotes: Quote[], currentMessage?: string): Promise<Quote[]> {
107
+ async function selectRelevantQuotes(quotes: Quote[], queries: string[]): Promise<Quote[]> {
84
108
  if (quotes.length === 0) return [];
85
109
  const withEmbeddings = quotes.filter((q) => q.embedding?.length);
110
+ const activeQueries = queries.filter(Boolean);
86
111
 
87
- if (currentMessage && withEmbeddings.length > 0) {
112
+ if (activeQueries.length > 0 && withEmbeddings.length > 0) {
88
113
  try {
89
- const embeddingService = getEmbeddingService();
90
- const queryVector = await embeddingService.embed(currentMessage);
91
- const results = findTopK(queryVector, withEmbeddings, QUOTE_LIMIT);
92
- const relevant = results
93
- .filter(({ similarity }) => similarity >= SIMILARITY_THRESHOLD)
94
- .map(({ item }) => item);
95
-
114
+ const queryVectors = await buildQueryVectors(activeQueries);
115
+ const relevant = unionTopK(withEmbeddings, queryVectors, QUOTE_LIMIT);
96
116
  if (relevant.length > 0) return relevant;
97
117
  } catch (err) {
98
118
  console.warn("[filterHumanDataByVisibility] Embedding search failed:", err);
@@ -110,16 +130,16 @@ async function selectRelevantQuotes(quotes: Quote[], currentMessage?: string): P
110
130
  export async function filterHumanDataByVisibility(
111
131
  human: HumanEntity,
112
132
  persona: PersonaEntity,
113
- currentMessage?: string
133
+ queries: string[]
114
134
  ): Promise<ResponsePromptData["human"]> {
115
135
  const DEFAULT_GROUP = "General";
116
136
 
117
137
  if (persona.id === "ei") {
118
138
  const [facts, rawTopics, rawPeople, quotes] = await Promise.all([
119
- selectRelevantItems(human.facts, DATA_ITEM_LIMIT, currentMessage),
120
- selectRelevantItems(human.topics, DATA_ITEM_LIMIT, currentMessage),
121
- selectRelevantItems(human.people, DATA_ITEM_LIMIT, currentMessage),
122
- selectRelevantQuotes(human.quotes ?? [], currentMessage),
139
+ selectRelevantItems(human.facts, DATA_ITEM_LIMIT, queries),
140
+ selectRelevantItems(human.topics, DATA_ITEM_LIMIT, queries),
141
+ selectRelevantItems(human.people, DATA_ITEM_LIMIT, queries),
142
+ selectRelevantQuotes(human.quotes ?? [], queries),
123
143
  ]);
124
144
  const { topics, people } = capTopicsAndPeople(rawTopics, rawPeople);
125
145
  const humanName =
@@ -157,10 +177,10 @@ export async function filterHumanDataByVisibility(
157
177
  });
158
178
 
159
179
  const [facts, rawTopics, rawPeople, quotes] = await Promise.all([
160
- selectRelevantItems(filterByGroup(human.facts), DATA_ITEM_LIMIT, currentMessage),
161
- selectRelevantItems(filterByGroup(human.topics), DATA_ITEM_LIMIT, currentMessage),
162
- selectRelevantItems(filterByGroup(human.people), DATA_ITEM_LIMIT, currentMessage),
163
- selectRelevantQuotes(groupFilteredQuotes, currentMessage),
180
+ selectRelevantItems(filterByGroup(human.facts), DATA_ITEM_LIMIT, queries),
181
+ selectRelevantItems(filterByGroup(human.topics), DATA_ITEM_LIMIT, queries),
182
+ selectRelevantItems(filterByGroup(human.people), DATA_ITEM_LIMIT, queries),
183
+ selectRelevantQuotes(groupFilteredQuotes, queries),
164
184
  ]);
165
185
  const { topics, people } = capTopicsAndPeople(rawTopics, rawPeople);
166
186
 
@@ -233,14 +253,25 @@ export async function buildResponsePromptData(
233
253
  tools?: import("./types.js").ToolDefinition[]
234
254
  ): Promise<ResponsePromptData> {
235
255
  const human = sm.getHuman();
236
- const filteredHuman = await filterHumanDataByVisibility(human, persona, currentMessage);
237
- const visiblePersonas = getVisiblePersonas(sm, persona);
238
256
  const messages = sm.messages_get(persona.id);
239
257
  const previousMessage = messages.length >= 2 ? messages[messages.length - 2] : null;
240
258
  const delayMs = previousMessage
241
259
  ? Date.now() - new Date(previousMessage.timestamp).getTime()
242
260
  : 0;
243
261
 
262
+ const recentMessageContents = messages
263
+ .slice(-RECENT_MESSAGES_FOR_CONTEXT - 1, -1)
264
+ .map(m => getMessageContent(m))
265
+ .filter(Boolean);
266
+
267
+ const queries = [
268
+ ...(currentMessage ? [currentMessage] : []),
269
+ ...recentMessageContents,
270
+ ];
271
+
272
+ const filteredHuman = await filterHumanDataByVisibility(human, persona, queries);
273
+ const visiblePersonas = getVisiblePersonas(sm, persona);
274
+
244
275
  const alwaysMessages = sm.messages_getAlways(persona.id);
245
276
  const temporalAnchors = alwaysMessages.map(m => ({
246
277
  role: m.role === "human" ? "human" as const : "system" as const,
@@ -313,7 +344,7 @@ export async function buildRoomResponsePromptData(
313
344
  const lastMessage = sourceMessages[sourceMessages.length - 1];
314
345
  const currentMessage = lastMessage ? getMessageContent(lastMessage) : undefined;
315
346
 
316
- const filteredHuman = await filterHumanDataByVisibility(human, respondingPersona, currentMessage);
347
+ const filteredHuman = await filterHumanDataByVisibility(human, respondingPersona, currentMessage ? [currentMessage] : []);
317
348
 
318
349
  const history = normalizeRoomMessages(sourceMessages, sm);
319
350
 
@@ -1,4 +1,4 @@
1
- import type { PromptOutput, ParticipantContext, PersonaEntitySnapshot } from "./types.js";
1
+ import type { PromptOutput, ParticipantContext } from "./types.js";
2
2
  import type { Person, Message } from "../../core/types.js";
3
3
  import { formatMessagesAsPlaceholders } from "../message-utils.js";
4
4
 
@@ -12,7 +12,6 @@ export interface PersonUpdatePromptData {
12
12
  persona_name: string;
13
13
  participant_context?: ParticipantContext;
14
14
  known_identifier_types?: string[];
15
- persona_entity?: PersonaEntitySnapshot;
16
15
  }
17
16
 
18
17
  function participantContextSection(ctx: ParticipantContext | undefined): string {
@@ -121,38 +120,22 @@ Detail you add should:
121
120
  **ABSOLUTELY VITAL**: Do **NOT** embellish. Record only what the user actually said or demonstrated.`;
122
121
 
123
122
  } else if (isEiPersona) {
124
- const entityRef = data.persona_entity
125
- ? `## This Persona's Defined Identity (for reference)
126
-
127
- The following is ${personName}'s current self-definition — their traits, topics, and long description as set in the Persona editor. Use this as a **baseline**, not a ceiling.
128
-
129
- **Long description:**
130
- ${data.persona_entity.long_description}
131
-
132
- **Traits:**
133
- ${data.persona_entity.traits.map(t => `- **${t.name}**: ${t.description}`).join('\n')}
134
-
135
- **Topics:**
136
- ${data.persona_entity.topics.map(t => `- **${t.name}**: ${t.perspective}`).join('\n')}
137
-
138
- `
139
- : '';
140
-
141
- descriptionSection = `${entityRef}This record is the HUMAN USER's **observed experience** of ${personName} over time — not the Persona's own definition. Think of it as field notes from someone who has been talking with this Persona across many conversations.
123
+ descriptionSection = `This record is the HUMAN USER's **observed experience** of ${personName} over time. Think of it as field notes from someone who has been talking with this Persona across many conversations.
142
124
 
143
125
  ## Your job: add, never truncate
144
126
 
145
127
  The description is allowed to grow. **Never remove or summarize away existing content.**
146
128
 
147
129
  Add anything from the Most Recent Messages that:
148
- - Extends or nuances what's already known (new behaviors, new opinions, recurring themes)
149
- - Agrees with or contradicts the Persona's defined identity both are worth capturing
150
- - Reveals how the HUMAN USER experiences or relates to this Persona specifically
130
+ - Records a specific thing ${personName} said, did, or demonstrated in this conversation
131
+ - Captures how the HUMAN USER experienced or related to ${personName} in this specific exchange
132
+ - Notes behavior that stands out expected or surprising based on what the existing log already shows
151
133
 
152
134
  **Do NOT:**
153
135
  - Synthesize the existing description down to fewer sentences
154
136
  - Replace specific observations with vague summaries
155
137
  - Discard detail to "keep it brief" — brevity is wrong here
138
+ - Add anything that isn't directly observed in the Most Recent Messages
156
139
 
157
140
  If the new messages add nothing meaningful, return \`{}\`. Otherwise, return the **full updated description** — existing content preserved, new observations woven in.`;
158
141
 
@@ -58,7 +58,9 @@ Rules:
58
58
  - Never invent observations not supported by the log
59
59
  - Preserve traits and topics the log confirms — don't remove them
60
60
  - If the log shows no evidence on a trait, leave it unchanged
61
- - updated_identity must be complete and self-contained — not a diff`;
61
+ - updated_identity must be complete and self-contained — not a diff
62
+ - long_description is a character sketch, not a behavior log: capture who the persona IS, not what they did. Target 500–800 characters. If the current long_description exceeds that, distill it — remove detail that is already captured by traits or topics
63
+ - If the log shows a recurring behavioral pattern not yet in traits, add it as a trait and remove that detail from long_description rather than keeping it in both places`;
62
64
 
63
65
  const user = `## Current Identity
64
66