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
|
@@ -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
|
-
):
|
|
49
|
+
): PersonMatch[] {
|
|
35
50
|
const normName = normalizeForMatch(candidateName);
|
|
36
|
-
const matched = new
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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 (
|
|
302
|
+
if (isSingleton) {
|
|
257
303
|
matchedPerson = existing;
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
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
|
|
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
|
|