ei-tui 1.7.0 → 1.7.1

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": "1.7.0",
3
+ "version": "1.7.1",
4
4
  "author": "Flare576",
5
5
  "repository": {
6
6
  "type": "git",
@@ -14,6 +14,9 @@ import { BUILT_IN_FACT_NAMES } from "../constants/built-in-facts.js";
14
14
  import { getEmbeddingService, getItemEmbeddingText, cosineSimilarity, getPersonEmbeddingText } from "../embedding-service.js";
15
15
  import { levenshtein, normalizeForMatch } from "../utils/levenshtein.js";
16
16
 
17
+ export type PersonMatchStrength = 'strong' | 'weak';
18
+ export interface PersonMatch { person: Person; strength: PersonMatchStrength; }
19
+
17
20
  const MULTI_MATCH_SIMILARITY_THRESHOLD = 0.75;
18
21
  const ZERO_MATCH_COSINE_THRESHOLD = 0.80;
19
22
 
@@ -27,13 +30,25 @@ const SINGLETON_RELATIONSHIPS = new Set([
27
30
  'father', 'mother',
28
31
  ]);
29
32
 
33
+ function sharesNameToken(normalizedCandidateName: string, person: Person): boolean {
34
+ const candidateTokens = new Set(normalizedCandidateName.split(/\s+/).filter(t => t.length >= 3));
35
+ if (candidateTokens.size === 0) return false;
36
+ const personStrings = [normalizeForMatch(person.name), ...(person.identifiers ?? []).map(i => normalizeForMatch(i.value))];
37
+ for (const s of personStrings) {
38
+ for (const tok of s.split(/\s+/)) {
39
+ if (tok.length >= 3 && candidateTokens.has(tok)) return true;
40
+ }
41
+ }
42
+ return false;
43
+ }
44
+
30
45
  function matchPersonCandidate(
31
46
  candidateName: string,
32
47
  candidateIdentifiers: PersonIdentifier[],
33
48
  people: Person[]
34
- ): Person[] {
49
+ ): PersonMatch[] {
35
50
  const normName = normalizeForMatch(candidateName);
36
- const matched = new Set<Person>();
51
+ const matched = new Map<Person, PersonMatchStrength>();
37
52
 
38
53
  // Step 1: Exact match on any identifier value (type-agnostic)
39
54
  for (const person of people) {
@@ -41,19 +56,24 @@ function matchPersonCandidate(
41
56
  ...(person.identifiers ?? []).map(i => normalizeForMatch(i.value)),
42
57
  normalizeForMatch(person.name),
43
58
  ];
44
- if (allValues.includes(normName)) matched.add(person);
59
+ if (allValues.includes(normName)) matched.set(person, 'strong');
45
60
  }
46
61
  // Also check scan-extracted identifiers against existing identifier values
47
62
  for (const scanId of candidateIdentifiers) {
48
63
  const normVal = normalizeForMatch(scanId.value);
49
64
  for (const person of people) {
50
65
  if ((person.identifiers ?? []).some(i => normalizeForMatch(i.value) === normVal)) {
51
- matched.add(person);
66
+ // Corroboration gate (#78 C1): a scan-extracted identifier only STRONG-binds when the
67
+ // candidate's name shares a token with the match. A bare identifier hit with zero name
68
+ // overlap is the cross-attribution signature (one person's handle on another's record),
69
+ // so it drops to WEAK and must clear the cosine gate — or become a new record.
70
+ const strength: PersonMatchStrength = sharesNameToken(normName, person) ? 'strong' : 'weak';
71
+ if (matched.get(person) !== 'strong') matched.set(person, strength);
52
72
  }
53
73
  }
54
74
  }
55
75
 
56
- if (matched.size > 0) return [...matched];
76
+ if (matched.size > 0) return [...matched].map(([person, strength]) => ({ person, strength }));
57
77
 
58
78
  // Step 2: Fuzzy match — skip for short names (< 6 chars): "mike"↔"jake" = 2 edits, false positive.
59
79
  if (normName.length >= 6) {
@@ -63,11 +83,11 @@ function matchPersonCandidate(
63
83
  ...(person.identifiers ?? []).map(i => normalizeForMatch(i.value)),
64
84
  normalizeForMatch(person.name),
65
85
  ];
66
- if (allValues.some(v => levenshtein(normName, v) <= threshold)) matched.add(person);
86
+ if (allValues.some(v => levenshtein(normName, v) <= threshold)) matched.set(person, 'weak');
67
87
  }
68
88
  }
69
89
 
70
- if (matched.size > 0) return [...matched];
90
+ if (matched.size > 0) return [...matched].map(([person, strength]) => ({ person, strength }));
71
91
 
72
92
  // Step 2.5: First-name match — "Lucas Jeremy Scherer" should find "Lucas".
73
93
  // Only fires when first word is >= 4 chars to avoid short-name collisions.
@@ -78,11 +98,11 @@ function matchPersonCandidate(
78
98
  normalizeForMatch(person.name),
79
99
  ...(person.identifiers ?? []).map(i => normalizeForMatch(i.value)),
80
100
  ];
81
- if (allNames.some(n => n.split(/\s+/)[0] === candidateFirstWord)) matched.add(person);
101
+ if (allNames.some(n => n.split(/\s+/)[0] === candidateFirstWord)) matched.set(person, 'weak');
82
102
  }
83
103
  }
84
104
 
85
- return [...matched];
105
+ return [...matched].map(([person, strength]) => ({ person, strength }));
86
106
  }
87
107
 
88
108
  export async function handleFactFind(response: LLMResponse, state: StateManager): Promise<void> {
@@ -182,6 +202,27 @@ export async function handleHumanTopicScan(response: LLMResponse, state: StateMa
182
202
  console.log(`[handleHumanTopicScan] Queued ${result.topics.length} topic(s) for matching`);
183
203
  }
184
204
 
205
+ async function confirmMatchByCosine(
206
+ person: Person,
207
+ candidate: { name: string; relationship?: string; description?: string },
208
+ threshold: number
209
+ ): Promise<Person | null> {
210
+ if (!person.embedding || person.embedding.length === 0) return null;
211
+ try {
212
+ const embeddingService = getEmbeddingService();
213
+ const candidateVector = await embeddingService.embed(getPersonEmbeddingText({
214
+ name: candidate.name, relationship: candidate.relationship, description: candidate.description,
215
+ }));
216
+ const sim = cosineSimilarity(person.embedding, candidateVector);
217
+ if (sim >= threshold) return person;
218
+ console.debug(`[handleHumanPersonScan] Weak single-match "${candidate.name}" → "${person.name}" rejected (cosine ${sim.toFixed(3)} < ${threshold}) — new record`);
219
+ return null;
220
+ } catch (err) {
221
+ console.warn(`[handleHumanPersonScan] Weak-match cosine failed for "${candidate.name}":`, err);
222
+ return null;
223
+ }
224
+ }
225
+
185
226
  export async function handleHumanPersonScan(response: LLMResponse, state: StateManager): Promise<void> {
186
227
  const result = response.parsed as PersonScanResult | undefined;
187
228
 
@@ -213,7 +254,12 @@ export async function handleHumanPersonScan(response: LLMResponse, state: StateM
213
254
  let matchedPerson: Person | null = null;
214
255
 
215
256
  if (matches.length === 1) {
216
- matchedPerson = matches[0];
257
+ const { person, strength } = matches[0];
258
+ if (strength === 'strong') {
259
+ matchedPerson = person;
260
+ } else {
261
+ matchedPerson = await confirmMatchByCosine(person, candidate, MULTI_MATCH_SIMILARITY_THRESHOLD);
262
+ }
217
263
  } else if (matches.length > 1) {
218
264
  try {
219
265
  const embeddingService = getEmbeddingService();
@@ -224,7 +270,7 @@ export async function handleHumanPersonScan(response: LLMResponse, state: StateM
224
270
  });
225
271
  const candidateVector = await embeddingService.embed(candidateText);
226
272
  let bestSimilarity = MULTI_MATCH_SIMILARITY_THRESHOLD;
227
- for (const person of matches) {
273
+ for (const { person } of matches) {
228
274
  if (person.embedding) {
229
275
  const sim = cosineSimilarity(person.embedding, candidateVector);
230
276
  if (sim > bestSimilarity) {
@@ -238,7 +284,7 @@ export async function handleHumanPersonScan(response: LLMResponse, state: StateM
238
284
  }
239
285
  } catch (err) {
240
286
  console.warn(`[handleHumanPersonScan] Multi-match embedding failed for "${candidate.name}", using first match:`, err);
241
- matchedPerson = matches[0];
287
+ matchedPerson = matches[0].person;
242
288
  }
243
289
  } else {
244
290
  // Step 3: relationship filter → uniqueness match or cosine on the relevant subset.
@@ -253,10 +299,13 @@ export async function handleHumanPersonScan(response: LLMResponse, state: StateM
253
299
  const normExistingName = normalizeForMatch(existing.name);
254
300
  const isUnknownPlaceholder = normExistingName === 'unknown' || normExistingName === normRel;
255
301
  const isSingleton = SINGLETON_RELATIONSHIPS.has(normRel!);
256
- if (isUnknownPlaceholder || isSingleton) {
302
+ if (isSingleton) {
257
303
  matchedPerson = existing;
258
- const reason = isUnknownPlaceholder ? 'unnamed placeholder' : 'singleton relationship';
259
- console.debug(`[handleHumanPersonScan] Relationship unique match: "${candidate.name}" "${existing.name}" (sole ${candidate.relationship}, ${reason})`);
304
+ console.debug(`[handleHumanPersonScan] Relationship unique match: "${candidate.name}" "${existing.name}" (sole ${candidate.relationship}, singleton relationship)`);
305
+ } else if (isUnknownPlaceholder) {
306
+ // M1 (deferred, #78): a placeholder with no embedding cannot be confirmed here and will fork a new record instead of promoting. Acceptable under the dupe-tolerant policy; revisit with embedding backfill.
307
+ matchedPerson = await confirmMatchByCosine(existing, candidate, ZERO_MATCH_COSINE_THRESHOLD);
308
+ console.debug(`[handleHumanPersonScan] Relationship unique match gated by cosine: "${candidate.name}" → "${existing.name}" (sole ${candidate.relationship}, unnamed placeholder) — ${matchedPerson ? 'confirmed' : 'rejected, new record'}`);
260
309
  }
261
310
  } else {
262
311
  // N>1 same relationship → cosine within that subset.
@@ -607,17 +607,6 @@ export function queuePersonUpdate(
607
607
 
608
608
  const candidateIdentifiers = context.candidateIdentifiers ?? [];
609
609
 
610
- if (!isNewItem && existingItem && candidateIdentifiers.length > 0) {
611
- const merged = [...(existingItem.identifiers ?? [])];
612
- for (const ci of candidateIdentifiers) {
613
- if (!merged.some(ei => ei.value === ci.value)) {
614
- merged.push(ci);
615
- }
616
- }
617
- existingItem = { ...existingItem, identifiers: merged };
618
- state.human_person_upsert(existingItem);
619
- }
620
-
621
610
  const userIdentifierTypes = [...new Set(
622
611
  state.getHuman().people
623
612
  .flatMap(p => (p.identifiers ?? []).map(i => i.type))
@@ -642,6 +631,7 @@ export function queuePersonUpdate(
642
631
  persona_name: chunk.channelDisplayName,
643
632
  participant_context: buildParticipantContext(primaryPersonaIdForUpdate, state),
644
633
  known_identifier_types: userIdentifierTypes,
634
+ suggested_identifiers: !isNewItem ? candidateIdentifiers : undefined,
645
635
  });
646
636
 
647
637
  state.queue_enqueue({
@@ -128,15 +128,26 @@ export async function updatePersona(
128
128
  const persona = sm.persona_getById(personaId);
129
129
  if (!persona) return false;
130
130
 
131
- if ('long_description' in updates) {
132
- const merged = { ...persona, ...updates };
133
- const embedding = await computePersonaDescriptionEmbedding(merged);
134
- if (embedding) {
135
- updates = { ...updates, description_embedding: embedding };
136
- }
137
- }
131
+ const shouldRefreshDescriptionEmbedding = 'long_description' in updates;
132
+ const mergedForEmbedding = shouldRefreshDescriptionEmbedding
133
+ ? { ...persona, ...updates }
134
+ : null;
138
135
 
139
136
  sm.persona_update(personaId, updates);
137
+
138
+ if (shouldRefreshDescriptionEmbedding && mergedForEmbedding) {
139
+ void computePersonaDescriptionEmbedding(mergedForEmbedding).then((embedding) => {
140
+ if (!embedding) return;
141
+ const current = sm.persona_getById(personaId);
142
+ if (!current) return;
143
+ if (
144
+ current.long_description !== mergedForEmbedding.long_description ||
145
+ current.short_description !== mergedForEmbedding.short_description
146
+ ) return;
147
+ sm.persona_update(personaId, { description_embedding: embedding });
148
+ });
149
+ }
150
+
140
151
  return true;
141
152
  }
142
153
 
@@ -107,6 +107,8 @@ If you are unsure of the type, use \`Nickname\` as a fallback. Do NOT invent typ
107
107
 
108
108
  Only include \`identifiers\` when explicitly mentioned in the conversation — omit it entirely if nothing qualifies.
109
109
 
110
+ Do NOT attribute one person's handle or identifier to another person discussed in the same window. If "Marcus's GitHub is @mcodes" appears alongside a mention of Priya, @mcodes belongs to Marcus's record only — never Priya's.
111
+
110
112
  ## Confidence & Relationship Type
111
113
 
112
114
  For each person, rate how important they are to the human user's life:
@@ -1,5 +1,6 @@
1
1
  import type { PromptOutput, ParticipantContext } from "./types.js";
2
2
  import type { Person, Message } from "../../core/types.js";
3
+ import type { PersonIdentifier } from "../../core/types/data-items.js";
3
4
  import { formatMessagesAsPlaceholders } from "../message-utils.js";
4
5
 
5
6
  export interface PersonUpdatePromptData {
@@ -12,6 +13,7 @@ export interface PersonUpdatePromptData {
12
13
  persona_name: string;
13
14
  participant_context?: ParticipantContext;
14
15
  known_identifier_types?: string[];
16
+ suggested_identifiers?: PersonIdentifier[];
15
17
  }
16
18
 
17
19
  function participantContextSection(ctx: ParticipantContext | undefined): string {
@@ -171,20 +173,29 @@ The description should NOT:
171
173
  ? `CRITICAL: The HUMAN USER is ${humanName}. They wrote these messages. Do NOT assign their names, nicknames, or handles as identifiers for this person's record — UNLESS this IS the user's own Self record (relationship: "Self").`
172
174
  : `CRITICAL: The HUMAN USER wrote these messages. Do NOT assign their own names or handles as identifiers for this person's record — UNLESS this IS the user's own Self record (relationship: "Self"). Do NOT return \`relationship: "Self"\` unless you are certain this record is about the human user themselves.`;
173
175
 
176
+ const attributionGuard = `\nONLY add an identifier if it is explicitly stated about THIS SPECIFIC PERSON — not inferred from proximity in the conversation. If two different people are discussed near each other, do NOT attribute one person's handle, email, or name to the other.`;
174
177
  const isUnknownNewPerson = isNewItem && personName === 'Unknown';
175
178
  const unknownIdentifierGuard = isUnknownNewPerson
176
- ? `\nThis person's name is not yet known. ONLY add \`identifiers\` if their name, handle, or email is explicitly stated in the conversation about THEM specifically — not inferred, not guessed.`
179
+ ? `\nThis person's name is not yet known be especially careful: only record an identifier the conversation names for THEM specifically.`
177
180
  : '';
178
181
 
179
- const identifierSection = `${identityGuard}${unknownIdentifierGuard}
182
+ const suggestedIdentifiers = data.suggested_identifiers ?? [];
183
+ const suggestedIdentifiersBlock = !isNewItem && suggestedIdentifiers.length > 0
184
+ ? `\n\nThe scan flagged these identifiers as POSSIBLY belonging to this person: ${suggestedIdentifiers.map(i => `\`${i.type}=${i.value}\``).join(', ')}.
185
+ For EACH, add it to \`identifiers_to_add\` ONLY if the Most Recent Messages confirm it belongs to THIS SPECIFIC PERSON. If any actually belongs to someone else mentioned in the conversation, omit it. Do not add any you cannot confirm from the conversation.`
186
+ : '';
187
+
188
+ const identifierSection = `${identityGuard}${attributionGuard}${unknownIdentifierGuard}
180
189
 
181
190
  If you spot a platform handle, username, email, nickname, or full name explicitly mentioned in the conversation that isn't already in the person's identifiers, include it in \`identifiers_to_add\` (updates) or \`identifiers\` (new records). Always mark exactly one identifier as \`"is_primary": true\` — prefer the most formal or complete name.
182
191
 
192
+ Example of what NOT to do: if the conversation says "I talked to Priya and Marcus — Marcus's GitHub is @mcodes", do NOT add @mcodes to Priya's record; it belongs to Marcus only.
193
+
183
194
  For persons with a known relationship (Father, Mother, Sibling, etc.), also look for informal terms the HUMAN USER uses to address or refer to THAT SPECIFIC PERSON (\`Dad\`, \`Pop\`, \`Mom\`, \`Sis\`, etc.) and add them as \`{ "type": "Relationship", "value": "..." }\` identifiers.
184
195
 
185
196
  NEVER add dates, ages, birthdays, or anniversaries as identifiers. These are not identifying labels — if known, include them in the description instead.
186
197
 
187
- Known identifier types: ${allTypes}. If unsure of type, use \`Nickname\`.`;
198
+ Known identifier types: ${allTypes}. If unsure of type, use \`Nickname\`.${suggestedIdentifiersBlock}`;
188
199
 
189
200
  // ── OUTPUT FORMAT ─────────────────────────────────────────────────────────
190
201