ei-tui 0.5.4 → 0.6.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 (86) hide show
  1. package/package.json +1 -1
  2. package/src/core/constants/built-in-identifier-types.ts +24 -0
  3. package/src/core/embedding-service.ts +24 -1
  4. package/src/core/handlers/dedup.ts +34 -4
  5. package/src/core/handlers/heartbeat.ts +16 -0
  6. package/src/core/handlers/human-extraction.ts +201 -7
  7. package/src/core/handlers/human-matching.ts +71 -22
  8. package/src/core/handlers/index.ts +52 -14
  9. package/src/core/handlers/persona-generation.ts +2 -0
  10. package/src/core/handlers/persona-response.ts +37 -22
  11. package/src/core/handlers/persona-topics.ts +35 -271
  12. package/src/core/handlers/rewrite.ts +3 -0
  13. package/src/core/handlers/rooms.ts +41 -20
  14. package/src/core/handlers/utils.ts +10 -8
  15. package/src/core/heartbeat-manager.ts +60 -2
  16. package/src/core/llm-client.ts +1 -1
  17. package/src/core/message-manager.ts +3 -2
  18. package/src/core/orchestrators/ceremony.ts +54 -144
  19. package/src/core/orchestrators/dedup-phase.ts +0 -199
  20. package/src/core/orchestrators/extraction-chunker.ts +8 -3
  21. package/src/core/orchestrators/human-extraction.ts +37 -85
  22. package/src/core/orchestrators/index.ts +4 -8
  23. package/src/core/orchestrators/person-migration.ts +55 -0
  24. package/src/core/orchestrators/persona-topics.ts +64 -89
  25. package/src/core/orchestrators/room-extraction.ts +34 -0
  26. package/src/core/persona-manager.ts +21 -2
  27. package/src/core/personas/opencode-agent.ts +1 -0
  28. package/src/core/processor.ts +51 -14
  29. package/src/core/prompt-context-builder.ts +38 -5
  30. package/src/core/queue-processor.ts +4 -2
  31. package/src/core/room-manager.ts +6 -7
  32. package/src/core/state/human.ts +6 -0
  33. package/src/core/state/personas.ts +35 -10
  34. package/src/core/state/rooms.ts +21 -0
  35. package/src/core/state-manager.ts +61 -0
  36. package/src/core/types/data-items.ts +12 -0
  37. package/src/core/types/entities.ts +3 -0
  38. package/src/core/types/enums.ts +2 -7
  39. package/src/core/types/llm.ts +2 -0
  40. package/src/core/types/rooms.ts +2 -0
  41. package/src/core/utils/identifier-utils.ts +19 -0
  42. package/src/core/utils/index.ts +2 -1
  43. package/src/core/utils/levenshtein.ts +18 -0
  44. package/src/integrations/claude-code/importer.ts +1 -0
  45. package/src/integrations/cursor/importer.ts +1 -0
  46. package/src/prompts/ceremony/index.ts +1 -0
  47. package/src/prompts/ceremony/person-migration.ts +77 -0
  48. package/src/prompts/ceremony/rewrite.ts +1 -1
  49. package/src/prompts/ceremony/user-dedup.ts +15 -1
  50. package/src/prompts/heartbeat/check.ts +28 -12
  51. package/src/prompts/heartbeat/ei.ts +2 -0
  52. package/src/prompts/heartbeat/types.ts +12 -0
  53. package/src/prompts/human/index.ts +0 -2
  54. package/src/prompts/human/person-scan.ts +58 -14
  55. package/src/prompts/human/person-update.ts +171 -96
  56. package/src/prompts/human/topic-update.ts +1 -1
  57. package/src/prompts/human/types.ts +5 -1
  58. package/src/prompts/index.ts +3 -10
  59. package/src/prompts/message-utils.ts +9 -23
  60. package/src/prompts/persona/index.ts +3 -10
  61. package/src/prompts/persona/topics-rate.ts +95 -0
  62. package/src/prompts/persona/types.ts +8 -48
  63. package/src/prompts/response/index.ts +3 -7
  64. package/src/prompts/response/sections.ts +7 -57
  65. package/src/prompts/room/index.ts +1 -1
  66. package/src/prompts/room/sections.ts +8 -31
  67. package/tui/src/commands/me.tsx +14 -7
  68. package/tui/src/commands/persona.tsx +120 -83
  69. package/tui/src/components/MessageList.tsx +9 -4
  70. package/tui/src/components/RoomMessageList.tsx +10 -5
  71. package/tui/src/context/keyboard.tsx +2 -2
  72. package/tui/src/util/cyp-editor.tsx +13 -8
  73. package/tui/src/util/yaml-context.ts +66 -0
  74. package/tui/src/util/yaml-human.ts +274 -0
  75. package/tui/src/util/yaml-persona.ts +479 -0
  76. package/tui/src/util/yaml-provider.ts +215 -0
  77. package/tui/src/util/yaml-queue.ts +81 -0
  78. package/tui/src/util/yaml-quotes.ts +46 -0
  79. package/tui/src/util/yaml-serializers.ts +9 -1417
  80. package/tui/src/util/yaml-settings.ts +223 -0
  81. package/tui/src/util/yaml-shared.ts +32 -0
  82. package/tui/src/util/yaml-toolkit.ts +55 -0
  83. package/src/prompts/human/person-match.ts +0 -65
  84. package/src/prompts/persona/topics-match.ts +0 -70
  85. package/src/prompts/persona/topics-scan.ts +0 -98
  86. package/src/prompts/persona/topics-update.ts +0 -154
