ei-tui 0.1.25 → 0.2.0

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 (78) hide show
  1. package/README.md +42 -0
  2. package/package.json +1 -1
  3. package/src/README.md +4 -11
  4. package/src/cli/README.md +4 -5
  5. package/src/cli/retrieval.ts +3 -25
  6. package/src/cli.ts +3 -7
  7. package/src/core/AGENTS.md +1 -1
  8. package/src/core/constants/built-in-facts.ts +49 -0
  9. package/src/core/constants/index.ts +1 -0
  10. package/src/core/context-utils.ts +0 -1
  11. package/src/core/embedding-service.ts +8 -0
  12. package/src/core/handlers/dedup.ts +10 -16
  13. package/src/core/handlers/heartbeat.ts +2 -3
  14. package/src/core/handlers/human-extraction.ts +95 -30
  15. package/src/core/handlers/human-matching.ts +326 -248
  16. package/src/core/handlers/index.ts +8 -6
  17. package/src/core/handlers/persona-generation.ts +8 -8
  18. package/src/core/handlers/rewrite.ts +4 -29
  19. package/src/core/handlers/utils.ts +23 -1
  20. package/src/core/heartbeat-manager.ts +2 -4
  21. package/src/core/human-data-manager.ts +5 -27
  22. package/src/core/message-manager.ts +10 -10
  23. package/src/core/orchestrators/ceremony.ts +50 -39
  24. package/src/core/orchestrators/dedup-phase.ts +0 -1
  25. package/src/core/orchestrators/human-extraction.ts +351 -207
  26. package/src/core/orchestrators/index.ts +6 -4
  27. package/src/core/orchestrators/persona-generation.ts +3 -3
  28. package/src/core/processor.ts +99 -17
  29. package/src/core/prompt-context-builder.ts +4 -6
  30. package/src/core/state/human.ts +1 -26
  31. package/src/core/state/personas.ts +2 -2
  32. package/src/core/state-manager.ts +107 -14
  33. package/src/core/tools/builtin/read-memory.ts +7 -8
  34. package/src/core/types/data-items.ts +2 -4
  35. package/src/core/types/entities.ts +6 -4
  36. package/src/core/types/enums.ts +6 -9
  37. package/src/core/types/llm.ts +2 -2
  38. package/src/core/utils/crossFind.ts +2 -5
  39. package/src/core/utils/event-windows.ts +31 -0
  40. package/src/integrations/claude-code/importer.ts +8 -4
  41. package/src/integrations/claude-code/types.ts +2 -0
  42. package/src/integrations/opencode/importer.ts +7 -3
  43. package/src/prompts/AGENTS.md +73 -1
  44. package/src/prompts/ceremony/rewrite.ts +3 -22
  45. package/src/prompts/ceremony/types.ts +3 -3
  46. package/src/prompts/generation/descriptions.ts +2 -2
  47. package/src/prompts/generation/types.ts +2 -2
  48. package/src/prompts/heartbeat/types.ts +2 -2
  49. package/src/prompts/human/event-scan.ts +122 -0
  50. package/src/prompts/human/fact-find.ts +106 -0
  51. package/src/prompts/human/fact-scan.ts +0 -2
  52. package/src/prompts/human/index.ts +17 -10
  53. package/src/prompts/human/person-match.ts +65 -0
  54. package/src/prompts/human/person-scan.ts +52 -59
  55. package/src/prompts/human/person-update.ts +241 -0
  56. package/src/prompts/human/topic-match.ts +65 -0
  57. package/src/prompts/human/topic-scan.ts +51 -71
  58. package/src/prompts/human/topic-update.ts +295 -0
  59. package/src/prompts/human/types.ts +63 -40
  60. package/src/prompts/index.ts +4 -8
  61. package/src/prompts/persona/topics-update.ts +2 -2
  62. package/src/prompts/persona/traits.ts +2 -2
  63. package/src/prompts/persona/types.ts +3 -3
  64. package/src/prompts/response/index.ts +1 -1
  65. package/src/prompts/response/sections.ts +9 -12
  66. package/src/prompts/response/types.ts +2 -3
  67. package/src/storage/embeddings.ts +1 -1
  68. package/src/storage/index.ts +1 -0
  69. package/src/storage/indexed.ts +174 -0
  70. package/src/storage/merge.ts +67 -2
  71. package/tui/src/commands/me.tsx +5 -14
  72. package/tui/src/commands/settings.tsx +15 -0
  73. package/tui/src/context/ei.tsx +5 -14
  74. package/tui/src/util/yaml-serializers.ts +48 -33
  75. package/src/cli/commands/traits.ts +0 -25
  76. package/src/prompts/human/item-match.ts +0 -74
  77. package/src/prompts/human/item-update.ts +0 -364
  78. package/src/prompts/human/trait-scan.ts +0 -115
@@ -1,35 +1,72 @@
1
- import { LLMRequestType, LLMPriority, LLMNextStep, type Message, type DataItemType, type Fact, type Trait, type Topic, type Person } from "../types.js";
1
+ import { LLMRequestType, LLMPriority, LLMNextStep, type Message, type Topic, type Person } from "../types.js";
2
2
  import type { StateManager } from "../state-manager.js";
