ei-tui 0.5.4 → 0.6.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 (86) hide show
  1. package/package.json +1 -1
  2. package/src/core/constants/built-in-identifier-types.ts +24 -0
  3. package/src/core/embedding-service.ts +24 -1
  4. package/src/core/handlers/dedup.ts +34 -4
  5. package/src/core/handlers/heartbeat.ts +16 -0
  6. package/src/core/handlers/human-extraction.ts +201 -7
  7. package/src/core/handlers/human-matching.ts +71 -22
  8. package/src/core/handlers/index.ts +52 -14
  9. package/src/core/handlers/persona-generation.ts +2 -0
  10. package/src/core/handlers/persona-response.ts +37 -22
  11. package/src/core/handlers/persona-topics.ts +35 -271
  12. package/src/core/handlers/rewrite.ts +3 -0
  13. package/src/core/handlers/rooms.ts +41 -20
  14. package/src/core/handlers/utils.ts +10 -8
  15. package/src/core/heartbeat-manager.ts +60 -2
  16. package/src/core/llm-client.ts +1 -1
  17. package/src/core/message-manager.ts +3 -2
  18. package/src/core/orchestrators/ceremony.ts +54 -144
  19. package/src/core/orchestrators/dedup-phase.ts +0 -199
  20. package/src/core/orchestrators/extraction-chunker.ts +8 -3
  21. package/src/core/orchestrators/human-extraction.ts +37 -85
  22. package/src/core/orchestrators/index.ts +4 -8
  23. package/src/core/orchestrators/person-migration.ts +55 -0
  24. package/src/core/orchestrators/persona-topics.ts +64 -89
  25. package/src/core/orchestrators/room-extraction.ts +34 -0
  26. package/src/core/persona-manager.ts +21 -2
  27. package/src/core/personas/opencode-agent.ts +1 -0
  28. package/src/core/processor.ts +51 -14
  29. package/src/core/prompt-context-builder.ts +38 -5
  30. package/src/core/queue-processor.ts +4 -2
  31. package/src/core/room-manager.ts +6 -7
  32. package/src/core/state/human.ts +6 -0
  33. package/src/core/state/personas.ts +35 -10
  34. package/src/core/state/rooms.ts +21 -0
  35. package/src/core/state-manager.ts +61 -0
  36. package/src/core/types/data-items.ts +12 -0
  37. package/src/core/types/entities.ts +3 -0
  38. package/src/core/types/enums.ts +2 -7
  39. package/src/core/types/llm.ts +2 -0
  40. package/src/core/types/rooms.ts +2 -0
  41. package/src/core/utils/identifier-utils.ts +19 -0
  42. package/src/core/utils/index.ts +2 -1
  43. package/src/core/utils/levenshtein.ts +18 -0
  44. package/src/integrations/claude-code/importer.ts +1 -0
  45. package/src/integrations/cursor/importer.ts +1 -0
  46. package/src/prompts/ceremony/index.ts +1 -0
  47. package/src/prompts/ceremony/person-migration.ts +77 -0
  48. package/src/prompts/ceremony/rewrite.ts +1 -1
  49. package/src/prompts/ceremony/user-dedup.ts +15 -1
  50. package/src/prompts/heartbeat/check.ts +28 -12
  51. package/src/prompts/heartbeat/ei.ts +2 -0
  52. package/src/prompts/heartbeat/types.ts +12 -0
  53. package/src/prompts/human/index.ts +0 -2
  54. package/src/prompts/human/person-scan.ts +58 -14
  55. package/src/prompts/human/person-update.ts +171 -96
  56. package/src/prompts/human/topic-update.ts +1 -1
  57. package/src/prompts/human/types.ts +5 -1
  58. package/src/prompts/index.ts +3 -10
  59. package/src/prompts/message-utils.ts +9 -23
  60. package/src/prompts/persona/index.ts +3 -10
  61. package/src/prompts/persona/topics-rate.ts +95 -0
  62. package/src/prompts/persona/types.ts +8 -48
  63. package/src/prompts/response/index.ts +3 -7
  64. package/src/prompts/response/sections.ts +7 -57
  65. package/src/prompts/room/index.ts +1 -1
  66. package/src/prompts/room/sections.ts +8 -31
  67. package/tui/src/commands/me.tsx +14 -7
  68. package/tui/src/commands/persona.tsx +120 -83
  69. package/tui/src/components/MessageList.tsx +9 -4
  70. package/tui/src/components/RoomMessageList.tsx +10 -5
  71. package/tui/src/context/keyboard.tsx +2 -2
  72. package/tui/src/util/cyp-editor.tsx +13 -8
  73. package/tui/src/util/yaml-context.ts +66 -0
  74. package/tui/src/util/yaml-human.ts +274 -0
  75. package/tui/src/util/yaml-persona.ts +479 -0
  76. package/tui/src/util/yaml-provider.ts +215 -0
  77. package/tui/src/util/yaml-queue.ts +81 -0
  78. package/tui/src/util/yaml-quotes.ts +46 -0
  79. package/tui/src/util/yaml-serializers.ts +9 -1417
  80. package/tui/src/util/yaml-settings.ts +223 -0
  81. package/tui/src/util/yaml-shared.ts +32 -0
  82. package/tui/src/util/yaml-toolkit.ts +55 -0
  83. package/src/prompts/human/person-match.ts +0 -65
  84. package/src/prompts/persona/topics-match.ts +0 -70
  85. package/src/prompts/persona/topics-scan.ts +0 -98
  86. package/src/prompts/persona/topics-update.ts +0 -154
