ei-tui 1.7.0 → 1.8.0

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.
@@ -140,6 +140,22 @@ export async function installMcpClients(): Promise<void> {
140
140
  console.log(`ℹ️ OMP not detected — skipping OMP extension install.`);
141
141
  }
142
142
 
143
+ // Shared, spec-standard skill discovery directory that Cursor, Codex, and
144
+ // base Pi (non-OMP) each independently walk up looking for
145
+ // (~/.agents/skills/<name>/SKILL.md) — unconditional because, unlike the
146
+ // harness-specific steps above, it needs no per-tool detection: every tool
147
+ // that reads it does so regardless of what else is installed on the machine.
148
+ // Claude Code, OMP, and OpenCode do NOT read this path — they keep their own
149
+ // installSkillsTo() calls inside installClaudeCode()/installOmp()/
150
+ // installOpenCodePlugin() untouched.
151
+ if (
152
+ !(await runInstallStep("Shared skills directory (~/.agents/skills)", () =>
153
+ installSkillsTo(join(home, ".agents", "skills"))
154
+ ))
155
+ ) {
156
+ failures.push("Shared skills directory");
157
+ }
158
+
143
159
  if (failures.length > 0) {
144
160
  throw new Error(
145
161
  `${failures.length} integration(s) failed to install: ${failures.join(", ")}. See warnings above for details.`,
@@ -172,29 +188,49 @@ function hookEntryHasCommand(entry: unknown, command: string): boolean {
172
188
  });
173
189
  }
174
190
 
