ei-tui 0.1.3 → 0.1.4

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.
@@ -2,6 +2,7 @@ import {
2
2
  LLMRequestType,
3
3
  LLMPriority,
4
4
  LLMNextStep,
5
+ ValidationLevel,
5
6
  RESERVED_PERSONA_NAMES,
6
7
  isReservedPersonaName,
7
8
  type LLMRequest,
@@ -35,9 +36,12 @@ import {
35
36
  buildResponsePrompt,
36
37
  buildPersonaTraitExtractionPrompt,
37
38
  buildHeartbeatCheckPrompt,
39
+ buildEiHeartbeatPrompt,
38
40
  type ResponsePromptData,
39
41
  type PersonaTraitExtractionPromptData,
40
42
  type HeartbeatCheckPromptData,
43
+ type EiHeartbeatPromptData,
44
+ type EiHeartbeatItem,
41
45
  } from "../prompts/index.js";
42
46
  import {
43
47
  orchestratePersonaGeneration,
@@ -369,7 +373,7 @@ export class Processor {
369
373
 
370
374
  const human = this.stateManager.getHuman();
371
375
 
372
- if (this.isTUI && human.settings?.opencode?.integration) {
376
+ if (this.isTUI && human.settings?.opencode?.integration && this.stateManager.queue_length() === 0) {
373
377
  await this.checkAndSyncOpenCode(human, now);
374
378
  }
375
379
 
@@ -434,12 +438,10 @@ export class Processor {
434
438
  },
435
439
  });
436
440
 
437
- const since = lastSync > 0 ? new Date(lastSync) : new Date(0);
438
-
439
441
  this.openCodeImportInProgress = true;
440
442
  import("../integrations/opencode/importer.js")
441
- .then(({ importOpenCodeSessions }) =>
442
- importOpenCodeSessions(since, {
443
+ .then(({ importOpenCodeSessions }) =>
444
+ importOpenCodeSessions({
443
445
  stateManager: this.stateManager,
444
446
  interface: this.interface,
445
447
  })
@@ -449,7 +451,7 @@ export class Processor {
449
451
  console.log(
450
452
  `[Processor] OpenCode sync complete: ${result.sessionsProcessed} sessions, ` +
451
453
  `${result.topicsCreated} topics created, ${result.messagesImported} messages imported, ` +
452
- `${result.topicUpdatesQueued} topic updates queued`
454
+ `${result.extractionScansQueued} extraction scans queued`
453
455
  );
454
456
  }
455
457
  })
@@ -491,20 +493,20 @@ export class Processor {
491
493
  private async queueHeartbeatCheck(personaId: string): Promise<void> {
492
494
  const persona = this.stateManager.persona_getById(personaId);
493
495
  if (!persona) return;
494
-
495
496
  this.stateManager.persona_update(personaId, { last_heartbeat: new Date().toISOString() });
496
-
497
497
  const human = this.stateManager.getHuman();
498
498
  const history = this.stateManager.messages_get(personaId);
499
- const filteredHuman = await this.filterHumanDataByVisibility(human, persona);
499
+ if (personaId === "ei") {
500
+ await this.queueEiHeartbeat(human, history);
501
+ return;
502
+ }
500
503
 
504
+ const filteredHuman = await this.filterHumanDataByVisibility(human, persona);
501
505
  const inactiveDays = persona.last_activity
502
506
  ? Math.floor((Date.now() - new Date(persona.last_activity).getTime()) / (1000 * 60 * 60 * 24))
503
507
  : 0;
504
-
505
508
  const sortByEngagementGap = <T extends { exposure_desired: number; exposure_current: number }>(items: T[]): T[] =>
506
509
  [...items].sort((a, b) => (b.exposure_desired - b.exposure_current) - (a.exposure_desired - a.exposure_current));
507
-
508
510
  const promptData: HeartbeatCheckPromptData = {
509
511
  persona: {
510
512
  name: persona.display_name,
@@ -532,6 +534,118 @@ export class Processor {
532
534
  });
533
535
  }
534
536
 
537
+ private async queueEiHeartbeat(human: HumanEntity, history: import("./types.js").Message[]): Promise<void> {
538
+ const now = Date.now();
539
+ const engagementGapThreshold = 0.2;
540
+ const cooldownMs = 7 * 24 * 60 * 60 * 1000;
541
+ const personas = this.stateManager.persona_getAll();
542
+ const items: EiHeartbeatItem[] = [];
543
+
544
+ const unverifiedFacts = human.facts
545
+ .filter(f => f.validated === ValidationLevel.None && f.learned_by !== "ei")
546
+ .slice(0, 5);
547
+ for (const fact of unverifiedFacts) {
548
+ const quote = human.quotes.find(q => q.data_item_ids.includes(fact.id));
549
+ items.push({
550
+ id: fact.id,
551
+ type: "Fact Check",
552
+ name: fact.name,
553
+ description: fact.description,
554
+ quote: quote?.text,
555
+ });
556
+ }
557
+
558
+ const underEngagedPeople = human.people
559
+ .filter(p =>
560
+ (p.exposure_desired - p.exposure_current) > engagementGapThreshold &&
561
+ (!p.last_ei_asked || now - new Date(p.last_ei_asked).getTime() > cooldownMs)
562
+ )
563
+ .sort((a, b) => (b.exposure_desired - b.exposure_current) - (a.exposure_desired - a.exposure_current))
564
+ .slice(0, 5);
565
+ for (const person of underEngagedPeople) {
566
+ const gap = Math.round((person.exposure_desired - person.exposure_current) * 100);
567
+ const quote = human.quotes.find(q => q.data_item_ids.includes(person.id));
568
+ items.push({
569
+ id: person.id,
570
+ type: "Low-Engagement Person",
571
+ engagement_delta: `${gap}%`,
572
+ relationship: person.relationship,
573
+ name: person.name,
574
+ description: person.description,
575
+ quote: quote?.text,
576
+ });
577
+ }
578
+
579
+ const underEngagedTopics = human.topics
580
+ .filter(t =>
581
+ (t.exposure_desired - t.exposure_current) > engagementGapThreshold &&
582
+ (!t.last_ei_asked || now - new Date(t.last_ei_asked).getTime() > cooldownMs)
583
+ )
584
+ .sort((a, b) => (b.exposure_desired - b.exposure_current) - (a.exposure_desired - a.exposure_current))
585
+ .slice(0, 5);
586
+ for (const topic of underEngagedTopics) {
587
+ const gap = Math.round((topic.exposure_desired - topic.exposure_current) * 100);
588
+ const quote = human.quotes.find(q => q.data_item_ids.includes(topic.id));
589
+ items.push({
590
+ id: topic.id,
591
+ type: "Low-Engagement Topic",
592
+ engagement_delta: `${gap}%`,
593
+ name: topic.name,
594
+ description: topic.description,
595
+ quote: quote?.text,
596
+ });
597
+ }
598
+
599
+ const activePersonas = personas
600
+ .filter(p => !p.is_archived && !p.is_paused && p.id !== "ei")
601
+ .map(p => {
602
+ const msgs = this.stateManager.messages_get(p.id);
603
+ const lastHuman = [...msgs].reverse().find(m => m.role === "human");
604
+ const lastTs = lastHuman?.timestamp ? new Date(lastHuman.timestamp).getTime() : 0;
605
+ return { persona: p, lastHumanTs: lastTs };
606
+ })
607
+ .filter(({ lastHumanTs }) => {
608
+ const daysSince = (now - lastHumanTs) / (1000 * 60 * 60 * 24);
609
+ return daysSince >= 3;
610
+ })
611
+ .sort((a, b) => a.lastHumanTs - b.lastHumanTs)
612
+ .slice(0, 3);
613
+ for (const { persona: p, lastHumanTs } of activePersonas) {
614
+ const daysSince = lastHumanTs > 0
615
+ ? Math.floor((now - lastHumanTs) / (1000 * 60 * 60 * 24))
616
+ : 999;
617
+ items.push({
618
+ id: p.id,
619
+ type: "Inactive Persona",
620
+ name: p.display_name,
621
+ short_description: p.short_description,
622
+ days_inactive: daysSince,
623
+ });
624
+ }
625
+
626
+ if (items.length === 0) {
627
+ console.log("[queueEiHeartbeat] No items to address, skipping");
628
+ return;
629
+ }
630
+
631
+ const promptData: EiHeartbeatPromptData = {
632
+ items,
633
+ recent_history: history.slice(-10),
634
+ };
635
+
636
+ const prompt = buildEiHeartbeatPrompt(promptData);
637
+
638
+ this.stateManager.queue_enqueue({
639
+ type: LLMRequestType.JSON,
640
+ priority: LLMPriority.Low,
641
+ system: prompt.system,
642
+ user: prompt.user,
643
+ next_step: LLMNextStep.HandleEiHeartbeat,
644
+ model: this.getModelForPersona("ei"),
645
+ data: { personaId: "ei", isTUI: this.isTUI },
646
+ });
647
+ }
648
+
535
649
 
536
650
  private classifyLLMError(error: string): string {
537
651
  const match = error.match(/\((\d{3})\)/);
@@ -627,9 +741,7 @@ export class Processor {
627
741
  }
628
742
  }
629
743
 
630
- if (response.request.next_step === LLMNextStep.HandleEiValidation) {
631
- this.interface.onHumanUpdated?.();
632
- }
744
+
633
745
 
634
746
  if (response.request.next_step === LLMNextStep.HandleHumanItemUpdate) {
635
747
  this.interface.onHumanUpdated?.();
@@ -813,23 +925,16 @@ export class Processor {
813
925
  async recallPendingMessages(personaId: string): Promise<string> {
814
926
  const persona = this.stateManager.persona_getById(personaId);
815
927
  if (!persona) return "";
816
-
817
928
  this.clearPendingRequestsFor(personaId);
818
- this.stateManager.queue_pause();
819
-
820
929
  const messages = this.stateManager.messages_get(personaId);
821
930
  const pendingIds = messages
822
931
  .filter(m => m.role === "human" && !m.read)
823
932
  .map(m => m.id);
824
-
825
933
  if (pendingIds.length === 0) return "";
826
-
827
934
  const removed = this.stateManager.messages_remove(personaId, pendingIds);
828
935
  const recalledContent = removed.map(m => m.content).join("\n\n");
829
-
830
936
  this.interface.onMessageAdded?.(personaId);
831
937
  this.interface.onMessageRecalled?.(personaId, recalledContent);
832
-
833
938
  return recalledContent;
834
939
  }
835
940
 
@@ -987,13 +1092,40 @@ export class Processor {
987
1092
  ): Promise<ResponsePromptData["human"]> {
988
1093
  const DEFAULT_GROUP = "General";
989
1094
  const QUOTE_LIMIT = 10;
1095
+ const DATA_ITEM_LIMIT = 15;
990
1096
  const SIMILARITY_THRESHOLD = 0.3;
1097
+ // Generic relevance selector for embedding-capable items.
1098
+ // Falls back to returning all items when no message/embeddings are available.
1099
+ const selectRelevantItems = async <T extends { id: string; embedding?: number[] }>(
1100
+ items: T[],
1101
+ limit: number
1102
+ ): Promise<T[]> => {
1103
+ if (items.length === 0) return [];
1104
+
1105
+ const withEmbeddings = items.filter(i => i.embedding?.length);
1106
+
1107
+ if (currentMessage && withEmbeddings.length > 0) {
1108
+ try {
1109
+ const embeddingService = getEmbeddingService();
1110
+ const queryVector = await embeddingService.embed(currentMessage);
1111
+ const results = findTopK(queryVector, withEmbeddings, limit);
1112
+ const relevant = results
1113
+ .filter(({ similarity }) => similarity >= SIMILARITY_THRESHOLD)
1114
+ .map(({ item }) => item);
991
1115
 
1116
+ if (relevant.length > 0) return relevant;
1117
+ } catch (err) {
1118
+ console.warn("[filterHumanDataByVisibility] Embedding search failed:", err);
1119
+ }
1120
+ }
1121
+
1122
+ // Fallback: return all items (caller may apply its own limit)
1123
+ return items;
1124
+ };
992
1125
  const selectRelevantQuotes = async (quotes: Quote[]): Promise<Quote[]> => {
993
1126
  if (quotes.length === 0) return [];
994
-
995
1127
  const withEmbeddings = quotes.filter(q => q.embedding?.length);
996
-
1128
+
997
1129
  if (currentMessage && withEmbeddings.length > 0) {
998
1130
  try {
999
1131
  const embeddingService = getEmbeddingService();
@@ -1002,35 +1134,31 @@ export class Processor {
1002
1134
  const relevant = results
1003
1135
  .filter(({ similarity }) => similarity >= SIMILARITY_THRESHOLD)
1004
1136
  .map(({ item }) => item);
1005
-
1137
+
1006
1138
  if (relevant.length > 0) return relevant;
1007
1139
  } catch (err) {
1008
1140
  console.warn("[filterHumanDataByVisibility] Embedding search failed:", err);
1009
1141
  }
1010
1142
  }
1011
-
1012
1143
  return [...quotes]
1013
1144
  .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
1014
1145
  .slice(0, QUOTE_LIMIT);
1015
1146
  };
1016
-
1017
1147
  if (persona.id === "ei") {
1018
- const relevantQuotes = await selectRelevantQuotes(human.quotes ?? []);
1019
- return {
1020
- facts: human.facts,
1021
- traits: human.traits,
1022
- topics: human.topics,
1023
- people: human.people,
1024
- quotes: relevantQuotes,
1025
- };
1148
+ const [facts, traits, topics, people, quotes] = await Promise.all([
1149
+ selectRelevantItems(human.facts, DATA_ITEM_LIMIT),
1150
+ selectRelevantItems(human.traits, DATA_ITEM_LIMIT),
1151
+ selectRelevantItems(human.topics, DATA_ITEM_LIMIT),
1152
+ selectRelevantItems(human.people, DATA_ITEM_LIMIT),
1153
+ selectRelevantQuotes(human.quotes ?? []),
1154
+ ]);
1155
+ return { facts, traits, topics, people, quotes };
1026
1156
  }
1027
-
1028
1157
  const visibleGroups = new Set<string>();
1029
1158
  if (persona.group_primary) {
1030
1159
  visibleGroups.add(persona.group_primary);
1031
1160
  }
1032
1161
  (persona.groups_visible ?? []).forEach((g) => visibleGroups.add(g));
1033
-
1034
1162
  const filterByGroup = <T extends DataItemBase>(items: T[]): T[] => {
1035
1163
  return items.filter((item) => {
1036
1164
  const itemGroups = item.persona_groups ?? [];
@@ -1038,21 +1166,20 @@ export class Processor {
1038
1166
  return effectiveGroups.some((g) => visibleGroups.has(g));
1039
1167
  });
1040
1168
  };
1041
-
1042
1169
  const groupFilteredQuotes = (human.quotes ?? []).filter((q) => {
1043
1170
  const effectiveGroups = q.persona_groups.length === 0 ? [DEFAULT_GROUP] : q.persona_groups;
1044
1171
  return effectiveGroups.some((g) => visibleGroups.has(g));
1045
1172
  });
1046
1173
 
1047
- const relevantQuotes = await selectRelevantQuotes(groupFilteredQuotes);
1174
+ const [facts, traits, topics, people, quotes] = await Promise.all([
1175
+ selectRelevantItems(filterByGroup(human.facts), DATA_ITEM_LIMIT),
1176
+ selectRelevantItems(filterByGroup(human.traits), DATA_ITEM_LIMIT),
1177
+ selectRelevantItems(filterByGroup(human.topics), DATA_ITEM_LIMIT),
1178
+ selectRelevantItems(filterByGroup(human.people), DATA_ITEM_LIMIT),
1179
+ selectRelevantQuotes(groupFilteredQuotes),
1180
+ ]);
1048
1181
 
1049
- return {
1050
- facts: filterByGroup(human.facts),
1051
- traits: filterByGroup(human.traits),
1052
- topics: filterByGroup(human.topics),
1053
- people: filterByGroup(human.people),
1054
- quotes: relevantQuotes,
1055
- };
1182
+ return { facts, traits, topics, people, quotes };
1056
1183
  }
1057
1184
 
1058
1185
  private getVisiblePersonas(
@@ -45,6 +45,10 @@ export class PersistenceState {
45
45
  return this.loadedExistingData;
46
46
  }
47
47
 
48
+ markExistingData(): void {
49
+ this.loadedExistingData = true;
50
+ }
51
+
48
52
  async flush(): Promise<void> {
49
53
  if (this.saveTimeout) {
50
54
  clearTimeout(this.saveTimeout);
@@ -1,4 +1,4 @@
1
- import type { LLMRequest, LLMNextStep, QueueFailResult } from "../types.js";
1
+ import type { LLMRequest, QueueFailResult } from "../types.js";
2
2
 
3
3
  const BASE_BACKOFF_MS = 2_000;
4
4
  const MAX_BACKOFF_MS = 30_000;
@@ -93,16 +93,7 @@ export class QueueState {
93
93
  return { dropped: false, retryDelay: delay };
94
94
  }
95
95
 
96
- getValidations(): LLMRequest[] {
97
- return this.queue.filter(
98
- (r) => r.next_step === ("handleEiValidation" as LLMNextStep)
99
- );
100
- }
101
96
 
102
- clearValidations(ids: string[]): void {
103
- const idSet = new Set(ids);
104
- this.queue = this.queue.filter((r) => !idSet.has(r.id));
105
- }
106
97
 
107
98
  clearPersonaResponses(personaId: string, nextStep: string): string[] {
108
99
  const removedIds: string[] = [];
@@ -275,14 +275,7 @@ export class StateManager {
275
275
  return result;
276
276
  }
277
277
 
278
- queue_getValidations(): LLMRequest[] {
279
- return this.queueState.getValidations();
280
- }
281
278
 
282
- queue_clearValidations(ids: string[]): void {
283
- this.queueState.clearValidations(ids);
284
- this.scheduleSave();
285
- }
286
279
 
287
280
  queue_clearPersonaResponses(personaId: string, nextStep: string): string[] {
288
281
  const result = this.queueState.clearPersonaResponses(personaId, nextStep);
@@ -338,6 +331,7 @@ export class StateManager {
338
331
  this.humanState.load(state.human);
339
332
  this.personaState.load(state.personas);
340
333
  this.queueState.load(state.queue);
334
+ this.persistenceState.markExistingData();
341
335
  this.scheduleSave();
342
336
  }
343
337
 
package/src/core/types.ts CHANGED
@@ -18,7 +18,6 @@ export enum ValidationLevel {
18
18
  Ei = "ei", // Ei mentioned it to user (don't mention again)
19
19
  Human = "human", // User explicitly confirmed (locked)
20
20
  }
21
-
22
21
  export enum LLMRequestType {
23
22
  Response = "response",
24
23
  JSON = "json",
@@ -47,9 +46,7 @@ export enum LLMNextStep {
47
46
  HandlePersonaTopicUpdate = "handlePersonaTopicUpdate",
48
47
  HandleHeartbeatCheck = "handleHeartbeatCheck",
49
48
  HandleEiHeartbeat = "handleEiHeartbeat",
50
- HandleEiValidation = "handleEiValidation",
51
49
  HandleOneShot = "handleOneShot",
52
- // Ceremony handlers
53
50
  HandlePersonaExpire = "handlePersonaExpire",
54
51
  HandlePersonaExplore = "handlePersonaExplore",
55
52
  HandleDescriptionCheck = "handleDescriptionCheck",
@@ -83,6 +80,7 @@ export interface Topic extends DataItemBase {
83
80
  category?: string; // Interest, Goal, Dream, Conflict, Concern, Fear, Hope, Plan, Project
84
81
  exposure_current: number;
85
82
  exposure_desired: number;
83
+ last_ei_asked?: string | null; // ISO timestamp of last time Ei proactively asked about this
86
84
  }
87
85
 
88
86
  /**
@@ -109,6 +107,7 @@ export interface Person extends DataItemBase {
109
107
  relationship: string;
110
108
  exposure_current: number;
111
109
  exposure_desired: number;
110
+ last_ei_asked?: string | null; // ISO timestamp of last time Ei proactively asked about this
112
111
  }
113
112
 
114
113
  export interface Quote {
@@ -180,7 +179,8 @@ export interface OpenCodeSettings {
180
179
  integration?: boolean;
181
180
  polling_interval_ms?: number; // Default: 1800000 (30 min)
182
181
  last_sync?: string; // ISO timestamp
183
- extraction_point?: string; // ISO timestamp - earliest unprocessed message, gradual extraction advances this
182
+ extraction_point?: string; // ISO timestamp - cursor for single-session archive scan
183
+ processed_sessions?: Record<string, string>; // sessionId → ISO timestamp of last import
184
184
  }
185
185
 
186
186
  export interface CeremonyConfig {
@@ -238,7 +238,6 @@ export interface PersonaEntity {
238
238
  last_activity: string;
239
239
  last_heartbeat?: string;
240
240
  last_extraction?: string;
241
- last_inactivity_ping?: string;
242
241
  }
243
242
 
244
243
  export interface PersonaCreationInput {
@@ -0,0 +1,44 @@
1
+ import type { HumanEntity, PersonaEntity, Fact, Trait, Topic, Person, Quote, PersonaTopic } from "../types.ts";
2
+ export type CrossFindResult =
3
+ | { type: "fact" } & Fact
4
+ | { type: "trait" } & Trait
5
+ | { type: "topic" } & Topic
6
+ | { type: "person" } & Person
7
+ | { type: "quote" } & Quote
8
+ | { type: "persona" } & PersonaEntity
9
+ | { type: "personaTopic"; personaId: string } & PersonaTopic
10
+ | { type: "personaTrait"; personaId: string } & Trait;
11
+
12
+ export function crossFind(
13
+ id: string,
14
+ human: HumanEntity,
15
+ personas?: PersonaEntity[],
16
+ ): CrossFindResult | null {
17
+
18
+ const fact = human.facts.find(f => f.id === id);
19
+ if (fact) return { type: "fact", ...fact };
20
+
21
+ const trait = human.traits.find(t => t.id === id);
22
+ if (trait) return { type: "trait", ...trait };
23
+
24
+ const person = human.people.find(p => p.id === id);
25
+ if (person) return { type: "person", ...person };
26
+
27
+ const topic = human.topics.find(t => t.id === id);
28
+ if (topic) return { type: "topic", ...topic };
29
+
30
+ const quote = human.quotes.find(q => q.id === id);
31
+ if (quote) return { type: "quote", ...quote };
32
+
33
+ for (const persona of personas ?? []) {
34
+ if (persona.id === id) return { type: "persona", ...persona };
35
+
36
+ const pTopic = persona.topics.find(t => t.id === id);
37
+ if (pTopic) return { type: "personaTopic", personaId: persona.id, ...pTopic };
38
+
39
+ const pTrait = persona.traits.find(t => t.id === id);
40
+ if (pTrait) return { type: "personaTrait", personaId: persona.id, ...pTrait };
41
+ }
42
+
43
+ return null;
44
+ }
@@ -0,0 +1,4 @@
1
+ import { crossFind } from "./crossFind.js";
2
+ import { applyDecayToValue } from "./decay.js";
3
+
4
+ export { crossFind, applyDecayToValue };