ei-tui 1.0.1 → 1.1.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/README.md +3 -1
- package/package.json +3 -1
- package/src/cli/README.md +42 -14
- package/src/cli/mcp.ts +237 -0
- package/src/cli.ts +17 -51
- package/src/core/handlers/human-extraction.ts +6 -0
- package/src/core/handlers/human-matching.ts +45 -10
- package/src/core/llm-client.ts +40 -4
- package/src/core/orchestrators/human-extraction.ts +28 -0
- package/src/core/orchestrators/index.ts +1 -0
- package/src/core/processor.ts +37 -41
- package/src/core/prompt-context-builder.ts +1 -0
- package/src/core/queue-processor.ts +13 -4
- package/src/core/tools/builtin/fetch-memory.ts +92 -0
- package/src/core/tools/builtin/fetch-message.ts +123 -0
- package/src/core/tools/builtin/find-memory.ts +99 -0
- package/src/core/tools/index.ts +88 -5
- package/src/integrations/persona-history/importer.ts +3 -1
- package/src/prompts/ceremony/dedup.ts +3 -3
- package/src/prompts/ceremony/types.ts +1 -1
- package/src/prompts/human/person-scan.ts +17 -0
- package/src/prompts/human/types.ts +4 -0
- package/src/prompts/response/sections.ts +14 -7
- package/src/prompts/response/types.ts +1 -0
- 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
|
-
| `
|
|
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
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"author": "Flare576",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -63,6 +63,8 @@
|
|
|
63
63
|
"test:evals:rewrite-real-data": "vite-node tests/evals/rewrite-real-data.eval.ts",
|
|
64
64
|
"test:evals:topic-validate": "vite-node tests/evals/topic-validate.eval.ts",
|
|
65
65
|
"test:evals:person-scan": "vite-node tests/evals/person-scan.eval.ts",
|
|
66
|
+
"test:evals:person-scan-confidence": "vite-node tests/evals/person-scan-confidence.eval.ts",
|
|
67
|
+
|
|
66
68
|
"test:evals:person-update": "vite-node tests/evals/person-update.eval.ts",
|
|
67
69
|
"test:evals:persona-trait": "vite-node tests/evals/persona-trait-extraction.eval.ts",
|
|
68
70
|
"test:evals:dedup": "vite-node tests/evals/dedup-tool-calls.eval.ts",
|
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
|
|
41
|
-
|
|
42
|
-
- **
|
|
43
|
-
- **
|
|
44
|
-
|
|
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
|
|
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
|
|
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
|
-
##
|
|
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
|
-
|
|
161
|
+
### `ei_search` / `ei_find_memory` arguments
|
|
134
162
|
|
|
135
163
|
| Arg | Type | Description |
|
|
136
164
|
|-----|------|-------------|
|
|
137
|
-
| `query` | string (optional) | Search text
|
|
138
|
-
| `persona` | string (optional) | Persona display_name to
|
|
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
|
-
| `
|
|
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
|
|
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
|
|
89
|
-
|
|
90
|
-
|
|
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
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
@@ -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,
|
|
@@ -85,6 +85,10 @@ export async function handleTopicUpdate(response: LLMResponse, state: StateManag
|
|
|
85
85
|
const primaryId = personaIds[0] ?? personaId;
|
|
86
86
|
|
|
87
87
|
const now = new Date().toISOString();
|
|
88
|
+
const { messages_analyze } = resolveMessageWindow(response, state);
|
|
89
|
+
const earliestMessageTimestamp = messages_analyze.length > 0
|
|
90
|
+
? messages_analyze.reduce((a, b) => a.timestamp < b.timestamp ? a : b).timestamp
|
|
91
|
+
: now;
|
|
88
92
|
const human = state.getHuman();
|
|
89
93
|
|
|
90
94
|
const resolveItemId = (): string => {
|
|
@@ -144,7 +148,7 @@ export async function handleTopicUpdate(response: LLMResponse, state: StateManag
|
|
|
144
148
|
exposure_current: calculateExposureCurrent(exposureImpact, existingTopic?.exposure_current ?? 0),
|
|
145
149
|
exposure_desired: result.exposure_desired ?? 0.5,
|
|
146
150
|
last_updated: now,
|
|
147
|
-
learned_on: isNewItem ?
|
|
151
|
+
learned_on: isNewItem ? earliestMessageTimestamp : existingTopic?.learned_on,
|
|
148
152
|
last_mentioned: now,
|
|
149
153
|
learned_by: isNewItem ? primaryId : existingTopic?.learned_by,
|
|
150
154
|
last_changed_by: primaryId,
|
|
@@ -168,6 +172,26 @@ export async function handleTopicUpdate(response: LLMResponse, state: StateManag
|
|
|
168
172
|
console.log(`[handleTopicUpdate] ${isNewItem ? "Created" : "Updated"} topic "${resolvedName}"`);
|
|
169
173
|
}
|
|
170
174
|
|
|
175
|
+
function ensureEiPersonaHasNickname(identifiers: PersonIdentifier[], state: StateManager): PersonIdentifier[] {
|
|
176
|
+
const eiPersonaId = identifiers.find(i => i.type === 'Ei Persona')?.value;
|
|
177
|
+
if (!eiPersonaId) return identifiers;
|
|
178
|
+
|
|
179
|
+
const persona = state.persona_getById(eiPersonaId);
|
|
180
|
+
if (!persona) return identifiers;
|
|
181
|
+
|
|
182
|
+
const hasNickname = identifiers.some(i => i.type === 'Nickname' && i.value === persona.display_name);
|
|
183
|
+
if (hasNickname) return identifiers;
|
|
184
|
+
|
|
185
|
+
const withoutPrimary = identifiers.map(i =>
|
|
186
|
+
i.type === 'Ei Persona' ? { ...i, is_primary: undefined } : i
|
|
187
|
+
).map(({ is_primary, ...rest }) => is_primary ? { ...rest, is_primary } : rest);
|
|
188
|
+
|
|
189
|
+
return [
|
|
190
|
+
{ type: 'Nickname', value: persona.display_name, is_primary: true as const },
|
|
191
|
+
...withoutPrimary.map(i => i.type === 'Ei Persona' ? { type: i.type, value: i.value } : i),
|
|
192
|
+
];
|
|
193
|
+
}
|
|
194
|
+
|
|
171
195
|
export async function handlePersonUpdate(response: LLMResponse, state: StateManager): Promise<void> {
|
|
172
196
|
const result = response.parsed as (PersonUpdateResult & {
|
|
173
197
|
identifiers?: PersonIdentifier[];
|
|
@@ -194,6 +218,10 @@ export async function handlePersonUpdate(response: LLMResponse, state: StateMana
|
|
|
194
218
|
const primaryId = personaIds[0] ?? personaId;
|
|
195
219
|
|
|
196
220
|
const now = new Date().toISOString();
|
|
221
|
+
const { messages_analyze } = resolveMessageWindow(response, state);
|
|
222
|
+
const earliestMessageTimestamp = messages_analyze.length > 0
|
|
223
|
+
? messages_analyze.reduce((a, b) => a.timestamp < b.timestamp ? a : b).timestamp
|
|
224
|
+
: now;
|
|
197
225
|
const human = state.getHuman();
|
|
198
226
|
|
|
199
227
|
const resolveItemId = (): string => {
|
|
@@ -264,7 +292,7 @@ export async function handlePersonUpdate(response: LLMResponse, state: StateMana
|
|
|
264
292
|
deduped.push(id);
|
|
265
293
|
}
|
|
266
294
|
}
|
|
267
|
-
resolvedIdentifiers = deduped;
|
|
295
|
+
resolvedIdentifiers = ensureEiPersonaHasNickname(deduped, state);
|
|
268
296
|
} else {
|
|
269
297
|
const base = [...(existingPerson?.identifiers ?? [])];
|
|
270
298
|
const sanitizedToAdd = sanitizeEiPersonaIdentifiers(
|
|
@@ -279,12 +307,16 @@ export async function handlePersonUpdate(response: LLMResponse, state: StateMana
|
|
|
279
307
|
base.push({ type: id.type, value: id.value, ...(id.is_primary ? { is_primary: id.is_primary } : {}) });
|
|
280
308
|
}
|
|
281
309
|
}
|
|
282
|
-
resolvedIdentifiers = base;
|
|
310
|
+
resolvedIdentifiers = ensureEiPersonaHasNickname(base, state);
|
|
283
311
|
}
|
|
284
312
|
|
|
313
|
+
const personName = resolvedIdentifiers.find(i => i.is_primary && i.type !== 'Ei Persona')?.value
|
|
314
|
+
?? resolvedIdentifiers.find(i => i.type !== 'Ei Persona')?.value
|
|
315
|
+
?? candidateName;
|
|
316
|
+
|
|
285
317
|
const person: Person = {
|
|
286
318
|
id: itemId,
|
|
287
|
-
name:
|
|
319
|
+
name: personName,
|
|
288
320
|
description: resolvedDescription,
|
|
289
321
|
sentiment: resolvedSentiment,
|
|
290
322
|
relationship: result.relationship ?? candidateRelationship ?? existingPerson?.relationship ?? "Unknown",
|
|
@@ -293,7 +325,7 @@ export async function handlePersonUpdate(response: LLMResponse, state: StateMana
|
|
|
293
325
|
identifiers: resolvedIdentifiers,
|
|
294
326
|
validated_date: isNewItem ? '' : (existingPerson?.validated_date ?? ''),
|
|
295
327
|
last_updated: now,
|
|
296
|
-
learned_on: isNewItem ?
|
|
328
|
+
learned_on: isNewItem ? earliestMessageTimestamp : existingPerson?.learned_on,
|
|
297
329
|
last_mentioned: now,
|
|
298
330
|
learned_by: isNewItem ? primaryId : existingPerson?.learned_by,
|
|
299
331
|
last_changed_by: primaryId,
|
|
@@ -323,14 +355,13 @@ function normalizeText(text: string): string {
|
|
|
323
355
|
.replace(/[\u2018\u2019\u0060\u00B4]/g, "'") // curly single, backtick, acute accent
|
|
324
356
|
.replace(/[\u2014\u2013\u2012]/g, '-') // em-dash, en-dash, figure dash
|
|
325
357
|
.replace(/\u00A0/g, ' ') // non-breaking space
|
|
326
|
-
.replace(/[\u2000-\u200F]/g, ' ')
|
|
358
|
+
.replace(/[\u2000-\u200F]/g, ' ') // unicode space variants
|
|
359
|
+
.replace(/[*_`~]/g, ''); // Markdown emphasis/code chars
|
|
327
360
|
}
|
|
328
361
|
|
|
329
362
|
function stripPunctuation(text: string): string {
|
|
330
|
-
// Remove characters LLMs commonly mangle, keep spaces and alphanumeric
|
|
331
|
-
// Strip: punctuation, unicode punctuation variants, curly quotes, dashes, etc.
|
|
332
|
-
// Keep: letters, digits, spaces
|
|
333
363
|
return text
|
|
364
|
+
.replace(/[*_`~]/g, ' ') // Markdown chars (kept by \w, must strip explicitly)
|
|
334
365
|
.replace(/[^\w\s]/gu, ' ') // replace non-word, non-space with space
|
|
335
366
|
.replace(/\s+/g, ' ') // collapse multiple spaces
|
|
336
367
|
.trim()
|
|
@@ -406,6 +437,10 @@ async function validateAndStoreQuotes(
|
|
|
406
437
|
if (!candidates || candidates.length === 0) return;
|
|
407
438
|
|
|
408
439
|
for (const candidate of candidates) {
|
|
440
|
+
if (!candidate.text) {
|
|
441
|
+
console.warn('[extraction] Skipping quote candidate with missing text field');
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
409
444
|
let found = false;
|
|
410
445
|
for (const message of messages) {
|
|
411
446
|
const msgText = getMessageText(message);
|
|
@@ -511,7 +546,7 @@ async function validateAndStoreQuotes(
|
|
|
511
546
|
break;
|
|
512
547
|
}
|
|
513
548
|
if (!found) {
|
|
514
|
-
console.warn(`[extraction] Quote not found in messages (both levels), skipping: "${candidate.text
|
|
549
|
+
console.warn(`[extraction] Quote not found in messages (both levels), skipping: "${candidate.text}"`);
|
|
515
550
|
}
|
|
516
551
|
}
|
|
517
552
|
}
|
package/src/core/llm-client.ts
CHANGED
|
@@ -450,6 +450,7 @@ const JSON_REPAIR_PATTERNS: Array<{ pattern: RegExp; replacement: string }> = [
|
|
|
450
450
|
{ pattern: /:\s*(\d{4}-\d{2}-\d{2}T[^"}\],\n]+)/g, replacement: ': "$1"' },
|
|
451
451
|
{ pattern: /:\s*0([1-9][0-9]*)([,\s\n\r\]}])/g, replacement: ": 0.$1$2" },
|
|
452
452
|
{ pattern: /,(\s*[\]}])/g, replacement: "$1" },
|
|
453
|
+
{ pattern: /"(\s*\n[ \t]+"[a-zA-Z_][a-zA-Z0-9_]*"\s*:)/g, replacement: '",$1' },
|
|
453
454
|
];
|
|
454
455
|
|
|
455
456
|
export function repairJSON(jsonStr: string): string {
|
|
@@ -529,6 +530,41 @@ export function rescueGemmaToolCalls(content: string): unknown[] {
|
|
|
529
530
|
return rescued;
|
|
530
531
|
}
|
|
531
532
|
|
|
533
|
+
function findOutermostObject(str: string): string | null {
|
|
534
|
+
const start = str.indexOf('{');
|
|
535
|
+
if (start === -1) return null;
|
|
536
|
+
|
|
537
|
+
let depth = 0;
|
|
538
|
+
let inString = false;
|
|
539
|
+
let escaped = false;
|
|
540
|
+
|
|
541
|
+
for (let i = start; i < str.length; i++) {
|
|
542
|
+
const ch = str[i];
|
|
543
|
+
|
|
544
|
+
if (escaped) {
|
|
545
|
+
escaped = false;
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
if (ch === '\\' && inString) {
|
|
549
|
+
escaped = true;
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
if (ch === '"') {
|
|
553
|
+
inString = !inString;
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
if (inString) continue;
|
|
557
|
+
|
|
558
|
+
if (ch === '{') depth++;
|
|
559
|
+
else if (ch === '}') {
|
|
560
|
+
depth--;
|
|
561
|
+
if (depth === 0) return str.slice(start, i + 1);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
return null;
|
|
566
|
+
}
|
|
567
|
+
|
|
532
568
|
export function parseJSONResponse(content: string): unknown {
|
|
533
569
|
const jsonMatch = content.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
534
570
|
const jsonStr = jsonMatch ? jsonMatch[1].trim() : content.trim();
|
|
@@ -541,10 +577,10 @@ export function parseJSONResponse(content: string): unknown {
|
|
|
541
577
|
return JSON.parse(repaired);
|
|
542
578
|
} catch {
|
|
543
579
|
// Last resort: extract the outermost {...} block from mixed prose/JSON content.
|
|
544
|
-
//
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
580
|
+
// Bracket-depth scan (not greedy regex) stops at the first valid close so extra
|
|
581
|
+
// trailing braces from models like Gemma are excluded from the extracted slice.
|
|
582
|
+
const extracted = findOutermostObject(jsonStr);
|
|
583
|
+
if (extracted) {
|
|
548
584
|
try {
|
|
549
585
|
return JSON.parse(extracted);
|
|
550
586
|
} catch {
|