ei-tui 0.1.23 → 0.1.25

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 (41) hide show
  1. package/package.json +1 -1
  2. package/src/core/AGENTS.md +16 -0
  3. package/src/core/handlers/dedup.ts +238 -0
  4. package/src/core/handlers/heartbeat.ts +13 -6
  5. package/src/core/handlers/index.ts +2 -0
  6. package/src/core/handlers/persona-response.ts +29 -3
  7. package/src/core/heartbeat-manager.ts +4 -0
  8. package/src/core/llm-client.ts +1 -0
  9. package/src/core/orchestrators/ceremony.ts +44 -33
  10. package/src/core/orchestrators/dedup-phase.ts +205 -0
  11. package/src/core/orchestrators/human-extraction.ts +2 -1
  12. package/src/core/orchestrators/index.ts +2 -2
  13. package/src/core/processor.ts +16 -7
  14. package/src/core/queue-processor.ts +9 -2
  15. package/src/core/state/queue.ts +1 -1
  16. package/src/core/types/enums.ts +1 -0
  17. package/src/prompts/ceremony/dedup.ts +292 -0
  18. package/src/prompts/ceremony/index.ts +3 -0
  19. package/src/prompts/ceremony/types.ts +45 -0
  20. package/src/prompts/heartbeat/check.ts +1 -0
  21. package/tui/src/app.tsx +7 -5
  22. package/tui/src/commands/archive.tsx +2 -2
  23. package/tui/src/commands/context.tsx +3 -4
  24. package/tui/src/commands/delete.tsx +4 -4
  25. package/tui/src/commands/dlq.ts +3 -4
  26. package/tui/src/commands/help.tsx +1 -1
  27. package/tui/src/commands/me.tsx +3 -4
  28. package/tui/src/commands/persona.tsx +2 -2
  29. package/tui/src/commands/provider.tsx +3 -5
  30. package/tui/src/commands/queue.ts +3 -4
  31. package/tui/src/commands/quotes.tsx +6 -8
  32. package/tui/src/commands/registry.ts +1 -1
  33. package/tui/src/commands/setsync.tsx +2 -2
  34. package/tui/src/commands/settings.tsx +3 -4
  35. package/tui/src/commands/spotify-auth.ts +0 -1
  36. package/tui/src/commands/tools.tsx +4 -5
  37. package/tui/src/context/overlay.tsx +17 -6
  38. package/tui/src/util/editor.ts +22 -11
  39. package/tui/src/util/persona-editor.tsx +6 -8
  40. package/tui/src/util/provider-editor.tsx +6 -8
  41. package/tui/src/util/toolkit-editor.tsx +3 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ei-tui",
3
- "version": "0.1.23",
3
+ "version": "0.1.25",
4
4
  "author": "Flare576",
