ei-tui 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +16 -0
  2. package/package.json +2 -23
  3. package/src/cli/README.md +12 -2
  4. package/src/cli/mcp.ts +12 -4
  5. package/src/cli/retrieval.ts +162 -0
  6. package/src/cli.ts +7 -1
  7. package/src/core/handlers/dedup.ts +4 -15
  8. package/src/core/handlers/document-segmentation.ts +5 -7
  9. package/src/core/handlers/heartbeat.ts +5 -10
  10. package/src/core/handlers/human-matching.ts +8 -0
  11. package/src/core/handlers/index.ts +2 -0
  12. package/src/core/handlers/knowledge-synthesis.ts +48 -0
  13. package/src/core/handlers/persona-generation.ts +4 -8
  14. package/src/core/handlers/persona-response.ts +3 -4
  15. package/src/core/handlers/persona-topics.ts +2 -4
  16. package/src/core/handlers/rewrite.ts +26 -9
  17. package/src/core/handlers/rooms.ts +6 -12
  18. package/src/core/heartbeat-manager.ts +10 -0
  19. package/src/core/llm-client.ts +13 -3
  20. package/src/core/message-manager.ts +2 -4
  21. package/src/core/orchestrators/ceremony.ts +45 -22
  22. package/src/core/orchestrators/human-extraction.ts +10 -1
  23. package/src/core/processor.ts +275 -7
  24. package/src/core/queue-manager.ts +10 -0
  25. package/src/core/state-manager.ts +35 -0
  26. package/src/core/tools/builtin/fetch-memory.ts +6 -6
  27. package/src/core/tools/builtin/fetch-message.ts +27 -1
  28. package/src/core/tools/builtin/find-memory.ts +11 -3
  29. package/src/core/tools/index.ts +3 -3
  30. package/src/core/tools/types.ts +1 -1
  31. package/src/core/types/data-items.ts +1 -1
  32. package/src/core/types/entities.ts +7 -1
  33. package/src/core/types/enums.ts +1 -0
  34. package/src/core/types/integrations.ts +3 -1
  35. package/src/core/types/llm.ts +0 -9
  36. package/src/core/utils/message-id.ts +114 -0
  37. package/src/integrations/claude-code/importer.ts +12 -5
  38. package/src/integrations/cursor/importer.ts +12 -5
  39. package/src/integrations/document/importer.ts +1 -1
  40. package/src/integrations/document/unsource.ts +11 -14
  41. package/src/integrations/opencode/importer.ts +19 -6
  42. package/src/integrations/opencode/json-reader.ts +65 -0
  43. package/src/integrations/opencode/sqlite-reader.ts +33 -0
  44. package/src/integrations/opencode/types.ts +8 -0
  45. package/src/integrations/persona-history/importer.ts +9 -0
  46. package/src/prompts/ceremony/people-rewrite.ts +2 -2
  47. package/src/prompts/ceremony/topic-rewrite.ts +2 -2
  48. package/src/prompts/heartbeat/check.ts +5 -2
  49. package/src/prompts/heartbeat/ei.ts +7 -0
  50. package/src/prompts/heartbeat/types.ts +5 -0
  51. package/src/prompts/index.ts +3 -0
  52. package/src/prompts/response/sections.ts +30 -16
  53. package/src/prompts/room/sections.ts +28 -6
  54. package/src/prompts/synthesis/index.ts +101 -0
  55. package/src/prompts/synthesis/types.ts +26 -0
  56. package/src/prompts/trait-utils.ts +33 -0
  57. package/tui/README.md +2 -0
  58. package/tui/src/commands/generate.tsx +98 -0
  59. package/tui/src/commands/unsource.tsx +17 -10
  60. package/tui/src/components/GeneratedDocsOverlay.tsx +136 -0
  61. package/tui/src/components/PromptInput.tsx +2 -0
  62. package/tui/src/context/ei.tsx +49 -2
  63. package/tui/src/util/help-content.ts +11 -0
  64. package/tui/src/util/logger.ts +22 -2
  65. package/tui/src/util/provider-detection.ts +5 -2
  66. package/tui/src/util/yaml-provider.ts +2 -8
package/README.md CHANGED
@@ -185,6 +185,22 @@ Ei splits the document into segments, runs them through the extraction pipeline,
185
185
 
186
186
  Both surfaces show you which documents have been imported and let you remove their extracted knowledge (web: Delete button in the Documents tab; TUI: `/unsource <source_tag>`).
187
187
 