3
3
  import {
4
- buildHumanFactScanPrompt,
5
- buildHumanTraitScanPrompt,
4
+ buildFactFindPrompt,
6
5
  buildHumanTopicScanPrompt,
7
6
  buildHumanPersonScanPrompt,
8
- buildHumanItemMatchPrompt,
9
- buildHumanItemUpdatePrompt,
10
- type FactScanCandidate,
11
- type TraitScanCandidate,
7
+ buildTopicMatchPrompt,
8
+ buildTopicUpdatePrompt,
9
+ buildPersonMatchPrompt,
10
+ buildPersonUpdatePrompt,
11
+ buildEventScanPrompt,
12
12
  type TopicScanCandidate,
13
13
  type PersonScanCandidate,
14
14
  type ItemMatchResult,
15
+ type ParticipantContext,
15
16
  } from "../../prompts/human/index.js";
16
17
  import { chunkExtractionContext } from "./extraction-chunker.js";
17
- import { getEmbeddingService, findTopK } from "../embedding-service.js";
18
+ import { getEmbeddingService, findTopK, getTopicEmbeddingText, getPersonEmbeddingText } from "../embedding-service.js";
18
19
  import { resolveTokenLimit } from "../llm-client.js";
20
+ import { BUILT_IN_FACT_NAMES } from "../constants/built-in-facts.js";
21
+ import { buildEventWindows } from "../utils/event-windows.js";
19
22
 
20
- type ScanCandidate = FactScanCandidate | TraitScanCandidate | TopicScanCandidate | PersonScanCandidate;
23
+ function buildParticipantContext(personaId: string, state: StateManager): ParticipantContext {
24
+ const persona = state.persona_getById(personaId);
25
+ const human = state.getHuman();
26
+
27
+ const persona_description = persona?.long_description || undefined;
28
+
29
+ const fullNameFact = human.facts.find(f => f.name === "Full Name");
30
+ const nicknameFact = human.facts.find(f => f.name === "Nickname/Preferred Name");
31
+ const fullName = fullNameFact?.description || "";
32
+ const nickname = nicknameFact?.description || "";
33
+ let human_name: string | undefined;
34
+ if (fullName && nickname) human_name = `${fullName} (${nickname})`;
35
+ else if (fullName) human_name = fullName;
36
+ else if (nickname) human_name = nickname;
37
+
38
+ let human_age: number | undefined;
39
+ const birthdayFact = human.facts.find(f => f.name === "Birthday");
40
+ if (birthdayFact?.description) {
41
+ const birth = new Date(birthdayFact.description);
42
+ if (!isNaN(birth.getTime())) {
43
+ human_age = Math.floor((Date.now() - birth.getTime()) / (365.25 * 24 * 60 * 60 * 1000));
44
+ }
45
+ }
46
+
47
+ return {
48
+ persona_name: persona?.display_name ?? personaId,
49
+ persona_description,
50
+ human_name,
51
+ human_age,
52
+ };
53
+ }
21
54
 
22
55
  export interface ExtractionContext {
23
56
  personaId: string;
24
57
  personaDisplayName: string;
25
58
  messages_context: Message[];
26
59
  messages_analyze: Message[];
27
- extraction_flag?: "f" | "r" | "p" | "o";
60
+ extraction_flag?: "f" | "t" | "p" | "e";
28
61
  }
29
62
 
30
63
  export interface ExtractionOptions {
31
64
  /** Ceremony phase number (1=Dedup, 2=Expose) */
32
65
  ceremony_progress?: number;
66
+ /** Override model for extraction LLM calls */
67
+ extraction_model?: string;
68
+ /** Override token budget for chunking */
69
+ extraction_token_limit?: number;
33
70
  }
34
71
 