5
5
  "repository": {
6
6
  "type": "git",
@@ -49,6 +49,22 @@ Priority queue for LLM requests:
49
49
 
50
50
  **Async model**: Handlers queue work, don't await results inline.
51
51
 
52
+ ### llm-client.ts
53
+
54
+ Multi-provider LLM abstraction layer:
55
+ - Handles requests to Anthropic, OpenAI, Bedrock, local models
56
+ - **Sets `max_tokens: 64000`** for all requests
57
+ - Prevents unbounded generation (test showed timeout after 2min without limit)
58
+ - Local models silently clamp to their configured maximums
59
+ - Anthropic Opus 4 accepts 64K (200K total context - 64K output = 136K input budget)
60
+
61
+ **JSON Response Parsing** (`parseJSONResponse()`):
62
+ - **Strategy 1**: Extract from markdown code blocks (```json)
63
+ - **Strategy 2**: Auto-repair malformed JSON (trailing commas, etc.)
64
+ - **Strategy 3**: Extract outermost `{...}` from mixed prose/JSON (handles LLM preamble)
65
+
66
+ No prompt changes needed for JSON-only output—parser handles natural language gracefully.
67
+
52
68
  ### handlers/index.ts (1000+ lines)
53
69
 
54
70
  All `LLMNextStep` handlers in one file. Each handler:
@@ -0,0 +1,238 @@
1
+ import { StateManager } from "../state-manager.js";
2
+ import { LLMResponse } from "../types.js";
3
+ import type { DedupResult } from "../../prompts/ceremony/types.js";
4
+ import type { DataItemType, Fact, Trait, Topic, Person, Quote } from "../types/data-items.js";
5
+ import { getEmbeddingService } from "../embedding-service.js";
6
+
7
+ /**
8
+ * handleDedupCurate — Process Opus deduplication decisions
9
+ *
10
+ * This handler receives merge decisions from Opus and applies them:
11
+ * 1. Updates: Entities with revised descriptions/merged data
12
+ * 2. Removes: Duplicate entities to delete (with foreign key updates)
13
+ * 3. Adds: New entities created from consolidation
14
+ *
15
+ * CRITICAL: Quote foreign keys must be updated BEFORE deletions to maintain
16
+ * referential integrity.
17
+ */
18
+ export async function handleDedupCurate(
19
+ response: LLMResponse,
20
+ stateManager: StateManager
21
+ ): Promise<void> {
22
+ const entity_type = response.request.data.entity_type as DataItemType;
23
+ const entity_ids = response.request.data.entity_ids as string[];
24
+ const state = stateManager.getHuman();
25
+
26
+ // Validate entity_type
27
+ if (!entity_type || !['fact', 'trait', 'topic', 'person'].includes(entity_type)) {
28
+ console.error(`[Dedup] Invalid entity_type: "${entity_type}" (from request data)`, response.request.data);
29
+ return;
30
+ }
31
+
32
+ // Parse Opus response
33
+ let decisions: DedupResult;
34
+ try {
35
+ decisions = response.parsed as DedupResult;
36
+ if (!decisions || typeof decisions !== 'object') {
37
+ throw new Error("Invalid response format");
38
+ }
39
+ } catch (err) {
40
+ console.error(`[Dedup] Failed to parse Opus response:`, err);
41
+ return;
42
+ }
43
+
44
+ // Validate response structure
45
+ if (!Array.isArray(decisions.update) || !Array.isArray(decisions.remove) || !Array.isArray(decisions.add)) {
46
+ console.error(`[Dedup] Invalid response structure - missing update/remove/add arrays`);
47
+ return;
48
+ }
49
+
50
+ console.log(`[Dedup] Processing cluster: ${decisions.update.length} updates, ${decisions.remove.length} removals, ${decisions.add.length} additions`);
51
+
52
+ // Map entity_type to pluralized state property name
53
+ const pluralMap: Record<DataItemType, 'facts' | 'traits' | 'topics' | 'people'> = {
54
+ fact: 'facts',
55
+ trait: 'traits',
56
+ topic: 'topics',
57
+ person: 'people'
58
+ };
59
+ const entityList = state[pluralMap[entity_type]];
60
+
61
+ // Validate entityList exists
62
+ if (!entityList || !Array.isArray(entityList)) {
63
+ console.error(`[Dedup] entityList is ${entityList === undefined ? 'undefined' : 'not an array'} for entity_type="${entity_type}" (looking for state.${entity_type}s)`, {
64
+ entity_type,
65
+ entity_ids,
66
+ stateKeys: Object.keys(state),
67
+ factsExists: !!state.facts,
68
+ traitsExists: !!state.traits,
69
+ topicsExists: !!state.topics,
70
+ peopleExists: !!state.people
71
+ });
72
+ return;
73
+ }
74
+ const entities = entity_ids
75
+ .map((id: string) => entityList.find((e: Fact | Trait | Topic | Person) => e.id === id))
76
+ .filter((e: Fact | Trait | Topic | Person | undefined): e is (Fact | Trait | Topic | Person) => e !== undefined);
77
+
78
+ if (entities.length === 0) {
79
+ console.warn(`[Dedup] No entities found for cluster (already merged?)`);
80
+ return;
81
+ }
82
+
83
+ // =========================================================================
84
+ // PHASE 1: Update Quote foreign keys FIRST (before deletions)
85
+ // =========================================================================
86
+
87
+ for (const removal of decisions.remove) {
88
+ const quotes = state.quotes.filter((q: Quote) =>
89
+ q.data_item_ids.includes(removal.to_be_removed)
90
+ );
91
+
92
+ for (const quote of quotes) {
93
+ const updatedIds = quote.data_item_ids
94
+ .map((id: string) => id === removal.to_be_removed ? removal.replaced_by : id)
95
+ .filter((id: string, idx: number, arr: string[]) => arr.indexOf(id) === idx); // Dedupe
96
+
97
+ stateManager.human_quote_update(quote.id, {
98
+ data_item_ids: updatedIds
99
+ });
100
+ }
101
+
102
+ if (quotes.length > 0) {
103
+ console.log(`[Dedup] Updated ${quotes.length} quotes referencing ${removal.to_be_removed}`);
104
+ }
105
+ }
106
+
107
+ // =========================================================================
108
+ // PHASE 2: Apply updates (merge decisions)
109
+ // =========================================================================
110
+
111
+ for (const update of decisions.update) {
112
+ const entity = entityList.find((e: Fact | Trait | Topic | Person) => e.id === update.id);
113
+
114
+ if (!entity) {
115
+ console.warn(`[Dedup] Entity ${update.id} not found (already merged?)`);
116
+ continue; // Graceful skip
117
+ }
118
+
119
+ // Recalculate embedding if description changed
120
+ let embedding = entity.embedding;
121
+ if (update.description !== entity.description) {
122
+ try {
123
+ const embeddingService = getEmbeddingService();
124
+ embedding = await embeddingService.embed(update.description);
125
+ } catch (err) {
126
+ console.warn(`[Dedup] Failed to recalculate embedding for ${update.id}`, err);
127
+ // Fallback to old embedding if recalculation fails
128
+ }
129
+ }
130
+
131
+ // Build complete entity with updates (preserve original fields if LLM omits them)
132
+ const updatedEntity = {
133
+ ...entity,
134
+ name: update.name ?? entity.name,
135
+ description: update.description ?? entity.description,
136
+ sentiment: update.sentiment ?? entity.sentiment,
137
+ last_updated: new Date().toISOString(),
138
+ embedding,
139
+ // Type-specific fields
140
+ ...(update.strength !== undefined && { strength: update.strength }),
141
+ ...(update.confidence !== undefined && { confidence: update.confidence }),
142
+ ...(update.exposure_current !== undefined && { exposure_current: update.exposure_current }),
143
+ ...(update.exposure_desired !== undefined && { exposure_desired: update.exposure_desired }),
144
+ ...(update.relationship !== undefined && { relationship: update.relationship }),
145
+ ...(update.category !== undefined && { category: update.category }),
146
+ };
147
+
148
+ // Type-safe cast based on entity_type
149
+ if (entity_type === 'fact') {
150
+ stateManager.human_fact_upsert(updatedEntity as Fact);
151
+ } else if (entity_type === 'trait') {
152
+ stateManager.human_trait_upsert(updatedEntity as Trait);
153
+ } else if (entity_type === 'topic') {
154
+ stateManager.human_topic_upsert(updatedEntity as Topic);
155
+ } else if (entity_type === 'person') {
156
+ stateManager.human_person_upsert(updatedEntity as Person);
157
+ }
158
+ console.log(`[Dedup] Updated ${entity_type} "${update.name}"`);
159
+ }
160
+
161
+ // =========================================================================
162
+ // PHASE 3: Apply removals (soft-delete with replaced_by tracking)
163
+ // =========================================================================
164
+
165
+ for (const removal of decisions.remove) {
166
+ const entity = entityList.find((e: Fact | Trait | Topic | Person) => e.id === removal.to_be_removed);
167
+
168
+ if (!entity) {
169
+ console.warn(`[Dedup] Entity ${removal.to_be_removed} already deleted`);
170
+ continue; // Graceful skip
171
+ }
172
+
173
+ // Remove via StateManager (also cleans up quote references)
174
+ const removeMethod = `human_${entity_type}_remove` as
175
+ 'human_fact_remove' | 'human_trait_remove' | 'human_topic_remove' | 'human_person_remove';
176
+
177
+ const removed = stateManager[removeMethod](removal.to_be_removed);
178
+ if (removed) {
179
+ console.log(`[Dedup] Removed ${entity_type} "${entity.name}" (merged into ${removal.replaced_by})`);
180
+ }
181
+ }
182
+
183
+ // =========================================================================
184
+ // PHASE 4: Apply additions (new entities from consolidation)
185
+ // =========================================================================
186
+
187
+ for (const addition of decisions.add) {
188
+ // Compute embedding for new entity
189
+ let embedding: number[] | undefined;
190
+ try {
191
+ const embeddingService = getEmbeddingService();
192
+ embedding = await embeddingService.embed(addition.description);
193
+ } catch (err) {
194
+ console.warn(`[Dedup] Failed to compute embedding for new entity "${addition.name}"`, err);
195
+ continue; // Skip this addition if embedding fails
196
+ }
197
+
198
+ // Generate ID for new entity
199
+ const id = crypto.randomUUID();
200
+
201
+ // Build complete entity
202
+ const newEntity = {
203
+ id,
204
+ type: entity_type,
205
+ name: addition.name,
206
+ description: addition.description,
207
+ sentiment: addition.sentiment ?? 0.0,
208
+ last_updated: new Date().toISOString(),
209
+ embedding,
210
+ // Type-specific fields with defaults
211
+ ...(entity_type === 'trait' && { strength: addition.strength ?? 0.5 }),
212
+ ...(entity_type === 'fact' && {
213
+ confidence: addition.confidence ?? 0.5,
214
+ validated: 'unknown' as import("../types/enums.js").ValidationLevel,
215
+ validated_date: ''
216
+ }),
217
+ ...((entity_type === 'topic' || entity_type === 'person') && {
218
+ exposure_current: addition.exposure_current ?? 0.0,
219
+ exposure_desired: addition.exposure_desired ?? 0.5,
220
+ last_ei_asked: null
221
+ }),
222
+ ...(entity_type === 'person' && { relationship: addition.relationship ?? 'Unknown' }),
223
+ ...(entity_type === 'topic' && { category: addition.category ?? 'Interest' }),
224
+ };
225
+
226
+ // Type-safe cast based on entity_type
227
+ if (entity_type === 'fact') {
228
+ stateManager.human_fact_upsert(newEntity as Fact);
229
+ } else if (entity_type === 'trait') {
230
+ stateManager.human_trait_upsert(newEntity as Trait);
231
+ } else if (entity_type === 'topic') {
232
+ stateManager.human_topic_upsert(newEntity as Topic);
233
+ } else if (entity_type === 'person') {
234
+ stateManager.human_person_upsert(newEntity as Person);
235
+ }
236
+ console.log(`[Dedup] Added new ${entity_type} "${addition.name}"`);
237
+ }
238
+ }
@@ -19,16 +19,17 @@ export function handleHeartbeatCheck(response: LLMResponse, state: StateManager)
19
19
 
20
20
  const result = response.parsed as HeartbeatCheckResult | undefined;
21
21
  if (!result) {
22
- console.error("[handleHeartbeatCheck] No parsed result");
22
+ console.error(`[HeartbeatCheck ${personaDisplayName}] No parsed result`);
23
23
  return;
24
24
  }
25
+ console.log(`[HeartbeatCheck ${personaDisplayName}] Parsed result - should_respond: ${result.should_respond}, topic: ${result.topic ?? '(none)'}, message: ${result.message ? '(present)' : '(none)'}`);
25
26
 
26
27
  const now = new Date().toISOString();
27
28
  state.persona_update(personaId, { last_heartbeat: now });
28
29
  state.queue_clearPersonaResponses(personaId, LLMNextStep.HandleHeartbeatCheck);
29
30
 
30
31
  if (!result.should_respond) {
31
- console.log(`[handleHeartbeatCheck] ${personaDisplayName} chose not to reach out`);
32
+ console.log(`[HeartbeatCheck ${personaDisplayName}] Chose not to reach out (should_respond=false)`);
32
33
  return;
33
34
  }
34
35
 
@@ -42,21 +43,24 @@ export function handleHeartbeatCheck(response: LLMResponse, state: StateManager)
42
43
  context_status: ContextStatus.Default,
43
44
  };
44
45
  state.messages_append(personaId, message);
45
- console.log(`[handleHeartbeatCheck] ${personaDisplayName} proactively messaged about: ${result.topic ?? "general"}`);
46
+ console.log(`[HeartbeatCheck ${personaDisplayName}] Added proactive message - topic: ${result.topic ?? 'general'}, message: "${result.message.substring(0, 100)}${result.message.length > 100 ? '...' : ''}"`);
47
+ } else {
48
+ console.log(`[HeartbeatCheck ${personaDisplayName}] should_respond=true but no message provided`);
46
49
  }
47
50
  }
48
51
 
49
52
  export function handleEiHeartbeat(response: LLMResponse, state: StateManager): void {
50
53
  const result = response.parsed as EiHeartbeatResult | undefined;
51
54
  if (!result) {
52
- console.error("[handleEiHeartbeat] No parsed result");
55
+ console.error("[EiHeartbeat] No parsed result");
53
56
  return;
54
57
  }
58
+ console.log(`[EiHeartbeat] Parsed result - should_respond: ${result.should_respond}, id: ${result.id ?? '(none)'}, my_response: ${result.my_response ? '(present)' : '(none)'}`);
55
59
  const now = new Date().toISOString();
56
60
  state.persona_update("ei", { last_heartbeat: now });
57
61
  state.queue_clearPersonaResponses("ei", LLMNextStep.HandleEiHeartbeat);
58
62
  if (!result.should_respond || !result.id) {
59
- console.log("[handleEiHeartbeat] Ei chose not to reach out");
63
+ console.log("[EiHeartbeat] Chose not to reach out (should_respond=false or no id)");
60
64
  return;
61
65
  }
62
66
  const isTUI = response.request.data.isTUI as boolean;
@@ -84,7 +88,10 @@ export function handleEiHeartbeat(response: LLMResponse, state: StateManager): v
84
88
  return;
85
89
  }
86
90
 
87
- if (result.my_response) sendMessage(result.my_response);
91
+ if (result.my_response) {
92
+ console.log(`[EiHeartbeat] Sending message: "${result.my_response.substring(0, 100)}${result.my_response.length > 100 ? '...' : ''}"`);
93
+ sendMessage(result.my_response);
94
+ }
88
95
 
89
96
  switch (found.type) {
90
97
  case "person":
@@ -17,6 +17,7 @@ import {
17
17
  import { handleHumanFactScan, handleHumanTraitScan, handleHumanTopicScan, handleHumanPersonScan } from "./human-extraction.js";
18
18
  import { handleHumanItemMatch, handleHumanItemUpdate } from "./human-matching.js";
19
19
  import { handleRewriteScan, handleRewriteRewrite } from "./rewrite.js";
20
+ import { handleDedupCurate } from "./dedup.js";
20
21
 
21
22
  export const handlers: Record<LLMNextStep, ResponseHandler> = {
22
23
  handlePersonaResponse,
@@ -41,4 +42,5 @@ export const handlers: Record<LLMNextStep, ResponseHandler> = {
41
42
  handleToolContinuation,
42
43
  handleRewriteScan,
43
44
  handleRewriteRewrite,
45
+ handleDedupCurate,
44
46
  };
@@ -1,10 +1,12 @@
1
1
  import {
2
2
  ContextStatus,
3
+ LLMNextStep,
3
4
  type LLMResponse,
4
5
  type Message,
5
6
  } from "../types.js";
6
7
  import type { StateManager } from "../state-manager.js";
7
8
  import type { PersonaResponseResult } from "../../prompts/response/index.js";
9
+ import { handlers } from "./index.js";
8
10
 
9
11
  export type ResponseHandler = (response: LLMResponse, state: StateManager) => void | Promise<void>;
10
12
 
@@ -87,11 +89,35 @@ export function handlePersonaResponse(response: LLMResponse, state: StateManager
87
89
  /**
88
90
  * handleToolContinuation — second LLM call in the tool flow (may loop if LLM calls more tools).
89
91
  * The QueueProcessor already injected tool history into messages and got the
90
- * final persona response. Parse and store it exactly like handlePersonaResponse.
92
+ * final persona response. Route to the original handler based on originalNextStep in data.
91
93
  */
92
94
  export function handleToolContinuation(response: LLMResponse, state: StateManager): void {
93
- console.log(`[handleToolContinuation] Routing to handlePersonaResponse`);
94
- handlePersonaResponse(response, state);
95
+ const originalStep = response.request.data.originalNextStep as LLMNextStep | undefined;
96
+
97
+ if (!originalStep) {
98
+ console.error(`[handleToolContinuation] No originalNextStep in data, falling back to handlePersonaResponse`);
99
+ handlePersonaResponse(response, state);
100
+ return;
101
+ }
102
+
103
+ console.log(`[handleToolContinuation] Original request was ${originalStep}, routing accordingly`);
104
+
105
+ const handler = handlers[originalStep];
106
+
107
+ if (!handler) {
108
+ console.error(`[handleToolContinuation] No handler found for ${originalStep}, falling back to handlePersonaResponse`);
109
+ handlePersonaResponse(response, state);
110
+ return;
111
+ }
112
+
113
+ // Avoid infinite loop - if original was already HandleToolContinuation, go to PersonaResponse
114
+ if (originalStep === "handleToolContinuation") {
115
+ console.log(`[handleToolContinuation] Original was tool continuation, routing to handlePersonaResponse`);
116
+ handlePersonaResponse(response, state);
117
+ return;
118
+ }
119
+
120
+ handler(response, state);
95
121
  }
96
122
 
97
123
  export function handleOneShot(_response: LLMResponse, _state: StateManager): void {
@@ -188,6 +188,8 @@ export async function queueHeartbeatCheck(sm: StateManager, personaId: string, i
188
188
  const persona = sm.persona_getById(personaId);
189
189
  if (!persona) return;
190
190
  sm.persona_update(personaId, { last_heartbeat: new Date().toISOString() });
191
+ const model = getModelForPersona(sm, personaId);
192
+ console.log(`[HeartbeatCheck ${persona.display_name}] Queueing heartbeat check (model: ${model})`);
191
193
  const human = sm.getHuman();
192
194
  const history = sm.messages_get(personaId);
193
195
  const contextWindowHours = persona.context_window_hours ?? DEFAULT_CONTEXT_WINDOW_HOURS;
@@ -228,6 +230,7 @@ export async function queueHeartbeatCheck(sm: StateManager, personaId: string, i
228
230
  };
229
231
 
230
232
  const prompt = buildHeartbeatCheckPrompt(promptData);
233
+ console.log(`[HeartbeatCheck ${persona.display_name}] Prompt data - topics: ${promptData.human.topics.length}, people: ${promptData.human.people.length}, inactive_days: ${inactiveDays}`);
231
234
 
232
235
  sm.queue_enqueue({
233
236
  type: LLMRequestType.JSON,
@@ -238,4 +241,5 @@ export async function queueHeartbeatCheck(sm: StateManager, personaId: string, i
238
241
  model: getModelForPersona(sm, personaId),
239
242
  data: { personaId, personaDisplayName: persona.display_name },
240
243
  });
244
+ console.log(`[HeartbeatCheck ${persona.display_name}] Request queued`);
241
245
  }
@@ -189,6 +189,7 @@ export async function callLLMRaw(
189
189
  model,
190
190
  messages: finalMessages,
191
191
  temperature,
192
+ max_tokens: 64000, // Opus 4: 128K max output, 200K total context. Local models clamp to their config. Prevents runaway generation.
192
193
  };
193
194
 
194
195
  if (options.tools && options.tools.length > 0) {
@@ -10,6 +10,7 @@ import {
10
10
  type ExtractionOptions,
11
11
  } from "./human-extraction.js";
12
12
  import { queuePersonaTopicScan, type PersonaTopicContext } from "./persona-topics.js";
13
+ import { queueDedupPhase } from "./dedup-phase.js";
13
14
  import { buildPersonaExpirePrompt, buildPersonaExplorePrompt, buildDescriptionCheckPrompt, buildRewriteScanPrompt, type RewriteItemType } from "../../prompts/ceremony/index.js";
14
15
 
15
16
  export function isNewDay(lastCeremony: string | undefined, now: Date): boolean {
@@ -69,40 +70,19 @@ export function startCeremony(state: StateManager): void {
69
70
  },
70
71
  });
71
72
 
72
- const personas = state.persona_getAll();
73
- const activePersonas = personas.filter(p =>
74
- !p.is_paused &&
75
- !p.is_archived &&
76
- !p.is_static
77
- );
78
-
79
- const lastCeremony = human.settings?.ceremony?.last_ceremony
80
- ? new Date(human.settings.ceremony.last_ceremony).getTime()
81
- : 0;
73
+ // PHASE 1: Deduplication (runs BEFORE Expose)
74
+ console.log("[ceremony] Starting Phase 1: Deduplication");
75
+ queueDedupPhase(state);
82
76
 
83
- const personasWithActivity = activePersonas.filter(p => {
84
- const lastActivity = p.last_activity ? new Date(p.last_activity).getTime() : 0;
85
- return lastActivity > lastCeremony;
86
- });
87
-
88
- console.log(`[ceremony] Processing ${personasWithActivity.length} personas with activity (of ${activePersonas.length} active)`);
89
-
90
- const options: ExtractionOptions = { ceremony_progress: true };
91
-
92
- for (let i = 0; i < personasWithActivity.length; i++) {
93
- const persona = personasWithActivity[i];
94
- const isLast = i === personasWithActivity.length - 1;
95
-
96
- console.log(`[ceremony] Queuing exposure for ${persona.display_name} (${i + 1}/${personasWithActivity.length})${isLast ? " (last)" : ""}`);
97
- queueExposurePhase(persona.id, state, options);
77
+ // Check if dedup work was queued
78
+ 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");
81
+ handleCeremonyProgress(state, 1);
98
82
  }
99
83
 
100
84
  const duration = Date.now() - startTime;
101
- console.log(`[ceremony] Exposure phase queued in ${duration}ms`);
102
-
103
- // Check immediately — if zero messages were queued (no unextracted messages for any persona),
104
- // this will see an empty queue and proceed directly to Decay → Expire.
105
- handleCeremonyProgress(state);
85
+ console.log(`[ceremony] Dedup phase queued in ${duration}ms`);
106
86
  }
107
87
 
108
88
  /**
@@ -193,11 +173,43 @@ function queueExposurePhase(personaId: string, state: StateManager, options?: Ex
193
173
  * If any ceremony_progress items remain in the queue, does nothing — more work pending.
194
174
  * If the queue is clear of ceremony items, advances to Decay → Prune → Expire.
195
175
  */
196
- export function handleCeremonyProgress(state: StateManager): void {
176
+ export function handleCeremonyProgress(state: StateManager, lastPhase: number): void {
197
177
  if (state.queue_hasPendingCeremonies()) {
198
- return; // Still processing exposure scans
178
+ return; // Still processing ceremony items
199
179
  }
200
180
 
181
+ if (lastPhase === 1) {
182
+ // Dedup phase complete → start Expose phase
183
+ console.log("[ceremony:progress] Dedup complete, starting Expose phase");
184
+
185
+ const personas = state.persona_getAll();
186
+ const activePersonas = personas.filter(p =>
187
+ !p.is_paused &&
188
+ !p.is_archived &&
189
+ !p.is_static
190
+ );
191
+
192
+ // Find personas with unprocessed messages (any message with p/r/o/f = false)
193
+ const personasWithUnprocessed = activePersonas.filter(p => {
194
+ const messages = state.messages_get(p.id);
195
+ return messages.some(msg =>
196
+ !msg.p ||
197
+ !msg.r ||
198
+ !msg.o ||
199
+ !msg.f
200
+ );
201
+ });
202
+
203
+ console.log(`[ceremony:expose] Found ${activePersonas.length} active personas, ${personasWithUnprocessed.length} with unprocessed messages`);
204
+
205
+ const options: ExtractionOptions = { ceremony_progress: 2 };
206
+ for (const persona of personasWithUnprocessed) {
207
+ queueExposurePhase(persona.id, state, options);
208
+ }
209
+ return;
210
+ }
211
+
212
+ // Phase 2 (Expose) complete → advance to Decay/Prune/Expire/Explore
201
213
  console.log("[ceremony:progress] All exposure scans complete, advancing to Decay");
202
214
 
203
215
  const personas = state.persona_getAll();
@@ -215,7 +227,6 @@ export function handleCeremonyProgress(state: StateManager): void {
215
227
  if (eiIndex > -1) {
216
228
  activePersonas.splice(eiIndex, 1);
217
229
  }
218
-
219
230
  // Decay phase: apply decay + prune for ALL active personas
220
231
  for (const persona of activePersonas) {
221
232
  applyDecayPhase(persona.id, state);