ei-tui 1.0.1 → 1.2.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.
Files changed (61) hide show
  1. package/README.md +3 -1
  2. package/package.json +2 -21
  3. package/src/cli/README.md +42 -14
  4. package/src/cli/mcp.ts +237 -0
  5. package/src/cli.ts +17 -51
  6. package/src/core/handlers/dedup.ts +4 -15
  7. package/src/core/handlers/document-segmentation.ts +2 -3
  8. package/src/core/handlers/heartbeat.ts +5 -10
  9. package/src/core/handlers/human-extraction.ts +6 -0
  10. package/src/core/handlers/human-matching.ts +53 -10
  11. package/src/core/handlers/index.ts +2 -0
  12. package/src/core/handlers/knowledge-synthesis.ts +50 -0
  13. package/src/core/handlers/persona-generation.ts +4 -8
  14. package/src/core/handlers/persona-response.ts +3 -4
  15. package/src/core/handlers/persona-topics.ts +2 -4
  16. package/src/core/handlers/rewrite.ts +26 -9
  17. package/src/core/handlers/rooms.ts +6 -12
  18. package/src/core/llm-client.ts +53 -7
  19. package/src/core/message-manager.ts +2 -4
  20. package/src/core/orchestrators/ceremony.ts +44 -13
  21. package/src/core/orchestrators/human-extraction.ts +38 -1
  22. package/src/core/orchestrators/index.ts +1 -0
  23. package/src/core/processor.ts +192 -41
  24. package/src/core/prompt-context-builder.ts +1 -0
  25. package/src/core/queue-manager.ts +10 -0
  26. package/src/core/queue-processor.ts +13 -4
  27. package/src/core/state-manager.ts +35 -0
  28. package/src/core/tools/builtin/fetch-memory.ts +92 -0
  29. package/src/core/tools/builtin/fetch-message.ts +123 -0
  30. package/src/core/tools/builtin/find-memory.ts +99 -0
  31. package/src/core/tools/index.ts +88 -5
  32. package/src/core/tools/types.ts +1 -1
  33. package/src/core/types/data-items.ts +1 -1
  34. package/src/core/types/entities.ts +7 -1
  35. package/src/core/types/enums.ts +1 -0
  36. package/src/core/types/integrations.ts +3 -1
  37. package/src/integrations/claude-code/importer.ts +6 -0
  38. package/src/integrations/cursor/importer.ts +6 -0
  39. package/src/integrations/document/unsource.ts +5 -3
  40. package/src/integrations/opencode/importer.ts +13 -1
  41. package/src/integrations/persona-history/importer.ts +12 -1
  42. package/src/prompts/ceremony/dedup.ts +3 -3
  43. package/src/prompts/ceremony/people-rewrite.ts +2 -2
  44. package/src/prompts/ceremony/topic-rewrite.ts +2 -2
  45. package/src/prompts/ceremony/types.ts +1 -1
  46. package/src/prompts/human/person-scan.ts +17 -0
  47. package/src/prompts/human/types.ts +4 -0
  48. package/src/prompts/index.ts +3 -0
  49. package/src/prompts/response/sections.ts +14 -7
  50. package/src/prompts/response/types.ts +1 -0
  51. package/src/prompts/synthesis/index.ts +101 -0
  52. package/src/prompts/synthesis/types.ts +26 -0
  53. package/tui/src/commands/generate.tsx +98 -0
  54. package/tui/src/commands/unsource.tsx +17 -10
  55. package/tui/src/components/GeneratedDocsOverlay.tsx +136 -0
  56. package/tui/src/components/PromptInput.tsx +2 -0
  57. package/tui/src/context/ei.tsx +49 -2
  58. package/tui/src/util/logger.ts +22 -2
  59. package/tui/src/util/provider-detection.ts +5 -2
  60. package/tui/src/util/yaml-provider.ts +2 -8
  61. package/src/core/tools/builtin/read-memory.ts +0 -70
package/README.md CHANGED
@@ -193,7 +193,9 @@ Personas can use tools. Not just read-from-memory tools — *actual* tools. Web
193
193
 
194
194
  | Tool | What it does |
195
195
  |------|-------------|
