ei-tui 0.6.4 → 0.6.6
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 +14 -7
- package/src/core/handlers/index.ts +1 -0
- package/src/core/llm-client.ts +12 -1
- package/src/core/orchestrators/human-extraction.ts +70 -11
- package/src/core/orchestrators/index.ts +2 -0
- package/src/core/processor.ts +3 -0
- package/src/core/queue-processor.ts +8 -5
- package/src/core/state-manager.ts +18 -0
- package/src/core/types/entities.ts +3 -1
- 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 +66 -23
- package/tui/src/components/HelpOverlay.tsx +63 -33
- package/tui/src/context/ei.tsx +9 -2
- package/tui/src/context/overlay.tsx +2 -0
- package/tui/src/index.tsx +7 -0
- package/tui/src/util/e2e-flags.ts +13 -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 +162 -40
- package/tui/src/util/yaml-persona.ts +11 -10
- package/tui/src/util/yaml-provider.ts +34 -9
- 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
|
}
|
|
@@ -199,6 +201,11 @@ export async function handleTopicUpdate(response: LLMResponse, state: StateManag
|
|
|
199
201
|
: state.messages_get(personaId);
|
|
200
202
|
await validateAndStoreQuotes(result.quotes, allMessages, itemId, personaDisplayName, personaGroup, state);
|
|
201
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
|
+
|
|
202
209
|
console.log(`[handleTopicUpdate] ${isNewItem ? "Created" : "Updated"} topic "${resolvedName}"`);
|
|
203
210
|
}
|
|
204
211
|
|
package/src/core/llm-client.ts
CHANGED
|
@@ -51,9 +51,10 @@ function isGuid(str: string): boolean {
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
function buildResolvedModel(account: ProviderAccount, model: ModelConfig): ResolvedModel {
|
|
54
|
+
const apiModelId = model.model_id ?? model.name;
|
|
54
55
|
return {
|
|
55
56
|
provider: account.name,
|
|
56
|
-
model:
|
|
57
|
+
model: apiModelId === "(default)" ? undefined : apiModelId,
|
|
57
58
|
config: {
|
|
58
59
|
name: account.name,
|
|
59
60
|
baseURL: account.url,
|
|
@@ -164,10 +165,16 @@ function findModelAndAccount(
|
|
|
164
165
|
const model = account?.models?.find((m) => m.name === modelName);
|
|
165
166
|
return { model, account };
|
|
166
167
|
}
|
|
168
|
+
// Try matching by model UUID first
|
|
167
169
|
for (const account of accounts) {
|
|
168
170
|
const model = account.models?.find((m) => m.id === spec);
|
|
169
171
|
if (model) return { model, account };
|
|
170
172
|
}
|
|
173
|
+
// Fall back to matching by account name (bare spec like "EG" or "RnP")
|
|
174
|
+
const accountByName = accounts.find(
|
|
175
|
+
(a) => a.name.toLowerCase() === spec.toLowerCase() && a.enabled
|
|
176
|
+
);
|
|
177
|
+
if (accountByName) return { model: undefined, account: accountByName };
|
|
171
178
|
return { model: undefined, account: undefined };
|
|
172
179
|
}
|
|
173
180
|
|
|
@@ -265,6 +272,10 @@ export async function callLLMRaw(
|
|
|
265
272
|
max_tokens: modelConfig?.max_output_tokens ?? DEFAULT_MAX_OUTPUT_TOKENS,
|
|
266
273
|
};
|
|
267
274
|
|
|
275
|
+
if (modelConfig?.thinking_budget !== undefined) {
|
|
276
|
+
requestBody.think = { budget_tokens: modelConfig.thinking_budget };
|
|
277
|
+
}
|
|
278
|
+
|
|
268
279
|
if (options.tools && options.tools.length > 0) {
|
|
269
280
|
requestBody.tools = options.tools;
|
|
270
281
|
requestBody.tool_choice = "auto";
|
|
@@ -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 };
|
|
@@ -523,15 +531,15 @@ export function queuePersonUpdate(
|
|
|
523
531
|
candidateIdentifiers?: PersonIdentifier[];
|
|
524
532
|
extraction_model?: string;
|
|
525
533
|
},
|
|
526
|
-
state: StateManager
|
|
534
|
+
state: StateManager,
|
|
535
|
+
resolvedItem?: Person | null
|
|
527
536
|
): number {
|
|
528
|
-
const human = state.getHuman();
|
|
529
537
|
const matchedGuid = matchResult.matched_guid;
|
|
530
538
|
const isNewItem = matchedGuid === null;
|
|
531
539
|
|
|
532
|
-
let existingItem: Person | null = null;
|
|
533
|
-
if (!isNewItem && matchedGuid) {
|
|
534
|
-
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;
|
|
535
543
|
}
|
|
536
544
|
|
|
537
545
|
const candidateIdentifiers = context.candidateIdentifiers ?? [];
|
|
@@ -548,7 +556,7 @@ export function queuePersonUpdate(
|
|
|
548
556
|
}
|
|
549
557
|
|
|
550
558
|
const userIdentifierTypes = [...new Set(
|
|
551
|
-
|
|
559
|
+
state.getHuman().people
|
|
552
560
|
.flatMap(p => (p.identifiers ?? []).map(i => i.type))
|
|
553
561
|
.filter(Boolean)
|
|
554
562
|
)];
|
|
@@ -598,4 +606,55 @@ export function queuePersonUpdate(
|
|
|
598
606
|
return chunks.length;
|
|
599
607
|
}
|
|
600
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
|
+
|
|
601
660
|
|
package/src/core/processor.ts
CHANGED
|
@@ -37,9 +37,10 @@ export interface QueueProcessorStartOptions {
|
|
|
37
37
|
onEnqueue?: EnqueueCallback;
|
|
38
38
|
/**
|
|
39
39
|
* Called when a tool executor updates its provider config (e.g. Spotify refresh token rotation).
|
|
40
|
-
* Injected by Processor
|
|
40
|
+
* Injected by Processor pointing to stateManager.queue_enqueue.
|
|
41
41
|
*/
|
|
42
42
|
onProviderConfigUpdate?: (providerId: string, updates: Record<string, string>) => void;
|
|
43
|
+
onUsageUpdate?: (modelId: string, usage: { calls: number; tokens_in: number; tokens_out: number }) => void;
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
export class QueueProcessor {
|
|
@@ -52,6 +53,7 @@ export class QueueProcessor {
|
|
|
52
53
|
private currentTools: ToolDefinition[] | undefined;
|
|
53
54
|
private currentOnEnqueue: EnqueueCallback | undefined;
|
|
54
55
|
private currentOnProviderConfigUpdate: ((providerId: string, updates: Record<string, string>) => void) | undefined;
|
|
56
|
+
private currentOnUsageUpdate: ((modelId: string, usage: { calls: number; tokens_in: number; tokens_out: number }) => void) | undefined;
|
|
55
57
|
|
|
56
58
|
getState(): QueueProcessorState {
|
|
57
59
|
return this.state;
|
|
@@ -70,6 +72,7 @@ export class QueueProcessor {
|
|
|
70
72
|
this.currentTools = options?.tools;
|
|
71
73
|
this.currentOnEnqueue = options?.onEnqueue;
|
|
72
74
|
this.currentOnProviderConfigUpdate = options?.onProviderConfigUpdate;
|
|
75
|
+
this.currentOnUsageUpdate = options?.onUsageUpdate;
|
|
73
76
|
this.abortController = new AbortController();
|
|
74
77
|
|
|
75
78
|
this.processRequest(request)
|
|
@@ -197,7 +200,7 @@ export class QueueProcessor {
|
|
|
197
200
|
hydratedUser,
|
|
198
201
|
messages,
|
|
199
202
|
request.model,
|
|
200
|
-
{ signal: this.abortController?.signal, tools: openAITools },
|
|
203
|
+
{ signal: this.abortController?.signal, tools: openAITools, onUsageUpdate: this.currentOnUsageUpdate },
|
|
201
204
|
this.currentAccounts
|
|
202
205
|
);
|
|
203
206
|
|
|
@@ -304,7 +307,7 @@ export class QueueProcessor {
|
|
|
304
307
|
hydratedUser,
|
|
305
308
|
messages,
|
|
306
309
|
request.model,
|
|
307
|
-
{ signal: this.abortController?.signal, tools: openAITools },
|
|
310
|
+
{ signal: this.abortController?.signal, tools: openAITools, onUsageUpdate: this.currentOnUsageUpdate },
|
|
308
311
|
this.currentAccounts
|
|
309
312
|
);
|
|
310
313
|
if (thinking) {
|
|
@@ -496,7 +499,7 @@ export class QueueProcessor {
|
|
|
496
499
|
reformatUserPrompt,
|
|
497
500
|
messages, // existing tool history — gives full context without duplicating the ask
|
|
498
501
|
request.model,
|
|
499
|
-
{ signal: this.abortController?.signal },
|
|
502
|
+
{ signal: this.abortController?.signal, onUsageUpdate: this.currentOnUsageUpdate },
|
|
500
503
|
this.currentAccounts
|
|
501
504
|
);
|
|
502
505
|
|
|
@@ -553,7 +556,7 @@ export class QueueProcessor {
|
|
|
553
556
|
reformatUserPrompt,
|
|
554
557
|
[], // no message history needed — schema is already in the system prompt
|
|
555
558
|
request.model,
|
|
556
|
-
{ signal: this.abortController?.signal },
|
|
559
|
+
{ signal: this.abortController?.signal, onUsageUpdate: this.currentOnUsageUpdate },
|
|
557
560
|
this.currentAccounts
|
|
558
561
|
);
|
|
559
562
|
|
|
@@ -1064,6 +1064,24 @@ export class StateManager {
|
|
|
1064
1064
|
return { success: true, cleared };
|
|
1065
1065
|
}
|
|
1066
1066
|
|
|
1067
|
+
model_update_usage(modelId: string, delta: { calls: number; tokens_in: number; tokens_out: number }): void {
|
|
1068
|
+
const human = this.humanState.get();
|
|
1069
|
+
const accounts = human.settings?.accounts;
|
|
1070
|
+
if (!accounts) return;
|
|
1071
|
+
|
|
1072
|
+
for (const account of accounts) {
|
|
1073
|
+
const model = account.models?.find(m => m.id === modelId);
|
|
1074
|
+
if (model) {
|
|
1075
|
+
model.total_calls = (model.total_calls ?? 0) + delta.calls;
|
|
1076
|
+
model.total_tokens_in = (model.total_tokens_in ?? 0) + delta.tokens_in;
|
|
1077
|
+
model.total_tokens_out = (model.total_tokens_out ?? 0) + delta.tokens_out;
|
|
1078
|
+
model.last_used = new Date().toISOString();
|
|
1079
|
+
this.scheduleSave();
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1067
1085
|
async flush(): Promise<void> {
|
|
1068
1086
|
await this.persistenceState.flush();
|
|
1069
1087
|
}
|
|
@@ -44,9 +44,11 @@ export interface BackupConfig {
|
|
|
44
44
|
*/
|
|
45
45
|
export interface ModelConfig {
|
|
46
46
|
id: string; // GUID (crypto.randomUUID())
|
|
47
|
-
name: string; //
|
|
47
|
+
name: string; // Display name shown in UI, e.g. "Gemma4 (thinking)", "(default)"
|
|
48
|
+
model_id?: string; // Actual model identifier sent to API — falls back to name if absent
|
|
48
49
|
token_limit?: number; // Input token limit (user sets effective limit)
|
|
49
50
|
max_output_tokens?: number; // Output token limit (API-enforced)
|
|
51
|
+
thinking_budget?: number; // Thinking token budget: 0 = disabled, N = enable with N tokens, undefined = don't send
|
|
50
52
|
total_calls?: number; // Usage counter
|
|
51
53
|
total_tokens_in?: number; // Usage counter
|
|
52
54
|
total_tokens_out?: number; // Usage counter
|
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
|
};
|