@@ -33,8 +33,9 @@ import { yoloMerge } from "../storage/merge.js";
33
33
  import { StateManager } from "./state-manager.js";
34
34
  import { QueueProcessor } from "./queue-processor.js";
35
35
  import { handlers } from "./handlers/index.js";
36
- import { normalizeRoomMessages } from "./handlers/utils.js";
37
- import { ContextStatus as ContextStatusEnum } from "./types.js";
36
+ import { normalizeRoomMessages, getMessageContent } from "./handlers/utils.js";
37
+ import { sanitizeEiPersonaIdentifiers } from "./utils/identifier-utils.js";
38
+ import { ContextStatus as ContextStatusEnum, RoomMode } from "./types.js";
38
39
  import { registerReadMemoryExecutor, registerFileReadExecutor } from "./tools/index.js";
39
40
  import { createReadMemoryExecutor } from "./tools/builtin/read-memory.js";
40
41
  import { EI_WELCOME_MESSAGE, EI_PERSONA_DEFINITION } from "../templates/welcome.js";
@@ -167,6 +168,9 @@ export class Processor {
167
168
  this.instanceId = ++processorInstanceCount;
168
169
  console.log(`[Processor ${this.instanceId}] CREATED`);
169
170
  this.detectEnvironment();
171
+ this.stateManager.setQueueChangeListener(() => {
172
+ this.interface.onQueueStateChanged?.("busy");
173
+ });
170
174
  }
171
175
 
