ei-tui 0.6.3 → 0.6.5
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/handlers/dedup.ts +4 -1
- package/src/core/handlers/human-matching.ts +33 -15
- package/src/core/handlers/index.ts +1 -0
- package/src/core/orchestrators/human-extraction.ts +72 -11
- package/src/core/orchestrators/index.ts +2 -0
- package/src/core/types/enums.ts +1 -0
- package/src/prompts/ceremony/dedup.ts +89 -1
- package/src/prompts/ceremony/index.ts +2 -1
- package/src/prompts/ceremony/types.ts +8 -0
- package/tui/src/commands/editor.tsx +3 -0
- package/tui/src/commands/help.tsx +1 -1
- package/tui/src/commands/me.tsx +52 -16
- package/tui/src/components/HelpOverlay.tsx +63 -33
- package/tui/src/context/overlay.tsx +2 -0
- package/tui/src/index.tsx +7 -0
- package/tui/src/util/editor.ts +36 -3
- package/tui/src/util/help-content.ts +136 -0
- package/tui/src/util/yaml-human.ts +129 -34
- package/tui/src/util/yaml-persona.ts +11 -10
- package/tui/src/util/yaml-settings.ts +21 -15
package/package.json
CHANGED
|
@@ -114,7 +114,9 @@ export async function handleDedupCurate(
|
|
|
114
114
|
// =========================================================================
|
|
115
115
|
// PHASE 1: Update Quote foreign keys FIRST (before deletions)
|
|
116
116
|
// =========================================================================
|
|
117
|
-
|
|
117
|
+
|
|
118
|
+
const liveEntityIds = new Set(entityList.map((e: Fact | Topic | Person) => e.id));
|
|
119
|
+
|
|
118
120
|
for (const removal of decisions.remove) {
|
|
119
121
|
const quotes = state.quotes.filter((q: Quote) =>
|
|
120
122
|
q.data_item_ids.includes(removal.to_be_removed)
|
|
@@ -123,6 +125,7 @@ export async function handleDedupCurate(
|
|
|
123
125
|
for (const quote of quotes) {
|
|
124
126
|
const updatedIds = quote.data_item_ids
|
|
125
127
|
.map((id: string) => id === removal.to_be_removed ? removal.replaced_by : id)
|
|
128
|
+
.filter((id: string) => liveEntityIds.has(id)) // Drop links to already-merged entities
|
|
126
129
|
.filter((id: string, idx: number, arr: string[]) => arr.indexOf(id) === idx); // Dedupe
|
|
127
130
|
|
|
128
131
|
stateManager.human_quote_update(quote.id, {
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
import type { PersonIdentifier } from "../types/data-items.js";
|
|
9
9
|
import type { StateManager } from "../state-manager.js";
|
|
10
10
|
import type { ItemMatchResult, ExposureImpact, TopicUpdateResult, PersonUpdateResult } from "../../prompts/human/types.js";
|
|
11
|
-
import { queueTopicUpdate, queuePersonUpdate, type ExtractionContext } from "../orchestrators/index.js";
|
|
11
|
+
import { queueTopicUpdate, queuePersonUpdate, queueTopicValidate, type ExtractionContext } from "../orchestrators/index.js";
|
|
12
12
|
import { getEmbeddingService, getTopicEmbeddingText, getPersonEmbeddingText } from "../embedding-service.js";
|
|
13
13
|
import { calculateExposureCurrent } from "../utils/exposure.js";
|
|
14
14
|
|
|
@@ -28,12 +28,13 @@ export function handleTopicMatch(response: LLMResponse, state: StateManager): vo
|
|
|
28
28
|
const { messages_context, messages_analyze } = resolveMessageWindow(response, state);
|
|
29
29
|
|
|
30
30
|
let matched_guid = result.matched_guid;
|
|
31
|
+
let resolvedTopic: import('../types/data-items.js').Topic | null = null;
|
|
31
32
|
if (matched_guid === "new") {
|
|
32
33
|
matched_guid = null;
|
|
33
34
|
} else if (matched_guid) {
|
|
34
35
|
const human = state.getHuman();
|
|
35
|
-
|
|
36
|
-
if (!
|
|
36
|
+
resolvedTopic = human.topics.find(t => t.id === matched_guid) ?? null;
|
|
37
|
+
if (!resolvedTopic) {
|
|
37
38
|
console.warn(`[handleTopicMatch] matched_guid "${matched_guid}" not found in topics — treating as new`);
|
|
38
39
|
matched_guid = null;
|
|
39
40
|
}
|
|
@@ -57,7 +58,7 @@ export function handleTopicMatch(response: LLMResponse, state: StateManager): vo
|
|
|
57
58
|
extraction_model: response.request.data.extraction_model as string | undefined,
|
|
58
59
|
};
|
|
59
60
|
|
|
60
|
-
queueTopicUpdate(result, context, state);
|
|
61
|
+
queueTopicUpdate(result, context, state, resolvedTopic);
|
|
61
62
|
const matched = matched_guid ? `matched GUID "${matched_guid}"` : "no match (new topic)";
|
|
62
63
|
console.log(`[handleTopicMatch] topic "${context.candidateName}": ${matched}`);
|
|
63
64
|
}
|
|
@@ -74,12 +75,13 @@ export function handlePersonMatch(response: LLMResponse, state: StateManager): v
|
|
|
74
75
|
const { messages_context, messages_analyze } = resolveMessageWindow(response, state);
|
|
75
76
|
|
|
76
77
|
let matched_guid = result.matched_guid;
|
|
78
|
+
let resolvedPerson: import('../types/data-items.js').Person | null = null;
|
|
77
79
|
if (matched_guid === "new") {
|
|
78
80
|
matched_guid = null;
|
|
79
81
|
} else if (matched_guid) {
|
|
80
82
|
const human = state.getHuman();
|
|
81
|
-
|
|
82
|
-
if (!
|
|
83
|
+
resolvedPerson = human.people.find(p => p.id === matched_guid) ?? null;
|
|
84
|
+
if (!resolvedPerson) {
|
|
83
85
|
console.warn(`[handlePersonMatch] matched_guid "${matched_guid}" not found in people — treating as new`);
|
|
84
86
|
matched_guid = null;
|
|
85
87
|
}
|
|
@@ -103,7 +105,7 @@ export function handlePersonMatch(response: LLMResponse, state: StateManager): v
|
|
|
103
105
|
extraction_model: response.request.data.extraction_model as string | undefined,
|
|
104
106
|
};
|
|
105
107
|
|
|
106
|
-
queuePersonUpdate(result, context, state);
|
|
108
|
+
queuePersonUpdate(result, context, state, resolvedPerson);
|
|
107
109
|
const matched = matched_guid ? `matched GUID "${matched_guid}"` : "no match (new person)";
|
|
108
110
|
console.log(`[handlePersonMatch] person "${context.candidateName}": ${matched}`);
|
|
109
111
|
}
|
|
@@ -123,6 +125,7 @@ export async function handleTopicUpdate(response: LLMResponse, state: StateManag
|
|
|
123
125
|
const roomId = response.request.data.roomId as string | undefined;
|
|
124
126
|
const candidateCategory = response.request.data.candidateCategory as string | undefined;
|
|
125
127
|
const candidateName = response.request.data.candidateName as string | undefined;
|
|
128
|
+
const candidateDescription = response.request.data.candidateDescription as string | undefined;
|
|
126
129
|
|
|
127
130
|
const personaIds = personaId.split("|").filter(Boolean);
|
|
128
131
|
const primaryId = personaIds[0] ?? personaId;
|
|
@@ -145,10 +148,15 @@ export async function handleTopicUpdate(response: LLMResponse, state: StateManag
|
|
|
145
148
|
const existingTopic = isNewItem ? undefined : human.topics.find(t => t.id === existingItemId);
|
|
146
149
|
|
|
147
150
|
const resolvedName = result.name || existingTopic?.name || candidateName;
|
|
148
|
-
const resolvedDescription = typeof result.description === 'string' ? result.description : existingTopic?.description;
|
|
151
|
+
const resolvedDescription = typeof result.description === 'string' ? result.description : existingTopic?.description ?? candidateDescription;
|
|
152
|
+
const resolvedSentiment = result.sentiment !== undefined ? result.sentiment : existingTopic?.sentiment ?? 0;
|
|
149
153
|
|
|
150
|
-
if (!resolvedName || !resolvedDescription
|
|
151
|
-
|
|
154
|
+
if (!resolvedName || !resolvedDescription) {
|
|
155
|
+
if (isNewItem) {
|
|
156
|
+
throw new Error(`[handleTopicUpdate] Cannot create new topic — missing required fields: name=${resolvedName}, description=${!!resolvedDescription}`);
|
|
157
|
+
}
|
|
158
|
+
console.log(`[handleTopicUpdate] Skipping update for "${resolvedName ?? existingItemId}" — no description available and existing record preserved`);
|
|
159
|
+
return;
|
|
152
160
|
}
|
|
153
161
|
|
|
154
162
|
let embedding: number[] | undefined;
|
|
@@ -173,7 +181,7 @@ export async function handleTopicUpdate(response: LLMResponse, state: StateManag
|
|
|
173
181
|
id: itemId,
|
|
174
182
|
name: resolvedName,
|
|
175
183
|
description: resolvedDescription,
|
|
176
|
-
sentiment:
|
|
184
|
+
sentiment: resolvedSentiment,
|
|
177
185
|
category: result.category ?? candidateCategory ?? existingTopic?.category,
|
|
178
186
|
exposure_current: calculateExposureCurrent(exposureImpact, existingTopic?.exposure_current ?? 0),
|
|
179
187
|
exposure_desired: result.exposure_desired ?? 0.5,
|
|
@@ -193,6 +201,11 @@ export async function handleTopicUpdate(response: LLMResponse, state: StateManag
|
|
|
193
201
|
: state.messages_get(personaId);
|
|
194
202
|
await validateAndStoreQuotes(result.quotes, allMessages, itemId, personaDisplayName, personaGroup, state);
|
|
195
203
|
|
|
204
|
+
if (isNewItem && embedding) {
|
|
205
|
+
const extractionModel = (response.request.data as Record<string, unknown>).extraction_model as string | undefined;
|
|
206
|
+
await queueTopicValidate(topic, state, extractionModel);
|
|
207
|
+
}
|
|
208
|
+
|
|
196
209
|
console.log(`[handleTopicUpdate] ${isNewItem ? "Created" : "Updated"} topic "${resolvedName}"`);
|
|
197
210
|
}
|
|
198
211
|
|
|
@@ -217,6 +230,7 @@ export async function handlePersonUpdate(response: LLMResponse, state: StateMana
|
|
|
217
230
|
const candidateIdentifiers = (response.request.data.candidateIdentifiers ?? []) as PersonIdentifier[];
|
|
218
231
|
|
|
219
232
|
const candidateName = response.request.data.candidateName as string;
|
|
233
|
+
const candidateDescription = response.request.data.candidateDescription as string | undefined;
|
|
220
234
|
const personaIds = personaId.split("|").filter(Boolean);
|
|
221
235
|
const primaryId = personaIds[0] ?? personaId;
|
|
222
236
|
|
|
@@ -237,11 +251,15 @@ export async function handlePersonUpdate(response: LLMResponse, state: StateMana
|
|
|
237
251
|
|
|
238
252
|
const existingPerson = isNewItem ? undefined : human.people.find(p => p.id === existingItemId);
|
|
239
253
|
|
|
240
|
-
const resolvedDescription = typeof result.description === 'string' ? result.description : existingPerson?.description;
|
|
241
|
-
const resolvedSentiment = result.sentiment !== undefined ? result.sentiment : existingPerson?.sentiment;
|
|
254
|
+
const resolvedDescription = typeof result.description === 'string' ? result.description : existingPerson?.description ?? candidateDescription;
|
|
255
|
+
const resolvedSentiment = result.sentiment !== undefined ? result.sentiment : existingPerson?.sentiment ?? 0;
|
|
242
256
|
|
|
243
|
-
if (!resolvedDescription
|
|
244
|
-
|
|
257
|
+
if (!resolvedDescription) {
|
|
258
|
+
if (isNewItem) {
|
|
259
|
+
throw new Error(`[handlePersonUpdate] Cannot create new person "${candidateName}" — no description available`);
|
|
260
|
+
}
|
|
261
|
+
console.log(`[handlePersonUpdate] Skipping update for "${candidateName}" — no description available and existing record preserved`);
|
|
262
|
+
return;
|
|
245
263
|
}
|
|
246
264
|
|
|
247
265
|
let embedding: number[] | undefined;
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
type ItemMatchResult,
|
|
14
14
|
type ParticipantContext,
|
|
15
15
|
} from "../../prompts/human/index.js";
|
|
16
|
+
import { buildValidatePrompt } from "../../prompts/ceremony/dedup.js";
|
|
16
17
|
import { chunkExtractionContext } from "./extraction-chunker.js";
|
|
17
18
|
import { getEmbeddingService, findTopK, getTopicEmbeddingText } from "../embedding-service.js";
|
|
18
19
|
import { resolveTokenLimit } from "../llm-client.js";
|
|
@@ -290,6 +291,13 @@ export function queueDirectTopicUpdate(
|
|
|
290
291
|
const EMBEDDING_TOP_K = 20;
|
|
291
292
|
const EMBEDDING_MIN_SIMILARITY = 0.3;
|
|
292
293
|
|
|
294
|
+
/**
|
|
295
|
+
* Minimum cosine similarity to trigger the post-create validate step.
|
|
296
|
+
* Higher than EMBEDDING_MIN_SIMILARITY (0.3) because we need near-duplicates,
|
|
297
|
+
* not just vague thematic overlap.
|
|
298
|
+
*/
|
|
299
|
+
export const VALIDATE_MIN_SIMILARITY = 0.85;
|
|
300
|
+
|
|
293
301
|
/**
|
|
294
302
|
* Queue a topic match request using embedding-based similarity (topics only).
|
|
295
303
|
*/
|
|
@@ -380,15 +388,15 @@ export function queueTopicUpdate(
|
|
|
380
388
|
candidateCategory: string;
|
|
381
389
|
extraction_model?: string;
|
|
382
390
|
},
|
|
383
|
-
state: StateManager
|
|
391
|
+
state: StateManager,
|
|
392
|
+
resolvedItem?: Topic | null
|
|
384
393
|
): number {
|
|
385
|
-
const human = state.getHuman();
|
|
386
394
|
const matchedGuid = matchResult.matched_guid;
|
|
387
395
|
const isNewItem = matchedGuid === null;
|
|
388
396
|
|
|
389
|
-
let existingItem: Topic | null = null;
|
|
390
|
-
if (!isNewItem && matchedGuid) {
|
|
391
|
-
existingItem =
|
|
397
|
+
let existingItem: Topic | null = resolvedItem ?? null;
|
|
398
|
+
if (!isNewItem && matchedGuid && existingItem === null) {
|
|
399
|
+
existingItem = state.getHuman().topics.find(t => t.id === matchedGuid) ?? null;
|
|
392
400
|
}
|
|
393
401
|
|
|
394
402
|
const extractionOptions: ExtractionOptions = { extraction_model: context.extraction_model };
|
|
@@ -423,6 +431,7 @@ export function queueTopicUpdate(
|
|
|
423
431
|
isNewItem,
|
|
424
432
|
existingItemId: existingItem?.id,
|
|
425
433
|
candidateName: isNewItem ? context.candidateName : undefined,
|
|
434
|
+
candidateDescription: isNewItem ? context.candidateDescription : undefined,
|
|
426
435
|
candidateCategory: context.candidateCategory,
|
|
427
436
|
analyze_from_timestamp: getAnalyzeFromTimestamp(chunk),
|
|
428
437
|
},
|
|
@@ -522,15 +531,15 @@ export function queuePersonUpdate(
|
|
|
522
531
|
candidateIdentifiers?: PersonIdentifier[];
|
|
523
532
|
extraction_model?: string;
|
|
524
533
|
},
|
|
525
|
-
state: StateManager
|
|
534
|
+
state: StateManager,
|
|
535
|
+
resolvedItem?: Person | null
|
|
526
536
|
): number {
|
|
527
|
-
const human = state.getHuman();
|
|
528
537
|
const matchedGuid = matchResult.matched_guid;
|
|
529
538
|
const isNewItem = matchedGuid === null;
|
|
530
539
|
|
|
531
|
-
let existingItem: Person | null = null;
|
|
532
|
-
if (!isNewItem && matchedGuid) {
|
|
533
|
-
existingItem =
|
|
540
|
+
let existingItem: Person | null = resolvedItem ?? null;
|
|
541
|
+
if (!isNewItem && matchedGuid && existingItem === null) {
|
|
542
|
+
existingItem = state.getHuman().people.find(p => p.id === matchedGuid) ?? null;
|
|
534
543
|
}
|
|
535
544
|
|
|
536
545
|
const candidateIdentifiers = context.candidateIdentifiers ?? [];
|
|
@@ -547,7 +556,7 @@ export function queuePersonUpdate(
|
|
|
547
556
|
}
|
|
548
557
|
|
|
549
558
|
const userIdentifierTypes = [...new Set(
|
|
550
|
-
|
|
559
|
+
state.getHuman().people
|
|
551
560
|
.flatMap(p => (p.identifiers ?? []).map(i => i.type))
|
|
552
561
|
.filter(Boolean)
|
|
553
562
|
)];
|
|
@@ -586,6 +595,7 @@ export function queuePersonUpdate(
|
|
|
586
595
|
isNewItem,
|
|
587
596
|
existingItemId: existingItem?.id,
|
|
588
597
|
candidateName: context.candidateName,
|
|
598
|
+
candidateDescription: isNewItem ? context.candidateDescription : undefined,
|
|
589
599
|
candidateRelationship: context.candidateRelationship,
|
|
590
600
|
candidateIdentifiers: isNewItem ? candidateIdentifiers : undefined,
|
|
591
601
|
analyze_from_timestamp: getAnalyzeFromTimestamp(chunk),
|
|
@@ -596,4 +606,55 @@ export function queuePersonUpdate(
|
|
|
596
606
|
return chunks.length;
|
|
597
607
|
}
|
|
598
608
|
|
|
609
|
+
export async function queueTopicValidate(
|
|
610
|
+
newTopic: Topic,
|
|
611
|
+
state: StateManager,
|
|
612
|
+
extractionModel?: string
|
|
613
|
+
): Promise<void> {
|
|
614
|
+
if (!newTopic.embedding || newTopic.embedding.length === 0) {
|
|
615
|
+
console.log(`[queueTopicValidate] Skipping "${newTopic.name}" — no embedding available`);
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const human = state.getHuman();
|
|
620
|
+
const candidates = human.topics.filter(t => t.id !== newTopic.id && t.embedding && t.embedding.length > 0);
|
|
621
|
+
|
|
622
|
+
if (candidates.length === 0) {
|
|
623
|
+
console.log(`[queueTopicValidate] No existing topics with embeddings to compare against`);
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const topResult = findTopK(newTopic.embedding, candidates, 1);
|
|
628
|
+
if (topResult.length === 0 || topResult[0].similarity < VALIDATE_MIN_SIMILARITY) {
|
|
629
|
+
const best = topResult[0];
|
|
630
|
+
console.log(`[queueTopicValidate] "${newTopic.name}" is genuinely new (best match: ${best ? `"${best.item.name}" @ ${best.similarity.toFixed(3)}` : "none"})`);
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const existingTopic = topResult[0].item;
|
|
635
|
+
const similarity = topResult[0].similarity;
|
|
636
|
+
|
|
637
|
+
console.log(`[queueTopicValidate] Near-duplicate candidate: "${newTopic.name}" ↔ "${existingTopic.name}" (${similarity.toFixed(3)}) — queuing validate`);
|
|
638
|
+
|
|
639
|
+
const prompt = buildValidatePrompt({
|
|
640
|
+
established: existingTopic,
|
|
641
|
+
newcomer: newTopic,
|
|
642
|
+
itemType: "topic",
|
|
643
|
+
similarity,
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
state.queue_enqueue({
|
|
647
|
+
type: LLMRequestType.JSON,
|
|
648
|
+
priority: LLMPriority.Normal,
|
|
649
|
+
model: extractionModel,
|
|
650
|
+
system: prompt.system,
|
|
651
|
+
user: prompt.user,
|
|
652
|
+
next_step: LLMNextStep.HandleTopicValidate,
|
|
653
|
+
data: {
|
|
654
|
+
entity_type: "topic",
|
|
655
|
+
entity_ids: [existingTopic.id, newTopic.id],
|
|
656
|
+
},
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
|
|
599
660
|
|
package/src/core/types/enums.ts
CHANGED
|
@@ -51,6 +51,7 @@ export enum LLMNextStep {
|
|
|
51
51
|
HandleRoomJudge = "handleRoomJudge",
|
|
52
52
|
HandlePersonaPreview = "handlePersonaPreview",
|
|
53
53
|
HandlePersonIdentifierMigration = "handlePersonIdentifierMigration",
|
|
54
|
+
HandleTopicValidate = "handleTopicValidate",
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
export enum ProviderType {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { DedupPromptData } from "./types.js";
|
|
1
|
+
import type { DedupPromptData, ValidatePromptData } from "./types.js";
|
|
2
2
|
|
|
3
3
|
// =============================================================================
|
|
4
4
|
// DEDUP CURATOR — Merge duplicate entities with data preservation
|
|
@@ -140,6 +140,94 @@ ${schemaReminder}`;
|
|
|
140
140
|
return { system, user };
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
// =============================================================================
|
|
144
|
+
// VALIDATE — Binary merge decision for a newly created record
|
|
145
|
+
// =============================================================================
|
|
146
|
+
|
|
147
|
+
export function buildValidatePrompt(data: ValidatePromptData): { system: string; user: string } {
|
|
148
|
+
const typeLabel = data.itemType.charAt(0).toUpperCase() + data.itemType.slice(1);
|
|
149
|
+
const pct = Math.round(data.similarity * 100);
|
|
150
|
+
|
|
151
|
+
const system = `# Your Task
|
|
152
|
+
|
|
153
|
+
A new ${typeLabel} record was just created from a real conversation. The moment it landed in the system, we checked it against everything already stored and found one record with a similarity score of ${pct}% — high enough that they might be the same thing under different words.
|
|
154
|
+
|
|
155
|
+
You are the last gate before a duplicate takes root.
|
|
156
|
+
|
|
157
|
+
**Established record**: Has been in the system. Learned from prior conversations.
|
|
158
|
+
**Newcomer**: Just synthesized from the most recent conversation. Description is current-state, not a log.
|
|
159
|
+
|
|
160
|
+
## What You're Deciding
|
|
161
|
+
|
|
162
|
+
Are these the same thing — the same interest, concern, goal, or moment — described twice? Or are they genuinely distinct, and both deserve to exist?
|
|
163
|
+
|
|
164
|
+
Similarity of meaning is not the same as identity. "Concern about job security" and "Fear of career stagnation" share semantic space. They are not the same record.
|
|
165
|
+
|
|
166
|
+
Ask yourself: *If a persona referenced the established record in conversation, would the newcomer feel like a repeat? Or would it feel like something different being said?*
|
|
167
|
+
|
|
168
|
+
If they are the same thing: **merge**. Preserve every unique detail from both. The newcomer's description is synthesized and current — weight it, but don't discard what the established record learned first.
|
|
169
|
+
|
|
170
|
+
If they are distinct: **keep both**. Return them both in \`update\` unchanged. Leave \`remove\` and \`add\` empty.
|
|
171
|
+
|
|
172
|
+
## Output Format
|
|
173
|
+
|
|
174
|
+
\`\`\`json
|
|
175
|
+
{
|
|
176
|
+
"update": [ /* one or both records — include ALL fields from whichever survive */ ],
|
|
177
|
+
"remove": [ /* { "to_be_removed": "uuid", "replaced_by": "uuid" } — only if merging */ ],
|
|
178
|
+
"add": []
|
|
179
|
+
}
|
|
180
|
+
\`\`\`
|
|
181
|
+
|
|
182
|
+
Rules:
|
|
183
|
+
- \`add\` is always empty here. We are not creating new records from this decision.
|
|
184
|
+
- If merging: the merged record goes in \`update\`, the absorbed record goes in \`remove\`.
|
|
185
|
+
- If keeping both: return both in \`update\` exactly as received. Do not modify either.
|
|
186
|
+
- Descriptions must stay concise — under 300 characters, never over 500. Synthesize; don't concatenate.
|
|
187
|
+
- When merging numeric fields: take the HIGHER value for \`exposure_current\`, \`exposure_desired\`, \`strength\`, \`confidence\`. Average \`sentiment\`.
|
|
188
|
+
- Do NOT invent information. Only what exists in these two records.
|
|
189
|
+
|
|
190
|
+
Return raw JSON only. No markdown fencing, no commentary.`;
|
|
191
|
+
|
|
192
|
+
const payload = JSON.stringify({
|
|
193
|
+
established: stripEmbedding(data.established),
|
|
194
|
+
newcomer: stripEmbedding(data.newcomer),
|
|
195
|
+
item_type: data.itemType,
|
|
196
|
+
similarity_score: data.similarity,
|
|
197
|
+
}, null, 2);
|
|
198
|
+
|
|
199
|
+
const schemaReminder = `**Return JSON:**
|
|
200
|
+
\n\`\`\`json
|
|
201
|
+
{
|
|
202
|
+
"update": [
|
|
203
|
+
{
|
|
204
|
+
"id": "uuid-of-surviving-record",
|
|
205
|
+
"type": "${data.itemType}",
|
|
206
|
+
"name": "canonical name",
|
|
207
|
+
"description": "merged or unchanged description"
|
|
208
|
+
}
|
|
209
|
+
],
|
|
210
|
+
"remove": [
|
|
211
|
+
{
|
|
212
|
+
"to_be_removed": "uuid-of-absorbed-record",
|
|
213
|
+
"replaced_by": "uuid-of-surviving-record"
|
|
214
|
+
}
|
|
215
|
+
],
|
|
216
|
+
"add": []
|
|
217
|
+
}
|
|
218
|
+
\`\`\`
|
|
219
|
+
|
|
220
|
+
If keeping both, return both in \`update\` unchanged with empty \`remove\` and \`add\`.`;
|
|
221
|
+
|
|
222
|
+
const user = `${payload}
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
${schemaReminder}`;
|
|
227
|
+
|
|
228
|
+
return { system, user };
|
|
229
|
+
}
|
|
230
|
+
|
|
143
231
|
// =============================================================================
|
|
144
232
|
// Helpers
|
|
145
233
|
// =============================================================================
|
|
@@ -2,7 +2,7 @@ export { buildPersonaExpirePrompt } from "./expire.js";
|
|
|
2
2
|
export { buildPersonaExplorePrompt } from "./explore.js";
|
|
3
3
|
export { buildDescriptionCheckPrompt } from "./description-check.js";
|
|
4
4
|
export { buildRewriteScanPrompt, buildRewritePrompt } from "./rewrite.js";
|
|
5
|
-
export { buildDedupPrompt } from "./dedup.js";
|
|
5
|
+
export { buildDedupPrompt, buildValidatePrompt } from "./dedup.js";
|
|
6
6
|
export { buildUserDedupPrompt } from "./user-dedup.js";
|
|
7
7
|
export { buildPersonMigrationPrompt, type PersonMigrationPromptData } from "./person-migration.js";
|
|
8
8
|
export type {
|
|
@@ -20,4 +20,5 @@ export type {
|
|
|
20
20
|
RewriteResult,
|
|
21
21
|
DedupPromptData,
|
|
22
22
|
DedupResult,
|
|
23
|
+
ValidatePromptData,
|
|
23
24
|
} from "./types.js";
|
|
@@ -107,6 +107,14 @@ export interface DedupPromptData {
|
|
|
107
107
|
similarityRange: { min: number; max: number }; // e.g., { min: 0.90, max: 0.98 }
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
/** Input: exactly 2 records — one established, one just created — for binary merge decision. */
|
|
111
|
+
export interface ValidatePromptData {
|
|
112
|
+
established: DataItemBase;
|
|
113
|
+
newcomer: DataItemBase;
|
|
114
|
+
itemType: RewriteItemType;
|
|
115
|
+
similarity: number;
|
|
116
|
+
}
|
|
117
|
+
|
|
110
118
|
/** Output: merge decisions (update/remove/add). */
|
|
111
119
|
export interface DedupResult {
|
|
112
120
|
update: Array<{
|
|
@@ -26,16 +26,19 @@ export const editorCommand: Command = {
|
|
|
26
26
|
});
|
|
27
27
|
|
|
28
28
|
if (result.aborted) {
|
|
29
|
+
ctx.setInputText("");
|
|
29
30
|
ctx.showNotification("Editor cancelled", "info");
|
|
30
31
|
return;
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
if (!result.success) {
|
|
35
|
+
ctx.setInputText("");
|
|
34
36
|
ctx.showNotification("Editor failed to open", "error");
|
|
35
37
|
return;
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
if (result.content === null) {
|
|
41
|
+
ctx.setInputText("");
|
|
39
42
|
ctx.showNotification("No changes made", "info");
|
|
40
43
|
return;
|
|
41
44
|
}
|
|
@@ -7,6 +7,6 @@ export const helpCommand: Command = {
|
|
|
7
7
|
description: "Show help screen",
|
|
8
8
|
usage: "/help or /h",
|
|
9
9
|
execute: async (_args, ctx) => {
|
|
10
|
-
ctx.showOverlay((_hideOverlay, _hideForEditor) => <HelpOverlay onDismiss={_hideOverlay} />, ctx.renderer);
|
|
10
|
+
ctx.showOverlay((_hideOverlay, _hideForEditor) => <HelpOverlay onDismiss={_hideOverlay} renderer={ctx.renderer} />, ctx.renderer);
|
|
11
11
|
},
|
|
12
12
|
};
|
package/tui/src/commands/me.tsx
CHANGED
|
@@ -6,37 +6,73 @@ import { ConfirmOverlay } from "../components/ConfirmOverlay.js";
|
|
|
6
6
|
|
|
7
7
|
type DataType = "facts" | "topics" | "people";
|
|
8
8
|
|
|
9
|
-
const
|
|
9
|
+
const TYPE_ALIASES: Record<string, DataType> = {
|
|
10
|
+
facts: "facts", fact: "facts",
|
|
11
|
+
topics: "topics", topic: "topics",
|
|
12
|
+
people: "people", person: "people", persons: "people",
|
|
13
|
+
};
|
|
10
14
|
|
|
11
15
|
export const meCommand: Command = {
|
|
12
16
|
name: "me",
|
|
13
17
|
aliases: [],
|
|
14
18
|
description: "Edit your data in $EDITOR",
|
|
15
|
-
usage: "/me [
|
|
16
|
-
|
|
19
|
+
usage: "/me [fact|topic|person] [new | <search>]",
|
|
20
|
+
|
|
17
21
|
async execute(args, ctx) {
|
|
18
22
|
const human = await ctx.ei.getHuman();
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
const filterType: DataType | null =
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
if (filterArg && !filterType) {
|
|
26
|
-
ctx.showNotification(`Invalid type: ${filterArg}. Use: facts, topics, people`, "error");
|
|
23
|
+
|
|
24
|
+
const typeArg = args[0]?.toLowerCase();
|
|
25
|
+
const filterType: DataType | null = typeArg ? (TYPE_ALIASES[typeArg] ?? null) : null;
|
|
26
|
+
|
|
27
|
+
if (typeArg && !filterType) {
|
|
28
|
+
ctx.showNotification(`Unknown type: ${typeArg}. Use: fact, topic, person`, "error");
|
|
27
29
|
return;
|
|
28
30
|
}
|
|
29
|
-
|
|
31
|
+
|
|
32
|
+
const secondArg = args[1]?.toLowerCase();
|
|
33
|
+
const isNew = secondArg === "new";
|
|
34
|
+
const searchTerm = !isNew && secondArg ? args.slice(1).join(" ") : null;
|
|
35
|
+
|
|
36
|
+
if (isNew && args.length > 2) {
|
|
37
|
+
ctx.showNotification(
|
|
38
|
+
`Use /me ${typeArg} new to create, or /me ${typeArg} ${args.slice(2).join(" ")} to search`,
|
|
39
|
+
"error"
|
|
40
|
+
);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if ((isNew || searchTerm) && !filterType) {
|
|
45
|
+
ctx.showNotification(`Specify a type first: /me fact|topic|person [new | <search>]`, "error");
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const filterItems = <T extends { name: string }>(items: T[]): T[] => {
|
|
50
|
+
if (isNew) return [];
|
|
51
|
+
if (searchTerm) return items.filter(i => i.name.toLowerCase().includes(searchTerm.toLowerCase()));
|
|
52
|
+
return items;
|
|
53
|
+
};
|
|
54
|
+
|
|
30
55
|
const filteredHuman = filterType ? {
|
|
31
56
|
...human,
|
|
32
|
-
facts:
|
|
33
|
-
topics: filterType === "topics" ? human.topics : [],
|
|
34
|
-
people: filterType === "people" ? human.people : [],
|
|
57
|
+
facts: filterType === "facts" ? filterItems(human.facts) : [],
|
|
58
|
+
topics: filterType === "topics" ? filterItems(human.topics) : [],
|
|
59
|
+
people: filterType === "people" ? filterItems(human.people) : [],
|
|
35
60
|
} : human;
|
|
61
|
+
|
|
62
|
+
const isEmpty = filteredHuman.facts.length === 0
|
|
63
|
+
&& filteredHuman.topics.length === 0
|
|
64
|
+
&& filteredHuman.people.length === 0;
|
|
65
|
+
|
|
66
|
+
if (searchTerm && isEmpty) {
|
|
67
|
+
ctx.showNotification(`No ${filterType} matching "${searchTerm}" — open editor to create one`, "info");
|
|
68
|
+
}
|
|
36
69
|
|
|
37
70
|
const personaLookup = new Map(ctx.ei.personas().map(p => [p.id, p.display_name]));
|
|
38
71
|
const allGroups = await ctx.ei.getGroupList();
|
|
39
|
-
|
|
72
|
+
const sections = filterType
|
|
73
|
+
? new Set<"facts" | "topics" | "people">([filterType])
|
|
74
|
+
: new Set<"facts" | "topics" | "people">(["facts", "topics", "people"]);
|
|
75
|
+
let yamlContent = humanToYAML(filteredHuman, personaLookup, allGroups, sections);
|
|
40
76
|
let editorIteration = 0;
|
|
41
77
|
|
|
42
78
|
while (true) {
|