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.
- package/package.json +1 -1
- package/skills/ei-persona/SKILL.md +2 -2
- package/skills/{coding-harness-reflect → ei-reflect}/SKILL.md +90 -63
- package/skills/ei-search/SKILL.md +235 -0
- package/src/cli/README.md +40 -21
- package/src/cli/install.ts +94 -60
- package/src/cli/mcp.ts +2 -2
- package/src/cli/persona-corrections.ts +1 -1
- package/src/cli/retrieval.ts +27 -0
- package/src/cli.ts +35 -9
- package/src/core/handlers/human-extraction.ts +64 -15
- package/src/core/orchestrators/human-extraction.ts +1 -11
- package/src/core/persona-manager.ts +18 -7
- package/src/prompts/human/person-scan.ts +2 -0
- package/src/prompts/human/person-update.ts +14 -3
package/src/cli/install.ts
CHANGED
|
@@ -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
|
|
177
|
-
const
|
|
178
|
-
|
|
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
|
-
//
|
|
319
|
-
//
|
|
320
|
-
|
|
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 —
|
|
362
|
+
// File doesn't exist or isn't valid JSON — nothing to remove.
|
|
335
363
|
}
|
|
336
364
|
|
|
337
|
-
const mcpServers =
|
|
338
|
-
mcpServers
|
|
339
|
-
config.mcpServers = mcpServers;
|
|
365
|
+
const mcpServers = config.mcpServers;
|
|
366
|
+
const hasEiEntry = typeof mcpServers === "object" && mcpServers !== null && "ei" in mcpServers;
|
|
340
367
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
347
|
-
|
|
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
|
-
//
|
|
443
|
-
|
|
444
|
-
|
|
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 —
|
|
484
|
+
// File doesn't exist or isn't valid JSON — nothing to remove.
|
|
456
485
|
}
|
|
457
486
|
|
|
458
|
-
const mcpServers =
|
|
459
|
-
mcpServers
|
|
460
|
-
|
|
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
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
-
|
|
468
|
-
|
|
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.
|
|
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
|
|
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
|
-
// (
|
|
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({
|
package/src/cli/retrieval.ts
CHANGED
|
@@ -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"
|
|
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
|
-
--
|
|
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
|
|
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 "
|
|
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
|
-
|
|
166
|
+
MCP (optional, manual)
|
|
164
167
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
165
168
|
|
|
166
|
-
|
|
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
|
-
):
|
|
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:
|