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/src/cli.ts CHANGED
@@ -20,8 +20,6 @@ const TYPE_ALIASES: Record<string, string> = {
20
20
  quotes: "quotes",
21
21
  fact: "facts",
22
22
  facts: "facts",
23
- trait: "traits",
24
- traits: "traits",
25
23
  person: "people",
26
24
  people: "people",
27
25
  topic: "topics",
@@ -38,28 +36,33 @@ Usage:
38
36
  ei -n 5 "search text" Limit results
39
37
  ei <type> "search text" Search a specific data type
40
38
  ei <type> -n 5 "search text" Type-specific with limit
39
+ ei --recent Return most recently mentioned items
40
+ ei --recent "query" Filter recent items by query
41
+ ei <type> --recent "query" Type-specific recent search
41
42
  ei --id <id> Look up a specific entity by ID
42
43
  echo <id> | ei --id Look up entity by ID from stdin
44
+ ei mcp Start the Ei MCP stdio server (for Cursor/Claude Desktop)
43
45
 
44
46
  Types:
45
47
  quote / quotes Quotes from conversation history
46
48
  fact / facts Facts about the user
47
- trait / traits Personality traits
48
49
  person / people People from the user's life
49
50
  topic / topics Topics of interest
50
51
 
51
52
  Options:
52
53
  --number, -n Maximum number of results (default: 10)
54
+ --recent, -r Sort by last_mentioned date (most recent first)
53
55
  --id Look up entity by ID (accepts value or stdin)
54
- --install Write the Ei tool file to ~/.config/opencode/tools/
56
+ --install Register Ei with OpenCode, Claude Code, and Cursor
55
57
  --help, -h Show this help message
56
58
 
57
59
  Examples:
58
60
  ei "debugging" # Search everything
59
61
  ei -n 5 "API design" # Top 5 across all types
60
62
  ei quote "you guessed it" # Search quotes only
61
- ei trait -n 3 "problem solving" # Top 3 matching traits
62
- ei --id abc-123 # Look up entity by ID
63
+ ei --recent # Most recently mentioned items
64
+ ei topics --recent "work" # Recent work-related topics
65
+ ei --id abc-123 # Look up entity by ID
63
66
  ei "memory leak" | jq .[0].id | ei --id # Pipe ID from search
64
67
  `);
65
68
  }
@@ -71,7 +74,7 @@ function buildOpenCodeToolContent(): string {
71
74
  'export default tool({',
72
75
  ' description: [',
73
76
  ' "Search the user\'s Ei knowledge base \u2014 a persistent memory store built from conversations.",',
74
- ' "Returns facts, personality traits, people, topics of interest, and quotes.",',
77
+ ' "Returns facts, people, topics of interest, and quotes.",',
75
78
  ' "Use this to recall anything about the user: preferences, relationships, or past discussions.",',
76
79
  ' "Results include entity IDs that can be passed back with lookup=true to get full detail.",',
77
80
  ' ].join(" "),',
@@ -80,10 +83,10 @@ function buildOpenCodeToolContent(): string {
80
83
  ' "Search text, or an entity ID when lookup=true. Supports natural language."',
81
84
  ' ),',
82
85
  ' type: tool.schema',
83
- ' .enum(["facts", "traits", "people", "topics", "quotes"])',
86
+ ' .enum(["facts", "people", "topics", "quotes"])',
84
87
  ' .optional()',
85
88
  ' .describe(',
86
- ' "Filter to a specific data type. Omit to search all types (balanced across all 5)."',
89
+ ' "Filter to a specific data type. Omit to search all types (balanced across all 4).",',
87
90
  ' ),',
88
91
  ' limit: tool.schema',
89
92
  ' .number()',
@@ -126,7 +129,7 @@ async function installOpenCodeTool(): Promise<void> {
126
129
  console.log(` Restart OpenCode to activate.`);
127
130
  }
128
131
 
129
- async function installClaudeCodeMcp(): Promise<void> {
132
+ async function installClaudeCode(): Promise<void> {
130
133
  const home = process.env.HOME || "~";
131
134
  const claudeJsonPath = join(home, ".claude.json");
132
135
 
@@ -136,7 +139,7 @@ async function installClaudeCodeMcp(): Promise<void> {
136
139
  const which = Bun.spawnSync(["which", "claude"], { stdout: "pipe", stderr: "pipe" });
137
140
  if (which.exitCode === 0) {
138
141
  const result = Bun.spawnSync(
139
- ["claude", "mcp", "add", "--scope", "user", "--transport", "stdio", "ei", "--", "ei"],
142
+ ["claude", "mcp", "add", "--scope", "user", "--transport", "stdio", "ei", "--", "ei", "mcp"],
140
143
  { stdout: "pipe", stderr: "pipe" }
141
144
  );
142
145
  if (result.exitCode === 0) {
@@ -159,17 +162,11 @@ async function installClaudeCodeMcp(): Promise<void> {
159
162
  // File doesn't exist or isn't valid JSON — start fresh
160
163
  }
161
164
 
162
- // Resolve the ei binary: if running as compiled binary, argv[1] is our path;
163
- // if running as 'bun src/cli.ts', fall back to 'ei' (assumed on PATH after npm install -g)
164
- const isBunScript = process.argv[1]?.endsWith("/cli.ts") || process.argv[1]?.endsWith("/cli.js");
165
- const command = isBunScript ? "ei" : (process.argv[1] ?? "ei");
166
-
167
165
  const mcpServers = (config.mcpServers ?? {}) as Record<string, unknown>;
168
166
  mcpServers["ei"] = {
169
167
  type: "stdio",
170
- command,
171
- args: [],
172
- env: {},
168
+ command: "ei",
169
+ args: ["mcp"],
173
170
  };
174
171
  config.mcpServers = mcpServers;
175
172
 
@@ -183,6 +180,41 @@ async function installClaudeCodeMcp(): Promise<void> {
183
180
  console.log(` Restart Claude Code to activate.`);
184
181
  }
185
182
 
183
+ async function installCursor(): Promise<void> {
184
+ const home = process.env.HOME || "~";
185
+ const cursorJsonPath = join(home, ".cursor", "mcp.json");
186
+
187
+ let config: Record<string, unknown> = {};
188
+ try {
189
+ const text = await Bun.file(cursorJsonPath).text();
190
+ config = JSON.parse(text) as Record<string, unknown>;
191
+ } catch {
192
+ // File doesn't exist or isn't valid JSON — start fresh
193
+ }
194
+
195
+ const mcpServers = (config.mcpServers ?? {}) as Record<string, unknown>;
196
+ mcpServers["ei"] = {
197
+ type: "stdio",
198
+ command: "ei",
199
+ args: ["mcp"],
200
+ };
201
+ config.mcpServers = mcpServers;
202
+
203
+ await Bun.$`mkdir -p ${join(home, ".cursor")}`;
204
+ const tmpPath = `${cursorJsonPath}.ei-install.tmp`;
205
+ await Bun.write(tmpPath, JSON.stringify(config, null, 2) + "\n");
206
+ const { rename } = await import(/* @vite-ignore */ "fs/promises");
207
+ await rename(tmpPath, cursorJsonPath);
208
+
209
+ console.log(`✓ Installed Ei MCP server to ${cursorJsonPath}`);
210
+ console.log(` Restart Cursor to activate.`);
211
+ }
212
+
213
+ async function installMcpClients(): Promise<void> {
214
+ await installClaudeCode();
215
+ await installCursor();
216
+ }
217
+
186
218
  async function main(): Promise<void> {
187
219
  const args = process.argv.slice(2);
188
220
 
@@ -205,10 +237,15 @@ async function main(): Promise<void> {
205
237
 
206
238
  if (args[0] === "--install") {
207
239
  await installOpenCodeTool();
208
- await installClaudeCodeMcp();
240
+ await installMcpClients();
209
241
  process.exit(0);
210
242
  }
211
243
 
244
+ if (args[0] === "mcp") {
245
+ const { handleMcpCommand } = await import("./cli/mcp.js");
246
+ await handleMcpCommand(args.slice(1));
247
+ process.exit(0);
248
+ }
212
249
 
213
250
  // Handle --id flag: look up entity by ID
214
251
  const idFlagIndex = args.indexOf("--id");
@@ -254,6 +291,7 @@ async function main(): Promise<void> {
254
291
  args: parseableArgs,
255
292
  options: {
256
293
  number: { type: "string", short: "n" },
294
+ recent: { type: "boolean", short: "r" },
257
295
  help: { type: "boolean", short: "h" },
258
296
  },
259
297
  allowPositionals: true,
@@ -271,8 +309,9 @@ async function main(): Promise<void> {
271
309
 
272
310
  const query = parsed.positionals.join(" ").trim();
273
311
  const limit = parsed.values.number ? parseInt(parsed.values.number, 10) : 10;
312
+ const recent = parsed.values.recent === true;
274
313
 
275
- if (!query) {
314
+ if (!query && !recent) {
276
315
  if (targetType) {
277
316
  console.error(`Search text required. Usage: ei ${targetType} "search text"`);
278
317
  } else {
@@ -286,12 +325,14 @@ async function main(): Promise<void> {
286
325
  process.exit(1);
287
326
  }
288
327
 
328
+ const options = { recent };
329
+
289
330
  let result;
290
331
  if (targetType) {
291
332
  const module = await import(`./cli/commands/${targetType}.js`);
292
- result = await module.execute(query, limit);
333
+ result = await module.execute(query, limit, options);
293
334
  } else {
294
- result = await retrieveBalanced(query, limit);
335
+ result = await retrieveBalanced(query, limit, options);
295
336
  }
296
337
 
297
338
  console.log(JSON.stringify(result, null, 2));
@@ -10,7 +10,7 @@ core/
10
10
  ├── state-manager.ts # In-memory state + persistence
11
11
  ├── queue-processor.ts # LLM request queue with priorities
12
12
  ├── llm-client.ts # Multi-provider LLM abstraction
13
- ├── types.ts # All core types (source: CONTRACTS.md)
13
+ ├── types.ts # All core types (canonical source CONTRACTS.md defers to these)
14
14
  ├── handlers/ # LLM response handlers
15
15
  ├── orchestrators/ # Multi-step workflows
16
16
  ├── personas/ # Persona loading logic
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Built-in biographical fact categories that Ei tracks.
3
+ *
4
+ * BUILT_IN_FACTS: Array of fact objects (name field only) for iteration/display.
5
+ * BUILT_IN_FACT_NAMES: Set<string> for O(1) lookup (is this fact built-in?).
6
+ */
7
+
8
+ export const BUILT_IN_FACTS: { name: string }[] = [
9
+ // Core Identity
10
+ { name: "Full Name" },
11
+ { name: "Nickname/Preferred Name" },
12
+ { name: "Birthday" },
13
+ { name: "Birthplace" },
14
+ { name: "Hometown" },
15
+ { name: "Current Location" },
16
+
17
+ // Professional
18
+ { name: "Current Job Title" },
19
+ { name: "Current Employer" },
20
+ { name: "Industry/Field" },
21
+ { name: "Years of Experience" },
22
+
23
+ // Personal
24
+ { name: "Marital Status" },
25
+ { name: "Spouse Name" },
26
+ { name: "Spouse Birthday" },
27
+ { name: "Date of Marriage" },
28
+ { name: "Children" },
29
+ { name: "Parents" },
30
+ { name: "Gender" },
31
+ { name: "Pronouns" },
32
+ { name: "Eye Color" },
33
+ { name: "Hair Color" },
34
+ { name: "Height" },
35
+ { name: "Weight" },
36
+
37
+ // Background
38
+ { name: "Nationality/Citizenship" },
39
+ { name: "Languages Spoken" },
40
+ { name: "Education Level" },
41
+ { name: "School/University" },
42
+ { name: "Field of Study" },
43
+ { name: "Military Service" },
44
+ { name: "Religious Affiliation" },
45
+ ];
46
+
47
+ export const BUILT_IN_FACT_NAMES: Set<string> = new Set(
48
+ BUILT_IN_FACTS.map((f) => f.name)
49
+ );
@@ -0,0 +1 @@
1
+ export * from "./built-in-facts";
@@ -49,7 +49,6 @@ export function stripHumanEmbeddings(human: HumanEntity): HumanEntity {
49
49
  return {
50
50
  ...human,
51
51
  facts: (human.facts ?? []).map(stripDataItemEmbedding),
52
- traits: (human.traits ?? []).map(stripDataItemEmbedding),
53
52
  topics: (human.topics ?? []).map(stripDataItemEmbedding),
54
53
  people: (human.people ?? []).map(stripDataItemEmbedding),
55
54
  quotes: (human.quotes ?? []).map(stripQuoteEmbedding),
@@ -54,6 +54,14 @@ export function getItemEmbeddingText(item: { name: string; description?: string
54
54
  return item.name;
55
55
  }
56
56
 
57
+ export function getTopicEmbeddingText(topic: { name: string; category?: string; description?: string }): string {
58
+ return [topic.name, topic.category, topic.description].filter(Boolean).join(' - ');
59
+ }
60
+
61
+ export function getPersonEmbeddingText(person: { name: string; relationship?: string; description?: string }): string {
62
+ return [person.name, person.relationship, person.description].filter(Boolean).join(' - ');
63
+ }
64
+
57
65
  export function needsEmbeddingUpdate(
58
66
  existing: { name: string; description?: string } | undefined,
59
67
  incoming: { name: string; description?: string }
@@ -1,7 +1,7 @@
1
1
  import { StateManager } from "../state-manager.js";
2
2
  import { LLMResponse } from "../types.js";
3
3
  import type { DedupResult } from "../../prompts/ceremony/types.js";
4
- import type { DataItemType, Fact, Trait, Topic, Person, Quote } from "../types/data-items.js";
4
+ import type { DataItemType, Fact, Topic, Person, Quote } from "../types/data-items.js";
5
5
  import { getEmbeddingService } from "../embedding-service.js";
6
6
 
7
7
  /**
@@ -24,7 +24,7 @@ export async function handleDedupCurate(
24
24
  const state = stateManager.getHuman();
25
25
 
26
26
  // Validate entity_type
27
- if (!entity_type || !['fact', 'trait', 'topic', 'person'].includes(entity_type)) {
27
+ if (!entity_type || !['topic', 'person'].includes(entity_type)) {
28
28
  console.error(`[Dedup] Invalid entity_type: "${entity_type}" (from request data)`, response.request.data);
29
29
  return;
30
30
  }
@@ -50,9 +50,8 @@ export async function handleDedupCurate(
50
50
  console.log(`[Dedup] Processing cluster: ${decisions.update.length} updates, ${decisions.remove.length} removals, ${decisions.add.length} additions`);
51
51
 
52
52
  // Map entity_type to pluralized state property name
53
- const pluralMap: Record<DataItemType, 'facts' | 'traits' | 'topics' | 'people'> = {
53
+ const pluralMap: Record<string, 'facts' | 'topics' | 'people'> = {
54
54
  fact: 'facts',
55
- trait: 'traits',
56
55
  topic: 'topics',
57
56
  person: 'people'
58
57
  };
@@ -65,15 +64,14 @@ export async function handleDedupCurate(
65
64
  entity_ids,
66
65
  stateKeys: Object.keys(state),
67
66
  factsExists: !!state.facts,
68
- traitsExists: !!state.traits,
69
67
  topicsExists: !!state.topics,
70
68
  peopleExists: !!state.people
71
69
  });
72
70
  return;
73
71
  }
74
72
  const entities = entity_ids
75
- .map((id: string) => entityList.find((e: Fact | Trait | Topic | Person) => e.id === id))
76
- .filter((e: Fact | Trait | Topic | Person | undefined): e is (Fact | Trait | Topic | Person) => e !== undefined);
73
+ .map((id: string) => entityList.find((e: Fact | Topic | Person) => e.id === id))
74
+ .filter((e: Fact | Topic | Person | undefined): e is (Fact | Topic | Person) => e !== undefined);
77
75
 
78
76
  if (entities.length === 0) {
79
77
  console.warn(`[Dedup] No entities found for cluster (already merged?)`);
@@ -109,7 +107,7 @@ export async function handleDedupCurate(
109
107
  // =========================================================================
110
108
 
111
109
  for (const update of decisions.update) {
112
- const entity = entityList.find((e: Fact | Trait | Topic | Person) => e.id === update.id);
110
+ const entity = entityList.find((e: Fact | Topic | Person) => e.id === update.id);
113
111
 
114
112
  if (!entity) {
115
113
  console.warn(`[Dedup] Entity ${update.id} not found (already merged?)`);
@@ -148,8 +146,6 @@ export async function handleDedupCurate(
148
146
  // Type-safe cast based on entity_type
149
147
  if (entity_type === 'fact') {
150
148
  stateManager.human_fact_upsert(updatedEntity as Fact);
151
- } else if (entity_type === 'trait') {
152
- stateManager.human_trait_upsert(updatedEntity as Trait);
153
149
  } else if (entity_type === 'topic') {
154
150
  stateManager.human_topic_upsert(updatedEntity as Topic);
155
151
  } else if (entity_type === 'person') {
@@ -163,7 +159,7 @@ export async function handleDedupCurate(
163
159
  // =========================================================================
164
160
 
165
161
  for (const removal of decisions.remove) {
166
- const entity = entityList.find((e: Fact | Trait | Topic | Person) => e.id === removal.to_be_removed);
162
+ const entity = entityList.find((e: Fact | Topic | Person) => e.id === removal.to_be_removed);
167
163
 
168
164
  if (!entity) {
169
165
  console.warn(`[Dedup] Entity ${removal.to_be_removed} already deleted`);
@@ -172,7 +168,7 @@ export async function handleDedupCurate(
172
168
 
173
169
  // Remove via StateManager (also cleans up quote references)
174
170
  const removeMethod = `human_${entity_type}_remove` as
175
- 'human_fact_remove' | 'human_trait_remove' | 'human_topic_remove' | 'human_person_remove';
171
+ 'human_fact_remove' | 'human_topic_remove' | 'human_person_remove';
176
172
 
177
173
  const removed = stateManager[removeMethod](removal.to_be_removed);
178
174
  if (removed) {
@@ -206,14 +202,10 @@ export async function handleDedupCurate(
206
202
  description: addition.description,
207
203
  sentiment: addition.sentiment ?? 0.0,
208
204
  last_updated: new Date().toISOString(),
205
+ learned_by: "ei",
206
+ last_changed_by: "ei",
209
207
  embedding,
210
208
  // Type-specific fields with defaults
211
- ...(entity_type === 'trait' && { strength: addition.strength ?? 0.5 }),
212
- ...(entity_type === 'fact' && {
213
- confidence: addition.confidence ?? 0.5,
214
- validated: 'unknown' as import("../types/enums.js").ValidationLevel,
215
- validated_date: ''
216
- }),
217
209
  ...((entity_type === 'topic' || entity_type === 'person') && {
218
210
  exposure_current: addition.exposure_current ?? 0.0,
219
211
  exposure_desired: addition.exposure_desired ?? 0.5,
@@ -224,11 +216,7 @@ export async function handleDedupCurate(
224
216
  };
225
217
 
226
218
  // Type-safe cast based on entity_type
227
- if (entity_type === 'fact') {
228
- stateManager.human_fact_upsert(newEntity as Fact);
229
- } else if (entity_type === 'trait') {
230
- stateManager.human_trait_upsert(newEntity as Trait);
231
- } else if (entity_type === 'topic') {
219
+ if (entity_type === 'topic') {
232
220
  stateManager.human_topic_upsert(newEntity as Topic);
233
221
  } else if (entity_type === 'person') {
234
222
  stateManager.human_person_upsert(newEntity as Person);
@@ -1,7 +1,6 @@
1
1
  import {
2
2
  ContextStatus,
3
3
  LLMNextStep,
4
- ValidationLevel,
5
4
  type LLMResponse,
6
5
  type Message,
7
6
  } from "../types.js";
@@ -77,13 +76,13 @@ export function handleEiHeartbeat(response: LLMResponse, state: StateManager): v
77
76
  timestamp: now,
78
77
  read: false,
79
78
  context_status: ContextStatus.Default,
80
- f: true, r: true, p: true, o: true,
79
+ f: true, t: true, p: true,
81
80
  });
82
81
 
83
82
  if (found.type === "fact") {
84
83
  const factsNav = isTUI ? "using /me facts" : "using \u2630 \u2192 My Data";
85
84
  sendMessage(`Another persona updated a fact called "${found.name}" to "${found.description}". If that's right, you can lock it from further changes by ${factsNav}.`);
86
- state.human_fact_upsert({ ...found, validated: ValidationLevel.Ei, validated_date: now });
85
+ state.human_fact_upsert({ ...found, validated_date: now });
87
86
  console.log(`[handleEiHeartbeat] Notified about fact "${found.name}"`);
88
87
  return;
89
88
  }
