ei-tui 0.1.19 → 0.1.20

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 (38) hide show
  1. package/package.json +1 -1
  2. package/src/core/context-utils.ts +57 -0
  3. package/src/core/handlers/heartbeat.ts +101 -0
  4. package/src/core/handlers/human-extraction.ts +87 -0
  5. package/src/core/handlers/human-matching.ts +337 -0
  6. package/src/core/handlers/index.ts +18 -1438
  7. package/src/core/handlers/persona-generation.ts +165 -0
  8. package/src/core/handlers/persona-response.ts +100 -0
  9. package/src/core/handlers/persona-topics.ts +305 -0
  10. package/src/core/handlers/rewrite.ts +297 -0
  11. package/src/core/handlers/utils.ts +49 -0
  12. package/src/core/heartbeat-manager.ts +241 -0
  13. package/src/core/human-data-manager.ts +235 -0
  14. package/src/core/llm-client.ts +70 -5
  15. package/src/core/message-manager.ts +322 -0
  16. package/src/core/persona-manager.ts +121 -0
  17. package/src/core/processor.ts +472 -1084
  18. package/src/core/prompt-context-builder.ts +200 -0
  19. package/src/core/queue-manager.ts +74 -0
  20. package/src/core/queue-processor.ts +150 -17
  21. package/src/core/tool-manager.ts +94 -0
  22. package/src/core/tools/builtin/directory-tree.ts +100 -0
  23. package/src/core/tools/builtin/get-file-info.ts +67 -0
  24. package/src/core/tools/builtin/grep.ts +175 -0
  25. package/src/core/tools/builtin/list-directory.ts +71 -0
  26. package/src/core/tools/builtin/search-files.ts +111 -0
  27. package/src/core/tools/index.ts +14 -3
  28. package/src/core/types/data-items.ts +80 -0
  29. package/src/core/types/entities.ts +151 -0
  30. package/src/core/types/enums.ts +61 -0
  31. package/src/core/types/integrations.ts +141 -0
  32. package/src/core/types/llm.ts +61 -0
  33. package/src/core/types.ts +9 -496
  34. package/src/prompts/response/index.ts +5 -2
  35. package/src/prompts/response/sections.ts +18 -0
  36. package/src/prompts/response/types.ts +3 -0
  37. package/tui/src/commands/me.tsx +2 -1
  38. package/tui/src/util/yaml-serializers.ts +6 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ei-tui",
3
- "version": "0.1.19",
3
+ "version": "0.1.20",
4
4
  "author": "Flare576",
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,57 @@
1
+ import type { Message, HumanEntity, Quote, DataItemBase } from "./types.js";
2
+ import { ContextStatus as ContextStatusEnum } from "./types.js";
3
+
4
+ // =============================================================================
5
+ // CONTEXT FILTERING
6
+ // =============================================================================
7
+
8
+ export function filterMessagesForContext(
9
+ messages: Message[],
10
+ contextBoundary: string | undefined,
11
+ contextWindowHours: number
12
+ ): Message[] {
13
+ if (messages.length === 0) return [];
14
+
15
+ const now = Date.now();
16
+ const windowStartMs = now - contextWindowHours * 60 * 60 * 1000;
17
+ const boundaryMs = contextBoundary ? new Date(contextBoundary).getTime() : 0;
18
+
19
+ return messages.filter((msg) => {
20
+ if (msg.context_status === ContextStatusEnum.Always) return true;
21
+ if (msg.context_status === ContextStatusEnum.Never) return false;
22
+
23
+ const msgMs = new Date(msg.timestamp).getTime();
24
+
25
+ if (contextBoundary) {
26
+ return msgMs >= boundaryMs;
27
+ }
28
+
29
+ return msgMs >= windowStartMs;
30
+ });
31
+ }
32
+
33
+ // =============================================================================
34
+ // EMBEDDING STRIPPING - Remove embeddings from data items before returning to FE
35
+ // Embeddings are internal implementation details for similarity search.
36
+ // =============================================================================
37
+
38
+ export function stripDataItemEmbedding<T extends DataItemBase>(item: T): T {
39
+ const { embedding, ...rest } = item;
40
+ return rest as T;
41
+ }
42
+
43
+ export function stripQuoteEmbedding(quote: Quote): Quote {
44
+ const { embedding, ...rest } = quote;
45
+ return rest;
46
+ }
47
+
48
+ export function stripHumanEmbeddings(human: HumanEntity): HumanEntity {
49
+ return {
50
+ ...human,
51
+ facts: (human.facts ?? []).map(stripDataItemEmbedding),
52
+ traits: (human.traits ?? []).map(stripDataItemEmbedding),
53
+ topics: (human.topics ?? []).map(stripDataItemEmbedding),
54
+ people: (human.people ?? []).map(stripDataItemEmbedding),
55
+ quotes: (human.quotes ?? []).map(stripQuoteEmbedding),
56
+ };
57
+ }
@@ -0,0 +1,101 @@
1
+ import {
2
+ ContextStatus,
3
+ ValidationLevel,
4
+ type LLMResponse,
5
+ type Message,
6
+ } from "../types.js";
7
+ import type { StateManager } from "../state-manager.js";
8
+ import type { HeartbeatCheckResult, EiHeartbeatResult } from "../../prompts/heartbeat/types.js";
9
+ import { crossFind } from "../utils/index.js";
10
+
11
+ export function handleHeartbeatCheck(response: LLMResponse, state: StateManager): void {
12
+ const personaId = response.request.data.personaId as string;
13
+ const personaDisplayName = response.request.data.personaDisplayName as string;
14
+ if (!personaId) {
15
+ console.error("[handleHeartbeatCheck] No personaId in request data");
16
+ return;
17
+ }
18
+
19
+ const result = response.parsed as HeartbeatCheckResult | undefined;
20
+ if (!result) {
21
+ console.error("[handleHeartbeatCheck] No parsed result");
22
+ return;
23
+ }
24
+
25
+ const now = new Date().toISOString();
26
+ state.persona_update(personaId, { last_heartbeat: now });
27
+
28
+ if (!result.should_respond) {
29
+ console.log(`[handleHeartbeatCheck] ${personaDisplayName} chose not to reach out`);
30
+ return;
31
+ }
32
+
33
+ if (result.message) {
34
+ const message: Message = {
35
+ id: crypto.randomUUID(),
36
+ role: "system",
37
+ verbal_response: result.message,
38
+ timestamp: now,
39
+ read: false,
40
+ context_status: ContextStatus.Default,
41
+ };
42
+ state.messages_append(personaId, message);
43
+ console.log(`[handleHeartbeatCheck] ${personaDisplayName} proactively messaged about: ${result.topic ?? "general"}`);
44
+ }
45
+ }
46
+
47
+ export function handleEiHeartbeat(response: LLMResponse, state: StateManager): void {
48
+ const result = response.parsed as EiHeartbeatResult | undefined;
49
+ if (!result) {
50
+ console.error("[handleEiHeartbeat] No parsed result");
51
+ return;
52
+ }
53
+ const now = new Date().toISOString();
54
+ state.persona_update("ei", { last_heartbeat: now });
55
+ if (!result.should_respond || !result.id) {
56
+ console.log("[handleEiHeartbeat] Ei chose not to reach out");
57
+ return;
58
+ }
59
+ const isTUI = response.request.data.isTUI as boolean;
60
+ const found = crossFind(result.id, state.getHuman(), state.persona_getAll());
61
+ if (!found) {
62
+ console.warn(`[handleEiHeartbeat] Could not find item with id "${result.id}"`);
63
+ return;
64
+ }
65
+
66
+ const sendMessage = (verbal_response: string) => state.messages_append("ei", {
67
+ id: crypto.randomUUID(),
68
+ role: "system",
69
+ verbal_response,
70
+ timestamp: now,
71
+ read: false,
72
+ context_status: ContextStatus.Default,
73
+ f: true, r: true, p: true, o: true,
74
+ });
75
+
76
+ if (found.type === "fact") {
77
+ const factsNav = isTUI ? "using /me facts" : "using \u2630 \u2192 My Data";
78
+ sendMessage(`Another persona updated a fact called "${found.name}" to "${found.description}". If that's right, you can lock it from further changes by ${factsNav}.`);
79
+ state.human_fact_upsert({ ...found, validated: ValidationLevel.Ei, validated_date: now });
80
+ console.log(`[handleEiHeartbeat] Notified about fact "${found.name}"`);
81
+ return;
82
+ }
83
+
84
+ if (result.my_response) sendMessage(result.my_response);
85
+
86
+ switch (found.type) {
87
+ case "person":
88
+ state.human_person_upsert({ ...found, last_ei_asked: now });
89
+ console.log(`[handleEiHeartbeat] Reached out about person "${found.name}"`);
90
+ break;
91
+ case "topic":
92
+ state.human_topic_upsert({ ...found, last_ei_asked: now });
93
+ console.log(`[handleEiHeartbeat] Reached out about topic "${found.name}"`);
94
+ break;
95
+ case "persona":
96
+ console.log(`[handleEiHeartbeat] Reached out about persona "${found.display_name}"`);
97
+ break;
98
+ default:
99
+ console.warn(`[handleEiHeartbeat] Unexpected item type "${found.type}" for id "${result.id}"`);
100
+ }
101
+ }
@@ -0,0 +1,87 @@
1
+ import type { LLMResponse } from "../types.js";
2
+ import type { StateManager } from "../state-manager.js";
3
+ import type {
4
+ FactScanResult,
5
+ TraitScanResult,
6
+ TopicScanResult,
7
+ PersonScanResult,
8
+ } from "../../prompts/human/types.js";
9
+ import { queueItemMatch, type ExtractionContext } from "../orchestrators/index.js";
10
+ import { markMessagesExtracted } from "./utils.js";
11
+
12
+ export async function handleHumanFactScan(response: LLMResponse, state: StateManager): Promise<void> {
13
+ const result = response.parsed as FactScanResult | undefined;
14
+
15
+ // Mark messages as scanned regardless of whether facts were found
16
+ markMessagesExtracted(response, state, "f");
17
+
18
+ if (!result?.facts || !Array.isArray(result.facts)) {
19
+ console.log("[handleHumanFactScan] No facts detected or invalid result");
20
+ return;
21
+ }
22
+
23
+ const context = response.request.data as unknown as ExtractionContext;
24
+ if (!context?.personaId) return;
25
+
26
+ for (const candidate of result.facts) {
27
+ await queueItemMatch("fact", candidate, context, state);
28
+ }
29
+ console.log(`[handleHumanFactScan] Queued ${result.facts.length} fact(s) for matching`);
30
+ }
31
+
32
+ export async function handleHumanTraitScan(response: LLMResponse, state: StateManager): Promise<void> {
33
+ const result = response.parsed as TraitScanResult | undefined;
34
+
35
+ markMessagesExtracted(response, state, "r");
36
+
37
+ if (!result?.traits || !Array.isArray(result.traits)) {
38
+ console.log("[handleHumanTraitScan] No traits detected or invalid result");
39
+ return;
40
+ }
41
+
42
+ const context = response.request.data as unknown as ExtractionContext;
43
+ if (!context?.personaId) return;
44
+
45
+ for (const candidate of result.traits) {
46
+ await queueItemMatch("trait", candidate, context, state);
47
+ }
48
+ console.log(`[handleHumanTraitScan] Queued ${result.traits.length} trait(s) for matching`);
49
+ }
50
+
51
+ export async function handleHumanTopicScan(response: LLMResponse, state: StateManager): Promise<void> {
52
+ const result = response.parsed as TopicScanResult | undefined;
53
+
54
+ markMessagesExtracted(response, state, "p");
55
+
56
+ if (!result?.topics || !Array.isArray(result.topics)) {
57
+ console.log("[handleHumanTopicScan] No topics detected or invalid result");
58
+ return;
59
+ }
60
+
61
+ const context = response.request.data as unknown as ExtractionContext;
62
+ if (!context?.personaId) return;
63
+
64
+ for (const candidate of result.topics) {
65
+ await queueItemMatch("topic", candidate, context, state);
66
+ }
67
+ console.log(`[handleHumanTopicScan] Queued ${result.topics.length} topic(s) for matching`);
68
+ }
69
+
70
+ export async function handleHumanPersonScan(response: LLMResponse, state: StateManager): Promise<void> {
71
+ const result = response.parsed as PersonScanResult | undefined;
72
+
73
+ markMessagesExtracted(response, state, "o");
74
+
75
+ if (!result?.people || !Array.isArray(result.people)) {
76
+ console.log("[handleHumanPersonScan] No people detected or invalid result");
77
+ return;
78
+ }
79
+
80
+ const context = response.request.data as unknown as ExtractionContext;
81
+ if (!context?.personaId) return;
82
+
83
+ for (const candidate of result.people) {
84
+ await queueItemMatch("person", candidate, context, state);
85
+ }
86
+ console.log(`[handleHumanPersonScan] Queued ${result.people.length} person(s) for matching`);
87
+ }
@@ -0,0 +1,337 @@
1
+ import {
2
+ ValidationLevel,
3
+ type LLMResponse,
4
+ type Message,
5
+ type Trait,
6
+ type Topic,
7
+ type Fact,
8
+ type Person,
9
+ type Quote,
10
+ type DataItemType,
11
+ type DataItemBase,
12
+ } from "../types.js";
13
+ import type { StateManager } from "../state-manager.js";
14
+ import type { ItemMatchResult, ItemUpdateResult, ExposureImpact } from "../../prompts/human/types.js";
15
+ import { queueItemUpdate, type ExtractionContext } from "../orchestrators/index.js";
16
+ import { getEmbeddingService, getItemEmbeddingText } from "../embedding-service.js";
17
+ import { crossFind } from "../utils/index.js";
18
+ import { splitMessagesByTimestamp, getMessageText } from "./utils.js";
19
+
20
+ export function handleHumanItemMatch(response: LLMResponse, state: StateManager): void {
21
+ const result = response.parsed as ItemMatchResult | undefined;
22
+ if (!result) {
23
+ console.error("[handleHumanItemMatch] No parsed result");
24
+ return;
25
+ }
26
+
27
+ const candidateType = response.request.data.candidateType as DataItemType;
28
+ const personaId = response.request.data.personaId as string;
29
+ const personaDisplayName = response.request.data.personaDisplayName as string;
30
+ const analyzeFrom = response.request.data.analyze_from_timestamp as string | null;
31
+ const allMessages = state.messages_get(personaId);
32
+ const { messages_context, messages_analyze } = splitMessagesByTimestamp(allMessages, analyzeFrom);
33
+ const context: ExtractionContext & { itemName: string; itemValue: string; itemCategory?: string } = {
34
+ personaId,
35
+ personaDisplayName,
36
+ messages_context,
37
+ messages_analyze,
38
+ itemName: response.request.data.itemName as string,
39
+ itemValue: response.request.data.itemValue as string,
40
+ itemCategory: response.request.data.itemCategory as string | undefined,
41
+ };
42
+
43
+ let resolvedType: DataItemType = candidateType;
44
+ let matched_guid = result.matched_guid;
45
+ if (matched_guid === "new") {
46
+ matched_guid = null;
47
+ } else if (matched_guid) {
48
+ const found = crossFind(matched_guid, state.getHuman());
49
+ if (!found) {
50
+ console.warn(`[handleHumanItemMatch] matched_guid "${matched_guid}" not found in human data — treating as new item`);
51
+ matched_guid = null;
52
+ } else if (found.type === "fact" && found.validated === ValidationLevel.Human) {
53
+ console.log(`[handleHumanItemMatch] Skipping locked fact "${found.name}" (human-validated)`);
54
+ return;
55
+ } else if (!(found.type === "fact" || found.type === "trait" || found.type === "topic" || found.type === "person")) {
56
+ console.warn(`[handleHumanItemMatch] matched_guid "${matched_guid}" resolved to non-human type "${found.type}" - Ignoring`);
57
+ return;
58
+ } else {
59
+ resolvedType = found.type;
60
+ context.itemName = found.name || context.itemName;
61
+ context.itemValue = found.description || context.itemValue;
62
+ }
63
+ }
64
+ result.matched_guid = matched_guid;
65
+ queueItemUpdate(resolvedType, result, context, state);
66
+ const matched = matched_guid ? `matched GUID "${matched_guid}"` : "no match (new item)";
67
+ console.log(`[handleHumanItemMatch] ${resolvedType} "${context.itemName}": ${matched}`);
68
+ }
69
+
70
+ export async function handleHumanItemUpdate(response: LLMResponse, state: StateManager): Promise<void> {
71
+ const result = response.parsed as ItemUpdateResult | undefined;
72
+
73
+ if (!result || Object.keys(result).length === 0) {
74
+ console.log("[handleHumanItemUpdate] No changes needed (empty result)");
75
+ return;
76
+ }
77
+
78
+ const candidateType = response.request.data.candidateType as DataItemType;
79
+ const isNewItem = response.request.data.isNewItem as boolean;
80
+ const existingItemId = response.request.data.existingItemId as string | undefined;
81
+ const personaId = response.request.data.personaId as string;
82
+ const personaDisplayName = response.request.data.personaDisplayName as string;
83
+
84
+ if (!result.name || !result.description || result.sentiment === undefined) {
85
+ console.error("[handleHumanItemUpdate] Missing required fields in result");
86
+ return;
87
+ }
88
+
89
+ const now = new Date().toISOString();
90
+ const resolveItemId = (): string => {
91
+ if (isNewItem || !existingItemId) return crypto.randomUUID();
92
+ const h = state.getHuman();
93
+ const arr = candidateType === "fact" ? h.facts : candidateType === "trait" ? h.traits : candidateType === "topic" ? h.topics : h.people;
94
+ // Guard: if existingItemId isn't in the correct type array, treat as new
95
+ // (prevents cross-type ID reuse when LLM matches against a different type's UUID)
96
+ return arr.find((x: DataItemBase) => x.id === existingItemId) ? existingItemId : crypto.randomUUID();
97
+ };
98
+ const itemId = resolveItemId();
99
+
100
+ const persona = state.persona_getById(personaId);
101
+ const personaGroup = persona?.group_primary ?? null;
102
+ const isEi = personaDisplayName.toLowerCase() === "ei";
103
+
104
+ const human = state.getHuman();
105
+ const getExistingItem = (): { learned_by?: string; last_changed_by?: string; persona_groups?: string[] } | undefined => {
106
+ if (isNewItem) return undefined;
107
+ switch (candidateType) {
108
+ case "fact": return human.facts.find(f => f.id === existingItemId);
109
+ case "trait": return human.traits.find(t => t.id === existingItemId);
110
+ case "topic": return human.topics.find(t => t.id === existingItemId);
111
+ case "person": return human.people.find(p => p.id === existingItemId);
112
+ }
113
+ };
114
+ const existingItem = getExistingItem();
115
+
116
+ const mergeGroups = (existing: string[] | undefined): string[] | undefined => {
117
+ if (!personaGroup) return existing;
118
+ if (isNewItem) return [personaGroup];
119
+ const groups = new Set(existing ?? []);
120
+ groups.add(personaGroup);
121
+ return Array.from(groups);
122
+ };
123
+
124
+ let embedding: number[] | undefined;
125
+ try {
126
+ const embeddingService = getEmbeddingService();
127
+ const text = getItemEmbeddingText({ name: result.name, description: result.description });
128
+ embedding = await embeddingService.embed(text);
129
+ } catch (err) {
130
+ console.warn(`[handleHumanItemUpdate] Failed to compute embedding for ${candidateType} "${result.name}":`, err);
131
+ }
132
+
133
+ switch (candidateType) {
134
+ case "fact": {
135
+ const fact: Fact = {
136
+ id: itemId,
137
+ name: result.name,
138
+ description: result.description,
139
+ sentiment: result.sentiment,
140
+ validated: ValidationLevel.None,
141
+ validated_date: now,
142
+ last_updated: now,
143
+ learned_by: isNewItem ? personaId : existingItem?.learned_by,
144
+ last_changed_by: personaId,
145
+ persona_groups: mergeGroups(existingItem?.persona_groups),
146
+ embedding,
147
+ };
148
+ applyOrValidate(state, "fact", fact, personaDisplayName, isEi, personaGroup);
149
+ break;
150
+ }
151
+ case "trait": {
152
+ const trait: Trait = {
153
+ id: itemId,
154
+ name: result.name,
155
+ description: result.description,
156
+ sentiment: result.sentiment,
157
+ strength: (result as any).strength ?? 0.5,
158
+ last_updated: now,
159
+ learned_by: isNewItem ? personaId : existingItem?.learned_by,
160
+ last_changed_by: personaId,
161
+ persona_groups: mergeGroups(existingItem?.persona_groups),
162
+ embedding,
163
+ };
164
+ applyOrValidate(state, "trait", trait, personaDisplayName, isEi, personaGroup);
165
+ break;
166
+ }
167
+ case "topic": {
168
+ const exposureImpact = (result as any).exposure_impact as ExposureImpact | undefined;
169
+ const itemCategory = response.request.data.itemCategory as string | undefined;
170
+ const existingTopic = human.topics.find(t => t.id === existingItemId);
171
+ const topic: Topic = {
172
+ id: itemId,
173
+ name: result.name,
174
+ description: result.description,
175
+ sentiment: result.sentiment,
176
+ category: (result as any).category ?? itemCategory ?? existingTopic?.category,
177
+ exposure_current: calculateExposureCurrent(exposureImpact),
178
+ exposure_desired: (result as any).exposure_desired ?? 0.5,
179
+ last_updated: now,
180
+ learned_by: isNewItem ? personaId : existingItem?.learned_by,
181
+ last_changed_by: personaId,
182
+ persona_groups: mergeGroups(existingItem?.persona_groups),
183
+ embedding,
184
+ };
185
+ applyOrValidate(state, "topic", topic, personaDisplayName, isEi, personaGroup);
186
+ break;
187
+ }
188
+ case "person": {
189
+ const exposureImpact = (result as any).exposure_impact as ExposureImpact | undefined;
190
+ const person: Person = {
191
+ id: itemId,
192
+ name: result.name,
193
+ description: result.description,
194
+ sentiment: result.sentiment,
195
+ relationship: (result as any).relationship ?? "Unknown",
196
+ exposure_current: calculateExposureCurrent(exposureImpact),
197
+ exposure_desired: (result as any).exposure_desired ?? 0.5,
198
+ last_updated: now,
199
+ learned_by: isNewItem ? personaId : existingItem?.learned_by,
200
+ last_changed_by: personaId,
201
+ persona_groups: mergeGroups(existingItem?.persona_groups),
202
+ embedding,
203
+ };
204
+ applyOrValidate(state, "person", person, personaDisplayName, isEi, personaGroup);
205
+ break;
206
+ }
207
+ }
208
+
209
+ const allMessages = state.messages_get(personaId);
210
+ await validateAndStoreQuotes(result.quotes, allMessages, itemId, personaDisplayName, personaGroup, state);
211
+
212
+ console.log(`[handleHumanItemUpdate] ${isNewItem ? "Created" : "Updated"} ${candidateType} "${result.name}"`);
213
+ }
214
+
215
+ async function validateAndStoreQuotes(
216
+ candidates: Array<{ text: string; reason: string }> | undefined,
217
+ messages: Message[],
218
+ dataItemId: string,
219
+ personaName: string,
220
+ personaGroup: string | null,
221
+ state: StateManager
222
+ ): Promise<void> {
223
+ if (!candidates || candidates.length === 0) return;
224
+
225
+ for (const candidate of candidates) {
226
+ let found = false;
227
+ for (const message of messages) {
228
+ const msgText = getMessageText(message);
229
+ const start = msgText.indexOf(candidate.text);
230
+ if (start !== -1) {
231
+ const end = start + candidate.text.length;
232
+
233
+ // Check for ANY overlapping quote in this message (not just exact match)
234
+ const existing = state.human_quote_getForMessage(message.id);
235
+ const overlapping = existing.find(q =>
236
+ q.start !== null && q.end !== null &&
237
+ start < q.end && end > q.start // ranges overlap
238
+ );
239
+
240
+ if (overlapping) {
241
+ // Merge: expand to the union of both ranges
242
+ const mergedStart = Math.min(start, overlapping.start!);
243
+ const mergedEnd = Math.max(end, overlapping.end!);
244
+ const mergedText = msgText.slice(mergedStart, mergedEnd);
245
+
246
+ // Merge data_item_ids and persona_groups (deduplicated)
247
+ const mergedDataItemIds = overlapping.data_item_ids.includes(dataItemId)
248
+ ? overlapping.data_item_ids
249
+ : [...overlapping.data_item_ids, dataItemId];
250
+ const group = personaGroup || "General";
251
+ const mergedGroups = overlapping.persona_groups.includes(group)
252
+ ? overlapping.persona_groups
253
+ : [...overlapping.persona_groups, group];
254
+
255
+ // Only recompute embedding if the text actually changed
256
+ let embedding = overlapping.embedding;
257
+ if (mergedText !== overlapping.text) {
258
+ try {
259
+ const embeddingService = getEmbeddingService();
260
+ embedding = await embeddingService.embed(mergedText);
261
+ } catch (err) {
262
+ console.warn(`[extraction] Failed to recompute embedding for merged quote: "${mergedText.slice(0, 30)}..."`, err);
263
+ }
264
+ }
265
+
266
+ state.human_quote_update(overlapping.id, {
267
+ start: mergedStart,
268
+ end: mergedEnd,
269
+ text: mergedText,
270
+ data_item_ids: mergedDataItemIds,
271
+ persona_groups: mergedGroups,
272
+ embedding,
273
+ });
274
+ console.log(`[extraction] Merged overlapping quote: "${mergedText.slice(0, 50)}..." (${mergedStart}-${mergedEnd})`);
275
+ found = true;
276
+ break;
277
+ }
278
+
279
+ let embedding: number[] | undefined;
280
+ try {
281
+ const embeddingService = getEmbeddingService();
282
+ embedding = await embeddingService.embed(candidate.text);
283
+ } catch (err) {
284
+ console.warn(`[extraction] Failed to compute embedding for quote: "${candidate.text.slice(0, 30)}..."`, err);
285
+ }
286
+
287
+ const quote: Quote = {
288
+ id: crypto.randomUUID(),
289
+ message_id: message.id,
290
+ data_item_ids: [dataItemId],
291
+ persona_groups: [personaGroup || "General"],
292
+ text: candidate.text,
293
+ speaker: message.role === "human" ? "human" : personaName,
294
+ timestamp: message.timestamp,
295
+ start: start,
296
+ end: end,
297
+ created_at: new Date().toISOString(),
298
+ created_by: "extraction",
299
+ embedding,
300
+ };
301
+ state.human_quote_add(quote);
302
+ console.log(`[extraction] Captured quote: "${candidate.text.slice(0, 50)}..."`);
303
+ found = true;
304
+ break;
305
+ }
306
+ }
307
+ if (!found) {
308
+ console.log(`[extraction] Quote not found in messages, skipping: "${candidate.text?.slice(0, 50)}..."`);
309
+ }
310
+ }
311
+ }
312
+
313
+ function calculateExposureCurrent(impact: ExposureImpact | undefined): number {
314
+ switch (impact) {
315
+ case "high": return 0.9;
316
+ case "medium": return 0.6;
317
+ case "low": return 0.3;
318
+ case "none": return 0.1;
319
+ default: return 0.5;
320
+ }
321
+ }
322
+
323
+ function applyOrValidate(
324
+ state: StateManager,
325
+ dataType: DataItemType,
326
+ item: Fact | Trait | Topic | Person,
327
+ _personaName: string,
328
+ _isEi: boolean,
329
+ _personaGroup: string | null
330
+ ): void {
331
+ switch (dataType) {
332
+ case "fact": state.human_fact_upsert(item as Fact); break;
333
+ case "trait": state.human_trait_upsert(item as Trait); break;
334
+ case "topic": state.human_topic_upsert(item as Topic); break;
335
+ case "person": state.human_person_upsert(item as Person); break;
336
+ }
337
+ }