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.
- package/package.json +1 -1
- package/src/core/AGENTS.md +16 -0
- package/src/core/handlers/dedup.ts +238 -0
- package/src/core/handlers/heartbeat.ts +13 -6
- package/src/core/handlers/index.ts +2 -0
- package/src/core/handlers/persona-response.ts +29 -3
- package/src/core/heartbeat-manager.ts +4 -0
- package/src/core/llm-client.ts +1 -0
- package/src/core/orchestrators/ceremony.ts +44 -33
- package/src/core/orchestrators/dedup-phase.ts +205 -0
- package/src/core/orchestrators/human-extraction.ts +2 -1
- package/src/core/orchestrators/index.ts +2 -2
- package/src/core/processor.ts +16 -7
- package/src/core/queue-processor.ts +9 -2
- package/src/core/state/queue.ts +1 -1
- package/src/core/types/enums.ts +1 -0
- package/src/prompts/ceremony/dedup.ts +292 -0
- package/src/prompts/ceremony/index.ts +3 -0
- package/src/prompts/ceremony/types.ts +45 -0
- package/src/prompts/heartbeat/check.ts +1 -0
- package/tui/src/app.tsx +7 -5
- package/tui/src/commands/archive.tsx +2 -2
- package/tui/src/commands/context.tsx +3 -4
- package/tui/src/commands/delete.tsx +4 -4
- package/tui/src/commands/dlq.ts +3 -4
- package/tui/src/commands/help.tsx +1 -1
- package/tui/src/commands/me.tsx +3 -4
- package/tui/src/commands/persona.tsx +2 -2
- package/tui/src/commands/provider.tsx +3 -5
- package/tui/src/commands/queue.ts +3 -4
- package/tui/src/commands/quotes.tsx +6 -8
- package/tui/src/commands/registry.ts +1 -1
- package/tui/src/commands/setsync.tsx +2 -2
- package/tui/src/commands/settings.tsx +3 -4
- package/tui/src/commands/spotify-auth.ts +0 -1
- package/tui/src/commands/tools.tsx +4 -5
- package/tui/src/context/overlay.tsx +17 -6
- package/tui/src/util/editor.ts +22 -11
- package/tui/src/util/persona-editor.tsx +6 -8
- package/tui/src/util/provider-editor.tsx +6 -8
- package/tui/src/util/toolkit-editor.tsx +3 -4
package/package.json
CHANGED
package/src/core/AGENTS.md
CHANGED
|
@@ -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(
|
|
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(`[
|
|
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(`[
|
|
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("[
|
|
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("[
|
|
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)
|
|
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.
|
|
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
|
-
|
|
94
|
-
|
|
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
|
}
|
package/src/core/llm-client.ts
CHANGED
|
@@ -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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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]
|
|
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
|
|
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);
|