196
- | `read_memory` | Semantic search of your personal memory — facts, traits, topics, people, quotes. Personas call this automatically when the conversation touches something they might know about you. Supports the `persona` filter to scope results to what a specific persona has learned. |
196
+ | `find_memory` | Semantic search of your personal memory — facts, traits, topics, people, quotes. Personas call this automatically when the conversation touches something they might know about you. Supports the `persona` filter to scope results to what a specific persona has learned. |
197
+ | `fetch_memory` | Full-record lookup for a specific human entity (Fact, Topic, Person, or Quote) by ID. Use after `find_memory` to retrieve complete details. |
198
+ | `fetch_message` | Retrieve a specific message by ID with optional surrounding context. Searches persona conversations and room messages. |
197
199
  | `file_read` | Read a file from your local filesystem *(TUI only)* |
198
200
  | `list_directory` | Explore folder structure *(TUI only)* |
199
201
  | `directory_tree` | Recursive directory tree *(TUI only)* |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ei-tui",
3
- "version": "1.0.1",
3
+ "version": "1.2.0",
4
4
  "author": "Flare576",
5
5
  "repository": {
6
6
  "type": "git",
@@ -51,26 +51,7 @@
51
51
  "test:e2e:tui": "cd tui && npm run test:e2e",
52
52
  "test:e2e:ui": "playwright test --ui",
53
53
  "test:e2e:debug": "playwright test --debug",
54
- "test:evals": "vite-node tests/evals/reflection-critic.eval.ts",
55
- "test:evals:observe": "vite-node tests/evals/reflection-critic.observe.ts",
56
- "test:evals:fact-find": "vite-node tests/evals/fact-find.eval.ts",
57
- "test:evals:topic-scan": "vite-node tests/evals/topic-scan.eval.ts",
58
- "test:evals:topic-match": "vite-node tests/evals/topic-match.eval.ts",
59
- "test:evals:topic-update": "vite-node tests/evals/topic-update.eval.ts",
60
- "test:evals:topic-technical": "vite-node tests/evals/topic-technical.eval.ts",
61
- "test:evals:rewrite-scan": "vite-node tests/evals/rewrite-scan.eval.ts",
62
- "test:evals:rewrite-rewrite": "vite-node tests/evals/rewrite-rewrite.eval.ts",
63
- "test:evals:rewrite-real-data": "vite-node tests/evals/rewrite-real-data.eval.ts",
64
- "test:evals:topic-validate": "vite-node tests/evals/topic-validate.eval.ts",
65
- "test:evals:person-scan": "vite-node tests/evals/person-scan.eval.ts",
66
- "test:evals:person-update": "vite-node tests/evals/person-update.eval.ts",
67
- "test:evals:persona-trait": "vite-node tests/evals/persona-trait-extraction.eval.ts",
68
- "test:evals:dedup": "vite-node tests/evals/dedup-tool-calls.eval.ts",
69
- "test:evals:response-read-memory": "vite-node tests/evals/response-read-memory.eval.ts",
70
- "test:evals:response-pending-update": "vite-node tests/evals/response-pending-update.eval.ts",
71
- "test:evals:heartbeat-pending-update": "vite-node tests/evals/heartbeat-pending-update.eval.ts",
72
- "test:evals:real-data": "vite-node tests/evals/real-data-example.eval.ts",
73
- "test:evals:persona-data-check": "vite-node tests/evals/persona-data-check.eval.ts",
54
+ "test:evals": "vite-node tests/evals/run.ts",
74
55
  "test:all": "npm run test && npm run test:e2e && npm run test:e2e:tui",
75
56
  "typecheck": "tsc --noEmit",
76
57
  "web": "cd web && npm run dev",
package/src/cli/README.md CHANGED
@@ -37,13 +37,29 @@ ei "memory leak" | jq '.[0].id' | ei --id
37
37
  ei --install
38
38
  ```
39
39
 
40
- This registers Ei with every supported agent environment it detects:
41
-
42
- - **OpenCode**: writes `~/.config/opencode/tools/ei.ts`
43
- - **Claude Code**: runs `claude mcp add` (or writes `~/.claude.json` as fallback)
44
- - **Cursor**: writes `~/.cursor/mcp.json`
40
+ This registers Ei with Claude Code and Cursor via MCP:
41
+
42
+ - **Claude Code**: writes `~/.claude.json` with an MCP server entry
43
+ - **Cursor**: writes `~/.cursor/mcp.json` with an MCP server entry
44
+
45
+ **OpenCode**: add the MCP server manually to `~/.config/opencode/opencode.jsonc`:
46
+
47
+ ```json
48
+ {
49
+ "mcp": {
50
+ "ei": {
51
+ "type": "local",
52
+ "command": ["bunx", "ei-tui", "mcp"],
53
+ "enabled": true,
54
+ "environment": {
55
+ "EI_DATA_PATH": "/path/to/your/ei/data"
56
+ }
57
+ }
58
+ }
59
+ }
60
+ ```
45
61
 
46
- Restart your agent tool after running to activate.
62
+ Restart your agent tool after changes to activate.
47
63
 
48
64
  ### MCP Server
49
65
 
@@ -123,23 +139,35 @@ conversations (facts, people, topics, quotes, personas).
123
139
 
124
140
  **How to use:**
125
141
  1. Call `ei_search` (server `user-ei`) with a natural-language query (or omit query and use `recent: true` to browse); optionally filter by `type` (facts, people, topics, quotes, personas) or `persona` display_name.
126
- 2. If you need full detail for a result, call `ei_lookup` with the entity `id` from step 1.
142
+ 2. If you need full detail for a human entity (fact, topic, person, quote), call `ei_fetch_memory` with the entity `id`.
143
+ 3. If you need full detail for a result including personas, call `ei_lookup` with the entity `id` from step 1.
144
+ 4. To fetch a specific message with surrounding context, call `ei_fetch_message` with the message `id` and optional `before`/`after` counts.
127
145
 
128
146
  Prefer querying Ei before asking the user for context they may have already shared.
129
147
  ```
130
148
 
131
- ## What the Tool Provides
149
+ ## MCP Tools Reference
150
+
151
+ The MCP server exposes these tools to Claude Code, Cursor, and OpenCode:
152
+
153
+ | Tool | Description |
154
+ |------|-------------|
155
+ | `ei_search` | Balanced search across all five data types (facts, topics, people, quotes, personas). Supports `type`, `persona`, `source`, `recent`, `limit` filters. |
156
+ | `ei_lookup` | Full-record lookup for any entity by ID (facts, topics, people, quotes, personas). |
157
+ | `ei_find_memory` | Grouped human-data search — facts, topics, people, quotes. Returns results grouped by type. Mirrors the persona `find_memory` tool interface. |
158
+ | `ei_fetch_memory` | Full-record lookup for a human entity (Fact, Topic, Person, or Quote) by ID. Returns the complete record including all fields. |
159
+ | `ei_fetch_message` | Retrieve a specific message by ID with optional `before`/`after` context window. Searches persona conversations and room messages. |
132
160
 
133
- The installed tool gives OpenCode agents access to all five data types with proper Zod-validated args:
161
+ ### `ei_search` / `ei_find_memory` arguments
134
162
 
135
163
  | Arg | Type | Description |
136
164
  |-----|------|-------------|
137
- | `query` | string (optional) | Search text, or entity ID when `lookup=true`. Omit to browse by recency. |
138
- | `persona` | string (optional) | Persona display_name to filter results only returns entities that persona has extracted |
139
- | `type` | enum (optional) | `facts` \| `people` \| `topics` \| `quotes` \| `personas` — omit for balanced results |
165
+ | `query` | string (optional) | Search text. Omit to browse by recency. |
166
+ | `persona` | string (optional) | Persona display_name to scope results to what that persona has learned |
167
+ | `type` | enum (optional, `ei_search` only) | `facts` \| `people` \| `topics` \| `quotes` \| `personas` — omit for balanced results |
168
+ | `types` | array (optional, `ei_find_memory` only) | `["facts", "topics", "people", "quotes"]` — omit for all human types |
140
169
  | `limit` | number (optional) | Max results, default 10 |
141
- | `lookup` | boolean (optional) | If true, fetch single entity by ID |
142
- | `recent` | boolean (optional) | If true, sort by most recently mentioned. Can be combined with `persona` or `query`. |
170
+ | `recent` | boolean (optional) | Sort by most recently mentioned instead of relevance |
143
171
 
144
172
  ## Output Shapes
145
173
 
package/src/cli/mcp.ts CHANGED
@@ -3,6 +3,8 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
3
3
  import { z } from "zod";
4
4
  import { retrieveBalanced, lookupById, loadLatestState, type BalancedResult } from "./retrieval.js";
5
5
  import type { StorageState } from "../core/types.js";
6
+ import type { Message } from "../core/types.js";
7
+ import type { RoomMessage } from "../core/types/rooms.js";
6
8
  import { resolvePersonaId, filterByPersona, filterTypeSpecificByPersona, filterBySource, filterTypeSpecificBySource } from "./persona-filter.js";
7
9
 
8
10
  // Exported so tests can inject their own transport
@@ -128,6 +130,241 @@ export function createMcpServer(): McpServer {
128
130
  }
129
131
  );