175
- async function installCodex(): Promise<void> {
176
- const dataPath = process.env.EI_DATA_PATH ?? join(resolveHome(), ".local", "share", "ei");
177
- const proc = Bun.spawn(
178
- ["codex", "mcp", "add", "ei", "--env", `EI_DATA_PATH=${dataPath}`, "--", "bunx", "ei-tui", "mcp"],
179
- {
191
+ export async function installCodex(): Promise<void> {
192
+ const home = resolveHome();
193
+ const configPath = join(home, ".codex", "config.toml");
194
+
195
+ // `codex mcp remove <name>` verified as a real subcommand (`codex mcp
196
+ // --help`, `codex mcp remove --help`) against codex-cli 0.142.3 —
197
+ // symmetric with the `codex mcp add` call this replaces. It rewrites
198
+ // ~/.codex/config.toml itself, so we don't hand-roll a TOML writer here;
199
+ // we only pre-check the file (via Bun's built-in TOML parser) to decide
200
+ // whether there's anything to remove, so a no-op run never touches —
201
+ // or lets the codex CLI reformat — the file at all.
202
+ let hasEiEntry = false;
203
+ try {
204
+ const text = await Bun.file(configPath).text();
205
+ const parsed = Bun.TOML.parse(text) as { mcp_servers?: Record<string, unknown> };
206
+ hasEiEntry = Boolean(parsed?.mcp_servers?.ei);
207
+ } catch {
208
+ // File doesn't exist, or isn't valid TOML — nothing to remove.
209
+ }
210
+
211
+ if (!hasEiEntry) {
212
+ console.log(`ℹ️ Ei MCP already absent from ${configPath} — nothing to remove.`);
213
+ } else {
214
+ const proc = Bun.spawn(["codex", "mcp", "remove", "ei"], {
180
215
  stdout: "pipe",
181
216
  stderr: "pipe",
217
+ });
218
+
219
+ const [stdout, stderr, exitCode] = await Promise.all([
220
+ new Response(proc.stdout).text(),
221
+ new Response(proc.stderr).text(),
222
+ proc.exited,
223
+ ]);
224
+
225
+ if (exitCode !== 0) {
226
+ console.warn(`⚠️ Codex MCP removal failed.`);
227
+ const detail = (stderr || stdout).trim();
228
+ if (detail) console.warn(` ${detail}`);
229
+ } else {
230
+ console.log(
231
+ `✓ Removed Ei MCP server registration from ${configPath} (skills now cover this capability; MCP remains available via manual setup — see README).`
232
+ );
182
233
  }
183
- );
184
-
185
- const [stdout, stderr, exitCode] = await Promise.all([
186
- new Response(proc.stdout).text(),
187
- new Response(proc.stderr).text(),
188
- proc.exited,
189
- ]);
190
-
191
- if (exitCode !== 0) {
192
- console.warn(`⚠️ Codex MCP install failed.`);
193
- const detail = (stderr || stdout).trim();
194
- if (detail) console.warn(` ${detail}`);
195
- } else {
196
- console.log(`✓ Installed Ei MCP server to Codex config (~/.codex/config.toml)`);
197
- console.log(` Restart Codex to activate MCP.`);
198
234
  }
199
235
 
200
236
  await installCodexHooks();
@@ -315,36 +351,34 @@ export async function installClaudeCode(): Promise<void> {
315
351
  const home = resolveHome();
316
352
  const claudeJsonPath = join(home, ".claude.json");
317
353
 
318
- // Claude Code supports ${VAR} substitution in env values, resolved from its
319
- // own environment at spawn time — so the value stays fresh if EI_DATA_PATH changes.
320
- const mcpEntry: Record<string, unknown> = {
321
- type: "stdio",
322
- command: "bunx",
323
- args: ["ei-tui", "mcp"],
324
- env: { EI_DATA_PATH: "${EI_DATA_PATH}" },
325
- };
326
-
327
- // Direct atomic write — we need full control over the config structure to
328
- // write the env field. `claude mcp add` doesn't support env vars.
354
+ // Direct config edit matches the atomic-write pattern the original
355
+ // registration used, but inverted: remove the "ei" entry instead of
356
+ // adding one. Every other key/entry in the file is left untouched.
329
357
  let config: Record<string, unknown> = {};
330
358
  try {
331
359
  const text = await Bun.file(claudeJsonPath).text();
332
360
  config = JSON.parse(text) as Record<string, unknown>;
333
361
  } catch {
334
- // File doesn't exist or isn't valid JSON — start fresh
362
+ // File doesn't exist or isn't valid JSON — nothing to remove.
335
363
  }
336
364
 
337
- const mcpServers = (config.mcpServers ?? {}) as Record<string, unknown>;
338
- mcpServers["ei"] = mcpEntry;
339
- config.mcpServers = mcpServers;
365
+ const mcpServers = config.mcpServers;
366
+ const hasEiEntry = typeof mcpServers === "object" && mcpServers !== null && "ei" in mcpServers;
340
367
 
341
- // Atomic write: write to temp file then rename to avoid partial writes
342
- const tmpPath = `${claudeJsonPath}.ei-install.tmp`;
343
- await Bun.write(tmpPath, JSON.stringify(config, null, 2) + "\n");
344
- await rename(tmpPath, claudeJsonPath);
368
+ if (!hasEiEntry) {
369
+ console.log(`ℹ️ Ei MCP already absent from ${claudeJsonPath} — nothing to remove.`);
370
+ } else {
371
+ delete (mcpServers as Record<string, unknown>)["ei"];
345
372
 
346
- console.log(`✓ Installed Ei MCP server to ${claudeJsonPath}`);
347
- console.log(` Restart Claude Code to activate.`);
373
+ // Atomic write: write to temp file then rename to avoid partial writes
374
+ const tmpPath = `${claudeJsonPath}.ei-install.tmp`;
375
+ await Bun.write(tmpPath, JSON.stringify(config, null, 2) + "\n");
376
+ await rename(tmpPath, claudeJsonPath);
377
+
378
+ console.log(
379
+ `✓ Removed Ei MCP server registration from ${claudeJsonPath} (skills now cover this capability; MCP remains available via manual setup — see README).`
380
+ );
381
+ }
348
382
 
349
383
  await installClaudeCodeHooks();
350
384
 
@@ -435,37 +469,37 @@ The following items MAY be relevant to your current task — use \\\`ei_search\\
435
469
  console.log(`✓ Installed Ei context hook to ~/.claude/hooks/ei-inject.ts`);
436
470
  }
437
471
 
438
- async function installCursor(): Promise<void> {
472
+ export async function installCursor(): Promise<void> {
439
473
  const home = resolveHome();
440
474
  const cursorJsonPath = join(home, ".cursor", "mcp.json");
441
475
 
442
- // Cursor does not support ${VAR} substitution in mcp.json literal values only.
443
- const mcpEntry: Record<string, unknown> = {
444
- type: "stdio",
445
- command: "bunx",
446
- args: ["ei-tui", "mcp"],
447
- env: { EI_DATA_PATH: process.env.EI_DATA_PATH ?? "" },
448
- };
449
-
476
+ // Direct config edit matches the atomic-write pattern the original
477
+ // registration used, but inverted: remove the "ei" entry instead of
478
+ // adding one. Every other key/entry in the file is left untouched.
450
479
  let config: Record<string, unknown> = {};
451
480
  try {
452
481
  const text = await Bun.file(cursorJsonPath).text();
453
482
  config = JSON.parse(text) as Record<string, unknown>;
454
483
  } catch {
455
- // File doesn't exist or isn't valid JSON — start fresh
484
+ // File doesn't exist or isn't valid JSON — nothing to remove.
456
485
  }
457
486
 
458
- const mcpServers = (config.mcpServers ?? {}) as Record<string, unknown>;
459
- mcpServers["ei"] = mcpEntry;
460
- config.mcpServers = mcpServers;
487
+ const mcpServers = config.mcpServers;
488
+ const hasEiEntry = typeof mcpServers === "object" && mcpServers !== null && "ei" in mcpServers;
489
+
490
+ if (!hasEiEntry) {
491
+ console.log(`ℹ️ Ei MCP already absent from ${cursorJsonPath} — nothing to remove.`);
492
+ } else {
493
+ delete (mcpServers as Record<string, unknown>)["ei"];
461
494
 
462
- await Bun.$`mkdir -p ${join(home, ".cursor")}`;
463
- const tmpPath = `${cursorJsonPath}.ei-install.tmp`;
464
- await Bun.write(tmpPath, JSON.stringify(config, null, 2) + "\n");
465
- await rename(tmpPath, cursorJsonPath);
495
+ const tmpPath = `${cursorJsonPath}.ei-install.tmp`;
496
+ await Bun.write(tmpPath, JSON.stringify(config, null, 2) + "\n");
497
+ await rename(tmpPath, cursorJsonPath);
466
498
 
467
- console.log(`✓ Installed Ei MCP server to ${cursorJsonPath}`);
468
- console.log(` Restart Cursor to activate.`);
499
+ console.log(
500
+ `✓ Removed Ei MCP server registration from ${cursorJsonPath} (skills now cover this capability; MCP remains available via manual setup — see README).`
501
+ );
502
+ }
469
503
 
470
504
  await installCursorHooks();
471
505
  }
package/src/cli/mcp.ts CHANGED
@@ -20,14 +20,14 @@ export function createMcpServer(): McpServer {
20
20
  "ei_search",
21
21
  {
22
22
  description:
23
- "Search the user's Ei knowledge base — a persistent memory store built from conversations. Use at session start to load user context, and mid-session whenever the user references past work, preferences, or people. Returns facts, people, topics of interest, quotes, and personas. TYPE GUIDANCE: 'facts' are ONLY user demographics (name, age, job title, location, family structure, physical traits). For interests, opinions, hobbies, or anything the human cares about, use 'topics'. For named individuals, use 'people'. For verbatim things said, use 'quotes'. For AI agent identities with traits and working style, use 'personas'. People results include an identifiers array (e.g. GitHub username, Discord handle, email, nickname) — query by any name or handle to find what Ei knows about that person. Persona results include traits and topics — use type='personas' with the persona's name OR a natural-language description of their role to load a persona's character sheet. Results include entity IDs that can be passed to ei_lookup for full detail. Omit query with recent=true to browse the most recently discussed items.",
23
+ "Search the user's Ei knowledge base — a persistent memory store built from conversations. Use at session start to load user context, and mid-session whenever the user references past work, preferences, or people. Balanced search (no type filter) returns facts, people, topics of interest, and quotes personas are excluded unless you pass type: \"personas\" explicitly. TYPE GUIDANCE: 'facts' are ONLY user demographics (name, age, job title, location, family structure, physical traits). For interests, opinions, hobbies, or anything the human cares about, use 'topics'. For named individuals, use 'people'. For verbatim things said, use 'quotes'. For AI agent identities with traits and working style, use 'personas'. People results include an identifiers array (e.g. GitHub username, Discord handle, email, nickname) — query by any name or handle to find what Ei knows about that person. Persona results include traits and topics — use type='personas' with the persona's name OR a natural-language description of their role to load a persona's character sheet. Results include entity IDs that can be passed to ei_lookup for full detail. Omit query with recent=true to browse the most recently discussed items.",
24
24
  inputSchema: {
25
25
  query: z.string().optional().describe("Search text. Supports natural language. Omit to browse without semantic filtering — useful with recent=true or persona filter."),
26
26
  type: z
27
27
  .enum(["facts", "people", "topics", "quotes", "personas"])
28
28
  .optional()
29
29
  .describe(
30
- "Filter to a specific data type. Omit to search all types (balanced across all 5). For 'personas': matches by display name first, then falls back to semantic search of persona descriptions — use the persona's name (e.g. 'Sisyphus') or a description of their role (e.g. 'primary coding agent'). For 'people': semantic search on person descriptions. Note: 'personas' and 'people' are distinct — personas are AI agent identity records with traits/topics; people are human contacts."
30
+ "Filter to a specific data type. Omit to search a balanced set across facts, people, topics, and quotes — personas are excluded from that balanced set and require passing type: \"personas\" explicitly. For 'personas': matches by display name first, then falls back to semantic search of persona descriptions — use the persona's name (e.g. 'Sisyphus') or a description of their role (e.g. 'primary coding agent'). For 'people': semantic search on person descriptions. Note: 'personas' and 'people' are distinct — personas are AI agent identity records with traits/topics; people are human contacts."
31
31
  ),
32
32
  persona: z
33
33
  .string()
@@ -97,7 +97,7 @@ const personaTopicSchema = z.strictObject({
97
97
  });
98
98
 
99
99
  // No minimum trait/topic count is enforced here on purpose — that floor
100
- // (coding-harness-reflect's "3 traits minimum") exists only as skill-level
100
+ // (ei-reflect's "3 traits minimum") exists only as skill-level
101
101
  // AGENT guidance today, not a server-side gate, so a single "add one trait"
102
102
  // edit is never blocked.
103
103
  const personaEntitySchema = z.strictObject({
@@ -628,3 +628,30 @@ export async function lookupById(id: string): Promise<({ type: string } & Record
628
628
  }
629
629
  return { type, ...withoutEmbedding };
630
630
  }
631
+
632
+ // Reverse lookup for the `Person.identifiers[]` array: mirrors
633
+ // StateManager#human_person_getByIdentifier's exact matching semantics
634
+ // (case-insensitive `type`, exact `value`; `type` is a user-extensible
635
+ // string like "Ei Persona" or "GitHub", not an enum) so a caller who only
636
+ // has a (type, value) pair — e.g. a Persona record's linked human identity —
637
+ // can resolve straight to the same enriched shape `lookupById` returns.
638
+ // Like the StateManager method it mirrors, this returns the FIRST matching
639
+ // Person and does not change that first-match behavior — safe for identifier
640
+ // types that are unique by construction (e.g. "Ei Persona", a UUID assigned
641
+ // once per persona), but arbitrary if more than one Person shares a value
642
+ // under a type that isn't guaranteed unique (e.g. "Nickname", "First Name").
643
+ // Delegating to lookupById reuses all of its enrichment (embedding
644
+ // stripping, linked_quotes) with zero duplication; this does mean
645
+ // loadLatestState() is called twice on the not-found-then-found path,
646
+ // consistent with every other exported function in this file already
647
+ // calling it independently.
648
+ export async function lookupByIdentifier(type: string, value: string): Promise<({ type: string } & Record<string, unknown>) | null> {
649
+ const state = await loadLatestState();
650
+ if (!state) return null;
651
+ const typeLower = type.toLowerCase();
652
+ const person = state.human.people.find(p =>
653
+ p.identifiers?.some(i => i.type.toLowerCase() === typeLower && i.value === value)
654
+ );
655
+ if (!person) return null;
656
+ return lookupById(person.id);
657
+ }
package/src/cli.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  import { parseArgs } from "util";
15
- import { retrieveBalanced, lookupById, resolveExternalMessage, loadLatestState } from "./cli/retrieval";
15
+ import { retrieveBalanced, lookupById, lookupByIdentifier, resolveExternalMessage, loadLatestState } from "./cli/retrieval";
16
16
  import type { StorageState } from "./core/types";
17
17
  import { resolvePersonaId, filterByPersona, filterTypeSpecificByPersona, filterBySource, filterTypeSpecificBySource } from "./cli/persona-filter.js";
18
18
  import { installMcpClients } from "./cli/install.js";
@@ -80,7 +80,7 @@ Ei
80
80
 
81
81
  Usage:
82
82
  ei Launch the TUI chat interface
83
- ei "search text" Search all data types (top 10)
83
+ ei "search text" Balanced search: facts/people/topics/quotes, no personas (top 10; use "ei personas")
84
84
  ei -n 5 "search text" Limit results
85
85
  ei <type> "search text" Search a specific data type
86
86
  ei <type> -n 5 "search text" Type-specific with limit
@@ -90,6 +90,7 @@ Usage:
90
90
  ei --persona "Name" "query" Filter results to what a persona has learned
91
91
  ei --id <id> Look up a specific entity by ID
92
92
  echo <id> | ei --id Look up entity by ID from stdin
93
+ ei --identifier <type> <value> Look up a person by identifier type + value, e.g. --identifier "GitHub" "flare576"
93
94
  ei mcp Start the Ei MCP stdio server (for Claude Code/Cursor/Codex)
94
95
  ei create <type> --json '<json>' Create a new entity (fact/topic/person/persona)
95
96
  ei update <type> <id> --json '<json>' Replace an entity by ID (full record, not a patch; fact/topic/person/quote/persona)
@@ -108,7 +109,8 @@ Options:
108
109
  --persona, -p Filter to entities a specific persona has learned about
109
110
  --source, -s Filter to entities from a specific source (prefix match, e.g. "cursor", "codex:my-machine", "opencode:my-machine:ses_abc123")
110
111
  --id Look up entity by ID (accepts value or stdin)
111
- --install Register Ei with Claude Code, Cursor, Codex, and OpenCode (MCP + context hooks + skills where supported)
112
+ --identifier <type> <value> Look up a person by identifier type + value (case-insensitive type, exact value; no stdin support)
113
+ --install Register Ei with Claude Code, Cursor, Codex, and OpenCode (skills + context hooks where supported; MCP is removed by default on Claude Code/Cursor/Codex — see README for manual MCP setup)
112
114
  --sync Pull latest state from remote sync server into state.backup.json (no TUI required)
113
115
  --session <id> Session ID to enrich the query with recent context (use with --hook-source)
114
116
  --hook-source <src> Source of the hook: "opencode-plugin" (OpenCode SQLite), "cursor", or "codex"
@@ -118,14 +120,15 @@ Options:
118
120
 
119
121
  Examples:
120
122
  ei "debugging" # Search everything
121
- ei -n 5 "API design" # Top 5 across all types
123
+ ei -n 5 "API design" # Top 5 across facts/people/topics/quotes (no personas)
122
124
  ei quote "you guessed it" # Search quotes only
123
125
  ei --recent # Most recently mentioned items
124
126
  ei topics --recent "work" # Recent work-related topics
125
127
  ei --persona "Architect" "work stuff" # What Architect knows about work
126
128
  ei topics --source cursor "X" # Topics learned from Cursor sessions
127
129
  ei --id abc-123 # Look up entity by ID
128
- ei "memory leak" | jq .[0].id | ei --id # Pipe ID from search
130
+ ei --identifier "GitHub" "flare576" # Look up a person by identifier type + value
131
+ ei "memory leak" | jq -r '.[0] | if .id != null then .id else .message_id end' | ei --id # Pipe ID from search (quote-safe)
129
132
  ei create fact --json '{"name":"Field of Study","description":"CS","sentiment":0,"validated_date":""}'
130
133
  ei update fact abc-123 --json '{"name":"Field of Study","description":"Updated","sentiment":0,"validated_date":""}'
131
134
  ei update quote <id> --json '{"data_item_ids":["person-b-id"], ...}' # Repoint a quote after splitting a bad merge (fetch the full record via 'ei --id <id>' first)
@@ -160,15 +163,16 @@ async function main(): Promise<void> {
160
163
  await installMcpClients();
161
164
  console.log(`
162
165
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
163
- Codex
166
+ MCP (optional, manual)
164
167
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
165
168
 
166
- If Codex was detected, Ei MCP was registered via:
169
+ Ei now ships as Agent Skills by default. Any existing Ei MCP registration
170
+ in Claude Code, Cursor, or Codex was just removed in favor of the
171
+ ei-search, ei-curate, and ei-persona skills. MCP is still available if you
172
+ want it — add it back manually:
167
173
 
168
174
  codex mcp add ei --env EI_DATA_PATH="${process.env.EI_DATA_PATH ?? "~/.local/share/ei"}" -- bunx ei-tui mcp
169
175
 
170
- Restart Codex to activate.
171
-
172
176
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
173
177
  OpenCode: add to ~/.config/opencode/opencode.jsonc
174
178
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -392,6 +396,28 @@ async function main(): Promise<void> {
392
396
  console.log(JSON.stringify(entity, null, 2));
393
397
  process.exit(0);
394
398
  }
399
+
400
+ // Handle --identifier flag: look up a person by identifier type + value.
401
+ // Deliberately simpler than --id: exactly two positional args, no
402
+ // stdin-piping support (--id remains the primary pipe-drill-down target).
403
+ const identifierFlagIndex = args.indexOf("--identifier");
404
+ if (identifierFlagIndex !== -1) {
405
+ const idType = args[identifierFlagIndex + 1]?.trim();
406
+ const idValue = args[identifierFlagIndex + 2]?.trim();
407
+
408
+ if (!idType || !idValue) {
409
+ console.error("--identifier requires two values. Usage: ei --identifier <type> <value>");
410
+ process.exit(1);
411
+ }
412
+
413
+ const entity = await lookupByIdentifier(idType, idValue);
414
+ if (!entity) {
415
+ console.error(`No person found with identifier ${idType}: ${idValue}`);
416
+ process.exit(1);
417
+ }
418
+ console.log(JSON.stringify(entity, null, 2));
419
+ process.exit(0);
420
+ }
395
421
  let targetType: string | null = null;
396
422
  let parseableArgs = args;
397
423
 
@@ -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: