ei-tui 0.9.1 → 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.1",
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
 
@@ -135,9 +135,9 @@ export function handleReflectionCritic(response: LLMResponse, state: StateManage
135
135
  if (personRecord) {
136
136
  state.human_person_upsert({
137
137
  ...personRecord,
138
- description: result.updated_identity.long_description,
138
+ description: "",
139
139
  });
140
- console.log(`[ReflectionCritic ${personaDisplayName}] Person record description replaced (was log, now distilled identity)`);
140
+ console.log(`[ReflectionCritic ${personaDisplayName}] Person record description cleared ready for fresh evidence after reflection`);
141
141
  }
142
142
 
143
143
  const persona = state.persona_getById(personaId);
@@ -298,6 +298,15 @@ export async function handleHumanPersonScan(response: LLMResponse, state: StateM
298
298
  }
299
299
  }
300
300
 
301
+ if (matchedPerson && response.request.data.reflection_progress === 1) {
302
+ const linkedPersonaId = matchedPerson.identifiers
303
+ ?.find(i => i.type === "Ei Persona")?.value;
304
+ if (linkedPersonaId) {
305
+ console.log(`[handleHumanPersonScan] Skipping update for "${candidate.name}" — scan marked as reflection drain (reflection_progress=1)`);
306
+ continue;
307
+ }
308
+ }
309
+
301
310
  const matchResult: ItemMatchResult = { matched_guid: matchedPerson?.id ?? null };
302
311
  queuePersonUpdate(matchResult, {
303
312
  ...context,
@@ -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))
@@ -253,6 +253,8 @@ export function checkAndQueueHumanExtraction(
253
253
  const unextractedPeople = sm.messages_getUnextracted(personaId, "p", undefined, "exclude");
254
254
  const peopleThreshold = Math.min(EXTRACTION_TAPER_CAP, human.people.length);
255
255
  if (unextractedPeople.length > 0 && unextractedPeople.length >= peopleThreshold) {
256
+ const personaForScan = sm.persona_getById(personaId);
257
+ const personScanOptions = personaForScan?.pending_update ? { reflection_progress: 1 } : undefined;
256
258
  const context: ExtractionContext = {
257
259
  personaId,
258
260
  personaDisplayName,
@@ -260,9 +262,9 @@ export function checkAndQueueHumanExtraction(
260
262
  messages_analyze: unextractedPeople,
261
263
  extraction_flag: "p",
262
264
  };
263
- queuePersonScan(context, sm);
265
+ queuePersonScan(context, sm, personScanOptions);
264
266
  console.log(
265
- `[Processor] Human Seed extraction: people (threshold: ${peopleThreshold}, unextracted: ${unextractedPeople.length})`
267
+ `[Processor] Human Seed extraction: people (threshold: ${peopleThreshold}, unextracted: ${unextractedPeople.length}${personScanOptions ? ", reflection_progress=1" : ""})`
266
268
  );
267
269
  }
268
270
  }
@@ -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";
@@ -139,7 +140,10 @@ function queueExposurePhase(personaId: string, state: StateManager, options?: Ex
139
140
  messages_analyze: unextractedPeople,
140
141
  extraction_flag: "p",
141
142
  };
142
- queuePersonScan(context, state, options);
143
+ const personScanOptions = persona.pending_update
144
+ ? { ...options, reflection_progress: 1 }
145
+ : options;
146
+ queuePersonScan(context, state, personScanOptions);
143
147
  }
144
148
 
145
149
  const totalUnextracted = unextractedFacts.length + unextractedTopics.length + unextractedPeople.length;
@@ -206,20 +210,26 @@ export function handleCeremonyProgress(state: StateManager, lastPhase: number):
206
210
  const rooms = state.getRoomList();
207
211
  for (const room of rooms) {
208
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);
209
221
  for (const personaId of room.persona_ids) {
210
222
  const shortId = personaId.slice(0, 8);
211
223
  const unprocessedRaw = state.getRoomUnextractedMessagesForPersona(room.id, shortId);
212
224
  if (unprocessedRaw.length === 0) continue;
213
225
  const personaForRoom = state.persona_getById(personaId);
214
226
  if (!personaForRoom) continue;
215
- const allRoomMessagesRaw = state.getRoomActivePath(room.id);
216
- const processedIds = new Set(allRoomMessagesRaw.filter(m => !!m.persona_extracted?.[shortId]).map(m => m.id));
217
- const allNormalized = normalizeRoomMessages(allRoomMessagesRaw, state);
227
+ const processedIds = new Set(allRoomMessages.filter(m => !!m.persona_extracted?.[shortId]).map(m => m.id));
218
228
  const unprocessedNormalized = normalizeRoomMessages(unprocessedRaw, state);
219
229
  const personaTopicContext: PersonaTopicContext = {
220
230
  personaId,
221
231
  personaDisplayName: personaForRoom.display_name,
222
- messages_context: allNormalized.filter(m => processedIds.has(m.id)),
232
+ messages_context: allRoomMessages.filter(m => processedIds.has(m.id)),
223
233
  messages_analyze: unprocessedNormalized,
224
234
  topics: personaForRoom.topics,
225
235
  };
@@ -445,6 +455,35 @@ const REWRITE_DESCRIPTION_THRESHOLD = 750;
445
455
  * Phase 2 items enqueue at Normal priority, naturally processing before more
446
456
  * Low-priority Phase 1 scans.
447
457
  */
458
+ /**
459
+ * Forces an unconditional, threshold-bypassing Person scan on Apply/Dismiss.
460
+ * Cannot be replaced by checkAndQueueHumanExtraction — that function gates on
461
+ * MIN(10, people_count) and would silently skip messages if the threshold isn't
462
+ * met, leaving reflection-era noise unprocessed and ungated.
463
+ */
464
+ export function queueReflectionDrain(personaId: string, state: StateManager): void {
465
+ const persona = state.persona_getById(personaId);
466
+ if (!persona) return;
467
+
468
+ const allMessages = state.messages_get(personaId);
469
+ const unextractedPeople = state.messages_getUnextracted(personaId, "p");
470
+
471
+ if (unextractedPeople.length === 0) {
472
+ console.log(`[reflection:drain] No unextracted messages for ${persona.display_name} — drain complete`);
473
+ return;
474
+ }
475
+
476
+ const context: ExtractionContext = {
477
+ personaId,
478
+ personaDisplayName: persona.display_name,
479
+ messages_context: allMessages.filter(m => m.p === true),
480
+ messages_analyze: unextractedPeople,
481
+ extraction_flag: "p",
482
+ };
483
+ queuePersonScan(context, state, { reflection_progress: 1 });
484
+ console.log(`[reflection:drain] Queued Person scan for ${persona.display_name} (${unextractedPeople.length} messages) — clears on completion`);
485
+ }
486
+
448
487
  export function queueRewritePhase(state: StateManager): void {
449
488
  const human = state.getHuman();
450
489
  const rewriteModel = human.settings?.rewrite_model;
@@ -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";
@@ -67,6 +66,8 @@ export interface ExtractionContext {
67
66
  export interface ExtractionOptions {
68
67
  /** Ceremony phase number (1=Dedup, 2=Expose) */
69
68
  ceremony_progress?: number;
69
+ /** Set to 1 on scans queued while there is a Pending Reflection. */
70
+ reflection_progress?: number;
70
71
  /** Override model for extraction LLM calls */
71
72
  extraction_model?: string;
72
73
  /**
@@ -98,21 +99,23 @@ function getExtractionMaxTokens(state: StateManager, options?: ExtractionOptions
98
99
  export function queueFactFind(context: ExtractionContext, state: StateManager, options?: ExtractionOptions): number {
99
100
  const human = state.getHuman();
100
101
  const extractionModel = options?.extraction_model;
101
- const missing_fact_names = human.facts
102
- .filter(f => !f.description || f.description === "")
103
- .map(f => f.name)
104
- .filter(name => BUILT_IN_FACT_NAMES.has(name));
105
-
106
- if (missing_fact_names.length === 0) return 0;
107
102
 
108
103
  const { chunks } = chunkExtractionContext(context, getExtractionMaxTokens(state, options));
109
104
 
110
- // Pre-mark messages before enqueuing prevents duplicate scans if the
111
- // 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.
112
108
  for (const chunk of chunks) {
113
109
  state.messages_markExtracted(chunk.personaId, chunk.messages_analyze.map(m => m.id), "f");
114
110
  }
115
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
+
116
119
  for (const chunk of chunks) {
117
120
  const prompt = buildFactFindPrompt({
118
121
  persona_name: chunk.personaDisplayName,
@@ -584,18 +587,6 @@ export function queuePersonUpdate(
584
587
 
585
588
  const primaryPersonaIdForUpdate = context.personaId.split("|")[0];
586
589
 
587
- const linkedPersonaId = !isNewItem
588
- ? (existingItem?.identifiers ?? []).find(i => i.type.toLowerCase() === 'ei persona')?.value
589
- : undefined;
590
- const linkedPersonaEntity = linkedPersonaId ? state.persona_getById(linkedPersonaId) : undefined;
591
- const personaEntitySnapshot: PersonaEntitySnapshot | undefined = linkedPersonaEntity
592
- ? {
593
- long_description: linkedPersonaEntity.long_description ?? '',
594
- traits: (linkedPersonaEntity.traits ?? []).map(t => ({ name: t.name, description: t.description })),
595
- topics: (linkedPersonaEntity.topics ?? []).map(t => ({ name: t.name, perspective: t.perspective })),
596
- }
597
- : undefined;
598
-
599
590
  for (const chunk of chunks) {
600
591
  const prompt = buildPersonUpdatePrompt({
601
592
  existing_item: existingItem,
@@ -607,7 +598,6 @@ export function queuePersonUpdate(
607
598
  persona_name: chunk.personaDisplayName,
608
599
  participant_context: buildParticipantContext(primaryPersonaIdForUpdate, state),
609
600
  known_identifier_types: userIdentifierTypes,
610
- persona_entity: personaEntitySnapshot,
611
601
  });
612
602
 
613
603
  state.queue_enqueue({
@@ -19,6 +19,7 @@ export {
19
19
  shouldStartCeremony,
20
20
  startCeremony,
21
21
  handleCeremonyProgress,
22
+ queueReflectionDrain,
22
23
  prunePersonaMessages,
23
24
  runHumanCeremony,
24
25
  } from "./ceremony.js";
@@ -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;
@@ -39,7 +39,7 @@ import { ContextStatus as ContextStatusEnum, RoomMode } from "./types.js";
39
39
  import { registerReadMemoryExecutor, registerFileReadExecutor } from "./tools/index.js";
40
40
  import { createReadMemoryExecutor } from "./tools/builtin/read-memory.js";
41
41
  import { EI_WELCOME_MESSAGE, EI_PERSONA_DEFINITION } from "../templates/welcome.js";
42
- import { shouldStartCeremony, startCeremony, handleCeremonyProgress, queueUserDedupRequest, queueRoomCapture, queuePersonaCapture, checkAndQueueRoomExtraction, queueTargetedPersonUpdate, queueTargetedTopicUpdate } from "./orchestrators/index.js";
42
+ import { shouldStartCeremony, startCeremony, handleCeremonyProgress, queueReflectionDrain, queueUserDedupRequest, queueRoomCapture, queuePersonaCapture, checkAndQueueRoomExtraction, queueTargetedPersonUpdate, queueTargetedTopicUpdate } from "./orchestrators/index.js";
43
43
  import { BUILT_IN_FACTS } from "./constants/built-in-facts.js";
44
44
  import { DEFAULT_SEED_TRAITS } from "./constants/seed-traits.js";
45
45
 
@@ -1755,6 +1755,43 @@ const toolNextSteps = new Set([
1755
1755
  if (ok) this.interface.onPersonaUpdated?.(personaId);
1756
1756
  }
1757
1757
 
1758
+ async finalizeReflection(
1759
+ personaId: string,
1760
+ action: "apply" | "dismiss",
1761
+ identity?: { short_description?: string; long_description: string; traits: NonNullable<PersonaEntity["pending_update"]>["traits"]; topics: NonNullable<PersonaEntity["pending_update"]>["topics"] }
1762
+ ): Promise<void> {
1763
+ const persona = this.stateManager.persona_getById(personaId);
1764
+ if (!persona) return;
1765
+
1766
+ const source = identity ?? (persona.pending_update ? {
1767
+ short_description: persona.pending_update.short_description,
1768
+ long_description: persona.pending_update.long_description,
1769
+ traits: persona.pending_update.traits,
1770
+ topics: persona.pending_update.topics,
1771
+ } : null);
1772
+
1773
+ const updates: Partial<PersonaEntity> = { pending_update: undefined };
1774
+
1775
+ if (action === "apply" && source) {
1776
+ updates.short_description = source.short_description;
1777
+ updates.long_description = source.long_description;
1778
+ updates.traits = source.traits.map(t => ({
1779
+ ...t,
1780
+ id: t.id?.startsWith("pending-") ? crypto.randomUUID() : t.id,
1781
+ }));
1782
+ updates.topics = source.topics.map(t => ({
1783
+ ...t,
1784
+ id: t.id?.startsWith("pending-") ? crypto.randomUUID() : t.id,
1785
+ }));
1786
+ }
1787
+
1788
+ const ok = await updatePersona(this.stateManager, personaId, updates);
1789
+ if (ok) {
1790
+ queueReflectionDrain(personaId, this.stateManager);
1791
+ this.interface.onPersonaUpdated?.(personaId);
1792
+ }
1793
+ }
1794
+
1758
1795
  async updateRoom(roomId: string, updates: Partial<RoomEntity>): Promise<void> {
1759
1796
  const ok = this.stateManager.updateRoom(roomId, updates);
1760
1797
  if (ok) this.interface.onRoomUpdated?.(roomId);
@@ -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
 
@@ -233,7 +233,7 @@ export const personaCommand: Command = {
233
233
  }
234
234
 
235
235
  if (reviewResult.content === null) {
236
- await ctx.ei.updatePersona(personaId, { pending_update: undefined });
236
+ await ctx.ei.finalizeReflection(personaId, "dismiss");
237
237
  ctx.showNotification(`Dismissed pending changes for ${persona.display_name}`, "info");
238
238
  return;
239
239
  }
@@ -267,17 +267,16 @@ export const personaCommand: Command = {
267
267
  return;
268
268
  }
269
269
 
270
- await ctx.ei.updatePersona(personaId, { pending_update: undefined });
270
+ await ctx.ei.finalizeReflection(personaId, "dismiss");
271
271
  ctx.showNotification(`Dismissed pending changes for ${persona.display_name}`, "info");
272
272
  return;
273
273
  }
274
274
 
275
- await ctx.ei.updatePersona(personaId, {
275
+ await ctx.ei.finalizeReflection(personaId, "apply", {
276
276
  long_description: previewParsed.long_description,
277
277
  short_description: previewParsed.short_description,
278
278
  traits: previewParsed.traits,
279
279
  topics: previewParsed.topics,
280
- pending_update: undefined,
281
280
  });
282
281
  ctx.showNotification(`Applied changes to ${persona.display_name}`, "info");
283
282
  return;
@@ -327,13 +327,7 @@ export const reflectCommand: Command = {
327
327
  topics: persona.pending_update!.topics,
328
328
  };
329
329
 
330
- await ctx.ei.updatePersona(personaId, {
331
- long_description: source.long_description,
332
- short_description: source.short_description,
333
- traits: source.traits,
334
- topics: source.topics,
335
- pending_update: undefined,
336
- });
330
+ await ctx.ei.finalizeReflection(personaId, "apply", source);
337
331
 
338
332
  if (fs.existsSync(folderPath)) {
339
333
  fs.rmSync(folderPath, { recursive: true, force: true });
@@ -363,7 +357,7 @@ export const reflectCommand: Command = {
363
357
  return;
364
358
  }
365
359
 
366
- await ctx.ei.updatePersona(personaId, { pending_update: undefined });
360
+ await ctx.ei.finalizeReflection(personaId, "dismiss");
367
361
  const folderPath = getReflectFolder(persona);
368
362
  if (fs.existsSync(folderPath)) {
369
363
  fs.rmSync(folderPath, { recursive: true, force: true });
@@ -80,6 +80,7 @@ export interface EiContextValue {
80
80
  deletePersona: (personaId: string) => Promise<void>;
81
81
  setContextBoundary: (personaId: string, timestamp: string | null) => Promise<void>;
82
82
  updatePersona: (personaId: string, updates: Partial<PersonaEntity>) => Promise<void>;
83
+ finalizeReflection: (personaId: string, action: "apply" | "dismiss", identity?: { short_description?: string; long_description: string; traits: NonNullable<PersonaEntity["pending_update"]>["traits"]; topics: NonNullable<PersonaEntity["pending_update"]>["topics"] }) => Promise<void>;
83
84
  getPersona: (personaId: string) => Promise<PersonaEntity | null>;
84
85
  resolvePersonaName: (nameOrAlias: string) => Promise<string | null>;
85
86
  getHuman: () => Promise<HumanEntity>;
@@ -360,6 +361,16 @@ export const EiProvider: ParentComponent = (props) => {
360
361
  await refreshPersonas();
361
362
  };
362
363
 
364
+ const finalizeReflection = async (
365
+ personaId: string,
366
+ action: "apply" | "dismiss",
367
+ identity?: { short_description?: string; long_description: string; traits: NonNullable<PersonaEntity["pending_update"]>["traits"]; topics: NonNullable<PersonaEntity["pending_update"]>["topics"] }
368
+ ) => {
369
+ if (!processor) return;
370
+ await processor.finalizeReflection(personaId, action, identity);
371
+ await refreshPersonas();
372
+ };
373
+
363
374
  const getPersona = async (personaId: string) => {
364
375
  if (!processor) return null;
365
376
  return processor.getPersona(personaId);
@@ -937,6 +948,7 @@ export const EiProvider: ParentComponent = (props) => {
937
948
  deletePersona,
938
949
  setContextBoundary,
939
950
  updatePersona,
951
+ finalizeReflection,
940
952
  getPersona,
941
953
  resolvePersonaName,
942
954
  getHuman,