172
176
  private detectEnvironment(): void {
@@ -229,6 +233,7 @@ export class Processor {
229
233
  }
230
234
  this.bootstrapTools();
231
235
  this.seedBuiltinFacts();
236
+ this.migrateLearnedOn();
232
237
  this.seedSettings();
233
238
  registerReadMemoryExecutor(createReadMemoryExecutor(this.searchHumanData.bind(this), this.getPersonaList.bind(this)));
234
239
  if (this.isTUI) {
@@ -606,7 +611,8 @@ export class Processor {
606
611
  max_calls_per_interaction: 1,
607
612
  });
608
613
 
609
- // submit_response tool — auto-injected for HandlePersonaResponse and HandleRoomResponse.
614
+ // submit_response tool — auto-injected for Heartbeat steps only (HandleHeartbeatCheck).
615
+ // PersonaResponse and RoomResponse agents now use natural Markdown output instead.
610
616
  // Not user-configurable; invisible in the tools UI. Terminates the tool loop immediately
611
617
  // when called; its arguments become response.parsed.
612
618
  this.stateManager.tools_upsertBuiltin({
@@ -796,6 +802,7 @@ export class Processor {
796
802
  sentiment: 0,
797
803
  validated_date: '',
798
804
  last_updated: now,
805
+ learned_on: now,
799
806
  };
800
807
  human.facts.push(newFact);
801
808
  seededCount++;
@@ -807,6 +814,27 @@ export class Processor {
807
814
  }
808
815
  }
809
816
 
817
+ private migrateLearnedOn(): void {
818
+ const human = this.stateManager.getHuman();
819
+
820
+ const backfill = <T extends { learned_on?: string; last_updated: string }>(items: T[]): T[] =>
821
+ items.map(item => item.learned_on ? item : { ...item, learned_on: item.last_updated });
822
+
823
+ const facts = backfill(human.facts);
824
+ const topics = backfill(human.topics);
825
+ const people = backfill(human.people);
826
+
827
+ const changed =
828
+ facts.some((f, i) => f !== human.facts[i]) ||
829
+ topics.some((t, i) => t !== human.topics[i]) ||
830
+ people.some((p, i) => p !== human.people[i]);
831
+
832
+ if (changed) {
833
+ this.stateManager.setHuman({ ...human, facts, topics, people });
834
+ console.log("[Processor] Backfilled learned_on for existing data items");
835
+ }
836
+ }
837
+
810
838
  private seedSettings(): void {
811
839
  const human = this.stateManager.getHuman();
812
840
  let modified = false;
@@ -1020,6 +1048,7 @@ const toolNextSteps = new Set([
1020
1048
  LLMNextStep.HandleEiHeartbeat,
1021
1049
  LLMNextStep.HandleToolContinuation,
1022
1050
  LLMNextStep.HandleDedupCurate,
1051
+ LLMNextStep.HandlePersonIdentifierMigration,
1023
1052
  ]);
1024
1053
  const toolPersonaId =
1025
1054
  personaId ??
@@ -1034,8 +1063,13 @@ const toolNextSteps = new Set([
1034
1063
  (request.next_step === LLMNextStep.HandleToolContinuation &&
1035
1064
  request.data.originalNextStep === LLMNextStep.HandleDedupCurate);
1036
1065
 
1066
+ const isPersonMigrationRequest =
1067
+ request.next_step === LLMNextStep.HandlePersonIdentifierMigration ||
1068
+ (request.next_step === LLMNextStep.HandleToolContinuation &&
1069
+ request.data.originalNextStep === LLMNextStep.HandlePersonIdentifierMigration);
1070
+
1037
1071
  let tools: ToolDefinition[] = [];
1038
- if (isDedupRequest) {
1072
+ if (isDedupRequest || isPersonMigrationRequest) {
1039
1073
  const readMemory = this.stateManager.tools_getByName("read_memory");
1040
1074
  if (readMemory?.enabled) {
1041
1075
  tools = [readMemory];
@@ -1046,8 +1080,6 @@ const toolNextSteps = new Set([
1046
1080
 
1047
1081
  // Auto-inject each handler's dedicated submit tool — infrastructure, not user-visible.
1048
1082
  const submitToolByStep: Partial<Record<string, string>> = {
1049
- [LLMNextStep.HandlePersonaResponse]: "submit_response",
1050
- [LLMNextStep.HandleRoomResponse]: "submit_response",
1051
1083
  [LLMNextStep.HandleHeartbeatCheck]: "submit_heartbeat_check",
1052
1084
  [LLMNextStep.HandleEiHeartbeat]: "submit_ei_heartbeat",
1053
1085
  [LLMNextStep.HandleDedupCurate]: "submit_dedup_decisions",
@@ -1063,8 +1095,11 @@ const toolNextSteps = new Set([
1063
1095
  }
1064
1096
  }
1065
1097
 
1098
+ const toolPersonaName = toolPersonaId
1099
+ ? (this.stateManager.persona_getById(toolPersonaId)?.display_name ?? toolPersonaId)
1100
+ : "none";
1066
1101
  console.log(
1067
- `[Tools] Dispatch for ${request.next_step} persona=${toolPersonaId ?? "none"}: ${tools.length} tool(s) attached`
1102
+ `[Tools] Dispatch for ${request.next_step} persona=${toolPersonaName}: ${tools.length} tool(s) attached`
1068
1103
  );
1069
1104
 
1070
1105
  this.queueProcessor.start(
@@ -1391,16 +1426,19 @@ const toolNextSteps = new Set([
1391
1426
 
1392
1427
  if (!roomId || !parentMessageId || !personaDisplayName) return request;
1393
1428
 
1429
+ const room = this.stateManager.getRoom(roomId);
1430
+ if (room?.mode !== RoomMode.FreeForAll) return request;
1431
+
1394
1432
  const siblings = this.stateManager.getRoomChildren(roomId, parentMessageId)
1395
- .filter((m: RoomMessage) => m.role === "persona" && m.verbal_response)
1433
+ .filter((m: RoomMessage) => m.role === "persona" && getMessageContent(m))
1396
1434
  .map((m: RoomMessage) => ({
1397
1435
  name: this.stateManager.persona_getById(m.persona_id ?? "")?.display_name ?? "Participant",
1398
- verbal_response: m.verbal_response!,
1436
+ verbal_response: getMessageContent(m),
1399
1437
  }));
1400
1438
 
1401
1439
  if (siblings.length === 0) return request;
1402
1440
 
1403
- const siblingSection = buildSiblingAwarenessSection(siblings, personaDisplayName);
1441
+ const siblingSection = buildSiblingAwarenessSection(siblings);
1404
1442
  return { ...request, system: request.system + "\n\n" + siblingSection };
1405
1443
  }
1406
1444
 
@@ -1586,9 +1624,7 @@ const toolNextSteps = new Set([
1586
1624
 
1587
1625
  if (
1588
1626
  response.request.next_step === LLMNextStep.HandlePersonaTraitExtraction ||
1589
- response.request.next_step === LLMNextStep.HandlePersonaTopicScan ||
1590
- response.request.next_step === LLMNextStep.HandlePersonaTopicMatch ||
1591
- response.request.next_step === LLMNextStep.HandlePersonaTopicUpdate
1627
+ response.request.next_step === LLMNextStep.HandlePersonaTopicRating
1592
1628
  ) {
1593
1629
  const personaId = response.request.data.personaId as string;
1594
1630
  if (personaId) {
@@ -1819,7 +1855,8 @@ const toolNextSteps = new Set([
1819
1855
  }
1820
1856
 
1821
1857
  async upsertPerson(person: Person): Promise<void> {
1822
- await upsertPerson(this.stateManager, person);
1858
+ const sanitized = { ...person, identifiers: sanitizeEiPersonaIdentifiers(person.identifiers ?? [], this.stateManager) };
1859
+ await upsertPerson(this.stateManager, sanitized);
1823
1860
  this.interface.onHumanUpdated?.();
1824
1861
  }
1825
1862
 
@@ -4,11 +4,43 @@ import { getEmbeddingService, findTopK } from "./embedding-service.js";
4
4
  import type { ResponsePromptData, PromptOutput } from "../prompts/index.js";
5
5
  import { buildRoomResponsePrompt } from "../prompts/room/index.js";
6
6
  import type { RoomParticipantIdentity } from "../prompts/room/types.js";
7
- import { normalizeRoomMessages } from "./handlers/utils.js";
7
+ import { normalizeRoomMessages, getMessageContent } from "./handlers/utils.js";
8
8
 
9
9
  const QUOTE_LIMIT = 10;
10
10
  const DATA_ITEM_LIMIT = 15;
11
11
  const SIMILARITY_THRESHOLD = 0.3;
12
+ const HUMAN_CONTEXT_COMBINED_LIMIT = 10;
13
+ const HUMAN_CONTEXT_MIN_EACH = 2;
14
+
15
+ // =============================================================================
16
+ // COMBINED TOPICS + PEOPLE CAP
17
+ // =============================================================================
18
+
19
+ function capTopicsAndPeople<T extends { id: string }, P extends { id: string }>(
20
+ topics: T[],
21
+ people: P[]
22
+ ): { topics: T[]; people: P[] } {
23
+ const guaranteed = {
24
+ topics: topics.slice(0, HUMAN_CONTEXT_MIN_EACH),
25
+ people: people.slice(0, HUMAN_CONTEXT_MIN_EACH),
26
+ };
27
+
28
+ let remaining = HUMAN_CONTEXT_COMBINED_LIMIT - guaranteed.topics.length - guaranteed.people.length;
29
+ const extraTopics: T[] = [];
30
+ const extraPeople: P[] = [];
31
+ let ti = HUMAN_CONTEXT_MIN_EACH;
32
+ let pi = HUMAN_CONTEXT_MIN_EACH;
33
+
34
+ while (remaining > 0 && (ti < topics.length || pi < people.length)) {
35
+ if (ti < topics.length && remaining > 0) { extraTopics.push(topics[ti++]); remaining--; }
36
+ if (pi < people.length && remaining > 0) { extraPeople.push(people[pi++]); remaining--; }
37
+ }
38
+
39
+ return {
40
+ topics: [...guaranteed.topics, ...extraTopics],
41
+ people: [...guaranteed.people, ...extraPeople],
42
+ };
43
+ }
12
44
 
13
45
  // =============================================================================
14
46
  // EMBEDDING-BASED RELEVANCE SELECTION
@@ -38,7 +70,6 @@ async function selectRelevantItems<T extends { id: string; embedding?: number[]
38
70
  }
39
71
  }
40
72
 
41
- // Fallback: return top items by recency
42
73
  return [...items]
43
74
  .sort((a, b) => {
44
75
  const aTime = (a as { last_updated?: string }).last_updated ?? "";
@@ -83,12 +114,13 @@ export async function filterHumanDataByVisibility(
83
114
  const DEFAULT_GROUP = "General";
84
115
 
85
116
  if (persona.id === "ei") {
86
- const [facts, topics, people, quotes] = await Promise.all([
117
+ const [facts, rawTopics, rawPeople, quotes] = await Promise.all([
87
118
  selectRelevantItems(human.facts, DATA_ITEM_LIMIT, currentMessage),
88
119
  selectRelevantItems(human.topics, DATA_ITEM_LIMIT, currentMessage),
89
120
  selectRelevantItems(human.people, DATA_ITEM_LIMIT, currentMessage),
90
121
  selectRelevantQuotes(human.quotes ?? [], currentMessage),
91
122
  ]);
123
+ const { topics, people } = capTopicsAndPeople(rawTopics, rawPeople);
92
124
  return {
93
125
  facts,
94
126
  topics,
@@ -118,12 +150,13 @@ export async function filterHumanDataByVisibility(
118
150
  return effectiveGroups.some((g) => visibleGroups.has(g));
119
151
  });
120
152
 
121
- const [facts, topics, people, quotes] = await Promise.all([
153
+ const [facts, rawTopics, rawPeople, quotes] = await Promise.all([
122
154
  selectRelevantItems(filterByGroup(human.facts), DATA_ITEM_LIMIT, currentMessage),
123
155
  selectRelevantItems(filterByGroup(human.topics), DATA_ITEM_LIMIT, currentMessage),
124
156
  selectRelevantItems(filterByGroup(human.people), DATA_ITEM_LIMIT, currentMessage),
125
157
  selectRelevantQuotes(groupFilteredQuotes, currentMessage),
126
158
  ]);
159
+ const { topics, people } = capTopicsAndPeople(rawTopics, rawPeople);
127
160
 
128
161
  return {
129
162
  facts,
@@ -241,7 +274,7 @@ export async function buildRoomResponsePromptData(
241
274
  const sourceMessages = byTime.length >= byCount.length ? byTime : byCount;
242
275
 
243
276
  const lastMessage = sourceMessages[sourceMessages.length - 1];
244
- const currentMessage = lastMessage?.verbal_response;
277
+ const currentMessage = lastMessage ? getMessageContent(lastMessage) : undefined;
245
278
 
246
279
  const filteredHuman = await filterHumanDataByVisibility(human, respondingPersona, currentMessage);
247
280
 
@@ -113,10 +113,12 @@ export class QueueProcessor {
113
113
  // =========================================================================
114
114
  let messages: ChatMessage[] = [];
115
115
 
116
- const isResponseType = request.type === "response" as LLMRequestType;
116
+ const isPersonaResponse = request.next_step === LLMNextStep.HandlePersonaResponse
117
+ || request.next_step === LLMNextStep.HandleRoomResponse
118
+ || request.type === "response" as LLMRequestType;
117
119
  const isToolContinuation = request.next_step === LLMNextStep.HandleToolContinuation;
118
120
 
119
- if (isResponseType || isToolContinuation) {
121
+ if (isPersonaResponse || isToolContinuation) {
120
122
  const personaId = request.data.personaId as string | undefined;
121
123
  const isRoomRequest = !!(request.data.roomId as string | undefined);
122
124
  // Room conversation is embedded in the prompt via placeholders — don't inject persona history.
@@ -4,6 +4,7 @@ import type { StateManager } from "./state-manager.js";
4
4
  import { buildRoomResponsePromptData } from "./prompt-context-builder.js";
5
5
  import { buildRoomJudgePrompt } from "../prompts/room/index.js";
6
6
  import type { RoomHistoryMessage, RoomJudgeCandidate } from "../prompts/room/types.js";
7
+ import { getMessageContent } from "./handlers/utils.js";
7
8
 
8
9
  export function getRoomList(sm: StateManager, includeArchived = false): RoomSummary[] {
9
10
  return sm.getRoomList(includeArchived);
@@ -45,7 +46,7 @@ async function queueRoomPersonaResponses(
45
46
  const model = persona.model ?? sm.getHuman().settings?.default_model ?? "";
46
47
 
47
48
  sm.queue_enqueue({
48
- type: LLMRequestType.JSON,
49
+ type: LLMRequestType.Raw,
49
50
  priority: LLMPriority.Room,
50
51
  system: promptOutput.system,
51
52
  user: promptOutput.user,
@@ -201,7 +202,7 @@ export async function sendFfaMessage(
201
202
  const model = persona.model ?? sm.getHuman().settings?.default_model ?? "";
202
203
 
203
204
  sm.queue_enqueue({
204
- type: LLMRequestType.JSON,
205
+ type: LLMRequestType.Raw,
205
206
  priority: LLMPriority.Room,
206
207
  system: promptOutput.system,
207
208
  user: promptOutput.user,
@@ -282,8 +283,7 @@ export async function activateRoom(
282
283
  ? (human.settings?.name_display ?? "Human")
283
284
  : (sm.persona_getById(m.persona_id ?? "")?.display_name ?? "Unknown"),
284
285
  speaker_id: m.role === "human" ? "human" : (m.persona_id ?? ""),
285
- verbal_response: m.verbal_response,
286
- action_response: m.action_response,
286
+ verbal_response: getMessageContent(m) || undefined,
287
287
  silence_reason: m.silence_reason,
288
288
  }));
289
289
 
@@ -293,8 +293,7 @@ export async function activateRoom(
293
293
  ? (human.settings?.name_display ?? "Human")
294
294
  : (sm.persona_getById(m.persona_id ?? "")?.display_name ?? "Unknown"),
295
295
  speaker_id: m.role === "human" ? "human" : (m.persona_id ?? ""),
296
- verbal_response: m.verbal_response,
297
- action_response: m.action_response,
296
+ verbal_response: getMessageContent(m) || undefined,
298
297
  silence_reason: m.silence_reason,
299
298
  }));
300
299
 
@@ -380,7 +379,7 @@ export async function selectCYPBranch(
380
379
  const model = persona.model ?? sm.getHuman().settings?.default_model ?? "";
381
380
 
382
381
  sm.queue_enqueue({
383
- type: LLMRequestType.JSON,
382
+ type: LLMRequestType.Raw,
384
383
  priority: LLMPriority.Room,
385
384
  system: promptOutput.system,
386
385
  user: promptOutput.user,
@@ -89,6 +89,12 @@ export class HumanState {
89
89
  }
90
90
 
91
91
  person_upsert(person: Person): void {
92
+ const identifiers = person.identifiers ?? [];
93
+ person = { ...person, identifiers };
94
+ const primary = identifiers.find(i => i.is_primary) ?? identifiers[0];
95
+ if (primary) {
96
+ person = { ...person, name: primary.value };
97
+ }
92
98
  const idx = this.human.people.findIndex((p) => p.id === person.id);
93
99
  person.last_updated = new Date().toISOString();
94
100
  if (idx >= 0) {
@@ -1,15 +1,15 @@
1
1
  import type { PersonaEntity, Message, ContextStatus } from "../types.js";
2
2
 
3
- /**
4
- * Migration: If a persisted message has the old `content` field but no `verbal_response`,
5
- * move content → verbal_response. Runs on every load (no-op for already-migrated data).
6
- */
7
- function migrateMessage(msg: Message & { content?: string }): Message {
8
- if (msg.content !== undefined && msg.verbal_response === undefined) {
9
- const { content, ...rest } = msg;
10
- return { ...rest, verbal_response: content };
11
- }
12
- return msg;
3
+ function migrateMessage(msg: Message): Message {
4
+ if (msg.content) return msg;
5
+ if (msg.role === 'human') return msg;
6
+ if (msg.silence_reason) return msg;
7
+ const parts: string[] = [];
8
+ if (msg.action_response) parts.push(`_${msg.action_response}_`);
9
+ if (msg.verbal_response) parts.push(msg.verbal_response);
10
+ if (parts.length === 0) return msg;
11
+ const { verbal_response: _vr, action_response: _ar, ...rest } = msg;
12
+ return { ...rest, content: parts.join('\n\n') };
13
13
  }
14
14
 
15
15
  export interface PersonaData {
@@ -246,4 +246,29 @@ export class PersonaState {
246
246
  }
247
247
  return count;
248
248
  }
249
+
250
+ messages_getUnextractedForPersona(personaId: string, shortId: string, sinceTimestamp?: string): Message[] {
251
+ const data = this.personas.get(personaId);
252
+ if (!data) return [];
253
+ return data.messages
254
+ .filter(m => {
255
+ if (sinceTimestamp && new Date(m.timestamp).getTime() < new Date(sinceTimestamp).getTime()) return false;
256
+ return !m.persona_extracted?.[shortId];
257
+ })
258
+ .map(m => ({ ...m }));
259
+ }
260
+
261
+ messages_markPersonaExtracted(personaId: string, messageIds: string[], shortId: string): number {
262
+ const data = this.personas.get(personaId);
263
+ if (!data) return 0;
264
+ const idsSet = new Set(messageIds);
265
+ let count = 0;
266
+ for (const msg of data.messages) {
267
+ if (idsSet.has(msg.id) && !msg.persona_extracted?.[shortId]) {
268
+ msg.persona_extracted = { ...msg.persona_extracted, [shortId]: true };
269
+ count++;
270
+ }
271
+ }
272
+ return count;
273
+ }
249
274
  }
@@ -179,4 +179,25 @@ export class RoomState {
179
179
  }
180
180
  return count;
181
181
  }
182
+
183
+ messages_getUnextractedForPersona(roomId: string, shortId: string): RoomMessage[] {
184
+ const activePath = new Set(this.messages_getActivePath(roomId).map(m => m.id));
185
+ return (this.rooms.get(roomId)?.messages ?? [])
186
+ .filter(m => activePath.has(m.id) && !m.persona_extracted?.[shortId])
187
+ .map(m => ({ ...m }));
188
+ }
189
+
190
+ messages_markPersonaExtracted(roomId: string, messageIds: string[], shortId: string): number {
191
+ const room = this.rooms.get(roomId);
192
+ if (!room) return 0;
193
+ const ids = new Set(messageIds);
194
+ let count = 0;
195
+ for (const msg of room.messages) {
196
+ if (ids.has(msg.id) && !msg.persona_extracted?.[shortId]) {
197
+ msg.persona_extracted = { ...msg.persona_extracted, [shortId]: true };
198
+ count++;
199
+ }
200
+ }
201
+ return count;
202
+ }
182
203
  }
@@ -34,6 +34,11 @@ export class StateManager {
34
34
  private roomState = new RoomState();
35
35
  private queueState = new QueueState();
36
36
  private persistenceState = new PersistenceState();
37
+ private queueChangeListener?: () => void;
38
+
39
+ setQueueChangeListener(listener: () => void): void {
40
+ this.queueChangeListener = listener;
41
+ }
37
42
  private providers: ToolProvider[] = [];
38
43
  private tools: ToolDefinition[] = [];
39
44
  private embeddingWarning = false;
@@ -62,6 +67,34 @@ export class StateManager {
62
67
  this.migrateMessageFlags();
63
68
  this.migrateInterestedPersonas();
64
69
  this.migrateProviderModel();
70
+ this.migrateRoomMessageContent();
71
+ }
72
+
73
+ private migrateRoomMessageContent(): void {
74
+ const rooms = this.roomState.getAll(true);
75
+ let migratedCount = 0;
76
+
77
+ for (const room of rooms) {
78
+ for (const msg of room.messages) {
79
+ if (msg.content) continue;
80
+ if (msg.role === 'human') continue;
81
+ if (msg.silence_reason) continue;
82
+ const parts: string[] = [];
83
+ const legacy = msg as RoomMessage & { verbal_response?: string; action_response?: string };
84
+ if (legacy.action_response) parts.push(`_${legacy.action_response}_`);
85
+ if (legacy.verbal_response) parts.push(legacy.verbal_response);
86
+ if (parts.length === 0) continue;
87
+ msg.content = parts.join('\n\n');
88
+ delete (msg as any).verbal_response;
89
+ delete (msg as any).action_response;
90
+ migratedCount++;
91
+ }
92
+ }
93
+
94
+ if (migratedCount > 0) {
95
+ this.scheduleSave();
96
+ console.log(`[StateManager] Migrated ${migratedCount} room messages to unified content field`);
97
+ }
65
98
  }
66
99
 
67
100
  /**
@@ -150,6 +183,7 @@ export class StateManager {
150
183
  exposure_current: 0.3,
151
184
  exposure_desired: 0.3,
152
185
  last_updated: fact.last_updated,
186
+ learned_on: fact.last_updated,
153
187
  learned_by: fact.learned_by,
154
188
  last_changed_by: fact.last_changed_by,
155
189
  persona_groups: fact.persona_groups,
@@ -518,6 +552,16 @@ export class StateManager {
518
552
  return count;
519
553
  }
520
554
 
555
+ getRoomUnextractedMessagesForPersona(roomId: string, shortId: string): RoomMessage[] {
556
+ return this.roomState.messages_getUnextractedForPersona(roomId, shortId);
557
+ }
558
+
559
+ markRoomMessagesPersonaExtracted(roomId: string, messageIds: string[], shortId: string): number {
560
+ const count = this.roomState.messages_markPersonaExtracted(roomId, messageIds, shortId);
561
+ if (count > 0) this.scheduleSave();
562
+ return count;
563
+ }
564
+
521
565
  private scheduleSave(): void {
522
566
  this.persistenceState.scheduleSave(this.buildStorageState());
523
567
  }
@@ -565,6 +609,12 @@ export class StateManager {
565
609
  return result;
566
610
  }
567
611
 
612
+ human_person_getByIdentifier(type: string | null, value: string): Person | undefined {
613
+ return this.getHuman().people.find(p =>
614
+ p.identifiers?.some(i => (!type || i.type === type) && i.value === value)
615
+ );
616
+ }
617
+
568
618
  human_quote_add(quote: Quote): void {
569
619
  this.humanState.quote_add(quote);
570
620
  this.scheduleSave();
@@ -713,6 +763,16 @@ export class StateManager {
713
763
  return result;
714
764
  }
715
765
 
766
+ messages_getUnextractedForPersona(personaId: string, shortId: string, sinceTimestamp?: string): Message[] {
767
+ return this.personaState.messages_getUnextractedForPersona(personaId, shortId, sinceTimestamp);
768
+ }
769
+
770
+ messages_markPersonaExtracted(personaId: string, messageIds: string[], shortId: string): number {
771
+ const result = this.personaState.messages_markPersonaExtracted(personaId, messageIds, shortId);
772
+ if (result > 0) this.scheduleSave();
773
+ return result;
774
+ }
775
+
716
776
  queue_enqueue(request: Omit<LLMRequest, "id" | "created_at" | "attempts" | "state">): string {
717
777
  const requestWithModel = {
718
778
  ...request,
@@ -720,6 +780,7 @@ export class StateManager {
720
780
  };
721
781
  const id = this.queueState.enqueue(requestWithModel);
722
782
  this.scheduleSave();
783
+ this.queueChangeListener?.();
723
784
  return id;
724
785
  }
725
786
 
@@ -10,6 +10,7 @@ export interface DataItemBase {
10
10
  description: string;
11
11
  sentiment: number;
12
12
  last_updated: string;
13
+ learned_on?: string; // ISO timestamp when item first entered the system. Immutable after creation. Pairs with learned_by.
13
14
  last_mentioned?: string; // Set by extraction only, never ceremony. Used for --recent sorting.
14
15
  learned_by?: string; // Persona ID that originally learned this item (stable UUID)
15
16
  last_changed_by?: string; // Persona ID that most recently updated this item (stable UUID)
@@ -54,7 +55,18 @@ export interface PersonaTopic {
54
55
  last_updated: string; // ISO timestamp
55
56
  }
56
57
 
58
+ export interface PersonIdentifier {
59
+ type: string; // User-extensible. "nickname", "github", "ei_persona", etc. NOT an enum.
60
+ value: string; // The identifier value. For ei_persona: the Persona UUID.
61
+ is_primary?: boolean; // True = this is the display name. Synced to DataItemBase.name on write.
62
+ }
63
+
57
64
  export interface Person extends DataItemBase {
65
+ identifiers?: PersonIdentifier[];
66
+ // DataItemBase.name stays. Must always equal:
67
+ // identifiers.find(i => i.is_primary)?.value ?? identifiers[0]?.value ?? name
68
+ // State manager syncs name on every write.
69
+ validated_date?: string; // ISO timestamp. Empty string or absent = candidate for Ei heartbeat intro. Same contract as Fact.validated_date.
58
70
  relationship: string;
59
71
  exposure_current: number;
60
72
  exposure_desired: number;
@@ -93,6 +93,7 @@ export interface HumanSettings {
93
93
  default_model?: string; // Will store ModelConfig.id GUID post-migration
94
94
  oneshot_model?: string; // Model for AI-assist (wand) requests; falls back to default_model. Will store ModelConfig.id GUID post-migration.
95
95
  rewrite_model?: string; // Model for rewrite ceremony step; must be capable (Sonnet/Opus class). Unset = rewrite disabled. Will store ModelConfig.id GUID post-migration.
96
+ people_migration_complete?: boolean; // Set to true when all Person records have identifiers. Ceremony migration step short-circuits when true.
96
97
  queue_paused?: boolean;
97
98
  skip_quote_delete_confirm?: boolean;
98
99
  name_display?: string;
@@ -147,6 +148,8 @@ export interface PersonaEntity {
147
148
  last_heartbeat?: string;
148
149
  last_extraction?: string;
149
150
  tools?: string[]; // IDs of ToolDefinitions this persona can use. Empty/absent = no tool access.
151
+ reflection_last_asked?: string; // ISO timestamp. Set ONLY when Persona explicitly surfaces identity drift (mentioned_reflection: true).
152
+ description_embedding?: number[]; // Embedding of long_description (short_description fallback). Excludes traits. See embedding-service.ts:getPersonaDescriptionText.
150
153
  }
151
154
 
152
155
  export interface PersonaCreationInput {
@@ -32,18 +32,12 @@ export enum LLMNextStep {
32
32
  HandleHumanPersonScan = "handleHumanPersonScan",
33
33
  HandleTopicMatch = "handleTopicMatch",
34
34
  HandleTopicUpdate = "handleTopicUpdate",
35
- HandlePersonMatch = "handlePersonMatch",
36
35
  HandlePersonUpdate = "handlePersonUpdate",
37
36
  HandlePersonaTraitExtraction = "handlePersonaTraitExtraction",
38
- HandlePersonaTopicScan = "handlePersonaTopicScan",
39
- HandlePersonaTopicMatch = "handlePersonaTopicMatch",
40
- HandlePersonaTopicUpdate = "handlePersonaTopicUpdate",
37
+ HandlePersonaTopicRating = "handlePersonaTopicRating",
41
38
  HandleHeartbeatCheck = "handleHeartbeatCheck",
42
39
  HandleEiHeartbeat = "handleEiHeartbeat",
43
40
  HandleOneShot = "handleOneShot",
44
- HandlePersonaExpire = "handlePersonaExpire",
45
- HandlePersonaExplore = "handlePersonaExplore",
46
- HandleDescriptionCheck = "handleDescriptionCheck",
47
41
  // Tool calling continuation (second LLM call after tool execution, may loop for more tool calls).
48
42
  // data.toolHistory: serialized LLMHistoryMessage[] (assistant + tool result messages)
49
43
  // data.toolCallCounts: serialized Map entries [[name, count], ...] carrying per-tool call counts
@@ -56,6 +50,7 @@ export enum LLMNextStep {
56
50
  HandleRoomResponse = "handleRoomResponse",
57
51
  HandleRoomJudge = "handleRoomJudge",
58
52
  HandlePersonaPreview = "handlePersonaPreview",
53
+ HandlePersonIdentifierMigration = "handlePersonIdentifierMigration",
59
54
  }
60
55
 
61
56
  export enum ProviderType {
@@ -8,6 +8,7 @@ import type { LLMRequestType, LLMPriority, LLMNextStep } from "./enums.js";
8
8
  export interface Message {
9
9
  id: string;
10
10
  role: "human" | "system";
11
+ content?: string; // Raw Markdown response (primary path going forward)
11
12
  verbal_response?: string; // Human text or persona's spoken reply
12
13
  action_response?: string; // Stage direction / action the persona performs
13
14
  silence_reason?: string; // Why the persona chose not to respond (not shown to LLM)
@@ -21,6 +22,7 @@ export interface Message {
21
22
  t?: boolean; // Topic extraction completed
22
23
  p?: boolean; // Person extraction completed
23
24
  e?: boolean; // Event (epic) extraction completed
25
+ persona_extracted?: Record<string, true>; // Per-persona topic scan tracking. Key = personaId.slice(0, 8)
24
26
  // Image generation fields (web-only, ephemeral)
25
27
  _synthesis?: boolean; // True if message was created by multi-message synthesis
26
28
  speaker_name?: string; // Display name of actual speaker; set on room messages for clean hydration
@@ -10,6 +10,7 @@ export interface RoomMessage {
10
10
  parent_id: string | null;
11
11
  role: "human" | "persona";
12
12
  persona_id?: string;
13
+ content?: string;
13
14
  verbal_response?: string;
14
15
  action_response?: string;
15
16
  silence_reason?: string;
@@ -21,6 +22,7 @@ export interface RoomMessage {
21
22
  t?: boolean;
22
23
  p?: boolean;
23
24
  e?: boolean;
25
+ persona_extracted?: Record<string, true>; // Per-persona topic scan tracking. Key = personaId.slice(0, 8)
24
26
  }
25
27
 
26
28
  export interface RoomEntity {
@@ -0,0 +1,19 @@
1
+ import type { PersonIdentifier } from "../types/data-items.js";
2
+ import type { StateManager } from "../state-manager.js";
3
+
4
+ export const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
5
+
6
+ export function sanitizeEiPersonaIdentifiers(
7
+ identifiers: PersonIdentifier[],
8
+ state: StateManager
9
+ ): PersonIdentifier[] {
10
+ return identifiers.map(id => {
11
+ if (id.type !== 'Ei Persona' && id.type !== 'AI Persona') return id;
12
+ if (UUID_REGEX.test(id.value)) return { ...id, type: 'Ei Persona' };
13
+ const matched = state.persona_getAll().find(p =>
14
+ p.display_name === id.value || p.aliases?.includes(id.value)
15
+ );
16
+ if (matched) return { ...id, type: 'Ei Persona', value: matched.id };
17
+ return id.type === 'AI Persona' ? id : { ...id, type: 'Nickname' };
18
+ });
19
+ }
@@ -1,4 +1,5 @@
1
1
  import { crossFind } from "./crossFind.js";
2
2
  import { applyDecayToValue } from "./decay.js";
3
+ import { UUID_REGEX, sanitizeEiPersonaIdentifiers } from "./identifier-utils.js";
3
4
 
4
- export { crossFind, applyDecayToValue };
5
+ export { crossFind, applyDecayToValue, UUID_REGEX, sanitizeEiPersonaIdentifiers };