130
132
 
133
+ server.registerTool(
134
+ "ei_find_memory",
135
+ {
136
+ description:
137
+ "Search Ei's persistent knowledge base — facts, topics, people, and quotes learned across ALL conversations over time. Use when you need context about the user, their life, relationships, or interests that may not be visible in the current exchange. Returns results grouped by type. Use `recent: true` to retrieve what's been discussed recently.",
138
+ inputSchema: {
139
+ query: z
140
+ .string()
141
+ .optional()
142
+ .describe(
143
+ "What to search for — a person, topic, fact, or anything Ei has learned about the user. Omit with recent: true to browse recent items."
144
+ ),
145
+ types: z
146
+ .array(z.enum(["facts", "topics", "people", "quotes"]))
147
+ .optional()
148
+ .describe("Limit search to specific memory types (default: all types)"),
149
+ limit: z
150
+ .number()
151
+ .optional()
152
+ .default(10)
153
+ .describe("Max results per type to return (default: 10, max: 20)"),
154
+ recent: z
155
+ .boolean()
156
+ .optional()
157
+ .describe(
158
+ "If true, return recently-mentioned results sorted by last_mentioned date instead of relevance. Combine with a query to filter recent results by topic."
159
+ ),
160
+ persona: z
161
+ .string()
162
+ .optional()
163
+ .describe(
164
+ "Filter results to what a specific persona has learned. Use the persona display name."
165
+ ),
166
+ },
167
+ },
168
+ async ({ query: rawQuery, types, limit, recent, persona }) => {
169
+ const query = rawQuery ?? "";
170
+ const effectiveLimit = Math.min(limit ?? 10, 20);
171
+ const options = { recent: recent ?? false };
172
+
173
+ const humanTypes = ["facts", "topics", "people", "quotes"] as const;
174
+ type HumanType = (typeof humanTypes)[number];
175
+ const requestedTypes: HumanType[] =
176
+ types && types.length > 0
177
+ ? (types as HumanType[])
178
+ : [...humanTypes];
179
+
180
+ let state: StorageState | null = null;
181
+ let personaId: string | undefined;
182
+ if (persona) {
183
+ state = await loadLatestState();
184
+ if (state) {
185
+ personaId = resolvePersonaId(state, persona) ?? undefined;
186
+ if (!personaId) {
187
+ return {
188
+ content: [{ type: "text" as const, text: `Persona "${persona}" not found.` }],
189
+ };
190
+ }
191
+ }
192
+ }
193
+
194
+ const grouped: Record<string, unknown[]> = {};
195
+ for (const t of requestedTypes) {
196
+ const module = await import(`./commands/${t}.js`);
197
+ let results = await (
198
+ module.execute as (
199
+ q: string,
200
+ l: number,
201
+ o: { recent: boolean }
202
+ ) => Promise<{ id: string }[]>
203
+ )(query, effectiveLimit, options);
204
+ if (personaId && state) {
205
+ results = filterTypeSpecificByPersona(results, state, personaId, t);
206
+ }
207
+ if (results.length > 0) {
208
+ grouped[t] = results;
209
+ }
210
+ }
211
+
212
+ if (Object.keys(grouped).length === 0) {
213
+ return {
214
+ content: [
215
+ {
216
+ type: "text" as const,
217
+ text: JSON.stringify({ result: "No relevant memories found for this query." }),
218
+ },
219
+ ],
220
+ };
221
+ }
222
+
223
+ return {
224
+ content: [{ type: "text" as const, text: JSON.stringify(grouped, null, 2) }],
225
+ };
226
+ }
227
+ );
228
+
229
+ server.registerTool(
230
+ "ei_fetch_memory",
231
+ {
232
+ description:
233
+ "Retrieve the full record for a specific memory by its ID. Use when ei_find_memory or ei_search returns an item and you need its complete details. Returns the full Fact, Topic, Person, or Quote record.",
234
+ inputSchema: {
235
+ id: z.string().describe("The ID of the memory record to retrieve"),
236
+ },
237
+ },
238
+ async ({ id }) => {
239
+ const result = await lookupById(id);
240
+
241
+ if (result === null || result.type === "persona") {
242
+ return {
243
+ content: [{ type: "text" as const, text: `No memory record found with ID: ${id}` }],
244
+ };
245
+ }
246
+
247
+ return {
248
+ content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
249
+ };
250
+ }
251
+ );
252
+
253
+ server.registerTool(
254
+ "ei_fetch_message",
255
+ {
256
+ description:
257
+ "Retrieve a specific message by its ID, with optional surrounding context. Use when ei_find_memory returns a quote with a message_id and you want to read the original conversation. The 'before' and 'after' parameters return that many additional messages for context (default 0).",
258
+ inputSchema: {
259
+ id: z.string().describe("The ID of the message to retrieve"),
260
+ before: z
261
+ .number()
262
+ .optional()
263
+ .default(0)
264
+ .describe("Number of preceding messages to include (default 0)"),
265
+ after: z
266
+ .number()
267
+ .optional()
268
+ .default(0)
269
+ .describe("Number of following messages to include (default 0)"),
270
+ },
271
+ },
272
+ async ({ id, before: beforeCount, after: afterCount }) => {
273
+ const state = await loadLatestState();
274
+ if (!state) {
275
+ return {
276
+ content: [{ type: "text" as const, text: "No saved state found. Is EI_DATA_PATH set correctly?" }],
277
+ };
278
+ }
279
+
280
+ const beforeN = Math.max(0, Math.floor(beforeCount ?? 0));
281
+ const afterN = Math.max(0, Math.floor(afterCount ?? 0));
282
+
283
+ const stripPersonaMessage = (m: Message) => ({
284
+ id: m.id,
285
+ role: m.role,
286
+ ...(m.content !== undefined ? { content: m.content } : {}),
287
+ ...(m.silence_reason !== undefined ? { silence_reason: m.silence_reason } : {}),
288
+ timestamp: m.timestamp,
289
+ ...(m.speaker_name !== undefined ? { speaker_name: m.speaker_name } : {}),
290
+ });
291
+
292
+ for (const { entity: persona, messages } of Object.values(state.personas)) {
293
+ const idx = messages.findIndex((m) => m.id === id);
294
+ if (idx === -1) continue;
295
+
296
+ const msg = messages[idx];
297
+ const beforeMsgs = messages.slice(Math.max(0, idx - beforeN), idx).map(stripPersonaMessage);
298
+ const afterMsgs = messages.slice(idx + 1, idx + 1 + afterN).map(stripPersonaMessage);
299
+
300
+ return {
301
+ content: [
302
+ {
303
+ type: "text" as const,
304
+ text: JSON.stringify(
305
+ { message: stripPersonaMessage(msg), before: beforeMsgs, after: afterMsgs, source: persona.display_name },
306
+ null,
307
+ 2
308
+ ),
309
+ },
310
+ ],
311
+ };
312
+ }
313
+
314
+ const resolveRoomPersonaName = (
315
+ m: RoomMessage,
316
+ personaMap: Record<string, { entity: { display_name: string }; messages: Message[] }>
317
+ ): string | undefined => {
318
+ if (m.role !== "persona" || !m.persona_id) return undefined;
319
+ return personaMap[m.persona_id]?.entity.display_name;
320
+ };
321
+
322
+ const stripRoomMessage = (
323
+ m: RoomMessage,
324
+ personaMap: Record<string, { entity: { display_name: string }; messages: Message[] }>
325
+ ) => ({
326
+ id: m.id,
327
+ role: m.role,
328
+ ...(m.content !== undefined ? { content: m.content } : {}),
329
+ ...(m.silence_reason !== undefined ? { silence_reason: m.silence_reason } : {}),
330
+ timestamp: m.timestamp,
331
+ ...(resolveRoomPersonaName(m, personaMap) !== undefined
332
+ ? { speaker_name: resolveRoomPersonaName(m, personaMap) }
333
+ : {}),
334
+ });
335
+
336
+ for (const room of Object.values(state.rooms ?? {})) {
337
+ const idx = room.messages.findIndex((m) => m.id === id);
338
+ if (idx === -1) continue;
339
+
340
+ const msg = room.messages[idx];
341
+ const beforeMsgs = room.messages
342
+ .slice(Math.max(0, idx - beforeN), idx)
343
+ .map((m) => stripRoomMessage(m, state.personas));
344
+ const afterMsgs = room.messages
345
+ .slice(idx + 1, idx + 1 + afterN)
346
+ .map((m) => stripRoomMessage(m, state.personas));
347
+
348
+ return {
349
+ content: [
350
+ {
351
+ type: "text" as const,
352
+ text: JSON.stringify(
353
+ { message: stripRoomMessage(msg, state.personas), before: beforeMsgs, after: afterMsgs, source: room.display_name },
354
+ null,
355
+ 2
356
+ ),
357
+ },
358
+ ],
359
+ };
360
+ }
361
+
362
+ return {
363
+ content: [{ type: "text" as const, text: `Message not found: ${id}` }],
364
+ };
365
+ }
366
+ );
367
+
131
368
  return server;