@@ -1,6 +1,14 @@
1
1
  import type { Message, RoomMessage, LLMResponse } from "../types.js";
2
2
  import type { StateManager } from "../state-manager.js";
3
3
 
4
+ export function getMessageContent(msg: { content?: string; verbal_response?: string; action_response?: string }): string {
5
+ if (msg.content) return msg.content;
6
+ const parts: string[] = [];
7
+ if (msg.action_response) parts.push(`_${msg.action_response}_`);
8
+ if (msg.verbal_response) parts.push(msg.verbal_response);
9
+ return parts.join('\n\n');
10
+ }
11
+
4
12
  export function normalizeRoomMessages(messages: RoomMessage[], state: StateManager): Message[] {
5
13
  const human = state.getHuman();
6
14
  const humanName = human.settings?.name_display ?? "Human";
@@ -12,6 +20,7 @@ export function normalizeRoomMessages(messages: RoomMessage[], state: StateManag
12
20
  id: m.id,
13
21
  role: m.role === "human" ? "human" as const : "system" as const,
14
22
  speaker_name: speakerName,
23
+ content: m.content,
15
24
  verbal_response: m.verbal_response,
16
25
  action_response: m.action_response,
17
26
  silence_reason: m.silence_reason,
@@ -111,13 +120,6 @@ export function markMessagesExtracted(
111
120
  }
112
121
  }
113
122
 
114
- /**
115
- * Returns the combined display text of a message for quote indexing.
116
- * Mirrors the rendering logic used in the frontends.
117
- */
118
123
  export function getMessageText(message: Message): string {
119
- const parts: string[] = [];
120
- if (message.action_response) parts.push(`_${message.action_response}_`);
121
- if (message.verbal_response) parts.push(message.verbal_response);
122
- return parts.join('\n\n');
124
+ return getMessageContent(message);
123
125
  }
@@ -6,6 +6,7 @@ import {
6
6
  type Message,
7
7
  } from "./types.js";
8
8
  import { StateManager } from "./state-manager.js";
9
+ import { getMessageContent } from "./handlers/utils.js";
9
10
  import {
10
11
  buildHeartbeatCheckPrompt,
11
12
  buildEiHeartbeatPrompt,
@@ -15,6 +16,10 @@ import {
15
16
  } from "../prompts/index.js";
16
17
  import { filterMessagesForContext } from "./context-utils.js";
17
18
  import { filterHumanDataByVisibility } from "./prompt-context-builder.js";
19
+ import { cosineSimilarity, computePersonaDescriptionEmbedding } from "./embedding-service.js";
20
+
21
+ const REFLECTION_SIMILARITY_THRESHOLD = 0.80;
22
+ const REFLECTION_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000; // 1 week between drift prompts
18
23
 
19
24
  // =============================================================================
20
25
  // MODEL HELPERS
@@ -43,7 +48,7 @@ export function countTrailingPersonaMessages(history: Message[]): number {
43
48
  for (let i = history.length - 1; i >= 0; i--) {
44
49
  const msg = history[i];
45
50
  if (msg.role === "human") break;
46
- if (msg.role === "system" && msg.verbal_response && msg.silence_reason === undefined) {
51
+ if (msg.role === "system" && getMessageContent(msg) && msg.silence_reason === undefined) {
47
52
  count++;
48
53
  }
49
54
  }
@@ -84,6 +89,20 @@ export async function queueEiHeartbeat(
84
89
  });
85
90
  }
86
91
 
92
+ const newPeople = human.people
93
+ .filter(p => !p.validated_date)
94
+ .slice(0, 3);
95
+ for (const person of newPeople) {
96
+ const quote = human.quotes.find((q) => q.data_item_ids.includes(person.id));
97
+ items.push({
98
+ id: person.id,
99
+ type: "New Person",
100
+ name: person.name,
101
+ description: person.description ?? '',
102
+ quote: quote?.text,
103
+ });
104
+ }
105
+
87
106
  const underEngagedPeople = human.people
88
107
  .filter(
89
108
  (p) =>
@@ -174,7 +193,7 @@ export async function queueEiHeartbeat(
174
193
  user: prompt.user,
175
194
  next_step: LLMNextStep.HandleEiHeartbeat,
176
195
  model: getModelForPersona(sm, "ei"),
177
- data: { personaId: "ei", isTUI },
196
+ data: { personaId: "ei", isTUI, newPersonIds: newPeople.map(p => p.id) },
178
197
  });
179
198
  }
180
199
 
@@ -213,6 +232,44 @@ export async function queueHeartbeatCheck(sm: StateManager, personaId: string, i
213
232
  b.exposure_desired - b.exposure_current - (a.exposure_desired - a.exposure_current)
214
233
  );
215
234
 
235
+ let driftContext: HeartbeatCheckPromptData["drift_context"];
236
+ const personRecord = sm.human_person_getByIdentifier("ei_persona", personaId);
237
+
238
+ if (personRecord?.embedding) {
239
+ let currentPersona = persona;
240
+
241
+ if (!currentPersona.description_embedding) {
242
+ const embedding = await computePersonaDescriptionEmbedding(currentPersona);
243
+ if (embedding) {
244
+ sm.persona_update(personaId, { description_embedding: embedding });
245
+ currentPersona = { ...currentPersona, description_embedding: embedding };
246
+ }
247
+ }
248
+
249
+ if (currentPersona.description_embedding) {
250
+ const lastAsked = currentPersona.reflection_last_asked
251
+ ? new Date(currentPersona.reflection_last_asked).getTime()
252
+ : 0;
253
+
254
+ // Gate: person must have been updated at least 1 week AFTER reflection was last asked.
255
+ // This handles both the cooldown AND the extraction echo — the ceremony extraction
256
+ // that fires right after a persona surfaces drift updates last_updated by minutes,
257
+ // which can never satisfy the 1-week offset requirement.
258
+ if (new Date(personRecord.last_updated).getTime() > lastAsked + REFLECTION_COOLDOWN_MS) {
259
+ const similarity = cosineSimilarity(personRecord.embedding, currentPersona.description_embedding);
260
+ if (similarity < REFLECTION_SIMILARITY_THRESHOLD) {
261
+ driftContext = {
262
+ people_description: personRecord.description ?? '',
263
+ persona_description: currentPersona.long_description ?? '',
264
+ };
265
+ console.log(`[HeartbeatCheck ${persona.display_name}] Drift detected (similarity: ${similarity.toFixed(3)}) - including reflection context`);
266
+ } else {
267
+ console.log(`[HeartbeatCheck ${persona.display_name}] Person updated but no drift (similarity: ${similarity.toFixed(3)})`);
268
+ }
269
+ }
270
+ }
271
+ }
272
+
216
273
  const promptData: HeartbeatCheckPromptData = {
217
274
  persona: {
218
275
  name: persona.display_name,
@@ -225,6 +282,7 @@ export async function queueHeartbeatCheck(sm: StateManager, personaId: string, i
225
282
  },
226
283
  recent_history: contextHistory.slice(-10),
227
284
  inactive_days: inactiveDays,
285
+ drift_context: driftContext,
228
286
  };
229
287
 
230
288
  const prompt = buildHeartbeatCheckPrompt(promptData);
@@ -337,7 +337,7 @@ export async function callLLMRaw(
337
337
  }
338
338
 
339
339
  let finalToolCalls = rawToolCalls;
340
- if ((!rawToolCalls || rawToolCalls.length === 0) && choice?.finish_reason === "stop" && typeof textContent === "string") {
340
+ if ((!rawToolCalls || rawToolCalls.length === 0) && choice?.finish_reason === "stop" && typeof textContent === "string" && textContent.trimStart().startsWith("<|tool_call>")) {
341
341
  const rescued = rescueGemmaToolCalls(textContent);
342
342
  if (rescued.length > 0) {
343
343
  console.log(`[LLM] Rescued ${rescued.length} tool call(s) from content (Gemma native format)`);
@@ -8,6 +8,7 @@ import {
8
8
  type LLMRequest,
9
9
  } from "./types.js";
10
10
  import { formatTimestamp } from "./format-utils.js";
11
+ import { getMessageContent } from "./handlers/utils.js";
11
12
  import { StateManager } from "./state-manager.js";
12
13
  import { QueueProcessor } from "./queue-processor.js";
13
14
  import {
@@ -113,7 +114,7 @@ export async function recallPendingMessages(
113
114
  .map((m) => m.id);
114
115
  if (pendingIds.length === 0) return "";
115
116
  const removed = sm.messages_remove(personaId, pendingIds);
116
- const recalledContent = removed.map((m) => m.verbal_response ?? "").join("\n\n");
117
+ const recalledContent = removed.map((m) => getMessageContent(m)).join("\n\n");
117
118
  onMessageAdded(personaId);
118
119
  onMessageRecalled(personaId, recalledContent);
119
120
  return recalledContent;
@@ -164,7 +165,7 @@ export async function sendMessage(
164
165
  const prompt = buildResponsePrompt(promptData);
165
166
 
166
167
  sm.queue_enqueue({
167
- type: LLMRequestType.Response,
168
+ type: LLMRequestType.Raw,
168
169
  priority: LLMPriority.High,
169
170
  system: prompt.system,
170
171
  user: prompt.user,
@@ -1,5 +1,6 @@
1
- import { LLMRequestType, LLMPriority, LLMNextStep, type CeremonyConfig, type PersonaTopic, type Topic, type Message, type DataItemBase } from "../types.js";
1
+ import { LLMRequestType, LLMPriority, LLMNextStep, RoomMode, type CeremonyConfig, type PersonaTopic, type Topic, type DataItemBase } from "../types.js";
2
2
  import type { StateManager } from "../state-manager.js";
3
+ import { normalizeRoomMessages } from "../handlers/utils.js";
3
4
  import { applyDecayToValue } from "../utils/index.js";
4
5
  import {
5
6
  queueFactFind,
@@ -9,9 +10,9 @@ import {
9
10
  type ExtractionContext,
10
11
  type ExtractionOptions,
11
12
  } from "./human-extraction.js";
12
- import { queuePersonaTopicScan, type PersonaTopicContext } from "./persona-topics.js";
13
- import { queueDedupPhase } from "./dedup-phase.js";
14
- import { buildPersonaExpirePrompt, buildPersonaExplorePrompt, buildDescriptionCheckPrompt, buildRewriteScanPrompt, type RewriteItemType } from "../../prompts/ceremony/index.js";
13
+ import { queuePersonaTopicRating, type PersonaTopicContext, type PersonaTopicOptions } from "./persona-topics.js";
14
+ import { queuePersonMigration } from "./person-migration.js";
15
+ import { buildRewriteScanPrompt, type RewriteItemType } from "../../prompts/ceremony/index.js";
15
16
 
16
17
  export function isNewDay(lastCeremony: string | undefined, now: Date): boolean {
17
18
  if (!lastCeremony) return true;
@@ -70,14 +71,14 @@ export function startCeremony(state: StateManager): void {
70
71
  },
71
72
  });
72
73
 
73
- // PHASE 1: Deduplication (runs BEFORE Expose)
74
- console.log("[ceremony] Starting Phase 1: Deduplication");
75
- queueDedupPhase(state);
76
-
77
- // Check if dedup work was queued
74
+ // PHASE 1: Person Migration
75
+ console.log("[ceremony] Starting Phase 1: Person Migration");
76
+ queuePersonMigration(state);
77
+
78
+ // Check if migration work was queued
78
79
  if (!state.queue_hasPendingCeremonies()) {
79
- // No dedup work found → immediately advance to Expose phase
80
- console.log("[ceremony] No dedup work, advancing to Expose phase");
80
+ // No migration work found → immediately advance to Expose phase
81
+ console.log("[ceremony] No migration work, advancing to Expose phase");
81
82
  handleCeremonyProgress(state, 1);
82
83
  }
83
84
 
@@ -142,16 +143,21 @@ function queueExposurePhase(personaId: string, state: StateManager, options?: Ex
142
143
  console.log(`[ceremony:exposure] Queued human extraction scans (f:${unextractedFacts.length}, t:${unextractedTopics.length}, p:${unextractedPeople.length})`);
143
144
  }
144
145
 
145
- const unextractedForPersonaTopics = state.messages_getUnextracted(personaId, "t");
146
- if (unextractedForPersonaTopics.length > 0) {
146
+ const human = state.getHuman();
147
+ const lastCeremony = human.settings?.ceremony?.last_ceremony;
148
+ const shortId = personaId.slice(0, 8);
149
+ const forPersonaTopics = state.messages_getUnextractedForPersona(personaId, shortId, lastCeremony ?? undefined);
150
+ if (forPersonaTopics.length > 0) {
147
151
  const personaTopicContext: PersonaTopicContext = {
148
152
  personaId,
149
153
  personaDisplayName: persona.display_name,
150
- messages_context: allMessages.filter(m => m.t === true),
151
- messages_analyze: unextractedForPersonaTopics,
154
+ messages_context: allMessages.filter(m => !!m.persona_extracted?.[shortId]),
155
+ messages_analyze: forPersonaTopics,
156
+ topics: persona.topics,
152
157
  };
153
- queuePersonaTopicScan(personaTopicContext, state);
154
- console.log(`[ceremony:exposure] Queued persona topic scan for ${persona.display_name}`);
158
+ const personaTopicOptions: PersonaTopicOptions = { ceremony_progress: options?.ceremony_progress };
159
+ queuePersonaTopicRating(personaTopicContext, state, personaTopicOptions);
160
+ console.log(`[ceremony:exposure] Queued persona topic rating for ${persona.display_name} (${forPersonaTopics.length} messages)`);
155
161
  }
156
162
  }
157
163
 
@@ -194,6 +200,32 @@ export function handleCeremonyProgress(state: StateManager, lastPhase: number):
194
200
  for (const persona of personasWithUnprocessed) {
195
201
  queueExposurePhase(persona.id, state, options);
196
202
  }
203
+
204
+ const rooms = state.getRoomList();
205
+ for (const room of rooms) {
206
+ if (room.mode === RoomMode.ChooseYourPath) continue;
207
+ for (const personaId of room.persona_ids) {
208
+ const shortId = personaId.slice(0, 8);
209
+ const unprocessedRaw = state.getRoomUnextractedMessagesForPersona(room.id, shortId);
210
+ if (unprocessedRaw.length === 0) continue;
211
+ const personaForRoom = state.persona_getById(personaId);
212
+ if (!personaForRoom) continue;
213
+ const allRoomMessagesRaw = state.getRoomActivePath(room.id);
214
+ const processedIds = new Set(allRoomMessagesRaw.filter(m => !!m.persona_extracted?.[shortId]).map(m => m.id));
215
+ const allNormalized = normalizeRoomMessages(allRoomMessagesRaw, state);
216
+ const unprocessedNormalized = normalizeRoomMessages(unprocessedRaw, state);
217
+ const personaTopicContext: PersonaTopicContext = {
218
+ personaId,
219
+ personaDisplayName: personaForRoom.display_name,
220
+ messages_context: allNormalized.filter(m => processedIds.has(m.id)),
221
+ messages_analyze: unprocessedNormalized,
222
+ topics: personaForRoom.topics,
223
+ };
224
+ const roomScanOptions: PersonaTopicOptions = { ceremony_progress: 2, roomId: room.id };
225
+ queuePersonaTopicRating(personaTopicContext, state, roomScanOptions);
226
+ console.log(`[ceremony:expose] Queued room persona topic rating: ${personaForRoom.display_name} in "${room.display_name}" (${unprocessedRaw.length} messages)`);
227
+ }
228
+ }
197
229
  return;
198
230
  }
199
231
 
@@ -238,16 +270,9 @@ export function handleCeremonyProgress(state: StateManager, lastPhase: number):
238
270
  runHumanCeremony(state);
239
271
 
240
272
  // Rewrite phase: fire-and-forget scans for bloated human data items
241
- // No ceremony_progress gating — Expire/Explore only touch persona topics, zero overlap
242
273
  queueRewritePhase(state);
243
274
 
244
- // Expire phase: queue LLM calls for each active persona
245
- // handlePersonaExpire already chains to Explore → DescriptionCheck
246
- for (const persona of activePersonas) {
247
- queueExpirePhase(persona.id, state);
248
- }
249
-
250
- console.log("[ceremony:progress] Ceremony Decay complete, Expire queued");
275
+ console.log("[ceremony:progress] Ceremony Decay complete");
251
276
  }
252
277
 
253
278
  // =============================================================================
@@ -334,124 +359,6 @@ export function prunePersonaMessages(personaId: string, state: StateManager): vo
334
359
  }
335
360
  }
336
361
 
337
- // =============================================================================
338
- // EXPIRE PHASE (queues LLM calls)
339
- // =============================================================================
340
-
341
- export function queueExpirePhase(personaId: string, state: StateManager): void {
342
- const persona = state.persona_getById(personaId);
343
- if (!persona) {
344
- console.error(`[ceremony:expire] Persona not found: ${personaId}`);
345
- return;
346
- }
347
-
348
- console.log(`[ceremony:expire] Queueing for ${persona.display_name}`);
349
-
350
- if (persona.topics.length === 0) {
351
- console.log(`[ceremony:expire] ${persona.display_name} has no topics, skipping to description check`);
352
- queueDescriptionCheck(personaId, state);
353
- return;
354
- }
355
-
356
- const prompt = buildPersonaExpirePrompt({
357
- persona_name: persona.display_name,
358
- topics: persona.topics,
359
- });
360
-
361
- state.queue_enqueue({
362
- type: LLMRequestType.JSON,
363
- priority: LLMPriority.Low,
364
- system: prompt.system,
365
- user: prompt.user,
366
- next_step: LLMNextStep.HandlePersonaExpire,
367
- data: { personaId, personaDisplayName: persona.display_name },
368
- });
369
- }
370
-
371
- // =============================================================================
372
- // EXPLORE PHASE (queues LLM calls — chained from handlePersonaExpire in handlers)
373
- // =============================================================================
374
-
375
- export function queueExplorePhase(personaId: string, state: StateManager): void {
376
- const persona = state.persona_getById(personaId);
377
- if (!persona) {
378
- console.error(`[ceremony:explore] Persona not found: ${personaId}`);
379
- queueDescriptionCheck(personaId, state);
380
- return;
381
- }
382
-
383
- console.log(`[ceremony:explore] Queueing for ${persona.display_name}`);
384
-
385
- const messages = state.messages_get(personaId);
386
- const recentMessages = messages.slice(-20);
387
- const themes = extractConversationThemes(recentMessages);
388
-
389
- const prompt = buildPersonaExplorePrompt({
390
- persona_name: persona.display_name,
391
- traits: persona.traits,
392
- remaining_topics: persona.topics,
393
- recent_conversation_themes: themes,
394
- });
395
-
396
- state.queue_enqueue({
397
- type: LLMRequestType.JSON,
398
- priority: LLMPriority.Low,
399
- system: prompt.system,
400
- user: prompt.user,
401
- next_step: LLMNextStep.HandlePersonaExplore,
402
- data: { personaId, personaDisplayName: persona.display_name },
403
- });
404
- }
405
-
406
- function extractConversationThemes(messages: Message[]): string[] {
407
- const humanMessages = messages.filter(m => m.role === "human");
408
- if (humanMessages.length === 0) return [];
409
-
410
- const words = humanMessages
411
- .map(m => (m.verbal_response ?? '').toLowerCase())
412
- .join(" ")
413
- .split(/\s+/)
414
- .filter(w => w.length > 4);
415
-
416
- const frequency: Record<string, number> = {};
417
- for (const word of words) {
418
- frequency[word] = (frequency[word] || 0) + 1;
419
- }
420
-
421
- return Object.entries(frequency)
422
- .filter(([_, count]) => count >= 2)
423
- .sort((a, b) => b[1] - a[1])
424
- .slice(0, 5)
425
- .map(([word]) => word);
426
- }
427
-
428
- export function queueDescriptionCheck(personaId: string, state: StateManager): void {
429
- const persona = state.persona_getById(personaId);
430
- if (!persona) {
431
- console.error(`[ceremony:description] Persona not found: ${personaId}`);
432
- return;
433
- }
434
-
435
- console.log(`[ceremony:description] Queueing for ${persona.display_name}`);
436
-
437
- const prompt = buildDescriptionCheckPrompt({
438
- persona_name: persona.display_name,
439
- current_short_description: persona.short_description,
440
- current_long_description: persona.long_description,
441
- traits: persona.traits,
442
- topics: persona.topics,
443
- });
444
-
445
- state.queue_enqueue({
446
- type: LLMRequestType.JSON,
447
- priority: LLMPriority.Low,
448
- system: prompt.system,
449
- user: prompt.user,
450
- next_step: LLMNextStep.HandleDescriptionCheck,
451
- data: { personaId, personaDisplayName: persona.display_name },
452
- });
453
- }
454
-
455
362
  // =============================================================================
456
363
  // HUMAN CEREMONY (synchronous — runs during Decay phase)
457
364
  // =============================================================================
@@ -550,7 +457,10 @@ export function queueRewritePhase(state: StateManager): void {
550
457
  }
551
458
  }
552
459
  for (const person of human.people) {
553
- if ((person.description?.length ?? 0) > REWRITE_DESCRIPTION_THRESHOLD && !person.rewrite_checked) {
460
+ const isPersonaLinked = (person.identifiers ?? []).some(
461
+ i => i.type.toLowerCase() === 'ei persona'
462
+ );
463
+ if (!isPersonaLinked && (person.description?.length ?? 0) > REWRITE_DESCRIPTION_THRESHOLD && !person.rewrite_checked) {
554
464
  itemsToScan.push({ item: person, type: "person" });
555
465
  }
556
466
  }
@@ -1,208 +1,9 @@
1
1
  import { StateManager } from "../state-manager.js";
2
2
  import { LLMRequestType, LLMPriority, LLMNextStep, type DataItemBase } from "../types.js";
3
- import type { DataItemType } from "../types/data-items.js";
4
- import { buildDedupPrompt } from "../../prompts/ceremony/dedup.js";
5
3
  import { buildUserDedupPrompt } from "../../prompts/ceremony/user-dedup.js";
6
4
 
7
- // =============================================================================
8
- // TYPES
9
- // =============================================================================
10
-
11
5
  type DedupableItem = DataItemBase & { relationship?: string };
12
6
 
13
- interface Cluster {
14
- ids: string[];
15
- minSim: number;
16
- maxSim: number;
17
- size: number;
18
- }
19
-
20
- // =============================================================================
21
- // DEDUP CANDIDATE FINDING (copied from ceremony.ts)
22
- // =============================================================================
23
-
24
- const DEDUP_DEFAULT_THRESHOLD = 0.95; // Raised from 0.90: Ei's topic corpus is a single dense project domain — mega-cluster persists all the way to 0.92. At 0.95, max cluster drops to 7 items.
25
-
26
- function findDedupCandidates<T extends DedupableItem>(
27
- items: T[],
28
- threshold: number
29
- ): Array<{ a: T; b: T; similarity: number }> {
30
- const withEmbeddings = items.filter(item =>
31
- item.embedding && item.embedding.length > 0 &&
32
- item.relationship !== "Persona"
33
- );
34
-
35
- const candidates: Array<{ a: T; b: T; similarity: number }> = [];
36
-
37
- for (let i = 0; i < withEmbeddings.length; i++) {
38
- for (let j = i + 1; j < withEmbeddings.length; j++) {
39
- const a = withEmbeddings[i];
40
- const b = withEmbeddings[j];
41
- const dot = a.embedding!.reduce((sum, v, k) => sum + v * b.embedding![k], 0);
42
- const normA = Math.sqrt(a.embedding!.reduce((sum, v) => sum + v * v, 0));
43
- const normB = Math.sqrt(b.embedding!.reduce((sum, v) => sum + v * v, 0));
44
- const similarity = normA && normB ? dot / (normA * normB) : 0;
45
-
46
- if (similarity >= threshold) {
47
- candidates.push({ a, b, similarity });
48
- }
49
- }
50
- }
51
-
52
- return candidates.sort((x, y) => y.similarity - x.similarity);
53
- }
54
-
55
- // =============================================================================
56
- // UNION-FIND CLUSTERING
57
- // =============================================================================
58
-
59
- function clusterPairs<T extends DedupableItem>(
60
- pairs: Array<{ a: T; b: T; similarity: number }>
61
- ): Cluster[] {
62
- const parent = new Map<string, string>();
63
-
64
- function find(x: string): string {
65
- if (!parent.has(x)) parent.set(x, x);
66
- if (parent.get(x) !== x) parent.set(x, find(parent.get(x)!));
67
- return parent.get(x)!;
68
- }
69
-
70
- function union(x: string, y: string): void {
71
- const px = find(x), py = find(y);
72
- if (px !== py) parent.set(px, py);
73
- }
74
-
75
- // Union all pairs
76
- for (const pair of pairs) {
77
- union(pair.a.id, pair.b.id);
78
- }
79
-
80
- // Group by root to create clusters
81
- const clusters = new Map<string, { ids: string[]; sims: number[] }>();
82
- for (const pair of pairs) {
83
- const root = find(pair.a.id);
84
- if (!clusters.has(root)) {
85
- clusters.set(root, { ids: [], sims: [] });
86
- }
87
- const cluster = clusters.get(root)!;
88
- if (!cluster.ids.includes(pair.a.id)) cluster.ids.push(pair.a.id);
89
- if (!cluster.ids.includes(pair.b.id)) cluster.ids.push(pair.b.id);
90
- cluster.sims.push(pair.similarity);
91
- }
92
-
93
- // Convert to Cluster objects
94
- return Array.from(clusters.values()).map(c => ({
95
- ids: c.ids,
96
- minSim: Math.min(...c.sims),
97
- maxSim: Math.max(...c.sims),
98
- size: c.ids.length
99
- }));
100
- }
101
-
102
- // =============================================================================
103
- // QUALITY GATES
104
- // =============================================================================
105
-
106
- function filterClusters(clusters: Cluster[]): Cluster[] {
107
- return clusters
108
- .filter(c => {
109
- if (c.size > 50) {
110
- console.warn(`[Dedup] Cluster rejected (size too large): ${c.size} items`);
111
- return false;
112
- }
113
- return true;
114
- })
115
- .filter(c => {
116
- const spread = c.maxSim - c.minSim;
117
- if (spread > 0.10) { // 10% threshold
118
- console.warn(`[Dedup] Cluster rejected (high spread): ${spread.toFixed(3)} range`);
119
- return false;
120
- }
121
- return true;
122
- });
123
- }
124
-
125
- // =============================================================================
126
- // MAIN QUEUEING FUNCTION
127
- // =============================================================================
128
-
129
- export function queueDedupPhase(state: StateManager): void {
130
- const human = state.getHuman();
131
- const rewriteModel = human.settings?.rewrite_model;
132
-
133
- if (!rewriteModel) {
134
- console.log("[Dedup] rewrite_model not set — skipping dedup phase");
135
- return;
136
- }
137
-
138
- const threshold = human.settings?.ceremony?.dedup_threshold ?? DEDUP_DEFAULT_THRESHOLD;
139
-
140
- console.log(`[Dedup] Starting deduplication phase (threshold: ${threshold})`);
141
-
142
- const entityTypes: Array<{ type: DataItemType; items: DedupableItem[] }> = [
143
- { type: "topic", items: human.topics },
144
- { type: "person", items: human.people },
145
- ];
146
-
147
- let totalClusters = 0;
148
-
149
- for (const { type, items } of entityTypes) {
150
- // Find dedup candidates
151
- const pairs = findDedupCandidates(items, threshold);
152
-
153
- if (pairs.length === 0) {
154
- console.log(`[Dedup] ${type}: No duplicates found`);
155
- continue;
156
- }
157
-
158
- // Cluster pairs via union-find
159
- const clusters = clusterPairs(pairs);
160
-
161
- // Apply quality gates
162
- const vettedClusters = filterClusters(clusters);
163
-
164
- console.log(`[Dedup] ${type}: ${pairs.length} pairs → ${clusters.length} clusters → ${vettedClusters.length} vetted`);
165
-
166
- // Queue Opus curation for each vetted cluster
167
- for (const cluster of vettedClusters) {
168
- // Hydrate cluster with full entity data
169
- const clusterEntities = cluster.ids
170
- .map(id => items.find(item => item.id === id))
171
- .filter((item): item is DedupableItem => item !== undefined);
172
-
173
- if (clusterEntities.length === 0) {
174
- console.warn(`[Dedup] Cluster hydration failed - no entities found`);
175
- continue;
176
- }
177
-
178
- // Build prompt
179
- const prompt = buildDedupPrompt({
180
- cluster: clusterEntities,
181
- itemType: type as "topic" | "person",
182
- similarityRange: { min: cluster.minSim, max: cluster.maxSim }
183
- });
184
-
185
- // Queue LLM request
186
- state.queue_enqueue({
187
- type: LLMRequestType.JSON,
188
- priority: LLMPriority.Normal,
189
- system: prompt.system,
190
- user: prompt.user,
191
- next_step: LLMNextStep.HandleDedupCurate,
192
- model: rewriteModel,
193
- data: {
194
- entity_type: type,
195
- entity_ids: cluster.ids,
196
- ceremony_progress: 1
197
- }
198
- });
199
- totalClusters++;
200
- }
201
- }
202
-
203
- console.log(`[Dedup] Queued ${totalClusters} clusters for curation`);
204
- }
205
-
206
7
  // =============================================================================
207
8
  // USER-TRIGGERED DEDUP
208
9
  // =============================================================================
@@ -1,5 +1,7 @@
1
1
  import type { Message } from "../types.js";
2
2
  import type { ExtractionContext } from "./human-extraction.js";
3
+ import { getMessageContent } from "../handlers/utils.js";
4
+ import { getMessageDisplayText } from "../../prompts/message-utils.js";
3
5
 
4
6
  const DEFAULT_MAX_TOKENS = 10000;
5
7
  const CHARS_PER_TOKEN = 4;
@@ -12,7 +14,10 @@ function estimateTokens(text: string): number {
12
14
  }
13
15
 
14
16
  function estimateMessageTokens(messages: Message[]): number {
15
- return messages.reduce((sum, msg) => sum + estimateTokens(msg.verbal_response ?? '') + 4, 0);
17
+ return messages.reduce((sum, msg) => {
18
+ const text = getMessageDisplayText(msg) ?? getMessageContent(msg);
19
+ return sum + estimateTokens(text) + 4;
20
+ }, 0);
16
21
  }
17
22
 
18
23
  function fitMessagesFromEnd(messages: Message[], maxTokens: number): Message[] {
@@ -20,7 +25,7 @@ function fitMessagesFromEnd(messages: Message[], maxTokens: number): Message[] {
20
25
  let tokens = 0;
21
26
 
22
27
  for (let i = messages.length - 1; i >= 0; i--) {
23
- const msgTokens = estimateTokens(messages[i].verbal_response ?? '') + 4;
28
+ const msgTokens = estimateTokens(getMessageContent(messages[i])) + 4;
24
29
  if (tokens + msgTokens > maxTokens) break;
25
30
  result.unshift(messages[i]);
26
31
  tokens += msgTokens;
@@ -39,7 +44,7 @@ function pullMessagesFromStart(
39
44
  let i = startIndex;
40
45
 
41
46
  while (i < messages.length) {
42
- const msgTokens = estimateTokens(messages[i].verbal_response ?? '') + 4;
47
+ const msgTokens = estimateTokens(getMessageContent(messages[i])) + 4;
43
48
  if (tokens + msgTokens > maxTokens && pulled.length > 0) break;
44
49
  pulled.push(messages[i]);
45
50
  tokens += msgTokens;