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.
Files changed (88) hide show
  1. package/README.md +42 -0
  2. package/package.json +2 -1
  3. package/src/README.md +4 -11
  4. package/src/cli/README.md +87 -7
  5. package/src/cli/commands/facts.ts +2 -2
  6. package/src/cli/commands/people.ts +2 -2
  7. package/src/cli/commands/quotes.ts +2 -2
  8. package/src/cli/commands/topics.ts +2 -2
  9. package/src/cli/mcp.ts +94 -0
  10. package/src/cli/retrieval.ts +67 -31
  11. package/src/cli.ts +64 -23
  12. package/src/core/AGENTS.md +1 -1
  13. package/src/core/constants/built-in-facts.ts +49 -0
  14. package/src/core/constants/index.ts +1 -0
  15. package/src/core/context-utils.ts +0 -1
  16. package/src/core/embedding-service.ts +8 -0
  17. package/src/core/handlers/dedup.ts +11 -23
  18. package/src/core/handlers/heartbeat.ts +2 -3
  19. package/src/core/handlers/human-extraction.ts +96 -30
  20. package/src/core/handlers/human-matching.ts +328 -248
  21. package/src/core/handlers/index.ts +8 -6
  22. package/src/core/handlers/persona-generation.ts +8 -8
  23. package/src/core/handlers/rewrite.ts +4 -51
  24. package/src/core/handlers/utils.ts +23 -1
  25. package/src/core/heartbeat-manager.ts +2 -4
  26. package/src/core/human-data-manager.ts +38 -36
  27. package/src/core/message-manager.ts +10 -10
  28. package/src/core/orchestrators/ceremony.ts +49 -44
  29. package/src/core/orchestrators/dedup-phase.ts +2 -4
  30. package/src/core/orchestrators/human-extraction.ts +351 -207
  31. package/src/core/orchestrators/index.ts +6 -4
  32. package/src/core/orchestrators/persona-generation.ts +3 -3
  33. package/src/core/processor.ts +167 -20
  34. package/src/core/prompt-context-builder.ts +4 -6
  35. package/src/core/state/human.ts +1 -26
  36. package/src/core/state/personas.ts +2 -2
  37. package/src/core/state-manager.ts +107 -14
  38. package/src/core/tools/builtin/read-memory.ts +13 -18
  39. package/src/core/types/data-items.ts +3 -4
  40. package/src/core/types/entities.ts +7 -4
  41. package/src/core/types/enums.ts +6 -9
  42. package/src/core/types/llm.ts +2 -2
  43. package/src/core/utils/crossFind.ts +2 -5
  44. package/src/core/utils/event-windows.ts +31 -0
  45. package/src/integrations/claude-code/importer.ts +14 -5
  46. package/src/integrations/claude-code/types.ts +3 -0
  47. package/src/integrations/cursor/importer.ts +282 -0
  48. package/src/integrations/cursor/index.ts +10 -0
  49. package/src/integrations/cursor/reader.ts +209 -0
  50. package/src/integrations/cursor/types.ts +140 -0
  51. package/src/integrations/opencode/importer.ts +14 -4
  52. package/src/prompts/AGENTS.md +73 -1
  53. package/src/prompts/ceremony/dedup.ts +0 -33
  54. package/src/prompts/ceremony/rewrite.ts +6 -41
  55. package/src/prompts/ceremony/types.ts +4 -4
  56. package/src/prompts/generation/descriptions.ts +2 -2
  57. package/src/prompts/generation/types.ts +2 -2
  58. package/src/prompts/heartbeat/types.ts +2 -2
  59. package/src/prompts/human/event-scan.ts +122 -0
  60. package/src/prompts/human/fact-find.ts +106 -0
  61. package/src/prompts/human/fact-scan.ts +0 -2
  62. package/src/prompts/human/index.ts +17 -10
  63. package/src/prompts/human/person-match.ts +65 -0
  64. package/src/prompts/human/person-scan.ts +52 -59
  65. package/src/prompts/human/person-update.ts +241 -0
  66. package/src/prompts/human/topic-match.ts +65 -0
  67. package/src/prompts/human/topic-scan.ts +51 -71
  68. package/src/prompts/human/topic-update.ts +295 -0
  69. package/src/prompts/human/types.ts +63 -40
  70. package/src/prompts/index.ts +4 -8
  71. package/src/prompts/persona/topics-update.ts +2 -2
  72. package/src/prompts/persona/traits.ts +2 -2
  73. package/src/prompts/persona/types.ts +3 -3
  74. package/src/prompts/response/index.ts +1 -1
  75. package/src/prompts/response/sections.ts +9 -12
  76. package/src/prompts/response/types.ts +2 -3
  77. package/src/storage/embeddings.ts +1 -1
  78. package/src/storage/index.ts +1 -0
  79. package/src/storage/indexed.ts +174 -0
  80. package/src/storage/merge.ts +67 -2
  81. package/tui/src/commands/me.tsx +5 -14
  82. package/tui/src/commands/settings.tsx +15 -0
  83. package/tui/src/context/ei.tsx +5 -14
  84. package/tui/src/util/yaml-serializers.ts +76 -33
  85. package/src/cli/commands/traits.ts +0 -25
  86. package/src/prompts/human/item-match.ts +0 -74
  87. package/src/prompts/human/item-update.ts +0 -364
  88. 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.25",
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, Traits, People, and Topics. In addition, there are "Quotes" that can tie to those four types of data.
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
- - Strength: The system will try to gauge how strongly you exhibit a Trait
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 all but "Traits" are kept up-to-date as the person chats with Personas, but not on always on every message. On each message to a Persona, a check is made:
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
- Again, except for Traits<sup>1</sup>, this is to extract quotes, description updates, title updates, etc. for the conversations the user is having, and keep them feeling alive.
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 # Install the Ei tool for OpenCode
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`, `trait`, `person`, `topic`, `quote` all work (singular or plural).
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 writes `~/.config/opencode/tools/ei.ts` with a complete tool definition. Restart OpenCode to activate.
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 five data types with proper Zod-validated args:
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` \| `traits` \| `people` \| `topics` \| `quotes` — omit for balanced results |
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 / Trait / Person / Topic**: `{ type, id, name, description, sentiment, ...type-specific fields }`
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
+ }
@@ -1,4 +1,4 @@
1
- import type { StorageState, Quote, Fact, Trait, Person, Topic } from "../core/types";
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 || !query) {
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", "trait", "person", "topic"] as const;
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 | TraitResult | PersonResult | TopicResult;
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);