132
369
  }
133
370
 
package/src/cli.ts CHANGED
@@ -68,7 +68,7 @@ Options:
68
68
  --persona, -p Filter to entities a specific persona has learned about
69
69
  --source, -s Filter to entities from a specific source (prefix match, e.g. "cursor", "opencode:my-machine", "opencode:my-machine:ses_abc123")
70
70
  --id Look up entity by ID (accepts value or stdin)
71
- --install Register Ei with OpenCode, Claude Code, and Cursor
71
+ --install Register Ei with Claude Code and Cursor via MCP
72
72
  --help, -h Show this help message
73
73
 
74
74
  Examples:
@@ -85,50 +85,9 @@ Examples:
85
85
  }
86
86
 
87
87
 
88
- async function installOpenCodeMcp(): Promise<void> {
89
- const home = process.env.HOME || "~";
90
- const opencodeDir = join(home, ".config", "opencode");
91
- const opencodeJsoncPath = join(opencodeDir, "opencode.jsonc");
92
-
93
- const eiDataPath = process.env.EI_DATA_PATH ?? (() => {
94
- const xdgData = process.env.XDG_DATA_HOME || join(home, ".local", "share");
95
- return join(xdgData, "ei");
96
- })();
97
-
98
- const mcpEntry = {
99
- type: "local",
100
- command: ["bunx", "ei-tui", "mcp"],
101
- enabled: true,
102
- environment: {
103
- EI_DATA_PATH: eiDataPath,
104
- },
105
- };
106
-
107
- let config: Record<string, unknown> = {};
108
- try {
109
- const rawText = await Bun.file(opencodeJsoncPath).text();
110
- // Strip // line comments before parsing — opencode.jsonc uses line comments only
111
- const stripped = rawText
112
- .split("\n")
113
- .map(line => line.replace(/\/\/.*$/, ""))
114
- .join("\n");
115
- config = JSON.parse(stripped) as Record<string, unknown>;
116
- } catch {
117
- // File doesn't exist or isn't valid — start fresh
118
- }
119
-
120
- const mcp = (config.mcp ?? {}) as Record<string, unknown>;
121
- mcp["ei"] = mcpEntry;
122
- config.mcp = mcp;
123
-
124
- await Bun.$`mkdir -p ${opencodeDir}`;
125
- const tmpPath = `${opencodeJsoncPath}.ei-install.tmp`;
126
- await Bun.write(tmpPath, JSON.stringify(config, null, 2) + "\n");
127
- const { rename } = await import(/* @vite-ignore */ "fs/promises");
128
- await rename(tmpPath, opencodeJsoncPath);
129
-
130
- console.log(`✓ Installed Ei MCP server to ~/.config/opencode/opencode.jsonc`);
131
- console.log(` Restart OpenCode to activate.`);
88
+ async function installMcpClients(): Promise<void> {
89
+ await installClaudeCode();
90
+ await installCursor();
132
91
  }
133
92
 
134
93
  async function installClaudeCode(): Promise<void> {
@@ -202,11 +161,6 @@ async function installCursor(): Promise<void> {
202
161
  console.log(` Restart Cursor to activate.`);
203
162
  }
204
163
 
205
- async function installMcpClients(): Promise<void> {
206
- await installClaudeCode();
207
- await installCursor();
208
- }
209
-
210
164
  async function main(): Promise<void> {
211
165
  const args = process.argv.slice(2);
212
166
 
@@ -228,9 +182,21 @@ async function main(): Promise<void> {
228
182
  }
229
183
 
230
184
  if (args[0] === "--install") {
231
- await installOpenCodeMcp();
232
185
  await installMcpClients();
233
186
  console.log(`
187
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
188
+ OpenCode: add to ~/.config/opencode/opencode.jsonc
189
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
190
+
191
+ "mcp": {
192
+ "ei": {
193
+ "type": "local",
194
+ "command": ["bunx", "ei-tui", "mcp"],
195
+ "enabled": true,
196
+ "environment": { "EI_DATA_PATH": "${process.env.EI_DATA_PATH ?? "~/.local/share/ei"}" }
197
+ }
198
+ }
199
+
234
200
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
235
201
  Add this to ~/.config/opencode/AGENTS.md
236
202
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -25,8 +25,7 @@ export async function handleDedupCurate(
25
25
 
26
26
  // Validate entity_type
27
27
  if (!entity_type || !['topic', 'person'].includes(entity_type)) {
28
- console.error(`[Dedup] Invalid entity_type: "${entity_type}" (from request data)`, response.request.data);
29
- return;
28
+ throw new Error(`[Dedup] Invalid entity_type: "${entity_type}" (from request data)`);
30
29
  }
31
30
 
32
31
  // Parse Opus response
@@ -37,14 +36,12 @@ export async function handleDedupCurate(
37
36
  throw new Error("Invalid response format");
38
37
  }
39
38
  } catch (err) {
40
- console.error(`[Dedup] Failed to parse Opus response:`, err);
41
- return;
39
+ throw new Error(`[Dedup] Failed to parse Opus response: ${err instanceof Error ? err.message : String(err)}`);
42
40
  }
43
41
 
44
42
  // Validate response structure
45
43
  if (!Array.isArray(decisions.update) || !Array.isArray(decisions.remove) || !Array.isArray(decisions.add)) {
46
- console.error(`[Dedup] Invalid response structure - missing update/remove/add arrays`);
47
- return;
44
+ throw new Error(`[Dedup] Invalid response structure - missing update/remove/add arrays`);
48
45
  }
49
46
 
50
47
  console.log(`[Dedup] Processing cluster: ${decisions.update.length} updates, ${decisions.remove.length} removals, ${decisions.add.length} additions`);
@@ -63,15 +60,7 @@ export async function handleDedupCurate(
63
60
 
64
61
  // Validate entityList exists
65
62
  if (!entityList || !Array.isArray(entityList)) {
66
- console.error(`[Dedup] entityList is ${entityList === undefined ? 'undefined' : 'not an array'} for entity_type="${entity_type}" (looking for state.${entity_type}s)`, {
67
- entity_type,
68
- entity_ids,
69
- stateKeys: Object.keys(state),
70
- factsExists: !!state.facts,
71
- topicsExists: !!state.topics,
72
- peopleExists: !!state.people
73
- });
74
- return;
63
+ throw new Error(`[Dedup] entityList is ${entityList === undefined ? 'undefined' : 'not an array'} for entity_type="${entity_type}" (looking for state.${entity_type}s)`);
75
64
  }
76
65
  const entities = entity_ids
77
66
  .map((id: string) => entityList.find((e: Fact | Topic | Person) => e.id === id))
@@ -30,8 +30,7 @@ export function handleDocumentSegmentation(response: LLMResponse, state: StateMa
30
30
  };
31
31
 
32
32
  if (!batchId || !filename) {
33
- console.error("[handleDocumentSegmentation] Missing batchId or filename in request data");
34
- return;
33
+ throw new Error("[handleDocumentSegmentation] Missing batchId or filename in request data");
35
34
  }
36
35
 
37
36
  let segments: string[];
@@ -103,7 +102,7 @@ export function finishDocumentBatch(batchId: string, filename: string, state: St
103
102
  ...updatedHuman.settings?.document,
104
103
  processed_documents: {
105
104
  ...(updatedHuman.settings?.document?.processed_documents ?? {}),
106
- [filename]: new Date().toISOString(),
105
+ [filename]: { created_at: new Date().toISOString(), type: "imported" },
107
106
  },
108
107
  },
109
108
  },
@@ -13,14 +13,12 @@ export function handleHeartbeatCheck(response: LLMResponse, state: StateManager)
13
13
  const personaId = response.request.data.personaId as string;
14
14
  const personaDisplayName = response.request.data.personaDisplayName as string;
15
15
  if (!personaId) {
16
- console.error("[handleHeartbeatCheck] No personaId in request data");
17
- return;
16
+ throw new Error("[handleHeartbeatCheck] No personaId in request data");
18
17
  }
19
18
 
20
19
  const result = response.parsed as HeartbeatCheckResult | undefined;
21
20
  if (!result) {
22
- console.error(`[HeartbeatCheck ${personaDisplayName}] No parsed result`);
23
- return;
21
+ throw new Error(`[HeartbeatCheck ${personaDisplayName}] No parsed result`);
24
22
  }
25
23
  console.log(`[HeartbeatCheck ${personaDisplayName}] Parsed result - should_respond: ${result.should_respond}, topic: ${result.topic ?? '(none)'}, message: ${result.message ? '(present)' : '(none)'}`);
26
24
 
@@ -52,8 +50,7 @@ export function handleHeartbeatCheck(response: LLMResponse, state: StateManager)
52
50
  export function handleEiHeartbeat(response: LLMResponse, state: StateManager): void {
53
51
  const result = response.parsed as EiHeartbeatResult | undefined;
54
52
  if (!result) {
55
- console.error("[EiHeartbeat] No parsed result");
56
- return;
53
+ throw new Error("[EiHeartbeat] No parsed result");
57
54
  }
58
55
  console.log(`[EiHeartbeat] Parsed result - should_respond: ${result.should_respond}, id: ${result.id ?? '(none)'}, my_response: ${result.my_response ? '(present)' : '(none)'}`);
59
56
  const now = new Date().toISOString();
@@ -127,8 +124,7 @@ export function handleReflectionCritic(response: LLMResponse, state: StateManage
127
124
 
128
125
  const result = response.parsed as ReflectionCriticResult | undefined;
129
126
  if (!result?.critique) {
130
- console.error(`[ReflectionCritic ${personaDisplayName}] Invalid or missing parsed result`);
131
- return;
127
+ throw new Error(`[ReflectionCritic ${personaDisplayName}] Invalid or missing parsed result`);
132
128
  }
133
129
 
134
130
  const personRecord = state.human_person_getByIdentifier("Ei Persona", personaId);
@@ -150,8 +146,7 @@ export function handleReflectionCritic(response: LLMResponse, state: StateManage
150
146
 
151
147
  const persona = state.persona_getById(personaId);
152
148
  if (!persona) {
153
- console.error(`[ReflectionCritic ${personaDisplayName}] Persona not found after critic`);
154
- return;
149
+ throw new Error(`[ReflectionCritic ${personaDisplayName}] Persona not found after critic`);
155
150
  }
156
151
 
157
152
  const mergedTopics = result.updated_identity.topics.map(updatedTopic => {
@@ -310,6 +310,12 @@ export async function handleHumanPersonScan(response: LLMResponse, state: StateM
310
310
  }
311
311
  }
312
312
 
313
+ const confidence = typeof candidate.confidence === 'number' ? candidate.confidence : null;
314
+ if (confidence !== null && confidence <= 2 && !matchedPerson) {
315
+ console.debug(`[handleHumanPersonScan] Skipping low-confidence new person "${candidate.name}" (confidence=${confidence}, relationship_type=${candidate.relationship_type ?? 'none'})`);
316
+ continue;
317
+ }
318
+
313
319
  const matchResult: ItemMatchResult = { matched_guid: matchedPerson?.id ?? null };
314
320
  queuePersonUpdate(matchResult, {
315
321
  ...context,