188
+ ## Knowledge Share
189
+
190
+ Sometimes you want to take what Ei knows and turn it into something you can hand to another human. A new teammate joining a project. A briefing doc before a meeting. A brain dump before a vacation.
191
+
192
+ **TUI**:
193
+ ```bash
194
+ /generate everything about the Uniform project
195
+ /generate comprehensive runbook for the IDP deployment issues
196
+ ```
197
+
198
+ Give it a subject description and Ei searches its memory, chases down related entities, and synthesizes a clean markdown document using your `rewrite_model` (an Opus-class model is recommended — it'll warn you if you're about to use something less capable). The document lands in `$EI_DATA_PATH/docs/` automatically.
199
+
200
+ `/generate` with no args opens a list of your existing generated documents where you can re-run, re-export, or delete them.
201
+
202
+ **Web**: Open **☰ menu** → **My Data** → **Documents** tab. Click **Generate**, enter a subject, and Ei does the rest. Generated docs appear in the same list as imported ones.
203
+
188
204
  ## Built-in Tool Integrations
189
205
 
190
206
  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:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ei-tui",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "author": "Flare576",
5
5
  "repository": {
6
6
  "type": "git",
@@ -51,28 +51,7 @@
51
51
  "test:e2e:tui": "cd tui && npm run test:e2e",
52
52
  "test:e2e:ui": "playwright test --ui",
53
53
  "test:e2e:debug": "playwright test --debug",
54
- "test:evals": "vite-node tests/evals/reflection-critic.eval.ts",
55
- "test:evals:observe": "vite-node tests/evals/reflection-critic.observe.ts",
56
- "test:evals:fact-find": "vite-node tests/evals/fact-find.eval.ts",
57
- "test:evals:topic-scan": "vite-node tests/evals/topic-scan.eval.ts",
58
- "test:evals:topic-match": "vite-node tests/evals/topic-match.eval.ts",
59
- "test:evals:topic-update": "vite-node tests/evals/topic-update.eval.ts",
60
- "test:evals:topic-technical": "vite-node tests/evals/topic-technical.eval.ts",
61
- "test:evals:rewrite-scan": "vite-node tests/evals/rewrite-scan.eval.ts",
62
- "test:evals:rewrite-rewrite": "vite-node tests/evals/rewrite-rewrite.eval.ts",
63
- "test:evals:rewrite-real-data": "vite-node tests/evals/rewrite-real-data.eval.ts",
64
- "test:evals:topic-validate": "vite-node tests/evals/topic-validate.eval.ts",
65
- "test:evals:person-scan": "vite-node tests/evals/person-scan.eval.ts",
66
- "test:evals:person-scan-confidence": "vite-node tests/evals/person-scan-confidence.eval.ts",
67
-
68
- "test:evals:person-update": "vite-node tests/evals/person-update.eval.ts",
69
- "test:evals:persona-trait": "vite-node tests/evals/persona-trait-extraction.eval.ts",
70
- "test:evals:dedup": "vite-node tests/evals/dedup-tool-calls.eval.ts",
71
- "test:evals:response-read-memory": "vite-node tests/evals/response-read-memory.eval.ts",
72
- "test:evals:response-pending-update": "vite-node tests/evals/response-pending-update.eval.ts",
73
- "test:evals:heartbeat-pending-update": "vite-node tests/evals/heartbeat-pending-update.eval.ts",
74
- "test:evals:real-data": "vite-node tests/evals/real-data-example.eval.ts",
75
- "test:evals:persona-data-check": "vite-node tests/evals/persona-data-check.eval.ts",
54
+ "test:evals": "vite-node tests/evals/run.ts",
76
55
  "test:all": "npm run test && npm run test:e2e && npm run test:e2e:tui",
77
56
  "typecheck": "tsc --noEmit",
78
57
  "web": "cd web && npm run dev",
package/src/cli/README.md CHANGED
@@ -13,7 +13,7 @@ ei personas -n 5 "query string" # Return up to 5 personas (name match)
13
13
  ei --persona "Beta" "query string" # Filter results to what Beta has learned
14
14
  ei --recent # Most recently mentioned items (no query needed)
15
15
  ei --persona "Beta" --recent # Most recently mentioned items Beta has learned
16
- ei --id <id> # Look up a specific entity by ID
16
+ ei --id <id> # Look up entity by ID — or fetch a message by FQ ID
17
17
  echo <id> | ei --id # Look up entity by ID from stdin
18
18
  ei --install # Register Ei with OpenCode, Claude Code, and Cursor
19
19
  ei mcp # Start the Ei MCP stdio server (for Cursor/Claude Desktop)
@@ -29,6 +29,16 @@ The `--id` flag is designed for piping. For example, search for a topic and then
29
29
  ei "memory leak" | jq '.[0].id' | ei --id
30
30
  ```
31
31
 
32
+ It also resolves fully-qualified message IDs from any supported integration, returning the original message content and session context:
33
+
34
+ ```sh
35
+ ei --id "opencode:jeremys-macbook-pro:ses_38a7...:msg_c75b..."
36
+ ei --id "claudecode:my-machine:session-uuid:message-uuid"
37
+ ei --id "cursor:my-machine:composer-uuid:bubble-uuid"
38
+ ```
39
+
40
+ Quotes surfaced by `ei_search` or `ei_find_memory` include a `message_id` field in this format — pipe it to `ei --id` to read the original conversation.
41
+
32
42
  # OpenCode Integration
33
43
 
34
44
  ## Quick Install
@@ -156,7 +166,7 @@ The MCP server exposes these tools to Claude Code, Cursor, and OpenCode:
156
166
  | `ei_lookup` | Full-record lookup for any entity by ID (facts, topics, people, quotes, personas). |
157
167
  | `ei_find_memory` | Grouped human-data search — facts, topics, people, quotes. Returns results grouped by type. Mirrors the persona `find_memory` tool interface. |
158
168
  | `ei_fetch_memory` | Full-record lookup for a human entity (Fact, Topic, Person, or Quote) by ID. Returns the complete record including all fields. |
159
- | `ei_fetch_message` | Retrieve a specific message by ID with optional `before`/`after` context window. Searches persona conversations and room messages. |
169
+ | `ei_fetch_message` | Retrieve a specific message by fully-qualified ID with optional `before`/`after` context window. Routes to the correct source: `ei:uuid` searches Ei state, `opencode:machine:session:id` queries the OpenCode SQLite DB, `claudecode:...` scans Claude Code JSONL files, `cursor:...` reads the Cursor global DB. Returns message content, surrounding context, and session metadata. |
160
170
 
161
171
  ### `ei_search` / `ei_find_memory` arguments
162
172
 
package/src/cli/mcp.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
3
  import { z } from "zod";
4
- import { retrieveBalanced, lookupById, loadLatestState, type BalancedResult } from "./retrieval.js";
4
+ import { retrieveBalanced, lookupById, resolveExternalMessage, loadLatestState, type BalancedResult } from "./retrieval.js";
5
5
  import type { StorageState } from "../core/types.js";
6
6
  import type { Message } from "../core/types.js";
7
7
  import type { RoomMessage } from "../core/types/rooms.js";
@@ -270,6 +270,17 @@ export function createMcpServer(): McpServer {
270
270
  },
271
271
  },
272
272
  async ({ id, before: beforeCount, after: afterCount }) => {
273
+ const beforeN = Math.max(0, Math.floor(beforeCount ?? 0));
274
+ const afterN = Math.max(0, Math.floor(afterCount ?? 0));
275
+
276
+ const externalResult = await resolveExternalMessage(id, beforeN, afterN);
277
+ if (externalResult) {
278
+ if ("error" in externalResult) {
279
+ return { content: [{ type: "text" as const, text: String(externalResult.error) }] };
280
+ }
281
+ return { content: [{ type: "text" as const, text: JSON.stringify(externalResult, null, 2) }] };
282
+ }
283
+
273
284
  const state = await loadLatestState();
274
285
  if (!state) {
275
286
  return {
@@ -277,9 +288,6 @@ export function createMcpServer(): McpServer {
277
288
  };
278
289
  }
279
290
 
280
- const beforeN = Math.max(0, Math.floor(beforeCount ?? 0));
281
- const afterN = Math.max(0, Math.floor(afterCount ?? 0));
282
-
283
291
  const stripPersonaMessage = (m: Message) => ({
284
292
  id: m.id,
285
293
  role: m.role,
@@ -6,6 +6,8 @@ import { crossFind } from "../core/utils/index.ts";
6
6
  import { join } from "path";
7
7
  import { readFile } from "fs/promises";
8
8
  import { getEmbeddingService, findTopK } from "../core/embedding-service";
9
+ import { parseMessageId } from "../core/utils/message-id.js";
10
+ import { getMachineId } from "../integrations/machine-id.js";
9
11
 
10
12
  const STATE_FILE = "state.json";
11
13
  const BACKUP_FILE = "state.backup.json";
@@ -86,6 +88,7 @@ export interface QuoteResult {
86
88
  text: string;
87
89
  speaker: string;
88
90
  timestamp: string;
91
+ message_id: string | null;
89
92
  linked_items: LinkedItem[];
90
93
  }
91
94
 
@@ -164,6 +167,7 @@ export function mapQuote(quote: Quote, state: StorageState): QuoteResult {
164
167
  text: quote.text,
165
168
  speaker: quote.speaker,
166
169
  timestamp: quote.timestamp,
170
+ message_id: quote.message_id,
167
171
  linked_items: resolveLinkedItems(quote.data_item_ids, state),
168
172
  };
169
173
  }
@@ -394,6 +398,164 @@ export async function retrieveBalanced(
394
398
  return embeddingFinal;
395
399
  }
396
400
 
401
+ const OPENCODE_MESSAGE_ID = /^msg_[a-zA-Z0-9]+$/;
402
+
403
+ /** @deprecated Use resolveExternalMessage */
404
+ export async function resolveOpenCodeMessage(
405
+ id: string,
406
+ before = 0,
407
+ after = 0
408
+ ): Promise<Record<string, unknown> | null> {
409
+ return resolveExternalMessage(id, before, after);
410
+ }
411
+
412
+ export async function resolveExternalMessage(
413
+ id: string,
414
+ before = 0,
415
+ after = 0
416
+ ): Promise<Record<string, unknown> | null> {
417
+ const parsed = parseMessageId(id);
418
+
419
+ switch (parsed.integration) {
420
+ case "ei": {
421
+ const state = await loadLatestState();
422
+ if (!state) return null;
423
+
424
+ for (const { entity: persona, messages } of Object.values(state.personas)) {
425
+ const idx = messages.findIndex(m => m.id === id);
426
+ if (idx === -1) continue;
427
+ const msg = messages[idx];
428
+ return {
429
+ type: "opencode_message",
430
+ message: { id: msg.id, role: msg.role, content: msg.content ?? "", timestamp: msg.timestamp },
431
+ before: messages.slice(Math.max(0, idx - before), idx).map(m => ({ id: m.id, role: m.role, content: m.content ?? "", timestamp: m.timestamp })),
432
+ after: messages.slice(idx + 1, idx + 1 + after).map(m => ({ id: m.id, role: m.role, content: m.content ?? "", timestamp: m.timestamp })),
433
+ session: { id: persona.id, title: persona.display_name, directory: "" },
434
+ source: "ei",
435
+ };
436
+ }
437
+
438
+ for (const room of Object.values(state.rooms ?? {})) {
439
+ const idx = room.messages.findIndex(m => m.id === id);
440
+ if (idx === -1) continue;
441
+ const msg = room.messages[idx];
442
+ return {
443
+ type: "opencode_message",
444
+ message: { id: msg.id, role: msg.role, content: msg.content ?? "", timestamp: msg.timestamp },
445
+ before: room.messages.slice(Math.max(0, idx - before), idx).map(m => ({ id: m.id, role: m.role, content: m.content ?? "", timestamp: m.timestamp })),
446
+ after: room.messages.slice(idx + 1, idx + 1 + after).map(m => ({ id: m.id, role: m.role, content: m.content ?? "", timestamp: m.timestamp })),
447
+ session: { id: room.id, title: room.display_name, directory: "" },
448
+ source: "ei",
449
+ };
450
+ }
451
+
452
+ return null;
453
+ }
454
+
455
+ case "opencode": {
456
+ if (parsed.machine !== getMachineId()) {
457
+ return { error: `Message is from machine '${parsed.machine}', not available on this machine (${getMachineId()})` };
458
+ }
459
+ try {
460
+ const { createOpenCodeReader } = await import("../integrations/opencode/reader-factory.js");
461
+ const reader = await createOpenCodeReader();
462
+ const win = await reader.getMessageById(parsed.nativeId, before, after);
463
+ if (!win) return null;
464
+ return {
465
+ type: "opencode_message",
466
+ message: { id: win.message.id, role: win.message.role, content: win.message.content, timestamp: win.message.timestamp, agent: win.message.agent },
467
+ before: win.before.map(m => ({ id: m.id, role: m.role, content: m.content, timestamp: m.timestamp, agent: m.agent })),
468
+ after: win.after.map(m => ({ id: m.id, role: m.role, content: m.content, timestamp: m.timestamp, agent: m.agent })),
469
+ session: { id: win.session.id, title: win.session.title, directory: win.session.directory },
470
+ source: "opencode",
471
+ };
472
+ } catch {
473
+ return null;
474
+ }
475
+ }
476
+
477
+ case "claudecode": {
478
+ if (parsed.machine !== getMachineId()) {
479
+ return { error: `Message is from machine '${parsed.machine}', not available on this machine (${getMachineId()})` };
480
+ }
481
+ try {
482
+ const { ClaudeCodeReader } = await import("../integrations/claude-code/reader.js");
483
+ const reader = new ClaudeCodeReader();
484
+ const messages = await reader.getMessagesForSession(parsed.session!);
485
+ const idx = messages.findIndex(m => m.id === parsed.nativeId);
486
+ if (idx === -1) return null;
487
+ const msg = messages[idx];
488
+ return {
489
+ type: "opencode_message",
490
+ message: { id: msg.id, role: msg.role, content: msg.content, timestamp: msg.timestamp },
491
+ before: messages.slice(Math.max(0, idx - before), idx).map(m => ({ id: m.id, role: m.role, content: m.content, timestamp: m.timestamp })),
492
+ after: messages.slice(idx + 1, idx + 1 + after).map(m => ({ id: m.id, role: m.role, content: m.content, timestamp: m.timestamp })),
493
+ session: { id: parsed.session!, title: parsed.session!, directory: "" },
494
+ source: "claudecode",
495
+ };
496
+ } catch {
497
+ return null;
498
+ }
499
+ }
500
+
501
+ case "cursor": {
502
+ if (parsed.machine !== getMachineId()) {
503
+ return { error: `Message is from machine '${parsed.machine}', not available on this machine (${getMachineId()})` };
504
+ }
505
+ try {
506
+ const { CursorReader } = await import("../integrations/cursor/reader.js");
507
+ const reader = new CursorReader();
508
+ const sessions = await reader.getSessions();
509
+ const session = sessions.find(s => s.id === parsed.session);
510
+ if (!session) return null;
511
+ const idx = session.messages.findIndex(m => m.id === parsed.nativeId);
512
+ if (idx === -1) return null;
513
+ const msg = session.messages[idx];
514
+ const mapCursor = (m: { id: string; type: 1 | 2; text: string; timestamp: string }) => ({
515
+ id: m.id,
516
+ role: (m.type === 1 ? "user" : "assistant") as "user" | "assistant",
517
+ content: m.text,
518
+ timestamp: m.timestamp,
519
+ });
520
+ return {
521
+ type: "opencode_message",
522
+ message: mapCursor(msg),
523
+ before: session.messages.slice(Math.max(0, idx - before), idx).map(mapCursor),
524
+ after: session.messages.slice(idx + 1, idx + 1 + after).map(mapCursor),
525
+ session: { id: session.id, title: session.name, directory: session.workspacePath },
526
+ source: "cursor",
527
+ };
528
+ } catch {
529
+ return null;
530
+ }
531
+ }
532
+
533
+ case "unknown":
534
+ default: {
535
+ // Backward compat: bare msg_xxx → treat as opencode (no machine qualifier)
536
+ if (OPENCODE_MESSAGE_ID.test(id)) {
537
+ try {
538
+ const { createOpenCodeReader } = await import("../integrations/opencode/reader-factory.js");
539
+ const reader = await createOpenCodeReader();
540
+ const win = await reader.getMessageById(id, before, after);
541
+ if (!win) return null;
542
+ return {
543
+ type: "opencode_message",
544
+ message: { id: win.message.id, role: win.message.role, content: win.message.content, timestamp: win.message.timestamp, agent: win.message.agent },
545
+ before: win.before.map(m => ({ id: m.id, role: m.role, content: m.content, timestamp: m.timestamp, agent: m.agent })),
546
+ after: win.after.map(m => ({ id: m.id, role: m.role, content: m.content, timestamp: m.timestamp, agent: m.agent })),
547
+ session: { id: win.session.id, title: win.session.title, directory: win.session.directory },
548
+ source: "opencode",
549
+ };
550
+ } catch {
551
+ return null;
552
+ }
553
+ }
554
+ return null;
555
+ }
556
+ }
557
+ }
558
+
397
559
  export async function lookupById(id: string): Promise<({ type: string } & Record<string, unknown>) | null> {
398
560
  const state = await loadLatestState();
399
561
  if (!state) {
package/src/cli.ts CHANGED
@@ -13,7 +13,7 @@
13
13
 
14
14
  import { parseArgs } from "util";
15
15
  import { join } from "path";
16
- import { retrieveBalanced, lookupById, loadLatestState } from "./cli/retrieval";
16
+ import { retrieveBalanced, lookupById, resolveExternalMessage, loadLatestState } from "./cli/retrieval";
17
17
  import type { StorageState } from "./core/types";
18
18
  import { resolvePersonaId, filterByPersona, filterTypeSpecificByPersona, filterBySource, filterTypeSpecificBySource } from "./cli/persona-filter.js";
19
19
  import pkg from "../package.json" assert { type: "json" };
@@ -247,6 +247,12 @@ mentions a person, or corrects something you assumed.
247
247
  // Strip surrounding quotes (from jq output or shell quoting)
248
248
  id = id.replace(/^["']|["']$/g, "");
249
249
 
250
+ const ocMessage = await resolveExternalMessage(id);
251
+ if (ocMessage) {
252
+ console.log(JSON.stringify(ocMessage, null, 2));
253
+ process.exit(0);
254
+ }
255
+
250
256
  const entity = await lookupById(id);
251
257
  if (!entity) {
252
258
  console.error(`No entity found with ID: ${id}`);
@@ -25,8 +25,7 @@ export async function handleDedupCurate(
25
25
 
26
26
  // Validate entity_type
27
27
  if (!entity_type || !['topic', 'person'].includes(entity_type)) {
28
- console.error(`[Dedup] Invalid entity_type: "${entity_type}" (from request data)`, response.request.data);
29
- return;
28
+ throw new Error(`[Dedup] Invalid entity_type: "${entity_type}" (from request data)`);
30
29
  }
31
30
 
32
31
  // Parse Opus response
@@ -37,14 +36,12 @@ export async function handleDedupCurate(
37
36
  throw new Error("Invalid response format");
38
37
  }
39
38
  } catch (err) {
40
- console.error(`[Dedup] Failed to parse Opus response:`, err);
41
- return;
39
+ throw new Error(`[Dedup] Failed to parse Opus response: ${err instanceof Error ? err.message : String(err)}`);
42
40
  }
43
41
 
44
42
  // Validate response structure
45
43
  if (!Array.isArray(decisions.update) || !Array.isArray(decisions.remove) || !Array.isArray(decisions.add)) {
46
- console.error(`[Dedup] Invalid response structure - missing update/remove/add arrays`);
47
- return;
44
+ throw new Error(`[Dedup] Invalid response structure - missing update/remove/add arrays`);
48
45
  }
49
46
 
50
47
  console.log(`[Dedup] Processing cluster: ${decisions.update.length} updates, ${decisions.remove.length} removals, ${decisions.add.length} additions`);
@@ -63,15 +60,7 @@ export async function handleDedupCurate(
63
60
 
64
61
  // Validate entityList exists
65
62
  if (!entityList || !Array.isArray(entityList)) {
66
- console.error(`[Dedup] entityList is ${entityList === undefined ? 'undefined' : 'not an array'} for entity_type="${entity_type}" (looking for state.${entity_type}s)`, {
67
- entity_type,
68
- entity_ids,
69
- stateKeys: Object.keys(state),
70
- factsExists: !!state.facts,
71
- topicsExists: !!state.topics,
72
- peopleExists: !!state.people
73
- });
74
- return;
63
+ throw new Error(`[Dedup] entityList is ${entityList === undefined ? 'undefined' : 'not an array'} for entity_type="${entity_type}" (looking for state.${entity_type}s)`);
75
64
  }
76
65
  const entities = entity_ids
77
66
  .map((id: string) => entityList.find((e: Fact | Topic | Person) => e.id === id))
@@ -5,6 +5,7 @@ import {
5
5
  queueAllScans,
6
6
  type ExtractionContext,
7
7
  } from "../orchestrators/human-extraction.js";
8
+ import { qualifyDocumentMessage } from "../utils/message-id.js";
8
9
 
9
10
  function parseSegmentArray(content: string): string[] | null {
10
11
  const jsonMatch = content.match(/```json\s*([\s\S]*?)```/) ?? content.match(/```\s*([\s\S]*?)```/);
@@ -30,8 +31,7 @@ export function handleDocumentSegmentation(response: LLMResponse, state: StateMa
30
31
  };
31
32
 
32
33
  if (!batchId || !filename) {
33
- console.error("[handleDocumentSegmentation] Missing batchId or filename in request data");
34
- return;
34
+ throw new Error("[handleDocumentSegmentation] Missing batchId or filename in request data");
35
35
  }
36
36
 
37
37
  let segments: string[];
@@ -49,18 +49,16 @@ export function handleDocumentSegmentation(response: LLMResponse, state: StateMa
49
49
  }
50
50
 
51
51
  const now = new Date().toISOString();
52
- const sourceTag = `import:document:${filename}`;
53
52
 
54
53
  for (const segment of segments) {
55
54
  const message: Message = {
56
- id: crypto.randomUUID(),
55
+ id: qualifyDocumentMessage(filename, crypto.randomUUID()),
57
56
  role: "system",
58
57
  content: segment,
59
58
  timestamp: now,
60
59
  read: true,
61
60
  context_status: ContextStatus.Always,
62
61
  external: true,
63
- source_tag: sourceTag,
64
62
  };
65
63
  state.messages_append("emmet", message);
66
64
  }
@@ -72,7 +70,7 @@ export function finishDocumentBatch(batchId: string, filename: string, state: St
72
70
  const sourceTag = `import:document:${filename}`;
73
71
 
74
72
  const emmettMessages = state.messages_get("emmet");
75
- const docMessages = emmettMessages.filter(m => m.external === true && m.source_tag === sourceTag);
73
+ const docMessages = emmettMessages.filter(m => m.external === true && m.id.startsWith(`${sourceTag}:`));
76
74
 
77
75
  if (docMessages.length === 0) {
78
76
  console.warn(`[finishDocumentBatch] No messages found for ${sourceTag} — skipping extraction`);
@@ -103,7 +101,7 @@ export function finishDocumentBatch(batchId: string, filename: string, state: St
103
101
  ...updatedHuman.settings?.document,
104
102
  processed_documents: {
105
103
  ...(updatedHuman.settings?.document?.processed_documents ?? {}),
106
- [filename]: new Date().toISOString(),
104
+ [filename]: { created_at: new Date().toISOString(), type: "imported" },
107
105
  },
108
106
  },
109
107
  },
@@ -13,14 +13,12 @@ export function handleHeartbeatCheck(response: LLMResponse, state: StateManager)
13
13
  const personaId = response.request.data.personaId as string;
14
14
  const personaDisplayName = response.request.data.personaDisplayName as string;
15
15
  if (!personaId) {
16
- console.error("[handleHeartbeatCheck] No personaId in request data");
17
- return;
16
+ throw new Error("[handleHeartbeatCheck] No personaId in request data");
18
17
  }
19
18
 
20
19
  const result = response.parsed as HeartbeatCheckResult | undefined;
21
20
  if (!result) {
22
- console.error(`[HeartbeatCheck ${personaDisplayName}] No parsed result`);
23
- return;
21
+ throw new Error(`[HeartbeatCheck ${personaDisplayName}] No parsed result`);
24
22
  }
25
23
  console.log(`[HeartbeatCheck ${personaDisplayName}] Parsed result - should_respond: ${result.should_respond}, topic: ${result.topic ?? '(none)'}, message: ${result.message ? '(present)' : '(none)'}`);
26
24
 
@@ -52,8 +50,7 @@ export function handleHeartbeatCheck(response: LLMResponse, state: StateManager)
52
50
  export function handleEiHeartbeat(response: LLMResponse, state: StateManager): void {
53
51
  const result = response.parsed as EiHeartbeatResult | undefined;
54
52
  if (!result) {
55
- console.error("[EiHeartbeat] No parsed result");
56
- return;
53
+ throw new Error("[EiHeartbeat] No parsed result");
57
54
  }
58
55
  console.log(`[EiHeartbeat] Parsed result - should_respond: ${result.should_respond}, id: ${result.id ?? '(none)'}, my_response: ${result.my_response ? '(present)' : '(none)'}`);
59
56
  const now = new Date().toISOString();
@@ -127,8 +124,7 @@ export function handleReflectionCritic(response: LLMResponse, state: StateManage
127
124
 
128
125
  const result = response.parsed as ReflectionCriticResult | undefined;
129
126
  if (!result?.critique) {
130
- console.error(`[ReflectionCritic ${personaDisplayName}] Invalid or missing parsed result`);
131
- return;
127
+ throw new Error(`[ReflectionCritic ${personaDisplayName}] Invalid or missing parsed result`);
132
128
  }
133
129
 
134
130
  const personRecord = state.human_person_getByIdentifier("Ei Persona", personaId);
@@ -150,8 +146,7 @@ export function handleReflectionCritic(response: LLMResponse, state: StateManage
150
146
 
151
147
  const persona = state.persona_getById(personaId);
152
148
  if (!persona) {
153
- console.error(`[ReflectionCritic ${personaDisplayName}] Persona not found after critic`);
154
- return;
149
+ throw new Error(`[ReflectionCritic ${personaDisplayName}] Persona not found after critic`);
155
150
  }
156
151
 
157
152
  const mergedTopics = result.updated_identity.topics.map(updatedTopic => {
@@ -139,6 +139,8 @@ export async function handleTopicUpdate(response: LLMResponse, state: StateManag
139
139
  ? incomingSources
140
140
  : [...new Set([...(existingTopic?.sources ?? []), ...incomingSources])];
141
141
 
142
+ const newDescLen = resolvedDescription?.length ?? 0;
143
+ const existingFloor = existingTopic?.rewrite_length_floor;
142
144
  const topic: Topic = {
143
145
  id: itemId,
144
146
  name: resolvedName,
@@ -156,6 +158,7 @@ export async function handleTopicUpdate(response: LLMResponse, state: StateManag
156
158
  sources: sources.length > 0 ? sources : undefined,
157
159
  persona_groups: personaGroupsMerged,
158
160
  embedding,
161
+ rewrite_length_floor: existingFloor !== undefined && newDescLen < existingFloor ? existingFloor : undefined,
159
162
  };
160
163
  state.human_topic_upsert(topic);
161
164
 
@@ -333,6 +336,11 @@ export async function handlePersonUpdate(response: LLMResponse, state: StateMana
333
336
  sources: personSources.length > 0 ? personSources : undefined,
334
337
  persona_groups: personaGroupsMerged,
335
338
  embedding,
339
+ rewrite_length_floor: (() => {
340
+ const floor = existingPerson?.rewrite_length_floor;
341
+ const newLen = resolvedDescription?.length ?? 0;
342
+ return floor !== undefined && newLen < floor ? floor : undefined;
343
+ })(),
336
344
  };
337
345
  state.human_person_upsert(person);
338
346
 
@@ -16,6 +16,7 @@ import { handleDedupCurate } from "./dedup.js";
16
16
  import { handleRoomResponse, handleRoomJudge } from "./rooms.js";
17
17
  import { handlePersonaPreview } from "./persona-preview.js";
18
18
  import { handleDocumentSegmentation } from "./document-segmentation.js";
19
+ import { handleKnowledgeSynthesis } from "./knowledge-synthesis.js";
19
20
 
20
21
  export const handlers: Record<LLMNextStep, ResponseHandler> = {
21
22
  handlePersonaResponse,
@@ -43,4 +44,5 @@ export const handlers: Record<LLMNextStep, ResponseHandler> = {
43
44
  [LLMNextStep.HandleTopicValidate]: handleDedupCurate,
44
45
  [LLMNextStep.HandleReflectionCritic]: handleReflectionCritic,
45
46
  [LLMNextStep.HandleDocumentSegmentation]: handleDocumentSegmentation,
47
+ [LLMNextStep.HandleKnowledgeSynthesis]: handleKnowledgeSynthesis,
46
48
  };
@@ -0,0 +1,48 @@
1
+ import { ContextStatus } from "../types.js";
2
+ import type { LLMResponse, Message } from "../types.js";
3
+ import type { StateManager } from "../state-manager.js";
4
+
5
+ export function handleKnowledgeSynthesis(response: LLMResponse, state: StateManager): void {
6
+ const { slug, subject } = response.request.data as {
7
+ slug: string;
8
+ subject: string;
9
+ };
10
+
11
+ if (!slug || !subject) {
12
+ throw new Error("[handleKnowledgeSynthesis] Missing slug or subject in request data");
13
+ }
14
+
15
+ const content = response.content?.trim() ?? "";
16
+ if (!content) {
17
+ throw new Error(`[handleKnowledgeSynthesis] Empty or null response content for slug "${slug}"`);
18
+ }
19
+
20
+ const now = new Date().toISOString();
21
+
22
+ const message: Message = {
23
+ id: `generate:document:${slug}:${crypto.randomUUID()}`,
24
+ role: "system",
25
+ content,
26
+ timestamp: now,
27
+ read: true,
28
+ context_status: ContextStatus.Always,
29
+ external: true,
30
+ };
31
+
32
+ state.messages_append("emmet", message);
33
+
34
+ const updatedHuman = state.getHuman();
35
+ state.setHuman({
36
+ ...updatedHuman,
37
+ settings: {
38
+ ...updatedHuman.settings,
39
+ document: {
40
+ ...updatedHuman.settings?.document,
41
+ processed_documents: {
42
+ ...(updatedHuman.settings?.document?.processed_documents ?? {}),
43
+ [slug]: { created_at: now, type: "generated", subject },
44
+ },
45
+ },
46
+ },
47
+ });
48
+ }
@@ -12,8 +12,7 @@ export function handlePersonaGeneration(response: LLMResponse, state: StateManag
12
12
  const personaId = response.request.data.personaId as string;
13
13
  const personaDisplayName = response.request.data.personaDisplayName as string;
14
14
  if (!personaId) {
15
- console.error("[handlePersonaGeneration] No personaId in request data");
16
- return;
15
+ throw new Error("[handlePersonaGeneration] No personaId in request data");
17
16
  }
18
17
 
19
18
  const result = response.parsed as PersonaGenerationResult | undefined;
@@ -115,14 +114,12 @@ export function handlePersonaTraitExtraction(response: LLMResponse, state: State
115
114
  const personaId = response.request.data.personaId as string;
116
115
  const personaDisplayName = response.request.data.personaDisplayName as string;
117
116
  if (!personaId) {
118
- console.error("[handlePersonaTraitExtraction] No personaId in request data");
119
- return;
117
+ throw new Error("[handlePersonaTraitExtraction] No personaId in request data");
120
118
  }
121
119
 
122
120
  const result = response.parsed as TraitResult[] | undefined;
123
121
  if (!result || !Array.isArray(result)) {
124
- console.error("[handlePersonaTraitExtraction] Invalid parsed result");
125
- return;
122
+ throw new Error("[handlePersonaTraitExtraction] Invalid parsed result");
126
123
  }
127
124
 
128
125
  if (result.length === 0) {
@@ -131,8 +128,7 @@ export function handlePersonaTraitExtraction(response: LLMResponse, state: State
131
128
 
132
129
  const persona = state.persona_getById(personaId);
133
130
  if (!persona) {
134
- console.error(`[handlePersonaTraitExtraction] Persona ${personaId} not found`);
135
- return;
131
+ throw new Error(`[handlePersonaTraitExtraction] Persona ${personaId} not found`);
136
132
  }
137
133
 
138
134
  const now = new Date().toISOString();
@@ -15,8 +15,7 @@ export function handlePersonaResponse(response: LLMResponse, state: StateManager
15
15
  const personaId = response.request.data.personaId as string;
16
16
  const personaDisplayName = response.request.data.personaDisplayName as string;
17
17
  if (!personaId) {
18
- console.error("[handlePersonaResponse] No personaId in request data");
19
- return;
18
+ throw new Error("[handlePersonaResponse] No personaId in request data");
20
19
  }
21
20
 
22
21
  state.messages_markPendingAsRead(personaId);
@@ -108,7 +107,7 @@ export function handleToolContinuation(response: LLMResponse, state: StateManage
108
107
  const originalStep = response.request.data.originalNextStep as LLMNextStep | undefined;
109
108
 
110
109
  if (!originalStep) {
111
- console.error(`[handleToolContinuation] No originalNextStep in data, falling back to handlePersonaResponse`);
110
+ console.warn(`[handleToolContinuation] No originalNextStep in data, falling back to handlePersonaResponse`);
112
111
  handlePersonaResponse(response, state);
113
112
  return;
114
113
  }
@@ -118,7 +117,7 @@ export function handleToolContinuation(response: LLMResponse, state: StateManage
118
117
  const handler = handlers[originalStep];
119
118
 
120
119
  if (!handler) {
121
- console.error(`[handleToolContinuation] No handler found for ${originalStep}, falling back to handlePersonaResponse`);
120
+ console.warn(`[handleToolContinuation] No handler found for ${originalStep}, falling back to handlePersonaResponse`);
122
121
  handlePersonaResponse(response, state);
123
122
  return;
124
123
  }