@@ -1,57 +1,92 @@
1
- import type { LLMResponse } from "../types.js";
1
+ import type { LLMResponse, Fact } from "../types.js";
2
2
  import type { StateManager } from "../state-manager.js";
3
3
  import type {
4
- FactScanResult,
5
- TraitScanResult,
4
+ FactFindResult,
6
5
  TopicScanResult,
7
6
  PersonScanResult,
7
+ TopicScanCandidate,
8
8
  } from "../../prompts/human/types.js";
9
- import { queueItemMatch, type ExtractionContext } from "../orchestrators/index.js";
9
+ import { queueTopicMatch, queuePersonMatch, type ExtractionContext } from "../orchestrators/index.js";
10
10
  import { markMessagesExtracted } from "./utils.js";
11
+ import { BUILT_IN_FACT_NAMES } from "../constants/built-in-facts.js";
12
+ import { getEmbeddingService, getItemEmbeddingText } from "../embedding-service.js";
11
13
 
12
- export async function handleHumanFactScan(response: LLMResponse, state: StateManager): Promise<void> {
13
- const result = response.parsed as FactScanResult | undefined;
14
+ export async function handleFactFind(response: LLMResponse, state: StateManager): Promise<void> {
15
+ const result = response.parsed as FactFindResult | undefined;
14
16
 
15
17
  // Mark messages as scanned regardless of whether facts were found
16
18
  markMessagesExtracted(response, state, "f");
17
19
 
18
20
  if (!result?.facts || !Array.isArray(result.facts)) {
19
- console.log("[handleHumanFactScan] No facts detected or invalid result");
21
+ console.log("[handleFactFind] No facts detected or invalid result");
20
22
  return;
21
23
  }
22
24
 
23
25
  const context = response.request.data as unknown as ExtractionContext;
24
26
  if (!context?.personaId) return;
25
27
 
26
- for (const candidate of result.facts) {
27
- await queueItemMatch("fact", candidate, context, state);
28
- }
29
- console.log(`[handleHumanFactScan] Queued ${result.facts.length} fact(s) for matching`);
30
- }
28
+ const human = state.getHuman();
29
+ const now = new Date().toISOString();
30
+ let upsertCount = 0;
31
31
 
32
- export async function handleHumanTraitScan(response: LLMResponse, state: StateManager): Promise<void> {
33
- const result = response.parsed as TraitScanResult | undefined;
34
-
35
- markMessagesExtracted(response, state, "r");
36
-
37
- if (!result?.traits || !Array.isArray(result.traits)) {
38
- console.log("[handleHumanTraitScan] No traits detected or invalid result");
39
- return;
40
- }
32
+ for (const factResult of result.facts) {
33
+ // Only upsert facts that match a built-in name
34
+ if (!BUILT_IN_FACT_NAMES.has(factResult.name)) {
35
+ console.log(`[handleFactFind] Skipping non-built-in fact: "${factResult.name}"`);
36
+ continue;
37
+ }
41
38
 
42
- const context = response.request.data as unknown as ExtractionContext;
43
- if (!context?.personaId) return;
39
+ // Find the existing fact in state
40
+ const existingFact = human.facts.find(f => f.name === factResult.name);
41
+ if (!existingFact) {
42
+ console.log(`[handleFactFind] Skipping unknown fact: "${factResult.name}"`);
43
+ continue;
44
+ }
44
45
 
45
- for (const candidate of result.traits) {
46
- await queueItemMatch("trait", candidate, context, state);
46
+ // Skip facts that already have descriptions (only fill empty ones)
47
+ if (existingFact.description && existingFact.description !== "") {
48
+ console.log(`[handleFactFind] Skipping fact with existing description: "${factResult.name}"`);
49
+ continue;
50
+ }
51
+
52
+ // Skip if the LLM returned a null/empty value — don't store null descriptions
53
+ if (!factResult.value) {
54
+ console.log(`[handleFactFind] Skipping fact with null/empty value: "${factResult.name}"`);
55
+ continue;
56
+ }
57
+
58
+ // Compute embedding for the updated fact
59
+ let embedding: number[] | undefined;
60
+ try {
61
+ const embeddingService = getEmbeddingService();
62
+ const text = getItemEmbeddingText({ name: factResult.name, description: factResult.value });
63
+ embedding = await embeddingService.embed(text);
64
+ } catch (err) {
65
+ console.warn(`[handleFactFind] Failed to compute embedding for fact "${factResult.name}":`, err);
66
+ }
67
+
68
+ const updatedFact: Fact = {
69
+ ...existingFact,
70
+ description: factResult.value,
71
+ last_updated: now,
72
+ last_mentioned: now,
73
+ learned_by: existingFact.learned_by ?? context.personaId,
74
+ last_changed_by: context.personaId,
75
+ embedding,
76
+ };
77
+
78
+ state.human_fact_upsert(updatedFact);
79
+ upsertCount++;
47
80
  }
48
- console.log(`[handleHumanTraitScan] Queued ${result.traits.length} trait(s) for matching`);
81
+
82
+ console.log(`[handleFactFind] Upserted ${upsertCount} fact(s)`);
49
83
  }
50
84
 
85
+
51
86
  export async function handleHumanTopicScan(response: LLMResponse, state: StateManager): Promise<void> {
52
87
  const result = response.parsed as TopicScanResult | undefined;
53
88
 
54
- markMessagesExtracted(response, state, "p");
89
+ markMessagesExtracted(response, state, "t");
55
90
 
56
91
  if (!result?.topics || !Array.isArray(result.topics)) {
57
92
  console.log("[handleHumanTopicScan] No topics detected or invalid result");
@@ -61,8 +96,9 @@ export async function handleHumanTopicScan(response: LLMResponse, state: StateMa
61
96
  const context = response.request.data as unknown as ExtractionContext;
62
97
  if (!context?.personaId) return;
63
98
 
99
+ const extractionModel = (response.request.data as Record<string, unknown>).extraction_model as string | undefined;
64
100
  for (const candidate of result.topics) {
65
- await queueItemMatch("topic", candidate, context, state);
101
+ await queueTopicMatch(candidate, context, state, extractionModel);
66
102
  }
67
103
  console.log(`[handleHumanTopicScan] Queued ${result.topics.length} topic(s) for matching`);
68
104
  }
@@ -70,7 +106,7 @@ export async function handleHumanTopicScan(response: LLMResponse, state: StateMa
70
106
  export async function handleHumanPersonScan(response: LLMResponse, state: StateManager): Promise<void> {
71
107
  const result = response.parsed as PersonScanResult | undefined;
72
108
 
73
- markMessagesExtracted(response, state, "o");
109
+ markMessagesExtracted(response, state, "p");
74
110
 
75
111
  if (!result?.people || !Array.isArray(result.people)) {
76
112
  console.log("[handleHumanPersonScan] No people detected or invalid result");
@@ -80,8 +116,38 @@ export async function handleHumanPersonScan(response: LLMResponse, state: StateM
80
116
  const context = response.request.data as unknown as ExtractionContext;
81
117
  if (!context?.personaId) return;
82
118
 
119
+ const extractionModel = (response.request.data as Record<string, unknown>).extraction_model as string | undefined;
83
120
  for (const candidate of result.people) {
84
- await queueItemMatch("person", candidate, context, state);
121
+ await queuePersonMatch(candidate, context, state, extractionModel);
85
122
  }
86
123
  console.log(`[handleHumanPersonScan] Queued ${result.people.length} person(s) for matching`);
87
124
  }
125
+
126
+ export async function handleEventScan(response: LLMResponse, state: StateManager): Promise<void> {
127
+ markMessagesExtracted(response, state, "e");
128
+
129
+ const result = response.parsed as { events?: Array<{ name: string; description: string; reason: string }> } | undefined;
130
+
131
+ if (!result?.events || !Array.isArray(result.events) || result.events.length === 0) {
132
+ console.log("[handleEventScan] No epic events detected");
133
+ return;
134
+ }
135
+
136
+ const context = response.request.data as unknown as ExtractionContext;
137
+ if (!context?.personaId) return;
138
+
139
+ const extractionModel = (response.request.data as Record<string, unknown>).extraction_model as string | undefined;
140
+
141
+ for (const event of result.events) {
142
+ const candidate: TopicScanCandidate = {
143
+ name: event.name,
144
+ description: event.description,
145
+ category: "Event",
146
+ reason: event.reason,
147
+ };
148
+ await queueTopicMatch(candidate, context, state, extractionModel);
149
+ }
150
+
151
+ console.log(`[handleEventScan] Queued ${result.events.length} event(s) for matching`);
152
+ }
153
+