ei-tui 0.1.25 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -0
- package/package.json +2 -1
- package/src/README.md +4 -11
- package/src/cli/README.md +87 -7
- package/src/cli/commands/facts.ts +2 -2
- package/src/cli/commands/people.ts +2 -2
- package/src/cli/commands/quotes.ts +2 -2
- package/src/cli/commands/topics.ts +2 -2
- package/src/cli/mcp.ts +94 -0
- package/src/cli/retrieval.ts +67 -31
- package/src/cli.ts +64 -23
- package/src/core/AGENTS.md +1 -1
- package/src/core/constants/built-in-facts.ts +49 -0
- package/src/core/constants/index.ts +1 -0
- package/src/core/context-utils.ts +0 -1
- package/src/core/embedding-service.ts +8 -0
- package/src/core/handlers/dedup.ts +11 -23
- package/src/core/handlers/heartbeat.ts +2 -3
- package/src/core/handlers/human-extraction.ts +96 -30
- package/src/core/handlers/human-matching.ts +328 -248
- package/src/core/handlers/index.ts +8 -6
- package/src/core/handlers/persona-generation.ts +8 -8
- package/src/core/handlers/rewrite.ts +4 -51
- package/src/core/handlers/utils.ts +23 -1
- package/src/core/heartbeat-manager.ts +2 -4
- package/src/core/human-data-manager.ts +38 -36
- package/src/core/message-manager.ts +10 -10
- package/src/core/orchestrators/ceremony.ts +49 -44
- package/src/core/orchestrators/dedup-phase.ts +2 -4
- package/src/core/orchestrators/human-extraction.ts +351 -207
- package/src/core/orchestrators/index.ts +6 -4
- package/src/core/orchestrators/persona-generation.ts +3 -3
- package/src/core/processor.ts +167 -20
- package/src/core/prompt-context-builder.ts +4 -6
- package/src/core/state/human.ts +1 -26
- package/src/core/state/personas.ts +2 -2
- package/src/core/state-manager.ts +107 -14
- package/src/core/tools/builtin/read-memory.ts +13 -18
- package/src/core/types/data-items.ts +3 -4
- package/src/core/types/entities.ts +7 -4
- package/src/core/types/enums.ts +6 -9
- package/src/core/types/llm.ts +2 -2
- package/src/core/utils/crossFind.ts +2 -5
- package/src/core/utils/event-windows.ts +31 -0
- package/src/integrations/claude-code/importer.ts +14 -5
- package/src/integrations/claude-code/types.ts +3 -0
- package/src/integrations/cursor/importer.ts +282 -0
- package/src/integrations/cursor/index.ts +10 -0
- package/src/integrations/cursor/reader.ts +209 -0
- package/src/integrations/cursor/types.ts +140 -0
- package/src/integrations/opencode/importer.ts +14 -4
- package/src/prompts/AGENTS.md +73 -1
- package/src/prompts/ceremony/dedup.ts +0 -33
- package/src/prompts/ceremony/rewrite.ts +6 -41
- package/src/prompts/ceremony/types.ts +4 -4
- package/src/prompts/generation/descriptions.ts +2 -2
- package/src/prompts/generation/types.ts +2 -2
- package/src/prompts/heartbeat/types.ts +2 -2
- package/src/prompts/human/event-scan.ts +122 -0
- package/src/prompts/human/fact-find.ts +106 -0
- package/src/prompts/human/fact-scan.ts +0 -2
- package/src/prompts/human/index.ts +17 -10
- package/src/prompts/human/person-match.ts +65 -0
- package/src/prompts/human/person-scan.ts +52 -59
- package/src/prompts/human/person-update.ts +241 -0
- package/src/prompts/human/topic-match.ts +65 -0
- package/src/prompts/human/topic-scan.ts +51 -71
- package/src/prompts/human/topic-update.ts +295 -0
- package/src/prompts/human/types.ts +63 -40
- package/src/prompts/index.ts +4 -8
- package/src/prompts/persona/topics-update.ts +2 -2
- package/src/prompts/persona/traits.ts +2 -2
- package/src/prompts/persona/types.ts +3 -3
- package/src/prompts/response/index.ts +1 -1
- package/src/prompts/response/sections.ts +9 -12
- package/src/prompts/response/types.ts +2 -3
- package/src/storage/embeddings.ts +1 -1
- package/src/storage/index.ts +1 -0
- package/src/storage/indexed.ts +174 -0
- package/src/storage/merge.ts +67 -2
- package/tui/src/commands/me.tsx +5 -14
- package/tui/src/commands/settings.tsx +15 -0
- package/tui/src/context/ei.tsx +5 -14
- package/tui/src/util/yaml-serializers.ts +76 -33
- package/src/cli/commands/traits.ts +0 -25
- package/src/prompts/human/item-match.ts +0 -74
- package/src/prompts/human/item-update.ts +0 -364
- package/src/prompts/human/trait-scan.ts +0 -115
package/README.md
CHANGED
|
@@ -116,6 +116,48 @@ Opencode saves all of its sessions locally, either in a JSON structure or, if yo
|
|
|
116
116
|
|
|
117
117
|
Then, Opencode can call into Ei and pull those details back out. That's why you always have a side-project or two going. See [TUI Readme](tui/README.md)
|
|
118
118
|
|
|
119
|
+
## Built-in Tool Integrations
|
|
120
|
+
|
|
121
|
+
Personas can use tools. Not just read-from-memory tools — *actual* tools. Web search. Your music. Your filesystem. Here's what ships with Ei out of the box:
|
|
122
|
+
|
|
123
|
+
### Ei Built-ins (always available, no setup)
|
|
124
|
+
|
|
125
|
+
| Tool | What it does |
|
|
126
|
+
|------|-------------|
|
|
127
|
+
| `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. |
|
|
128
|
+
| `file_read` | Read a file from your local filesystem *(TUI only)* |
|
|
129
|
+
| `list_directory` | Explore folder structure *(TUI only)* |
|
|
130
|
+
| `directory_tree` | Recursive directory tree *(TUI only)* |
|
|
131
|
+
| `search_files` | Find files by name pattern *(TUI only)* |
|
|
132
|
+
| `grep` | Search file contents by regex *(TUI only)* |
|
|
133
|
+
| `get_file_info` | File/directory metadata *(TUI only)* |
|
|
134
|
+
|
|
135
|
+
The filesystem tools make Ei a legitimate coding assistant in the TUI. Ask a persona to review a file, understand a project structure, or track down where something is defined — it can actually look.
|
|
136
|
+
|
|
137
|
+
### Tavily Web Search (requires free API key)
|
|
138
|
+
|
|
139
|
+
| Tool | What it does |
|
|
140
|
+
|------|-------------|
|
|
141
|
+
| `tavily_web_search` | Real-time web search — current events, fact-checking, anything that needs up-to-date information |
|
|
142
|
+
| `tavily_news_search` | Recent news articles |
|
|
143
|
+
|
|
144
|
+
Get a free key at [tavily.com](https://tavily.com) (1,000 requests/month free tier). Add it in **Settings → Tool Kits → Tavily Search**.
|
|
145
|
+
|
|
146
|
+
### Spotify (requires OAuth connection)
|
|
147
|
+
|
|
148
|
+
| Tool | What it does |
|
|
149
|
+
|------|-------------|
|
|
150
|
+
| `get_currently_playing` | What's playing right now — artist, title, album, progress |
|
|
151
|
+
| `get_liked_songs` | Your full liked songs library |
|
|
152
|
+
|
|
153
|
+
Connect in **Settings → Tool Kits → Spotify**. Once connected, personas can ask what you're listening to and actually know. Music-aware conversations.
|
|
154
|
+
|
|
155
|
+
### Assigning Tools to Personas
|
|
156
|
+
|
|
157
|
+
Tools aren't global — you choose which personas get access. Edit a persona and toggle the tools it can use. A focused work persona might only have filesystem tools. A general-purpose companion might have everything.
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
119
161
|
## Technical Details
|
|
120
162
|
|
|
121
163
|
This project is separated into five (5) logical parts:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ei-tui",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"author": "Flare576",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -58,6 +58,7 @@
|
|
|
58
58
|
},
|
|
59
59
|
"type": "module",
|
|
60
60
|
"dependencies": {
|
|
61
|
+
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
61
62
|
"@opentui/core": "^0.1.79",
|
|
62
63
|
"@opentui/solid": "^0.1.79",
|
|
63
64
|
"fastembed": "^2.1.0",
|
package/src/README.md
CHANGED
|
@@ -10,7 +10,7 @@ There are two distinct types of data: Human and Persona.
|
|
|
10
10
|
|
|
11
11
|
## Human
|
|
12
12
|
|
|
13
|
-
Human data is sort of the "Global" data - Each Persona can read and write elements to the humans Facts,
|
|
13
|
+
Human data is sort of the "Global" data - Each Persona can read and write elements to the humans Facts, People, and Topics. In addition, there are "Quotes" that can tie to those three types of data.
|
|
14
14
|
|
|
15
15
|
As the user uses the system, it tries to keep track of several data points for these elements:
|
|
16
16
|
|
|
@@ -24,12 +24,9 @@ As the user uses the system, it tries to keep track of several data points for t
|
|
|
24
24
|
* Current: How much the user has talked or heard about a subject, where:
|
|
25
25
|
+ 0.0: Obi-Wan Kenobi ...now that's a name I've not heard in a long time
|
|
26
26
|
+ 1.0: The user just spent 4 hours talking about Star Wars
|
|
27
|
-
-
|
|
28
|
-
* 1.0 on "Visual Learner" would mean that you've said or shown that it is the absolute best way for you to learn
|
|
29
|
-
* 0.0 on "Public Speaker" would mean you've said or shown that you have no desire, aptitude, or willingness to present
|
|
30
|
-
- Validated: "Facts" have proven almost as hard to get right as Traits, so I added a way for Ei and you to mark the ones that are true as "Validated"
|
|
27
|
+
- Validated: "Facts" have proven tricky, so I added a way for Ei and you to mark the ones that are true as "Validated"
|
|
31
28
|
|
|
32
|
-
Each of those types represents a piece of what the system "knows" about the person, and
|
|
29
|
+
Each of those types represents a piece of what the system "knows" about the person, and they're kept up-to-date as the person chats with Personas, but not always on every message. On each message to a Persona, a check is made:
|
|
33
30
|
|
|
34
31
|
```
|
|
35
32
|
if(Person.newMessages > count_of_human_[type]) {
|
|
@@ -37,9 +34,7 @@ if(Person.newMessages > count_of_human_[type]) {
|
|
|
37
34
|
}
|
|
38
35
|
```
|
|
39
36
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
> <sup>1</sup> Traits are unique because, after trying to extract them in the same way as the other pieces of data, I realized that it's sorta hard to understand a core aspect of someone in one message, or even 10. Even doing this analysis over a full 24 hours hasn't proven to be particularly effective, but it's the best we have so far.
|
|
37
|
+
This extracts quotes, description updates, title updates, etc. for the conversations the user is having, and keeps them feeling alive.
|
|
43
38
|
|
|
44
39
|
## Persona
|
|
45
40
|
|
|
@@ -72,8 +67,6 @@ I also frequently refer to this as "Extract," but this is the first step where w
|
|
|
72
67
|
|
|
73
68
|
Since we also pull out details during normal discourse (see above), this is the less-important step at this point, but still vital for catching up with the last few messages, or Personas that only received a few messages during the day and may not have hit the current limit for natural extraction.
|
|
74
69
|
|
|
75
|
-
Additionally, this is the ONLY time when Human Traits are created or updated - after (hopefully) enough messages have been exchanged for an agent to analyze it and say "Yup, Flare is _definitely_ verbose."
|
|
76
|
-
|
|
77
70
|
### Exposure Adjustment
|
|
78
71
|
|
|
79
72
|
Exposure is calculated by two metrics - `desired` and `current`. If an entity REALLY likes talking about a subject, their `desired` will be very high (1.0 max), ranging down to 0.0 for subjects which that entity does NOT wish to discuss. You may have guessed already, but `current` is how much they've recently talked about a topic.
|
package/src/cli/README.md
CHANGED
|
@@ -6,16 +6,16 @@ ei # Start the TUI
|
|
|
6
6
|
ei "query string" # Return up to 10 results across all types
|
|
7
7
|
ei -n 5 "query string" # Return up to 5 results
|
|
8
8
|
ei facts -n 5 "query string" # Return up to 5 facts
|
|
9
|
-
ei traits -n 5 "query string" # Return up to 5 traits
|
|
10
9
|
ei people -n 5 "query string" # Return up to 5 people
|
|
11
10
|
ei topics -n 5 "query string" # Return up to 5 topics
|
|
12
11
|
ei quotes -n 5 "query string" # Return up to 5 quotes
|
|
13
12
|
ei --id <id> # Look up a specific entity by ID
|
|
14
13
|
echo <id> | ei --id # Look up entity by ID from stdin
|
|
15
|
-
ei --install #
|
|
14
|
+
ei --install # Register Ei with OpenCode, Claude Code, and Cursor
|
|
15
|
+
ei mcp # Start the Ei MCP stdio server (for Cursor/Claude Desktop)
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
Type aliases: `fact`, `
|
|
18
|
+
Type aliases: `fact`, `person`, `topic`, `quote` all work (singular or plural).
|
|
19
19
|
|
|
20
20
|
# An Agentic Tool
|
|
21
21
|
|
|
@@ -33,16 +33,96 @@ ei "memory leak" | jq '.[0].id' | ei --id
|
|
|
33
33
|
ei --install
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
-
This
|
|
36
|
+
This registers Ei with every supported agent environment it detects:
|
|
37
|
+
|
|
38
|
+
- **OpenCode**: writes `~/.config/opencode/tools/ei.ts`
|
|
39
|
+
- **Claude Code**: runs `claude mcp add` (or writes `~/.claude.json` as fallback)
|
|
40
|
+
- **Cursor**: writes `~/.cursor/mcp.json`
|
|
41
|
+
|
|
42
|
+
Restart your agent tool after running to activate.
|
|
43
|
+
|
|
44
|
+
### MCP Server
|
|
45
|
+
|
|
46
|
+
Claude Code and Cursor call `ei mcp` to start the MCP stdio server. You can run it directly to test:
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
ei mcp
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Activating Ei in Your Agent
|
|
53
|
+
|
|
54
|
+
`ei --install` handles the technical wiring. This step tells your agent *when* and *how* to reach for it.
|
|
55
|
+
|
|
56
|
+
Without this, your agent has Ei available but may never call it. Add a snippet to your tool's config and it'll start querying Ei at the start of each session — and whenever you reference past context.
|
|
57
|
+
|
|
58
|
+
### OpenCode
|
|
59
|
+
|
|
60
|
+
Add to `~/.config/opencode/AGENTS.md` (applies to all projects):
|
|
61
|
+
|
|
62
|
+
```markdown
|
|
63
|
+
At session start, query Ei for user context:
|
|
64
|
+
|
|
65
|
+
\```bash
|
|
66
|
+
ei "What are the user's current preferences, active projects, and workflow?"
|
|
67
|
+
\```
|
|
68
|
+
|
|
69
|
+
Ei is a persistent knowledge base built from the user's conversations — facts, preferences,
|
|
70
|
+
people, topics. Use it when the user references past work, mentions how they like things done,
|
|
71
|
+
or asks "how did we do X." Query again mid-session when they correct you or reference something
|
|
72
|
+
from a previous session.
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Claude Code
|
|
76
|
+
|
|
77
|
+
Add to `~/.claude/CLAUDE.md` (user-level) or `CLAUDE.md` at project root:
|
|
78
|
+
|
|
79
|
+
```markdown
|
|
80
|
+
At session start, use the **ei** MCP to pull user context: call `ei_search` with a
|
|
81
|
+
natural-language query about the user's preferences, active projects, and workflow.
|
|
82
|
+
|
|
83
|
+
Use Ei when the user references past decisions, mentions people or preferences, or asks
|
|
84
|
+
"how did we do X." Query again when they correct you or reference something from a previous
|
|
85
|
+
session.
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Cursor
|
|
89
|
+
|
|
90
|
+
Create `.cursor/rules/ei-mcp.mdc` in your project (or `~/.cursor/rules/` for user-level):
|
|
91
|
+
|
|
92
|
+
```markdown
|
|
93
|
+
---
|
|
94
|
+
description: When to use the Ei MCP for user memory and context
|
|
95
|
+
alwaysApply: true
|
|
96
|
+
---
|
|
97
|
+
# Ei MCP — User knowledge base
|
|
98
|
+
|
|
99
|
+
The **ei** MCP (server `user-ei`) is a persistent knowledge base built from the user's
|
|
100
|
+
conversations (facts, people, topics, quotes).
|
|
101
|
+
|
|
102
|
+
**Use it when:**
|
|
103
|
+
- The user refers to past decisions, fixes, or "how we did X" and current chat/codebase
|
|
104
|
+
doesn't have that context.
|
|
105
|
+
- You need the user's preferences, contacts, or project conventions (e.g. who to ask for
|
|
106
|
+
access, how something was fixed).
|
|
107
|
+
- The question is about the user personally (people, workflow, prior discussions) rather
|
|
108
|
+
than only code.
|
|
109
|
+
|
|
110
|
+
**How to use:**
|
|
111
|
+
1. Call `ei_search` (server `user-ei`) with a natural-language query; optionally filter by
|
|
112
|
+
`type`: facts, people, topics, quotes.
|
|
113
|
+
2. If you need full detail for a result, call `ei_lookup` with the entity `id` from step 1.
|
|
114
|
+
|
|
115
|
+
Prefer querying Ei before asking the user for context they may have already shared.
|
|
116
|
+
```
|
|
37
117
|
|
|
38
118
|
## What the Tool Provides
|
|
39
119
|
|
|
40
|
-
The installed tool gives OpenCode agents access to all
|
|
120
|
+
The installed tool gives OpenCode agents access to all four data types with proper Zod-validated args:
|
|
41
121
|
|
|
42
122
|
| Arg | Type | Description |
|
|
43
123
|
|-----|------|-------------|
|
|
44
124
|
| `query` | string (required) | Search text, or entity ID when `lookup=true` |
|
|
45
|
-
| `type` | enum (optional) | `facts` \| `
|
|
125
|
+
| `type` | enum (optional) | `facts` \| `people` \| `topics` \| `quotes` — omit for balanced results |
|
|
46
126
|
| `limit` | number (optional) | Max results, default 10 |
|
|
47
127
|
| `lookup` | boolean (optional) | If true, fetch single entity by ID |
|
|
48
128
|
|
|
@@ -50,7 +130,7 @@ The installed tool gives OpenCode agents access to all five data types with prop
|
|
|
50
130
|
|
|
51
131
|
All search commands return arrays. Each result includes a `type` field.
|
|
52
132
|
|
|
53
|
-
**Fact /
|
|
133
|
+
**Fact / Person / Topic**: `{ type, id, name, description, sentiment, ...type-specific fields }`
|
|
54
134
|
|
|
55
135
|
**Quote**: `{ type, id, text, speaker, timestamp, linked_items[] }`
|
|
56
136
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { loadLatestState, retrieve } from "../retrieval";
|
|
2
2
|
import type { FactResult } from "../retrieval";
|
|
3
3
|
|
|
4
|
-
export async function execute(query: string, limit: number): Promise<FactResult[]> {
|
|
4
|
+
export async function execute(query: string, limit: number, options: { recent?: boolean } = {}): Promise<FactResult[]> {
|
|
5
5
|
const state = await loadLatestState();
|
|
6
6
|
if (!state) {
|
|
7
7
|
console.error("No saved state found. Is EI_DATA_PATH set correctly?");
|
|
@@ -13,7 +13,7 @@ export async function execute(query: string, limit: number): Promise<FactResult[
|
|
|
13
13
|
return [];
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
const results = await retrieve(facts, query, limit);
|
|
16
|
+
const results = await retrieve(facts, query, limit, options);
|
|
17
17
|
|
|
18
18
|
return results.map(fact => ({
|
|
19
19
|
id: fact.id,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { loadLatestState, retrieve } from "../retrieval";
|
|
2
2
|
import type { PersonResult } from "../retrieval";
|
|
3
3
|
|
|
4
|
-
export async function execute(query: string, limit: number): Promise<PersonResult[]> {
|
|
4
|
+
export async function execute(query: string, limit: number, options: { recent?: boolean } = {}): Promise<PersonResult[]> {
|
|
5
5
|
const state = await loadLatestState();
|
|
6
6
|
if (!state) {
|
|
7
7
|
console.error("No saved state found. Is EI_DATA_PATH set correctly?");
|
|
@@ -13,7 +13,7 @@ export async function execute(query: string, limit: number): Promise<PersonResul
|
|
|
13
13
|
return [];
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
const results = await retrieve(people, query, limit);
|
|
16
|
+
const results = await retrieve(people, query, limit, options);
|
|
17
17
|
|
|
18
18
|
return results.map(person => ({
|
|
19
19
|
id: person.id,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { loadLatestState, retrieve, mapQuote } from "../retrieval";
|
|
2
2
|
import type { QuoteResult } from "../retrieval";
|
|
3
3
|
|
|
4
|
-
export async function execute(query: string, limit: number): Promise<QuoteResult[]> {
|
|
4
|
+
export async function execute(query: string, limit: number, options: { recent?: boolean } = {}): Promise<QuoteResult[]> {
|
|
5
5
|
const state = await loadLatestState();
|
|
6
6
|
if (!state) {
|
|
7
7
|
console.error("No saved state found. Is EI_DATA_PATH set correctly?");
|
|
@@ -13,7 +13,7 @@ export async function execute(query: string, limit: number): Promise<QuoteResult
|
|
|
13
13
|
return [];
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
const results = await retrieve(quotes, query, limit);
|
|
16
|
+
const results = await retrieve(quotes, query, limit, options);
|
|
17
17
|
|
|
18
18
|
return results.map(quote => mapQuote(quote, state));
|
|
19
19
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { loadLatestState, retrieve } from "../retrieval";
|
|
2
2
|
import type { TopicResult } from "../retrieval";
|
|
3
3
|
|
|
4
|
-
export async function execute(query: string, limit: number): Promise<TopicResult[]> {
|
|
4
|
+
export async function execute(query: string, limit: number, options: { recent?: boolean } = {}): Promise<TopicResult[]> {
|
|
5
5
|
const state = await loadLatestState();
|
|
6
6
|
if (!state) {
|
|
7
7
|
console.error("No saved state found. Is EI_DATA_PATH set correctly?");
|
|
@@ -13,7 +13,7 @@ export async function execute(query: string, limit: number): Promise<TopicResult
|
|
|
13
13
|
return [];
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
const results = await retrieve(topics, query, limit);
|
|
16
|
+
const results = await retrieve(topics, query, limit, options);
|
|
17
17
|
|
|
18
18
|
return results.map(topic => ({
|
|
19
19
|
id: topic.id,
|
package/src/cli/mcp.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { retrieveBalanced, lookupById } from "./retrieval.js";
|
|
5
|
+
|
|
6
|
+
// Exported so tests can inject their own transport
|
|
7
|
+
export function createMcpServer(): McpServer {
|
|
8
|
+
const server = new McpServer({
|
|
9
|
+
name: "ei",
|
|
10
|
+
version: "1.0.0",
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
server.registerTool(
|
|
14
|
+
"ei_search",
|
|
15
|
+
{
|
|
16
|
+
description:
|
|
17
|
+
"Search the user's Ei knowledge base — a persistent memory store built from conversations. Returns facts, people, topics of interest, and quotes. Results include entity IDs that can be passed back to ei_lookup for full detail.",
|
|
18
|
+
inputSchema: {
|
|
19
|
+
query: z.string().describe("Search text. Supports natural language."),
|
|
20
|
+
type: z
|
|
21
|
+
.enum(["facts", "people", "topics", "quotes"])
|
|
22
|
+
.optional()
|
|
23
|
+
.describe(
|
|
24
|
+
"Filter to a specific data type. Omit to search all types (balanced across all 4)."
|
|
25
|
+
),
|
|
26
|
+
limit: z
|
|
27
|
+
.number()
|
|
28
|
+
.optional()
|
|
29
|
+
.default(10)
|
|
30
|
+
.describe("Maximum number of results to return."),
|
|
31
|
+
recent: z
|
|
32
|
+
.boolean()
|
|
33
|
+
.optional()
|
|
34
|
+
.describe("If true, sort by most recently mentioned."),
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
async ({ query, type, limit, recent }) => {
|
|
38
|
+
const options = { recent: recent ?? false };
|
|
39
|
+
const effectiveLimit = limit ?? 10;
|
|
40
|
+
|
|
41
|
+
let result: unknown;
|
|
42
|
+
if (type) {
|
|
43
|
+
const module = await import(`./commands/${type}.js`);
|
|
44
|
+
result = await (module.execute as (q: string, l: number, o: { recent: boolean }) => Promise<unknown>)(query, effectiveLimit, options);
|
|
45
|
+
} else {
|
|
46
|
+
result = await retrieveBalanced(query, effectiveLimit, options);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
server.registerTool(
|
|
56
|
+
"ei_lookup",
|
|
57
|
+
{
|
|
58
|
+
description:
|
|
59
|
+
"Look up a specific entity in the Ei knowledge base by ID. Returns the full entity record. Use IDs from ei_search results.",
|
|
60
|
+
inputSchema: {
|
|
61
|
+
id: z.string().describe("The entity ID to look up."),
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
async ({ id }) => {
|
|
65
|
+
const result = await lookupById(id);
|
|
66
|
+
const text =
|
|
67
|
+
result === null
|
|
68
|
+
? `No entity found with ID: ${id}`
|
|
69
|
+
: JSON.stringify(result, null, 2);
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
content: [{ type: "text" as const, text }],
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
return server;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function handleMcpCommand(_args: string[]): Promise<void> {
|
|
81
|
+
const server = createMcpServer();
|
|
82
|
+
const transport = new StdioServerTransport();
|
|
83
|
+
await server.connect(transport);
|
|
84
|
+
|
|
85
|
+
process.stderr.write("Ei MCP server running on stdio\n");
|
|
86
|
+
|
|
87
|
+
// Block until the client disconnects (stdin closes), otherwise
|
|
88
|
+
// the caller's process.exit(0) fires immediately and kills the server
|
|
89
|
+
// before it can process any messages.
|
|
90
|
+
await new Promise<void>((resolve) => {
|
|
91
|
+
process.stdin.once("end", resolve);
|
|
92
|
+
process.stdin.once("close", resolve);
|
|
93
|
+
});
|
|
94
|
+
}
|
package/src/cli/retrieval.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { StorageState, Quote, Fact,
|
|
1
|
+
import type { StorageState, Quote, Fact, Person, Topic } from "../core/types";
|
|
2
2
|
import { decodeAllEmbeddings } from "../storage/embeddings";
|
|
3
3
|
import { crossFind } from "../core/utils/index.ts";
|
|
4
4
|
import { join } from "path";
|
|
@@ -30,18 +30,43 @@ export async function loadLatestState(): Promise<StorageState | null> {
|
|
|
30
30
|
return null;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
export async function retrieve<T extends { id: string; embedding?: number[] }>(
|
|
33
|
+
export async function retrieve<T extends { id: string; embedding?: number[]; last_updated?: string; last_mentioned?: string }>(
|
|
34
34
|
items: T[],
|
|
35
35
|
query: string,
|
|
36
|
-
limit: number = 10
|
|
36
|
+
limit: number = 10,
|
|
37
|
+
options: { recent?: boolean } = {}
|
|
37
38
|
): Promise<T[]> {
|
|
38
|
-
if (items.length === 0
|
|
39
|
+
if (items.length === 0) {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const { recent } = options;
|
|
44
|
+
|
|
45
|
+
const sortByRecent = (a: T, b: T): number => {
|
|
46
|
+
const aDate = a.last_mentioned ?? (a as Record<string, unknown>).last_updated as string ?? "";
|
|
47
|
+
const bDate = b.last_mentioned ?? (b as Record<string, unknown>).last_updated as string ?? "";
|
|
48
|
+
return bDate.localeCompare(aDate);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
if (recent && !query) {
|
|
52
|
+
return [...items].sort(sortByRecent).slice(0, limit);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!query) {
|
|
39
56
|
return [];
|
|
40
57
|
}
|
|
41
58
|
|
|
42
59
|
const embeddingService = getEmbeddingService();
|
|
43
60
|
const queryVector = await embeddingService.embed(query);
|
|
44
61
|
|
|
62
|
+
if (recent) {
|
|
63
|
+
const topK = Math.max(limit * 5, 50);
|
|
64
|
+
const results = findTopK(queryVector, items, topK)
|
|
65
|
+
.filter(({ similarity }) => similarity >= EMBEDDING_MIN_SIMILARITY)
|
|
66
|
+
.map(({ item }) => item);
|
|
67
|
+
return results.sort(sortByRecent).slice(0, limit);
|
|
68
|
+
}
|
|
69
|
+
|
|
45
70
|
const results = findTopK(queryVector, items, limit);
|
|
46
71
|
|
|
47
72
|
return results
|
|
@@ -67,15 +92,6 @@ export interface FactResult {
|
|
|
67
92
|
name: string;
|
|
68
93
|
description: string;
|
|
69
94
|
sentiment: number;
|
|
70
|
-
validated: string;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export interface TraitResult {
|
|
74
|
-
id: string;
|
|
75
|
-
name: string;
|
|
76
|
-
description: string;
|
|
77
|
-
strength: number;
|
|
78
|
-
sentiment: number;
|
|
79
95
|
}
|
|
80
96
|
|
|
81
97
|
export interface PersonResult {
|
|
@@ -97,17 +113,16 @@ export interface TopicResult {
|
|
|
97
113
|
export type BalancedResult =
|
|
98
114
|
| ({ type: "quote" } & QuoteResult)
|
|
99
115
|
| ({ type: "fact" } & FactResult)
|
|
100
|
-
| ({ type: "trait" } & TraitResult)
|
|
101
116
|
| ({ type: "person" } & PersonResult)
|
|
102
117
|
| ({ type: "topic" } & TopicResult);
|
|
103
118
|
|
|
104
|
-
const DATA_TYPES = ["quote", "fact", "
|
|
119
|
+
const DATA_TYPES = ["quote", "fact", "person", "topic"] as const;
|
|
105
120
|
type DataType = typeof DATA_TYPES[number];
|
|
106
121
|
|
|
107
122
|
interface ScoredEntry {
|
|
108
123
|
type: DataType;
|
|
109
124
|
similarity: number;
|
|
110
|
-
mapped: QuoteResult | FactResult |
|
|
125
|
+
mapped: QuoteResult | FactResult | PersonResult | TopicResult;
|
|
111
126
|
itemId: string;
|
|
112
127
|
}
|
|
113
128
|
|
|
@@ -117,7 +132,6 @@ export function resolveLinkedItems(dataItemIds: string[], state: StorageState):
|
|
|
117
132
|
{ type: "topic", source: state.human.topics },
|
|
118
133
|
{ type: "person", source: state.human.people },
|
|
119
134
|
{ type: "fact", source: state.human.facts },
|
|
120
|
-
{ type: "trait", source: state.human.traits },
|
|
121
135
|
];
|
|
122
136
|
for (const { type, source } of collections) {
|
|
123
137
|
for (const entity of source) {
|
|
@@ -144,19 +158,9 @@ function mapFact(fact: Fact): FactResult {
|
|
|
144
158
|
name: fact.name,
|
|
145
159
|
description: fact.description,
|
|
146
160
|
sentiment: fact.sentiment,
|
|
147
|
-
validated: fact.validated,
|
|
148
161
|
};
|
|
149
162
|
}
|
|
150
163
|
|
|
151
|
-
function mapTrait(trait: Trait): TraitResult {
|
|
152
|
-
return {
|
|
153
|
-
id: trait.id,
|
|
154
|
-
name: trait.name,
|
|
155
|
-
description: trait.description,
|
|
156
|
-
strength: trait.strength ?? 0.5,
|
|
157
|
-
sentiment: trait.sentiment,
|
|
158
|
-
};
|
|
159
|
-
}
|
|
160
164
|
|
|
161
165
|
function mapPerson(person: Person): PersonResult {
|
|
162
166
|
return {
|
|
@@ -180,7 +184,8 @@ function mapTopic(topic: Topic): TopicResult {
|
|
|
180
184
|
|
|
181
185
|
export async function retrieveBalanced(
|
|
182
186
|
query: string,
|
|
183
|
-
limit: number = 10
|
|
187
|
+
limit: number = 10,
|
|
188
|
+
options: { recent?: boolean } = {}
|
|
184
189
|
): Promise<BalancedResult[]> {
|
|
185
190
|
const state = await loadLatestState();
|
|
186
191
|
if (!state) {
|
|
@@ -188,6 +193,24 @@ export async function retrieveBalanced(
|
|
|
188
193
|
return [];
|
|
189
194
|
}
|
|
190
195
|
|
|
196
|
+
const { recent } = options;
|
|
197
|
+
|
|
198
|
+
type AnyItem = { id: string; embedding?: number[]; last_updated?: string; last_mentioned?: string };
|
|
199
|
+
const recentDate = (item: AnyItem): string => item.last_mentioned ?? item.last_updated ?? "";
|
|
200
|
+
|
|
201
|
+
if (recent && !query) {
|
|
202
|
+
const allItems: Array<{ type: DataType; item: AnyItem; mapped: QuoteResult | FactResult | PersonResult | TopicResult }> = [
|
|
203
|
+
...state.human.quotes.map(q => ({ type: "quote" as DataType, item: q as AnyItem, mapped: mapQuote(q, state) })),
|
|
204
|
+
...state.human.facts.map(f => ({ type: "fact" as DataType, item: f as AnyItem, mapped: mapFact(f) })),
|
|
205
|
+
...state.human.people.map(p => ({ type: "person" as DataType, item: p as AnyItem, mapped: mapPerson(p) })),
|
|
206
|
+
...state.human.topics.map(t => ({ type: "topic" as DataType, item: t as AnyItem, mapped: mapTopic(t) })),
|
|
207
|
+
];
|
|
208
|
+
return allItems
|
|
209
|
+
.sort((a, b) => recentDate(b.item).localeCompare(recentDate(a.item)))
|
|
210
|
+
.slice(0, limit)
|
|
211
|
+
.map(({ type, mapped }) => ({ type, ...mapped }) as BalancedResult);
|
|
212
|
+
}
|
|
213
|
+
|
|
191
214
|
const embeddingService = getEmbeddingService();
|
|
192
215
|
const queryVector = await embeddingService.embed(query);
|
|
193
216
|
|
|
@@ -200,11 +223,26 @@ export async function retrieveBalanced(
|
|
|
200
223
|
}> = [
|
|
201
224
|
{ type: "quote", items: state.human.quotes, mapper: (q: Quote) => mapQuote(q, state) },
|
|
202
225
|
{ type: "fact", items: state.human.facts, mapper: mapFact },
|
|
203
|
-
{ type: "trait", items: state.human.traits, mapper: mapTrait },
|
|
204
226
|
{ type: "person", items: state.human.people, mapper: mapPerson },
|
|
205
227
|
{ type: "topic", items: state.human.topics, mapper: mapTopic },
|
|
206
228
|
];
|
|
207
229
|
|
|
230
|
+
if (recent) {
|
|
231
|
+
for (const { type, items, mapper } of typeConfigs) {
|
|
232
|
+
const topK = Math.max(limit * 5, 50);
|
|
233
|
+
const scored = findTopK(queryVector, items, topK);
|
|
234
|
+
for (const { item, similarity } of scored) {
|
|
235
|
+
if (similarity >= EMBEDDING_MIN_SIMILARITY) {
|
|
236
|
+
allScored.push({ type, similarity, mapped: mapper(item), itemId: item.id });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return allScored
|
|
241
|
+
.sort((a, b) => recentDate(b.mapped as AnyItem).localeCompare(recentDate(a.mapped as AnyItem)))
|
|
242
|
+
.slice(0, limit)
|
|
243
|
+
.map(({ type, mapped }) => ({ type, ...mapped }) as BalancedResult);
|
|
244
|
+
}
|
|
245
|
+
|
|
208
246
|
for (const { type, items, mapper } of typeConfigs) {
|
|
209
247
|
const scored = findTopK(queryVector, items, items.length);
|
|
210
248
|
for (const { item, similarity } of scored) {
|
|
@@ -217,7 +255,6 @@ export async function retrieveBalanced(
|
|
|
217
255
|
const result: ScoredEntry[] = [];
|
|
218
256
|
const used = new Set<string>();
|
|
219
257
|
|
|
220
|
-
// Floor: at least 1 result per type (if available and meets threshold)
|
|
221
258
|
for (const type of DATA_TYPES) {
|
|
222
259
|
if (result.length >= limit) break;
|
|
223
260
|
const best = allScored
|
|
@@ -229,7 +266,6 @@ export async function retrieveBalanced(
|
|
|
229
266
|
}
|
|
230
267
|
}
|
|
231
268
|
|
|
232
|
-
// Fill remaining slots with highest-similarity results across all types
|
|
233
269
|
const remaining = allScored
|
|
234
270
|
.filter(r => !used.has(r.itemId))
|
|
235
271
|
.sort((a, b) => b.similarity - a.similarity);
|