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
@@ -44,6 +44,7 @@ import { EI_WELCOME_MESSAGE, EI_PERSONA_DEFINITION } from "../templates/welcome.
44
44
  import { EMMETT_PERSONA_DEFINITION } from "../templates/emmett.js";
45
45
  import { shouldStartCeremony, startCeremony, handleCeremonyProgress, queueReflectionDrain, queueUserDedupRequest, queueRoomCapture, queuePersonaCapture, checkAndQueueRoomExtraction, queueTargetedPersonUpdate, queueTargetedTopicUpdate } from "./orchestrators/index.js";
46
46
  import { finishDocumentBatch } from "./handlers/document-segmentation.js";
47
+ import { buildSynthesisPrompt } from "../prompts/synthesis/index.js";
47
48
  import { BUILT_IN_FACTS } from "./constants/built-in-facts.js";
48
49
  import { DEFAULT_SEED_TRAITS } from "./constants/seed-traits.js";
49
50
 
@@ -138,6 +139,9 @@ import {
138
139
  import type { RoomCreationInput, RoomEntity, RoomMessage, RoomSummary } from "./types.js";
139
140
  import { previewUnsource as _previewUnsource } from "../integrations/document/unsource.js";
140
141
  import type { UnsourcePreview, UnsourceResult } from "../integrations/document/unsource.js";
142
+ import { isQualifiedMessageId, qualifyEiMessage, qualifyOpenCodeMessage } from "./utils/message-id.js";
143
+
144
+ import type { IOpenCodeReader } from "../integrations/opencode/types.js";
141
145
 
142
146
  const DEFAULT_LOOP_INTERVAL_MS = 100;
143
147
  const DEFAULT_OPENCODE_POLLING_MS = 60000;
@@ -241,18 +245,30 @@ export class Processor {
241
245
  this.bootstrapTools();
242
246
  this.seedBuiltinFacts();
243
247
  this.migrateLearnedOn();
248
+ await this.migrateMessageIds();
244
249
  this.seedSettings();
245
250
  registerFindMemoryExecutor(createFindMemoryExecutor(this.searchHumanData.bind(this), this.getPersonaList.bind(this), this.stateManager.getHuman.bind(this.stateManager)));
246
251
  registerFetchMemoryExecutor(createFetchMemoryExecutor(this.stateManager.getHuman.bind(this.stateManager)));
247
- registerFetchMessageExecutor(createFetchMessageExecutor(
248
- this.stateManager.persona_getAll.bind(this.stateManager),
249
- this.stateManager.messages_get.bind(this.stateManager),
250
- this.stateManager.getRoomList.bind(this.stateManager),
251
- this.stateManager.getRoomMessages.bind(this.stateManager),
252
- (roomId: string) => this.stateManager.getRoom(roomId)?.display_name ?? null
253
- ));
254
252
  if (this.isTUI) {
255
253
  await registerFileReadExecutor();
254
+ const { createOpenCodeReader } = await import("../integrations/opencode/reader-factory.js");
255
+ const openCodeReader = await createOpenCodeReader().catch(() => null);
256
+ registerFetchMessageExecutor(createFetchMessageExecutor(
257
+ this.stateManager.persona_getAll.bind(this.stateManager),
258
+ this.stateManager.messages_get.bind(this.stateManager),
259
+ this.stateManager.getRoomList.bind(this.stateManager),
260
+ this.stateManager.getRoomMessages.bind(this.stateManager),
261
+ (roomId: string) => this.stateManager.getRoom(roomId)?.display_name ?? null,
262
+ openCodeReader ? (id, before, after) => openCodeReader.getMessageById(id, before, after) : undefined
263
+ ));
264
+ } else {
265
+ registerFetchMessageExecutor(createFetchMessageExecutor(
266
+ this.stateManager.persona_getAll.bind(this.stateManager),
267
+ this.stateManager.messages_get.bind(this.stateManager),
268
+ this.stateManager.getRoomList.bind(this.stateManager),
269
+ this.stateManager.getRoomMessages.bind(this.stateManager),
270
+ (roomId: string) => this.stateManager.getRoom(roomId)?.display_name ?? null
271
+ ));
256
272
  }
257
273
  this.running = true;
258
274
  console.log(`[Processor ${this.instanceId}] initialized, starting loop`);
@@ -335,6 +351,140 @@ export class Processor {
335
351
  return executeUnsource(preview, this.stateManager);
336
352
  }
337
353
 
354
+ async generateDocument(subject: string): Promise<{ slug: string }> {
355
+ this.bootstrapEmmett();
356
+ const slugBase = subject
357
+ .toLowerCase()
358
+ .replace(/[^a-z0-9]+/g, "-")
359
+ .slice(0, 40);
360
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
361
+ const slug = `${slugBase}_${timestamp}`;
362
+
363
+ const primary = await this.searchHumanData(subject, { limit: 20 });
364
+ if (
365
+ primary.facts.length === 0 &&
366
+ primary.topics.length === 0 &&
367
+ primary.people.length === 0 &&
368
+ primary.quotes.length === 0
369
+ ) {
370
+ throw new Error(`No knowledge found about '${subject}'`);
371
+ }
372
+
373
+ const seenQuoteIds = new Set<string>();
374
+ const seenItemIds = new Set<string>(
375
+ [...primary.topics, ...primary.people, ...primary.facts].map(i => i.id)
376
+ );
377
+
378
+ const MAX_QUOTES_PER_ENTITY = 3;
379
+
380
+ const enrichTopic = (topic: import("../prompts/synthesis/types.js").EnrichedTopic["topic"]) => {
381
+ const linked = this.stateManager.human_quote_getForDataItem(topic.id)
382
+ .sort((a, b) => b.timestamp.localeCompare(a.timestamp))
383
+ .slice(0, MAX_QUOTES_PER_ENTITY);
384
+ linked.forEach(q => seenQuoteIds.add(q.id));
385
+ return { topic, quotes: linked };
386
+ };
387
+
388
+ const enrichPerson = (person: import("../prompts/synthesis/types.js").EnrichedPerson["person"]) => {
389
+ const linked = this.stateManager.human_quote_getForDataItem(person.id)
390
+ .sort((a, b) => b.timestamp.localeCompare(a.timestamp))
391
+ .slice(0, MAX_QUOTES_PER_ENTITY);
392
+ linked.forEach(q => seenQuoteIds.add(q.id));
393
+ return { person, quotes: linked };
394
+ };
395
+
396
+ const enrichedTopics = primary.topics.map(enrichTopic);
397
+ const enrichedPeople = primary.people.map(enrichPerson);
398
+
399
+ const human = this.stateManager.getHuman();
400
+ const allItems = [...human.facts, ...human.topics, ...human.people];
401
+
402
+ const MAX_SECONDARY_ENTITIES = 10;
403
+
404
+ const secondaryTopics: typeof enrichedTopics = [];
405
+ const secondaryPeople: typeof enrichedPeople = [];
406
+ const secondaryFacts: typeof primary.facts = [];
407
+
408
+ outer: for (const quote of [...enrichedTopics.flatMap(e => e.quotes), ...enrichedPeople.flatMap(e => e.quotes)]) {
409
+ for (const itemId of quote.data_item_ids) {
410
+ if (secondaryTopics.length + secondaryPeople.length + secondaryFacts.length >= MAX_SECONDARY_ENTITIES) break outer;
411
+ if (seenItemIds.has(itemId)) continue;
412
+ seenItemIds.add(itemId);
413
+ const item = allItems.find(i => i.id === itemId);
414
+ if (!item) continue;
415
+ if (human.topics.find(t => t.id === itemId)) {
416
+ secondaryTopics.push(enrichTopic(item as typeof primary.topics[0]));
417
+ } else if (human.people.find(p => p.id === itemId)) {
418
+ secondaryPeople.push(enrichPerson(item as typeof primary.people[0]));
419
+ } else if (human.facts.find(f => f.id === itemId)) {
420
+ secondaryFacts.push(item as typeof primary.facts[0]);
421
+ }
422
+ }
423
+ }
424
+
425
+ const standaloneQuotes = primary.quotes.filter(q => !seenQuoteIds.has(q.id));
426
+
427
+ const allLoadedFacts = [...primary.facts, ...secondaryFacts];
428
+ const allLoadedTopics = [...enrichedTopics, ...secondaryTopics];
429
+ const allLoadedPeople = [...enrichedPeople, ...secondaryPeople];
430
+
431
+ const loadedEntityNames = new Map<string, string>();
432
+ for (const f of allLoadedFacts) loadedEntityNames.set(f.id, f.name);
433
+ for (const { topic } of allLoadedTopics) loadedEntityNames.set(topic.id, topic.name);
434
+ for (const { person } of allLoadedPeople) loadedEntityNames.set(person.id, person.name);
435
+
436
+ const prompt = buildSynthesisPrompt({
437
+ subject,
438
+ facts: allLoadedFacts,
439
+ topics: allLoadedTopics,
440
+ people: allLoadedPeople,
441
+ standaloneQuotes,
442
+ loadedEntityNames,
443
+ });
444
+
445
+ const model = this.stateManager.getHuman().settings?.rewrite_model
446
+ ?? this.stateManager.getHuman().settings?.default_model;
447
+
448
+ this.stateManager.queue_enqueue({
449
+ type: LLMRequestType.Raw,
450
+ priority: LLMPriority.Normal,
451
+ system: prompt.system,
452
+ user: prompt.user,
453
+ next_step: LLMNextStep.HandleKnowledgeSynthesis,
454
+ model,
455
+ data: { slug, subject },
456
+ });
457
+
458
+ return { slug };
459
+ }
460
+
461
+ checkGenerationModel(): { model: string; isRewriteModel: boolean } {
462
+ const settings = this.stateManager.getHuman().settings;
463
+ if (settings?.rewrite_model) {
464
+ return { model: settings.rewrite_model, isRewriteModel: true };
465
+ }
466
+ return { model: settings?.default_model ?? "unknown", isRewriteModel: false };
467
+ }
468
+
469
+ async getGeneratedDocumentContent(slug: string): Promise<string | null> {
470
+ const messages = this.stateManager.messages_get("emmet");
471
+ const target = `generate:document:${slug}`;
472
+ const message = messages.find(m => m.id.startsWith(`${target}:`));
473
+ return message?.content ?? null;
474
+ }
475
+
476
+ async reRunDocument(slug: string): Promise<{ slug: string }> {
477
+ const docs = this.stateManager.getHuman().settings?.document?.processed_documents ?? {};
478
+ const entry = docs[slug];
479
+ if (!entry || entry.type !== "generated" || !entry.subject) {
480
+ throw new Error(`No generated document found for slug "${slug}"`);
481
+ }
482
+ const subject = entry.subject;
483
+ const preview = this.getUnsourcePreview(`generate:document:${slug}`);
484
+ await this.executeUnsource(preview);
485
+ return this.generateDocument(subject);
486
+ }
487
+
338
488
  /**
339
489
  * Seed built-in tool providers and tools if they don't exist yet.
340
490
  * Called on every startup (after state load/restore) — safe to call repeatedly.
@@ -874,6 +1024,104 @@ export class Processor {
874
1024
  }
875
1025
  }
876
1026
 
1027
+ private async migrateMessageIds(): Promise<void> {
1028
+ try {
1029
+ let msgRewrites = 0;
1030
+ let quoteRewrites = 0;
1031
+
1032
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1033
+
1034
+ const personas = this.stateManager.persona_getAll();
1035
+ for (const persona of personas) {
1036
+ for (const msg of this.stateManager.messages_get(persona.id)) {
1037
+ if (!msg.external && UUID_PATTERN.test(msg.id)) {
1038
+ this.stateManager.messages_update(persona.id, msg.id, { id: qualifyEiMessage(msg.id) });
1039
+ msgRewrites++;
1040
+ }
1041
+ }
1042
+ }
1043
+
1044
+ const rooms = this.stateManager.getRoomList();
1045
+ for (const room of rooms) {
1046
+ for (const msg of this.stateManager.getRoomMessages(room.id).slice()) {
1047
+ if (UUID_PATTERN.test(msg.id)) {
1048
+ this.stateManager.updateRoomMessage(room.id, msg.id, { id: qualifyEiMessage(msg.id) });
1049
+ msgRewrites++;
1050
+ }
1051
+ }
1052
+ }
1053
+
1054
+ const human = this.stateManager.getHuman();
1055
+ const quotes = human.quotes ?? [];
1056
+
1057
+ const eiUuidMap = new Map<string, string>();
1058
+ for (const persona of personas) {
1059
+ for (const msg of this.stateManager.messages_get(persona.id)) {
1060
+ if (msg.id.startsWith("ei:")) eiUuidMap.set(msg.id.slice(3), msg.id);
1061
+ }
1062
+ }
1063
+ for (const room of rooms) {
1064
+ for (const msg of this.stateManager.getRoomMessages(room.id)) {
1065
+ if (msg.id.startsWith("ei:")) eiUuidMap.set(msg.id.slice(3), msg.id);
1066
+ }
1067
+ }
1068
+
1069
+ const MSG_PATTERN = /^msg_[a-zA-Z0-9]+$/;
1070
+
1071
+ let openCodeReader: IOpenCodeReader | null = null;
1072
+ if (this.isTUI) {
1073
+ const { createOpenCodeReader } = await import("../integrations/opencode/reader-factory.js");
1074
+ openCodeReader = await createOpenCodeReader().catch(() => null);
1075
+ }
1076
+
1077
+ const updatedQuotes: typeof quotes = [];
1078
+ for (const quote of quotes) {
1079
+ const mid = quote.message_id;
1080
+ if (!mid || isQualifiedMessageId(mid)) {
1081
+ updatedQuotes.push(quote);
1082
+ continue;
1083
+ }
1084
+
1085
+ if (MSG_PATTERN.test(mid)) {
1086
+ if (openCodeReader) {
1087
+ const ocWindow = await openCodeReader.getMessageById(mid).catch(() => null);
1088
+ if (ocWindow) {
1089
+ const { getMachineId } = await import("../integrations/machine-id.js");
1090
+ updatedQuotes.push({ ...quote, message_id: qualifyOpenCodeMessage(getMachineId(), ocWindow.session.id, mid) });
1091
+ quoteRewrites++;
1092
+ continue;
1093
+ }
1094
+ }
1095
+ updatedQuotes.push(quote);
1096
+ continue;
1097
+ }
1098
+
1099
+ if (UUID_PATTERN.test(mid)) {
1100
+ const fqId = eiUuidMap.get(mid);
1101
+ if (fqId) {
1102
+ updatedQuotes.push({ ...quote, message_id: fqId });
1103
+ quoteRewrites++;
1104
+ continue;
1105
+ }
1106
+ updatedQuotes.push(quote);
1107
+ continue;
1108
+ }
1109
+
1110
+ updatedQuotes.push(quote);
1111
+ }
1112
+
1113
+ if (quoteRewrites > 0) {
1114
+ this.stateManager.setHuman({ ...human, quotes: updatedQuotes });
1115
+ }
1116
+
1117
+ if (msgRewrites > 0 || quoteRewrites > 0) {
1118
+ console.log(`[Processor] migrateMessageIds: rewrote ${msgRewrites} message IDs, ${quoteRewrites} quote message_ids`);
1119
+ }
1120
+ } catch (err) {
1121
+ console.error("[Processor] migrateMessageIds failed, continuing:", err);
1122
+ }
1123
+ }
1124
+
877
1125
  private seedSettings(): void {
878
1126
  const human = this.stateManager.getHuman();
879
1127
  let modified = false;
@@ -1087,6 +1335,7 @@ const toolNextSteps = new Set([
1087
1335
  LLMNextStep.HandleEiHeartbeat,
1088
1336
  LLMNextStep.HandleToolContinuation,
1089
1337
  LLMNextStep.HandleDedupCurate,
1338
+ LLMNextStep.HandleKnowledgeSynthesis,
1090
1339
  ]);
1091
1340
  const toolPersonaId =
1092
1341
  personaId ??
@@ -1101,9 +1350,17 @@ const toolNextSteps = new Set([
1101
1350
  (request.next_step === LLMNextStep.HandleToolContinuation &&
1102
1351
  request.data.originalNextStep === LLMNextStep.HandleDedupCurate);
1103
1352
 
1353
+ const isSynthesisRequest = request.next_step === LLMNextStep.HandleKnowledgeSynthesis ||
1354
+ (request.next_step === LLMNextStep.HandleToolContinuation &&
1355
+ request.data.originalNextStep === LLMNextStep.HandleKnowledgeSynthesis);
1356
+
1104
1357
  let tools: ToolDefinition[] = [];
1105
1358
  if (isDedupRequest) {
1106
1359
  tools = SYSTEM_TOOLS.filter(t => t.name === "find_memory");
1360
+ } else if (isSynthesisRequest) {
1361
+ tools = SYSTEM_TOOLS.filter(t =>
1362
+ t.name === "find_memory" || t.name === "fetch_memory" || t.name === "fetch_message"
1363
+ );
1107
1364
  } else if (toolNextSteps.has(request.next_step) && toolPersonaId) {
1108
1365
  tools = [...SYSTEM_TOOLS, ...this.stateManager.tools_getForPersona(toolPersonaId, this.isTUI)];
1109
1366
  }
@@ -1760,6 +2017,17 @@ const toolNextSteps = new Set([
1760
2017
  this.interface.onHumanUpdated?.();
1761
2018
  }
1762
2019
  }
2020
+
2021
+ const isSynthesisCompletion =
2022
+ response.request.next_step === LLMNextStep.HandleKnowledgeSynthesis ||
2023
+ (response.request.next_step === LLMNextStep.HandleToolContinuation &&
2024
+ response.request.data.originalNextStep === LLMNextStep.HandleKnowledgeSynthesis);
2025
+ if (isSynthesisCompletion) {
2026
+ const slug = response.request.data.slug as string;
2027
+ const hasContent = slug && this.stateManager.messages_get("emmet")
2028
+ .some(m => m.id.startsWith(`generate:document:${slug}:`));
2029
+ if (hasContent) this.interface.onDocumentGenerated?.(slug);
2030
+ }
1763
2031
  } catch (err) {
1764
2032
  const errorMsg = err instanceof Error ? err.message : String(err);
1765
2033
  const result = this.stateManager.queue_fail(response.request.id, errorMsg);
@@ -51,6 +51,15 @@ export async function getQueueStatus(sm: StateManager): Promise<QueueStatus> {
51
51
  }
52
52
  const extracting_documents = extractingSet.size > 0 ? Array.from(extractingSet) : undefined;
53
53
 
54
+ const generatingSet: string[] = [];
55
+ for (const item of activeItems) {
56
+ if (item.next_step === LLMNextStep.HandleKnowledgeSynthesis) {
57
+ const slug = (item.data as { slug?: string }).slug;
58
+ if (slug) generatingSet.push(slug);
59
+ }
60
+ }
61
+ const generating_documents = generatingSet.length > 0 ? generatingSet : undefined;
62
+
54
63
  return {
55
64
  state: sm.queue_isPaused()
56
65
  ? "paused"
@@ -62,6 +71,7 @@ export async function getQueueStatus(sm: StateManager): Promise<QueueStatus> {
62
71
  embedding_warning: sm.embedding_getWarning() || undefined,
63
72
  pending_documents,
64
73
  extracting_documents,
74
+ generating_documents,
65
75
  };
66
76
  }
67
77
 
@@ -71,6 +71,7 @@ export class StateManager {
71
71
  this.migrateProviderModel();
72
72
  this.migrateThemes();
73
73
  this.migrateFfaParentIds();
74
+ this.migrateDocumentSettings();
74
75
  }
75
76
 
76
77
  /**
@@ -586,6 +587,40 @@ export class StateManager {
586
587
  }
587
588
  }
588
589
 
590
+ private migrateDocumentSettings(): void {
591
+ const human = this.humanState.get();
592
+ const doc = human.settings?.document;
593
+ if (!doc) return;
594
+
595
+ let migrated = false;
596
+
597
+ const existing = doc.processed_documents ?? {};
598
+ for (const [key, value] of Object.entries(existing)) {
599
+ if (typeof value === "string") {
600
+ (existing as Record<string, unknown>)[key] = { created_at: value, type: "imported" };
601
+ migrated = true;
602
+ }
603
+ }
604
+
605
+ const legacy = (doc as Record<string, unknown>).generated_documents as
606
+ | Record<string, { subject: string; created_at: string }>
607
+ | undefined;
608
+ if (legacy) {
609
+ for (const [slug, record] of Object.entries(legacy)) {
610
+ existing[slug] = { created_at: record.created_at, type: "generated", subject: record.subject };
611
+ }
612
+ delete (doc as Record<string, unknown>).generated_documents;
613
+ migrated = true;
614
+ }
615
+
616
+ if (migrated) {
617
+ doc.processed_documents = existing as import("./types/entities.js").DocumentSettings["processed_documents"];
618
+ this.humanState.set(human);
619
+ this.scheduleSave();
620
+ console.log("[StateManager] Migrated document settings to unified processed_documents schema");
621
+ }
622
+ }
623
+
589
624
  getHuman(): HumanEntity {
590
625
  return this.humanState.get();
591
626
  }
@@ -4,20 +4,20 @@ import type { Fact, Topic, Person, Quote, HumanEntity } from "../../types.js";
4
4
  type GetHuman = () => HumanEntity;
5
5
 
6
6
  function cleanFact(f: Fact): Record<string, unknown> {
7
- const { embedding, rewrite_checked, persona_groups, ...rest } = f;
8
- void embedding; void rewrite_checked; void persona_groups;
7
+ const { embedding, persona_groups, ...rest } = f;
8
+ void embedding; void persona_groups;
9
9
  return rest;
10
10
  }
11
11
 
12
12
  function cleanTopic(t: Topic): Record<string, unknown> {
13
- const { embedding, rewrite_checked, persona_groups, last_ei_asked, ...rest } = t;
14
- void embedding; void rewrite_checked; void persona_groups; void last_ei_asked;
13
+ const { embedding, rewrite_length_floor, persona_groups, last_ei_asked, ...rest } = t;
14
+ void embedding; void rewrite_length_floor; void persona_groups; void last_ei_asked;
15
15
  return rest;
16
16
  }
17
17
 
18
18
  function cleanPerson(p: Person): Record<string, unknown> {
19
- const { embedding, rewrite_checked, persona_groups, last_ei_asked, ...rest } = p;
20
- void embedding; void rewrite_checked; void persona_groups; void last_ei_asked;
19
+ const { embedding, rewrite_length_floor, persona_groups, last_ei_asked, ...rest } = p;
20
+ void embedding; void rewrite_length_floor; void persona_groups; void last_ei_asked;
21
21
  return rest;
22
22
  }
23
23
 
@@ -2,6 +2,7 @@ import type { ToolExecutor } from "../types.js";
2
2
  import type { Message } from "../../types.js";
3
3
  import type { RoomMessage, RoomSummary } from "../../types/rooms.js";
4
4
  import type { PersonaEntity } from "../../types/entities.js";
5
+ import type { OpenCodeMessageWindow } from "../../../integrations/opencode/types.js";
5
6
 
6
7
  interface CleanMessage {
7
8
  id: string;
@@ -17,6 +18,9 @@ type GetPersonaMessages = (personaId: string) => Message[];
17
18
  type GetRoomList = () => RoomSummary[];
18
19
  type GetRoomMessages = (roomId: string) => RoomMessage[];
19
20
  type GetRoomDisplayName = (roomId: string) => string | null;
21
+ type GetOpenCodeMessage = (id: string, before: number, after: number) => Promise<OpenCodeMessageWindow | null>;
22
+
23
+ const OPENCODE_MESSAGE_ID = /^msg_[a-zA-Z0-9]+$/;
20
24
 
21
25
  function stripMessage(m: Message): CleanMessage {
22
26
  return {
@@ -45,7 +49,8 @@ export function createFetchMessageExecutor(
45
49
  getPersonaMessages: GetPersonaMessages,
46
50
  getRoomList: GetRoomList,
47
51
  getRoomMessages: GetRoomMessages,
48
- getRoomDisplayName: GetRoomDisplayName
52
+ getRoomDisplayName: GetRoomDisplayName,
53
+ getOpenCodeMessage?: GetOpenCodeMessage
49
54
  ): ToolExecutor {
50
55
  return {
51
56
  name: "fetch_message",
@@ -62,6 +67,27 @@ export function createFetchMessageExecutor(
62
67
  return JSON.stringify({ error: "Missing required argument: id" });
63
68
  }
64
69
 
70
+ if (OPENCODE_MESSAGE_ID.test(id)) {
71
+ if (!getOpenCodeMessage) {
72
+ return JSON.stringify({ error: "OpenCode message lookup not available in this runtime", id });
73
+ }
74
+ const window = await getOpenCodeMessage(id, before, after);
75
+ if (!window) {
76
+ return JSON.stringify({
77
+ error: "OpenCode message not found on this machine. It may exist on another device.",
78
+ id,
79
+ hint: "Check the linked topic's sources for the originating machine and session.",
80
+ });
81
+ }
82
+ return JSON.stringify({
83
+ message: { id: window.message.id, role: window.message.role, content: window.message.content, timestamp: window.message.timestamp, agent: window.message.agent },
84
+ before: window.before.map(m => ({ id: m.id, role: m.role, content: m.content, timestamp: m.timestamp, agent: m.agent })),
85
+ after: window.after.map(m => ({ id: m.id, role: m.role, content: m.content, timestamp: m.timestamp, agent: m.agent })),
86
+ session: { id: window.session.id, title: window.session.title, directory: window.session.directory },
87
+ source: "opencode",
88
+ });
89
+ }
90
+
65
91
  const personas = getAllPersonas();
66
92
 
67
93
  // TODO: add persona access gate when calling context is available —
@@ -15,6 +15,14 @@ type GetPersonaList = () => Promise<PersonaSummary[]>;
15
15
 
16
16
  type GetHuman = () => HumanEntity;
17
17
 
18
+ function formatSentiment(s: number): string {
19
+ const pct = Math.round(Math.abs(s) * 100);
20
+ const direction = s > 0.2 ? "positive" : s < -0.2 ? "negative" : "neutral";
21
+ if (direction === "neutral") return "neutral";
22
+ const intensity = pct >= 80 ? "strongly " : pct >= 50 ? "" : "slightly ";
23
+ return `${pct}% ${intensity}${direction}`;
24
+ }
25
+
18
26
  export function createFindMemoryExecutor(searchHumanData: SearchHumanData, getPersonaList?: GetPersonaList, getHuman?: GetHuman): ToolExecutor {
19
27
  return {
20
28
  name: "find_memory",
@@ -68,8 +76,8 @@ export function createFindMemoryExecutor(searchHumanData: SearchHumanData, getPe
68
76
 
69
77
  const output: Record<string, unknown[]> = {};
70
78
  if (results.facts.length > 0) output.facts = results.facts.map(f => ({ id: f.id, name: f.name, description: f.description }));
71
- if (results.topics.length > 0) output.topics = results.topics.map(t => ({ id: t.id, name: t.name, description: t.description }));
72
- if (results.people.length > 0) output.people = results.people.map(p => ({ id: p.id, name: p.name, relationship: p.relationship, description: p.description, identifiers: p.identifiers ?? [] }));
79
+ if (results.topics.length > 0) output.topics = results.topics.map(t => ({ id: t.id, name: t.name, description: t.description, sentiment: formatSentiment(t.sentiment) }));
80
+ if (results.people.length > 0) output.people = results.people.map(p => ({ id: p.id, name: p.name, relationship: p.relationship, description: p.description, identifiers: p.identifiers ?? [], sentiment: formatSentiment(p.sentiment) }));
73
81
 
74
82
  if (results.quotes.length > 0) {
75
83
  const human = getHuman ? getHuman() : null;
@@ -85,7 +93,7 @@ export function createFindMemoryExecutor(searchHumanData: SearchHumanData, getPe
85
93
  if (person) { linked_items.push({ id: person.id, name: person.name, type: "person" }); }
86
94
  }
87
95
  }
88
- return { id: q.id, text: q.text, speaker: q.speaker, linked_items };
96
+ return { id: q.id, text: q.text, speaker: q.speaker, message_id: q.message_id, linked_items };
89
97
  });
90
98
  }
91
99
 
@@ -29,7 +29,7 @@ export const SYSTEM_TOOLS: ToolDefinition[] = [
29
29
  provider_id: "ei",
30
30
  name: "find_memory",
31
31
  display_name: "Find Memory",
32
- description: "Semantic search of your personal memory — facts, topics, people, and quotes learned across ALL conversations over time, not just this one. Use when the human references something from the past, mentions a person, or asks about a topic you might have learned about. Supports optional filters: types (array of 'facts', 'topics', 'people', 'quotes'), limit (1-20, default 10), recent (true = sort by recency), persona (filter to what a specific persona has learned — use display name).",
32
+ description: "Semantic search of your personal memory — facts, topics, people, and quotes learned across ALL conversations over time, not just this one. Use when the human references something from the past, mentions a person, or asks about a topic you might have learned about. People and topic results include a sentiment field (e.g. '72% positive', 'neutral', '45% slightly negative') indicating how the human generally feels about that person or subject. Supports optional filters: types (array of 'facts', 'topics', 'people', 'quotes'), limit (1-20, default 10), recent (true = sort by recency), persona (filter to what a specific persona has learned — use display name).",
33
33
  input_schema: {
34
34
  type: "object",
35
35
  properties: {
@@ -52,7 +52,7 @@ export const SYSTEM_TOOLS: ToolDefinition[] = [
52
52
  provider_id: "ei",
53
53
  name: "fetch_memory",
54
54
  display_name: "Fetch Memory",
55
- description: "Retrieve the full record for a specific memory by its ID. Use when find_memory returns an item and you need its complete details, or when a system prompt references a memory ID. Returns the full Fact, Topic, Person, or Quote record.",
55
+ description: "Retrieve the full record for a specific memory by its ID. For most conversational use, find_memory results are sufficient. Use fetch_memory when you need provenance details (which sessions or documents the memory came from) or the raw sentiment score. Returns the complete Fact, Topic, Person, or Quote record including all fields.",
56
56
  input_schema: {
57
57
  type: "object",
58
58
  properties: {
@@ -64,7 +64,7 @@ export const SYSTEM_TOOLS: ToolDefinition[] = [
64
64
  builtin: true,
65
65
  enabled: true,
66
66
  created_at: new Date(0).toISOString(),
67
- max_calls_per_interaction: 3,
67
+ max_calls_per_interaction: 10,
68
68
  },
69
69
  {
70
70
  id: "builtin-fetch-message",
@@ -6,7 +6,7 @@
6
6
  /** A single tool call the LLM wants to make (from the API response). */
7
7
  export interface ToolCall {
8
8
  id: string; // call_abc123 — must be echoed back in tool result message
9
- name: string; // snake_case tool name ("web_search", "read_memory")
9
+ name: string; // snake_case tool name ("web_search", "find_memory")
10
10
  arguments: Record<string, unknown>; // Parsed from JSON string in the API response
11
11
  }
12
12
 
@@ -18,7 +18,7 @@ export interface DataItemBase {
18
18
  sources?: string[]; // Namespaced source identifiers — where items were learned from. Format: "provider:id" (e.g., "opencode:ses_abc123", "cursor:composerId"). Grow-only union.
19
19
  persona_groups?: string[];
20
20
  embedding?: number[];
21
- rewrite_checked?: boolean; // True after rewrite scan finds no changes. Cleared automatically when extraction upserts a fresh item.
21
+ rewrite_length_floor?: number; // Set after every rewrite scan: ceil(description.length * 1.1). Item is skipped by ceremony until description grows past this floor. Preserved across extraction upserts only cleared when description exceeds it.
22
22
  }
23
23
 
24
24
  export interface Fact extends DataItemBase {
@@ -20,9 +20,15 @@ export interface OpenCodeSettings {
20
20
  processed_sessions?: Record<string, string>; // sessionId → ISO timestamp of last import
21
21
  }
22
22
 
23
+ export interface DocumentRecord {
24
+ created_at: string;
25
+ type: "imported" | "generated";
26
+ subject?: string;
27
+ }
28
+
23
29
  export interface DocumentSettings {
24
30
  extraction_model?: string;
25
- processed_documents?: Record<string, string>;
31
+ processed_documents?: Record<string, DocumentRecord>;
26
32
  }
27
33
 
28
34
  export interface CeremonyConfig {
@@ -53,6 +53,7 @@ export enum LLMNextStep {
53
53
  HandleTopicValidate = "handleTopicValidate",
54
54
  HandleReflectionCritic = "handleReflectionCritic",
55
55
  HandleDocumentSegmentation = "handleDocumentSegmentation",
56
+ HandleKnowledgeSynthesis = "handleKnowledgeSynthesis",
56
57
  }
57
58
 
58
59
  export enum ProviderType {
@@ -29,7 +29,7 @@ export interface ToolProvider {
29
29
  export interface ToolDefinition {
30
30
  id: string; // UUID
31
31
  provider_id: string; // FK → ToolProvider.id (required)
32
- name: string; // Snake_case machine name ("web_search", "read_memory")
32
+ name: string; // Snake_case machine name ("web_search", "find_memory")
33
33
  display_name: string; // Human label
34
34
  description: string; // What the LLM reads to decide whether to call this tool
35
35
  input_schema: Record<string, unknown>; // JSON Schema for parameters the LLM can pass
@@ -77,6 +77,7 @@ export interface QueueStatus {
77
77
  embedding_warning?: boolean;
78
78
  pending_documents?: Array<{ batchId: string; filename: string; count: number }>;
79
79
  extracting_documents?: string[];
80
+ generating_documents?: string[];
80
81
  }
81
82
 
82
83
  export interface EiError {
@@ -121,6 +122,7 @@ export interface Ei_Interface {
121
122
  onRoomMessageAdded?: (roomId: string) => void;
122
123
  onRoomMessageQueued?: (roomId: string) => void;
123
124
  onRoomMessageProcessing?: (roomId: string) => void;
125
+ onDocumentGenerated?: (slug: string) => void;
124
126
  }
125
127
 
126
128
  // =============================================================================
@@ -27,15 +27,6 @@ export interface Message {
27
27
 
28
28
  external?: boolean; // Set by integration importers (OpenCode, Cursor, Claude Code); invisible to LLM context
29
29
 
30
- /**
31
- * Integration source tag. Set ONLY on external: true messages by importers (document, Slack, etc.)
32
- * to identify which external source this synthetic message came from.
33
- * Format: "import:document:filename" | "slack:channelId" | etc.
34
- * Enables quote provenance tracing: quote.message_id → message.source_tag → original source.
35
- * Never set on conversational messages.
36
- */
37
- source_tag?: string;
38
-
39
30
  }
40
31
 
41
32
  export interface ChatMessage {