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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ei-tui",
3
- "version": "0.6.4",
3
+ "version": "0.6.6",
4
4
  "author": "Flare576",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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
- const found = human.topics.find(t => t.id === matched_guid);
36
- if (!found) {
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
- const found = human.people.find(p => p.id === matched_guid);
82
- if (!found) {
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
 
@@ -88,4 +88,5 @@ export const handlers: Record<LLMNextStep, ResponseHandler> = {
88
88
  handleRoomJudge,
89
89
  handlePersonaPreview,
90
90
  [LLMNextStep.HandlePersonIdentifierMigration]: handlePersonIdentifierMigration,
91
+ [LLMNextStep.HandleTopicValidate]: handleDedupCurate,
91
92
  };
@@ -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: model.name === "(default)" ? undefined : model.name,
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 = human.topics.find(t => t.id === matchedGuid) ?? null;
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 = human.people.find(p => p.id === matchedGuid) ?? null;
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
- human.people
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
 
@@ -8,6 +8,8 @@ export {
8
8
  queueTopicUpdate,
9
9
  queuePersonUpdate,
10
10
  queueEventSummary,
11
+ queueTopicValidate,
12
+ VALIDATE_MIN_SIMILARITY,
11
13
  type ExtractionContext,
12
14
  type ExtractionOptions,
13
15
  } from "./human-extraction.js";
@@ -1130,6 +1130,9 @@ const toolNextSteps = new Set([
1130
1130
  });
1131
1131
  }
1132
1132
  },
1133
+ onUsageUpdate: (modelId, usage) => {
1134
+ this.stateManager.model_update_usage(modelId, usage);
1135
+ },
1133
1136
  }
1134
1137
  );
1135
1138
 
@@ -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 to persist the updated config back to storage.
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; // Model identifier, e.g. "claude-haiku-4-5", "(default)"
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
@@ -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
  };