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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ei-tui",
3
- "version": "0.6.3",
3
+ "version": "0.6.5",
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
  }
@@ -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 || result.sentiment === undefined) {
151
- throw new Error(`[handleTopicUpdate] Missing required fields: name=${resolvedName}, description=${!!resolvedDescription}, sentiment=${result.sentiment}`);
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: result.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 || resolvedSentiment === undefined) {
244
- throw new Error(`[handlePersonUpdate] Missing required fields: description=${!!resolvedDescription}, sentiment=${resolvedSentiment}`);
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;
@@ -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
  };
@@ -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 };
@@ -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 = 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;
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
- human.people
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
 
@@ -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";
@@ -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
  };
@@ -6,37 +6,73 @@ import { ConfirmOverlay } from "../components/ConfirmOverlay.js";
6
6
 
7
7
  type DataType = "facts" | "topics" | "people";
8
8
 
9
- const VALID_TYPES: DataType[] = ["facts", "topics", "people"];
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 [facts|topics|people]",
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 filterArg = args[0]?.toLowerCase();
21
- const filterType: DataType | null = filterArg && VALID_TYPES.includes(filterArg as DataType)
22
- ? filterArg as DataType
23
- : null;
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: filterType === "facts" ? human.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
- let yamlContent = humanToYAML(filteredHuman, personaLookup, allGroups);
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) {