35
72
  function getAnalyzeFromTimestamp(context: ExtractionContext): string | null {
@@ -40,16 +77,27 @@ function getAnalyzeFromTimestamp(context: ExtractionContext): string | null {
40
77
  const EXTRACTION_BUDGET_RATIO = 0.75;
41
78
  const MIN_EXTRACTION_TOKENS = 10000;
42
79
 
43
- function getExtractionMaxTokens(state: StateManager): number {
80
+ function getExtractionMaxTokens(state: StateManager, options?: ExtractionOptions): number {
81
+ if (options?.extraction_token_limit) {
82
+ return Math.max(MIN_EXTRACTION_TOKENS, Math.floor(options.extraction_token_limit * EXTRACTION_BUDGET_RATIO));
83
+ }
44
84
  const human = state.getHuman();
45
- const tokenLimit = resolveTokenLimit(human.settings?.default_model, human.settings?.accounts);
85
+ const modelForTokenLimit = options?.extraction_model ?? human.settings?.default_model;
86
+ const tokenLimit = resolveTokenLimit(modelForTokenLimit, human.settings?.accounts);
46
87
  return Math.max(MIN_EXTRACTION_TOKENS, Math.floor(tokenLimit * EXTRACTION_BUDGET_RATIO));
47
88
  }
48
89
 
49
- export function queueFactScan(context: ExtractionContext, state: StateManager, options?: ExtractionOptions): number {
50
- const { chunks } = chunkExtractionContext(context, getExtractionMaxTokens(state));
51
-
52
- if (chunks.length === 0) return 0;
90
+ export function queueFactFind(context: ExtractionContext, state: StateManager, options?: ExtractionOptions): number {
91
+ const human = state.getHuman();
92
+ const extractionModel = options?.extraction_model;
93
+ const missing_fact_names = human.facts
94
+ .filter(f => !f.description || f.description === "")
95
+ .map(f => f.name)
96
+ .filter(name => BUILT_IN_FACT_NAMES.has(name));
97
+
98
+ if (missing_fact_names.length === 0) return 0;
99
+
100
+ const { chunks } = chunkExtractionContext(context, getExtractionMaxTokens(state, options));
53
101
 
54
102
  // Pre-mark messages before enqueuing — prevents duplicate scans if the
55
103
  // queue check fires again during LLM latency (100ms loop × 5s call = 50 dupes)
@@ -58,8 +106,9 @@ export function queueFactScan(context: ExtractionContext, state: StateManager, o
58
106
  }
59
107
 
60
108
  for (const chunk of chunks) {
61
- const prompt = buildHumanFactScanPrompt({
109
+ const prompt = buildFactFindPrompt({
62
110
  persona_name: chunk.personaDisplayName,
111
+ missing_fact_names,
63
112
  messages_context: chunk.messages_context,
64
113
  messages_analyze: chunk.messages_analyze,
65
114
  });
@@ -67,9 +116,10 @@ export function queueFactScan(context: ExtractionContext, state: StateManager, o
67
116
  state.queue_enqueue({
68
117
  type: LLMRequestType.JSON,
69
118
  priority: LLMPriority.Low,
119
+ model: extractionModel,
70
120
  system: prompt.system,
71
121
  user: prompt.user,
72
- next_step: LLMNextStep.HandleHumanFactScan,
122
+ next_step: LLMNextStep.HandleFactFind,
73
123
  data: {
74
124
  ...options,
75
125
  personaId: chunk.personaId,
@@ -84,47 +134,16 @@ export function queueFactScan(context: ExtractionContext, state: StateManager, o
84
134
  return chunks.length;
85
135
  }
86
136
 
87
- export function queueTraitScan(context: ExtractionContext, state: StateManager, options?: ExtractionOptions): number {
88
- const { chunks } = chunkExtractionContext(context, getExtractionMaxTokens(state));
89
-
90
- if (chunks.length === 0) return 0;
91
-
92
- for (const chunk of chunks) {
93
- const prompt = buildHumanTraitScanPrompt({
94
- persona_name: chunk.personaDisplayName,
95
- messages_context: chunk.messages_context,
96
- messages_analyze: chunk.messages_analyze,
97
- });
98
-
99
- state.queue_enqueue({
100
- type: LLMRequestType.JSON,
101
- priority: LLMPriority.Low,
102
- system: prompt.system,
103
- user: prompt.user,
104
- next_step: LLMNextStep.HandleHumanTraitScan,
105
- data: {
106
- ...options,
107
- personaId: chunk.personaId,
108
- personaDisplayName: chunk.personaDisplayName,
109
- analyze_from_timestamp: getAnalyzeFromTimestamp(chunk),
110
- extraction_flag: context.extraction_flag,
111
- message_ids_to_mark: chunk.messages_analyze.map(m => m.id),
112
- },
113
- });
114
- }
115
-
116
- return chunks.length;
117
- }
118
-
119
137
  export function queueTopicScan(context: ExtractionContext, state: StateManager, options?: ExtractionOptions): number {
120
- const { chunks } = chunkExtractionContext(context, getExtractionMaxTokens(state));
138
+ const extractionModel = options?.extraction_model;
139
+ const { chunks } = chunkExtractionContext(context, getExtractionMaxTokens(state, options));
121
140
 
122
141
  if (chunks.length === 0) return 0;
123
142
 
124
143
  // Pre-mark messages before enqueuing — prevents duplicate scans if the
125
144
  // queue check fires again during LLM latency (100ms loop × 5s call = 50 dupes)
126
145
  for (const chunk of chunks) {
127
- state.messages_markExtracted(chunk.personaId, chunk.messages_analyze.map(m => m.id), "p");
146
+ state.messages_markExtracted(chunk.personaId, chunk.messages_analyze.map(m => m.id), "t");
128
147
  }
129
148
 
130
149
  for (const chunk of chunks) {
@@ -132,11 +151,13 @@ export function queueTopicScan(context: ExtractionContext, state: StateManager,
132
151
  persona_name: chunk.personaDisplayName,
133
152
  messages_context: chunk.messages_context,
134
153
  messages_analyze: chunk.messages_analyze,
154
+ participant_context: buildParticipantContext(context.personaId, state),
135
155
  });
136
156
 
137
157
  state.queue_enqueue({
138
158
  type: LLMRequestType.JSON,
139
159
  priority: LLMPriority.Low,
160
+ model: extractionModel,
140
161
  system: prompt.system,
141
162
  user: prompt.user,
142
163
  next_step: LLMNextStep.HandleHumanTopicScan,
@@ -155,14 +176,15 @@ export function queueTopicScan(context: ExtractionContext, state: StateManager,
155
176
  }
156
177
 
157
178
  export function queuePersonScan(context: ExtractionContext, state: StateManager, options?: ExtractionOptions): number {
158
- const { chunks } = chunkExtractionContext(context, getExtractionMaxTokens(state));
179
+ const extractionModel = options?.extraction_model;
180
+ const { chunks } = chunkExtractionContext(context, getExtractionMaxTokens(state, options));
159
181
 
160
182
  if (chunks.length === 0) return 0;
161
183
 
162
184
  // Pre-mark messages before enqueuing — prevents duplicate scans if the
163
185
  // queue check fires again during LLM latency (100ms loop × 5s call = 50 dupes)
164
186
  for (const chunk of chunks) {
165
- state.messages_markExtracted(chunk.personaId, chunk.messages_analyze.map(m => m.id), "o");
187
+ state.messages_markExtracted(chunk.personaId, chunk.messages_analyze.map(m => m.id), "p");
166
188
  }
167
189
 
168
190
  for (const chunk of chunks) {
@@ -175,6 +197,7 @@ export function queuePersonScan(context: ExtractionContext, state: StateManager,
175
197
  state.queue_enqueue({
176
198
  type: LLMRequestType.JSON,
177
199
  priority: LLMPriority.Low,
200
+ model: extractionModel,
178
201
  system: prompt.system,
179
202
  user: prompt.user,
180
203
  next_step: LLMNextStep.HandleHumanPersonScan,
@@ -193,10 +216,10 @@ export function queuePersonScan(context: ExtractionContext, state: StateManager,
193
216
  }
194
217
 
195
218
  export function queueAllScans(context: ExtractionContext, state: StateManager, options?: ExtractionOptions): void {
196
- queueFactScan(context, state, options);
197
- queueTraitScan(context, state, options);
219
+ queueFactFind(context, state, options);
198
220
  queuePersonScan(context, state, options);
199
221
  queueTopicScan(context, state, options);
222
+ queueEventSummary(context.personaId, state, options);
200
223
  }
201
224
 
202
225
  /**
@@ -214,32 +237,33 @@ export function queueAllScans(context: ExtractionContext, state: StateManager, o
214
237
  export function queueDirectTopicUpdate(
215
238
  topic: import("../types.js").Topic,
216
239
  context: ExtractionContext,
217
- state: StateManager
240
+ state: StateManager,
241
+ options?: ExtractionOptions
218
242
  ): number {
219
- const { chunks } = chunkExtractionContext(context, getExtractionMaxTokens(state));
243
+ const extractionModel = options?.extraction_model;
244
+ const { chunks } = chunkExtractionContext(context, getExtractionMaxTokens(state, options));
220
245
 
221
246
  if (chunks.length === 0) return 0;
222
247
 
223
248
  for (const chunk of chunks) {
224
- const prompt = buildHumanItemUpdatePrompt({
225
- data_type: "topic",
249
+ const prompt = buildTopicUpdatePrompt({
226
250
  existing_item: topic,
227
251
  messages_context: chunk.messages_context,
228
252
  messages_analyze: chunk.messages_analyze,
229
253
  persona_name: chunk.personaDisplayName,
254
+ participant_context: buildParticipantContext(context.personaId, state),
230
255
  });
231
256
 
232
257
  state.queue_enqueue({
233
258
  type: LLMRequestType.JSON,
234
259
  priority: LLMPriority.Normal,
260
+ model: extractionModel,
235
261
  system: prompt.system,
236
262
  user: prompt.user,
237
- next_step: LLMNextStep.HandleHumanItemUpdate,
263
+ next_step: LLMNextStep.HandleTopicUpdate,
238
264
  data: {
239
265
  personaId: context.personaId,
240
266
  personaDisplayName: context.personaDisplayName,
241
- candidateType: "topic",
242
- matchedType: "topic",
243
267
  isNewItem: false,
244
268
  existingItemId: topic.id,
245
269
  analyze_from_timestamp: getAnalyzeFromTimestamp(chunk),
@@ -250,227 +274,345 @@ export function queueDirectTopicUpdate(
250
274
  return chunks.length;
251
275
  }
252
276
 
253
- function truncateDescription(description: string, maxLength: number = 255): string {
254
- if (description.length <= maxLength) return description;
255
- return description.slice(0, maxLength) + "...";
256
- }
257
-
258
277
  const EMBEDDING_TOP_K = 20;
259
278
  const EMBEDDING_MIN_SIMILARITY = 0.3;
260
279
 
261
280
  /**
262
- * Queue an item match request using embedding-based similarity.
263
- *
264
- * Instead of sending ALL items to the LLM, we:
265
- * 1. Compute embedding for the candidate (name + value)
266
- * 2. Find top-K most similar existing items via cosine similarity
267
- * 3. Send only those candidates to the LLM for final matching decision
268
- *
269
- * This reduces prompt size from O(all_items) to O(K) where K=20.
281
+ * Queue a topic match request using embedding-based similarity (topics only).
270
282
  */
271
- export async function queueItemMatch(
272
- dataType: DataItemType,
273
- candidate: ScanCandidate,
283
+ export async function queueTopicMatch(
284
+ candidate: TopicScanCandidate,
274
285
  context: ExtractionContext,
275
- state: StateManager
286
+ state: StateManager,
287
+ extractionModel?: string
276
288
  ): Promise<void> {
277
289
  const human = state.getHuman();
278
-
279
- let itemName: string;
280
- let itemValue: string;
281
-
282
- switch (dataType) {
283
- case "fact":
284
- itemName = (candidate as FactScanCandidate).type_of_fact;
285
- itemValue = (candidate as FactScanCandidate).value_of_fact;
286
- break;
287
- case "trait":
288
- itemName = (candidate as TraitScanCandidate).type_of_trait;
289
- itemValue = (candidate as TraitScanCandidate).value_of_trait;
290
- break;
291
- case "topic":
292
- itemName = (candidate as TopicScanCandidate).value_of_topic;
293
- itemValue = (candidate as TopicScanCandidate).type_of_topic;
294
- break;
295
- case "person":
296
- itemName = (candidate as PersonScanCandidate).name_of_person;
297
- itemValue = (candidate as PersonScanCandidate).type_of_person;
298
- break;
299
- }
300
290
 
301
- // Traits are personality patterns they must only match against other traits.
302
- // Non-trait candidates (facts, topics, people) must never absorb trait content,
303
- // and trait candidates must never cross-match into facts/topics/people.
304
- const allItemsWithEmbeddings = [
305
- ...(dataType !== "trait" ? human.facts.map(f => ({ ...f, data_type: "fact" as DataItemType })) : []),
306
- ...human.traits.map(t => ({ ...t, data_type: "trait" as DataItemType })),
307
- ...(dataType !== "trait" ? human.topics.map(t => ({ ...t, data_type: "topic" as DataItemType })) : []),
308
- ...(dataType !== "trait" ? human.people.map(p => ({ ...p, data_type: "person" as DataItemType })) : []),
309
- ].filter(item => item.embedding && item.embedding.length > 0);
310
-
311
- let topKItems: Array<{
312
- data_type: DataItemType;
313
- data_id: string;
314
- data_name: string;
315
- data_description: string;
316
- }> = [];
317
-
318
- if (allItemsWithEmbeddings.length > 0) {
291
+ const topicsWithEmbeddings = human.topics.filter(t => t.embedding && t.embedding.length > 0);
292
+
293
+ let topKItems: Array<{ id: string; name: string; description: string; category?: string }> = [];
294
+
295
+ if (topicsWithEmbeddings.length > 0) {
319
296
  try {
320
297
  const embeddingService = getEmbeddingService();
321
- const candidateText = `${itemName}: ${itemValue}`;
298
+ const candidateText = getTopicEmbeddingText({
299
+ name: candidate.name,
300
+ category: candidate.category,
301
+ description: candidate.description,
302
+ });
322
303
  const candidateVector = await embeddingService.embed(candidateText);
323
304
 
324
- const topK = findTopK(candidateVector, allItemsWithEmbeddings, EMBEDDING_TOP_K);
325
-
305
+ const topK = findTopK(candidateVector, topicsWithEmbeddings, EMBEDDING_TOP_K);
326
306
  topKItems = topK
327
307
  .filter(({ similarity }) => similarity >= EMBEDDING_MIN_SIMILARITY)
328
308
  .map(({ item }) => ({
329
- data_type: item.data_type,
330
- data_id: item.id,
331
- data_name: item.name,
332
- data_description: item.data_type === dataType
333
- ? item.description
334
- : truncateDescription(item.description),
309
+ id: item.id,
310
+ name: item.name,
311
+ description: item.description,
312
+ category: item.category,
335
313
  }));
336
314
 
337
- console.log(`[queueItemMatch] Embedding search: ${allItemsWithEmbeddings.length} items → ${topKItems.length} candidates (top-K=${EMBEDDING_TOP_K}, min_sim=${EMBEDDING_MIN_SIMILARITY})`);
315
+ console.log(`[queueTopicMatch] Embedding search: ${topicsWithEmbeddings.length} topics → ${topKItems.length} candidates`);
338
316
  } catch (err) {
339
- console.error(`[queueItemMatch] Embedding search failed, falling back to all items:`, err);
317
+ console.error(`[queueTopicMatch] Embedding search failed, falling back to all topics:`, err);
340
318
  }
341
319
  }
342
320
 
343
321
  if (topKItems.length === 0) {
322
+ console.log(`[queueTopicMatch] No embeddings available, using all topics`);
323
+ topKItems = human.topics.map(t => ({
324
+ id: t.id,
325
+ name: t.name,
326
+ description: t.description,
327
+ category: t.category,
328
+ }));
329
+ }
344
330
 
345
- console.log(`[queueItemMatch] No embeddings available, using filtered items (dataType=${dataType})`);
346
-
347
- if (dataType !== "trait") {
348
- for (const fact of human.facts) {
349
- topKItems.push({
350
- data_type: "fact",
351
- data_id: fact.id,
352
- data_name: fact.name,
353
- data_description: dataType === "fact" ? fact.description : truncateDescription(fact.description),
354
- });
355
- }
356
- }
331
+ const prompt = buildTopicMatchPrompt({
332
+ candidate_name: candidate.name,
333
+ candidate_description: candidate.description,
334
+ candidate_category: candidate.category,
335
+ existing_topics: topKItems,
336
+ });
337
+
338
+ state.queue_enqueue({
339
+ type: LLMRequestType.JSON,
340
+ priority: LLMPriority.Normal,
341
+ model: extractionModel,
342
+ system: prompt.system,
343
+ user: prompt.user,
344
+ next_step: LLMNextStep.HandleTopicMatch,
345
+ data: {
346
+ ...context,
347
+ candidateName: candidate.name,
348
+ candidateDescription: candidate.description,
349
+ candidateCategory: candidate.category,
350
+ extraction_model: extractionModel,
351
+ },
352
+ });
353
+ }
354
+
355
+ /**
356
+ * Queue a person match request using embedding-based similarity (people only).
357
+ */
358
+ export async function queuePersonMatch(
359
+ candidate: PersonScanCandidate,
360
+ context: ExtractionContext,
361
+ state: StateManager,
362
+ extractionModel?: string
363
+ ): Promise<void> {
364
+ const human = state.getHuman();
365
+
366
+ const peopleWithEmbeddings = human.people.filter(p => p.embedding && p.embedding.length > 0);
367
+
368
+ let topKItems: Array<{ id: string; name: string; description: string; relationship?: string }> = [];
357
369
 
358
- for (const trait of human.traits) {
359
- topKItems.push({
360
- data_type: "trait",
361
- data_id: trait.id,
362
- data_name: trait.name,
363
- data_description: dataType === "trait" ? trait.description : truncateDescription(trait.description),
370
+ if (peopleWithEmbeddings.length > 0) {
371
+ try {
372
+ const embeddingService = getEmbeddingService();
373
+ const candidateText = getPersonEmbeddingText({
374
+ name: candidate.name,
375
+ relationship: candidate.relationship,
376
+ description: candidate.description,
364
377
  });
365
- }
378
+ const candidateVector = await embeddingService.embed(candidateText);
379
+
380
+ const topK = findTopK(candidateVector, peopleWithEmbeddings, EMBEDDING_TOP_K);
381
+ topKItems = topK
382
+ .filter(({ similarity }) => similarity >= EMBEDDING_MIN_SIMILARITY)
383
+ .map(({ item }) => ({
384
+ id: item.id,
385
+ name: item.name,
386
+ description: item.description,
387
+ relationship: item.relationship,
388
+ }));
366
389
 
367
- if (dataType !== "trait") {
368
- for (const topic of human.topics) {
369
- topKItems.push({
370
- data_type: "topic",
371
- data_id: topic.id,
372
- data_name: topic.name,
373
- data_description: dataType === "topic" ? topic.description : truncateDescription(topic.description),
374
- });
375
- }
376
-
377
- for (const person of human.people) {
378
- topKItems.push({
379
- data_type: "person",
380
- data_id: person.id,
381
- data_name: person.name,
382
- data_description: dataType === "person" ? person.description : truncateDescription(person.description),
383
- });
384
- }
390
+ console.log(`[queuePersonMatch] Embedding search: ${peopleWithEmbeddings.length} people → ${topKItems.length} candidates`);
391
+ } catch (err) {
392
+ console.error(`[queuePersonMatch] Embedding search failed, falling back to all people:`, err);
385
393
  }
386
394
  }
387
395
 
388
- const prompt = buildHumanItemMatchPrompt({
389
- candidate_type: dataType,
390
- candidate_name: itemName,
391
- candidate_value: itemValue,
392
- all_items: topKItems,
393
- });
394
-
396
+ if (topKItems.length === 0) {
397
+ console.log(`[queuePersonMatch] No embeddings available, using all people`);
398
+ topKItems = human.people.map(p => ({
399
+ id: p.id,
400
+ name: p.name,
401
+ description: p.description,
402
+ relationship: p.relationship,
403
+ }));
404
+ }
395
405
 
406
+ const prompt = buildPersonMatchPrompt({
407
+ candidate_name: candidate.name,
408
+ candidate_description: candidate.description,
409
+ candidate_relationship: candidate.relationship,
410
+ existing_people: topKItems,
411
+ });
396
412
 
397
413
  state.queue_enqueue({
398
414
  type: LLMRequestType.JSON,
399
415
  priority: LLMPriority.Normal,
416
+ model: extractionModel,
400
417
  system: prompt.system,
401
418
  user: prompt.user,
402
- next_step: LLMNextStep.HandleHumanItemMatch,
419
+ next_step: LLMNextStep.HandlePersonMatch,
403
420
  data: {
404
421
  ...context,
405
- candidateType: dataType,
406
- itemName,
407
- itemValue,
422
+ candidateName: candidate.name,
423
+ candidateDescription: candidate.description,
424
+ candidateRelationship: candidate.relationship,
425
+ extraction_model: extractionModel,
408
426
  },
409
427
  });
410
428
  }
411
429
 
412
- export function queueItemUpdate(
413
- candidateType: DataItemType,
430
+ export function queueTopicUpdate(
414
431
  matchResult: ItemMatchResult,
415
- context: ExtractionContext & { itemName: string; itemValue: string; itemCategory?: string },
432
+ context: ExtractionContext & {
433
+ candidateName: string;
434
+ candidateDescription: string;
435
+ candidateCategory: string;
436
+ extraction_model?: string;
437
+ },
416
438
  state: StateManager
417
439
  ): number {
418
440
  const human = state.getHuman();
419
441
  const matchedGuid = matchResult.matched_guid;
420
442
  const isNewItem = matchedGuid === null;
421
443
 
422
- let existingItem: Fact | Trait | Topic | Person | null = null;
423
- let matchedType: DataItemType | null = null;
444
+ let existingItem: Topic | null = null;
445
+ if (!isNewItem && matchedGuid) {
446
+ existingItem = human.topics.find(t => t.id === matchedGuid) ?? null;
447
+ }
424
448
 
425
- if (!isNewItem) {
426
- existingItem = human.facts.find(f => f.id === matchedGuid) ?? null;
427
- if (existingItem) matchedType = "fact";
449
+ const extractionOptions: ExtractionOptions = { extraction_model: context.extraction_model };
450
+ const { chunks } = chunkExtractionContext(context, getExtractionMaxTokens(state, extractionOptions));
428
451
 
429
- if (!existingItem) {
430
- existingItem = human.traits.find(t => t.id === matchedGuid) ?? null;
431
- if (existingItem) matchedType = "trait";
432
- }
452
+ if (chunks.length === 0) return 0;
433
453
 
434
- if (!existingItem) {
435
- existingItem = human.topics.find(t => t.id === matchedGuid) ?? null;
436
- if (existingItem) matchedType = "topic";
437
- }
454
+ for (const chunk of chunks) {
455
+ const prompt = buildTopicUpdatePrompt({
456
+ existing_item: existingItem,
457
+ new_topic_name: isNewItem ? context.candidateName : undefined,
458
+ new_topic_description: isNewItem ? context.candidateDescription : undefined,
459
+ new_topic_category: isNewItem ? context.candidateCategory : undefined,
460
+ messages_context: chunk.messages_context,
461
+ messages_analyze: chunk.messages_analyze,
462
+ persona_name: chunk.personaDisplayName,
463
+ participant_context: buildParticipantContext(context.personaId, state),
464
+ });
465
+
466
+ state.queue_enqueue({
467
+ type: LLMRequestType.JSON,
468
+ priority: LLMPriority.Normal,
469
+ model: context.extraction_model,
470
+ system: prompt.system,
471
+ user: prompt.user,
472
+ next_step: LLMNextStep.HandleTopicUpdate,
473
+ data: {
474
+ personaId: context.personaId,
475
+ personaDisplayName: context.personaDisplayName,
476
+ isNewItem,
477
+ existingItemId: existingItem?.id,
478
+ candidateCategory: context.candidateCategory,
479
+ analyze_from_timestamp: getAnalyzeFromTimestamp(chunk),
480
+ },
481
+ });
482
+ }
483
+
484
+ return chunks.length;
485
+ }
486
+
487
+ export function queueEventSummary(
488
+ personaId: string,
489
+ state: StateManager,
490
+ options?: ExtractionOptions
491
+ ): number {
492
+ const persona = state.persona_getById(personaId);
493
+ if (!persona) {
494
+ console.error(`[queueEventSummary] Persona not found: ${personaId}`);
495
+ return 0;
496
+ }
497
+
498
+ const unextractedMessages = state.messages_getUnextracted(personaId, "e");
499
+ if (unextractedMessages.length === 0) {
500
+ console.log(`[queueEventSummary] No unprocessed messages for ${persona.display_name}`);
501
+ return 0;
502
+ }
503
+
504
+ const human = state.getHuman();
505
+ const gapHours = human.settings?.ceremony?.event_window_hours ?? 8;
506
+
507
+ const sorted = [...unextractedMessages].sort(
508
+ (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
509
+ );
510
+
511
+ const windows = buildEventWindows(sorted, gapHours);
512
+
513
+ const allMessages = state.messages_get(personaId);
514
+ const extractionModel = options?.extraction_model;
515
+ let totalChunks = 0;
516
+
517
+ state.messages_markExtracted(personaId, sorted.map(m => m.id), "e");
518
+
519
+ for (const windowMessages of windows) {
520
+ if (windowMessages.length === 0) continue;
521
+
522
+ const windowStartTime = new Date(windowMessages[0].timestamp).getTime();
523
+ const messages_context = allMessages.filter(
524
+ m => m.e === true && new Date(m.timestamp).getTime() < windowStartTime
525
+ );
438
526
 
439
- if (!existingItem) {
440
- existingItem = human.people.find(p => p.id === matchedGuid) ?? null;
441
- if (existingItem) matchedType = "person";
527
+ const context: ExtractionContext = {
528
+ personaId,
529
+ personaDisplayName: persona.display_name,
530
+ messages_context,
531
+ messages_analyze: windowMessages,
532
+ extraction_flag: "e",
533
+ };
534
+
535
+ const { chunks } = chunkExtractionContext(context, getExtractionMaxTokens(state, options));
536
+
537
+ for (const chunk of chunks) {
538
+ const prompt = buildEventScanPrompt({
539
+ persona_name: chunk.personaDisplayName,
540
+ messages_context: chunk.messages_context,
541
+ messages_analyze: chunk.messages_analyze,
542
+ participant_context: buildParticipantContext(personaId, state),
543
+ });
544
+
545
+ state.queue_enqueue({
546
+ type: LLMRequestType.JSON,
547
+ priority: LLMPriority.Low,
548
+ model: extractionModel,
549
+ system: prompt.system,
550
+ user: prompt.user,
551
+ next_step: LLMNextStep.HandleEventScan,
552
+ data: {
553
+ ...options,
554
+ personaId: chunk.personaId,
555
+ personaDisplayName: chunk.personaDisplayName,
556
+ extraction_flag: "e",
557
+ message_ids_to_mark: chunk.messages_analyze.map(m => m.id),
558
+ },
559
+ });
560
+ totalChunks++;
442
561
  }
443
562
  }
444
563
 
445
- const { chunks } = chunkExtractionContext(context, getExtractionMaxTokens(state));
564
+ console.log(`[queueEventSummary] Queued ${totalChunks} event scan chunk(s) for ${persona.display_name} (${windows.length} window(s))`);
565
+ return totalChunks;
566
+ }
567
+
568
+ export function queuePersonUpdate(
569
+ matchResult: ItemMatchResult,
570
+ context: ExtractionContext & {
571
+ candidateName: string;
572
+ candidateDescription: string;
573
+ candidateRelationship: string;
574
+ extraction_model?: string;
575
+ },
576
+ state: StateManager
577
+ ): number {
578
+ const human = state.getHuman();
579
+ const matchedGuid = matchResult.matched_guid;
580
+ const isNewItem = matchedGuid === null;
581
+
582
+ let existingItem: Person | null = null;
583
+ if (!isNewItem && matchedGuid) {
584
+ existingItem = human.people.find(p => p.id === matchedGuid) ?? null;
585
+ }
586
+
587
+ const extractionOptions: ExtractionOptions = { extraction_model: context.extraction_model };
588
+ const { chunks } = chunkExtractionContext(context, getExtractionMaxTokens(state, extractionOptions));
446
589
 
447
590
  if (chunks.length === 0) return 0;
448
591
 
449
592
  for (const chunk of chunks) {
450
- const prompt = buildHumanItemUpdatePrompt({
451
- data_type: candidateType,
593
+ const prompt = buildPersonUpdatePrompt({
452
594
  existing_item: existingItem,
595
+ new_person_name: isNewItem ? context.candidateName : undefined,
596
+ new_person_description: isNewItem ? context.candidateDescription : undefined,
597
+ new_person_relationship: isNewItem ? context.candidateRelationship : undefined,
453
598
  messages_context: chunk.messages_context,
454
599
  messages_analyze: chunk.messages_analyze,
455
600
  persona_name: chunk.personaDisplayName,
456
- new_item_name: isNewItem ? context.itemName : undefined,
457
- new_item_value: isNewItem ? context.itemValue : undefined,
458
601
  });
459
602
 
460
603
  state.queue_enqueue({
461
604
  type: LLMRequestType.JSON,
462
605
  priority: LLMPriority.Normal,
606
+ model: context.extraction_model,
463
607
  system: prompt.system,
464
608
  user: prompt.user,
465
- next_step: LLMNextStep.HandleHumanItemUpdate,
609
+ next_step: LLMNextStep.HandlePersonUpdate,
466
610
  data: {
467
611
  personaId: context.personaId,
468
612
  personaDisplayName: context.personaDisplayName,
469
- candidateType,
470
- matchedType,
471
613
  isNewItem,
472
614
  existingItemId: existingItem?.id,
473
- itemCategory: context.itemCategory,
615
+ candidateRelationship: context.candidateRelationship,
474
616
  analyze_from_timestamp: getAnalyzeFromTimestamp(chunk),
475
617
  },
476
618
  });
@@ -478,3 +620,5 @@ export function queueItemUpdate(
478
620
 
479
621
  return chunks.length;
480
622
  }
623
+
624
+