ei-tui 0.1.3

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 (133) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +170 -0
  3. package/package.json +63 -0
  4. package/src/README.md +96 -0
  5. package/src/cli/README.md +47 -0
  6. package/src/cli/commands/facts.ts +25 -0
  7. package/src/cli/commands/people.ts +25 -0
  8. package/src/cli/commands/quotes.ts +19 -0
  9. package/src/cli/commands/topics.ts +25 -0
  10. package/src/cli/commands/traits.ts +25 -0
  11. package/src/cli/retrieval.ts +269 -0
  12. package/src/cli.ts +176 -0
  13. package/src/core/AGENTS.md +104 -0
  14. package/src/core/embedding-service.ts +241 -0
  15. package/src/core/handlers/index.ts +1057 -0
  16. package/src/core/index.ts +4 -0
  17. package/src/core/llm-client.ts +265 -0
  18. package/src/core/model-context-windows.ts +49 -0
  19. package/src/core/orchestrators/ceremony.ts +500 -0
  20. package/src/core/orchestrators/extraction-chunker.ts +138 -0
  21. package/src/core/orchestrators/human-extraction.ts +457 -0
  22. package/src/core/orchestrators/index.ts +28 -0
  23. package/src/core/orchestrators/persona-generation.ts +76 -0
  24. package/src/core/orchestrators/persona-topics.ts +117 -0
  25. package/src/core/personas/index.ts +5 -0
  26. package/src/core/personas/opencode-agent.ts +81 -0
  27. package/src/core/processor.ts +1413 -0
  28. package/src/core/queue-processor.ts +197 -0
  29. package/src/core/state/checkpoints.ts +68 -0
  30. package/src/core/state/human.ts +176 -0
  31. package/src/core/state/index.ts +5 -0
  32. package/src/core/state/personas.ts +217 -0
  33. package/src/core/state/queue.ts +144 -0
  34. package/src/core/state-manager.ts +347 -0
  35. package/src/core/types.ts +421 -0
  36. package/src/core/utils/decay.ts +33 -0
  37. package/src/index.ts +1 -0
  38. package/src/integrations/opencode/importer.ts +896 -0
  39. package/src/integrations/opencode/index.ts +16 -0
  40. package/src/integrations/opencode/json-reader.ts +304 -0
  41. package/src/integrations/opencode/reader-factory.ts +35 -0
  42. package/src/integrations/opencode/sqlite-reader.ts +189 -0
  43. package/src/integrations/opencode/types.ts +244 -0
  44. package/src/prompts/AGENTS.md +62 -0
  45. package/src/prompts/ceremony/description-check.ts +47 -0
  46. package/src/prompts/ceremony/expire.ts +30 -0
  47. package/src/prompts/ceremony/explore.ts +60 -0
  48. package/src/prompts/ceremony/index.ts +11 -0
  49. package/src/prompts/ceremony/types.ts +42 -0
  50. package/src/prompts/generation/descriptions.ts +91 -0
  51. package/src/prompts/generation/index.ts +15 -0
  52. package/src/prompts/generation/persona.ts +155 -0
  53. package/src/prompts/generation/seeds.ts +31 -0
  54. package/src/prompts/generation/types.ts +47 -0
  55. package/src/prompts/heartbeat/check.ts +179 -0
  56. package/src/prompts/heartbeat/ei.ts +208 -0
  57. package/src/prompts/heartbeat/index.ts +15 -0
  58. package/src/prompts/heartbeat/types.ts +70 -0
  59. package/src/prompts/human/fact-scan.ts +152 -0
  60. package/src/prompts/human/index.ts +32 -0
  61. package/src/prompts/human/item-match.ts +74 -0
  62. package/src/prompts/human/item-update.ts +322 -0
  63. package/src/prompts/human/person-scan.ts +115 -0
  64. package/src/prompts/human/topic-scan.ts +135 -0
  65. package/src/prompts/human/trait-scan.ts +115 -0
  66. package/src/prompts/human/types.ts +127 -0
  67. package/src/prompts/index.ts +90 -0
  68. package/src/prompts/message-utils.ts +39 -0
  69. package/src/prompts/persona/index.ts +16 -0
  70. package/src/prompts/persona/topics-match.ts +69 -0
  71. package/src/prompts/persona/topics-scan.ts +98 -0
  72. package/src/prompts/persona/topics-update.ts +157 -0
  73. package/src/prompts/persona/traits.ts +117 -0
  74. package/src/prompts/persona/types.ts +74 -0
  75. package/src/prompts/response/index.ts +147 -0
  76. package/src/prompts/response/sections.ts +355 -0
  77. package/src/prompts/response/types.ts +38 -0
  78. package/src/prompts/validation/ei.ts +93 -0
  79. package/src/prompts/validation/index.ts +6 -0
  80. package/src/prompts/validation/types.ts +22 -0
  81. package/src/storage/crypto.ts +96 -0
  82. package/src/storage/index.ts +5 -0
  83. package/src/storage/interface.ts +9 -0
  84. package/src/storage/local.ts +79 -0
  85. package/src/storage/merge.ts +69 -0
  86. package/src/storage/remote.ts +145 -0
  87. package/src/templates/welcome.ts +91 -0
  88. package/tui/README.md +62 -0
  89. package/tui/bunfig.toml +4 -0
  90. package/tui/src/app.tsx +55 -0
  91. package/tui/src/commands/archive.tsx +93 -0
  92. package/tui/src/commands/context.tsx +124 -0
  93. package/tui/src/commands/delete.tsx +71 -0
  94. package/tui/src/commands/details.tsx +41 -0
  95. package/tui/src/commands/editor.tsx +46 -0
  96. package/tui/src/commands/help.tsx +12 -0
  97. package/tui/src/commands/me.tsx +145 -0
  98. package/tui/src/commands/model.ts +47 -0
  99. package/tui/src/commands/new.ts +31 -0
  100. package/tui/src/commands/pause.ts +46 -0
  101. package/tui/src/commands/persona.tsx +58 -0
  102. package/tui/src/commands/provider.tsx +124 -0
  103. package/tui/src/commands/quit.ts +22 -0
  104. package/tui/src/commands/quotes.tsx +172 -0
  105. package/tui/src/commands/registry.test.ts +137 -0
  106. package/tui/src/commands/registry.ts +130 -0
  107. package/tui/src/commands/resume.ts +39 -0
  108. package/tui/src/commands/setsync.tsx +43 -0
  109. package/tui/src/commands/settings.tsx +83 -0
  110. package/tui/src/components/ConfirmOverlay.tsx +51 -0
  111. package/tui/src/components/ConflictOverlay.tsx +78 -0
  112. package/tui/src/components/HelpOverlay.tsx +69 -0
  113. package/tui/src/components/Layout.tsx +24 -0
  114. package/tui/src/components/MessageList.tsx +174 -0
  115. package/tui/src/components/PersonaListOverlay.tsx +186 -0
  116. package/tui/src/components/PromptInput.tsx +145 -0
  117. package/tui/src/components/ProviderListOverlay.tsx +208 -0
  118. package/tui/src/components/QuotesOverlay.tsx +157 -0
  119. package/tui/src/components/Sidebar.tsx +95 -0
  120. package/tui/src/components/StatusBar.tsx +77 -0
  121. package/tui/src/components/WelcomeOverlay.tsx +73 -0
  122. package/tui/src/context/ei.tsx +623 -0
  123. package/tui/src/context/keyboard.tsx +164 -0
  124. package/tui/src/context/overlay.tsx +53 -0
  125. package/tui/src/index.tsx +8 -0
  126. package/tui/src/storage/file.ts +185 -0
  127. package/tui/src/util/duration.ts +32 -0
  128. package/tui/src/util/editor.ts +188 -0
  129. package/tui/src/util/logger.ts +109 -0
  130. package/tui/src/util/persona-editor.tsx +181 -0
  131. package/tui/src/util/provider-editor.tsx +168 -0
  132. package/tui/src/util/syntax.ts +35 -0
  133. package/tui/src/util/yaml-serializers.ts +755 -0
@@ -0,0 +1,500 @@
1
+ import { LLMRequestType, LLMPriority, LLMNextStep, MESSAGE_MIN_COUNT, MESSAGE_MAX_AGE_DAYS, type CeremonyConfig, type PersonaTopic, type Topic } from "../types.js";
2
+ import type { StateManager } from "../state-manager.js";
3
+ import { applyDecayToValue } from "../utils/decay.js";
4
+ import {
5
+ queueFactScan,
6
+ queueTraitScan,
7
+ queueTopicScan,
8
+ queuePersonScan,
9
+ type ExtractionContext,
10
+ type ExtractionOptions,
11
+ } from "./human-extraction.js";
12
+ import { queuePersonaTopicScan, type PersonaTopicContext } from "./persona-topics.js";
13
+ import { buildPersonaExpirePrompt, buildPersonaExplorePrompt, buildDescriptionCheckPrompt } from "../../prompts/ceremony/index.js";
14
+
15
+ export function isNewDay(lastCeremony: string | undefined, now: Date): boolean {
16
+ if (!lastCeremony) return true;
17
+
18
+ const last = new Date(lastCeremony);
19
+ return last.toDateString() !== now.toDateString();
20
+ }
21
+
22
+ export function isPastCeremonyTime(ceremonyTime: string, now: Date): boolean {
23
+ const [hours, minutes] = ceremonyTime.split(":").map(Number);
24
+ const ceremonyMinutes = hours * 60 + minutes;
25
+ const nowMinutes = now.getHours() * 60 + now.getMinutes();
26
+ return nowMinutes >= ceremonyMinutes;
27
+ }
28
+
29
+ /**
30
+ * Flare Note: if we wanted to run the ceremony every 24h _or_, say "1 hour after the user has 'gone idle' after using
31
+ * the system", this is where you'd add that condition. Bear in mind that the prompts and flow were written for
32
+ * 1-per-day, so you'll want to revisit them carefully.
33
+ */
34
+ export function shouldStartCeremony(config: CeremonyConfig, state: StateManager, now: Date = new Date()): boolean {
35
+ if (!isNewDay(config.last_ceremony, now)) return false;
36
+ if (!isPastCeremonyTime(config.time, now)) return false;
37
+ // Don't start ceremony while import extraction or other queued work is pending.
38
+ // Archive scan injects messages that need extraction — pruning before extraction
39
+ // completes would lose knowledge.
40
+ if (state.queue_length() > 0) return false;
41
+ return true;
42
+ }
43
+
44
+ /**
45
+ * Start the ceremony by queuing Exposure scans for all active personas with recent activity.
46
+ *
47
+ * IMPORTANT: Sets last_ceremony FIRST to prevent re-triggering from the processor loop.
48
+ * The actual Decay → Prune → Expire → Explore phases happen later via handleCeremonyProgress
49
+ * once all exposure scans have completed.
50
+ */
51
+ export function startCeremony(state: StateManager): void {
52
+ const startTime = Date.now();
53
+ console.log(`[ceremony] Starting ceremony at ${new Date().toISOString()}`);
54
+
55
+ const human = state.getHuman();
56
+ const now = new Date();
57
+
58
+ // Set last_ceremony FIRST — this is our start gate.
59
+ // Prevents the processor loop from re-triggering startCeremony.
60
+ state.setHuman({
61
+ ...human,
62
+ settings: {
63
+ ...human.settings,
64
+ ceremony: {
65
+ ...human.settings?.ceremony,
66
+ time: human.settings?.ceremony?.time ?? "09:00",
67
+ last_ceremony: now.toISOString(),
68
+ },
69
+ },
70
+ });
71
+
72
+ const personas = state.persona_getAll();
73
+ const activePersonas = personas.filter(p =>
74
+ !p.is_paused &&
75
+ !p.is_archived &&
76
+ !p.is_static
77
+ );
78
+
79
+ const lastCeremony = human.settings?.ceremony?.last_ceremony
80
+ ? new Date(human.settings.ceremony.last_ceremony).getTime()
81
+ : 0;
82
+
83
+ const personasWithActivity = activePersonas.filter(p => {
84
+ const lastActivity = p.last_activity ? new Date(p.last_activity).getTime() : 0;
85
+ return lastActivity > lastCeremony;
86
+ });
87
+
88
+ console.log(`[ceremony] Processing ${personasWithActivity.length} personas with activity (of ${activePersonas.length} active)`);
89
+
90
+ const options: ExtractionOptions = { ceremony_progress: true };
91
+
92
+ for (let i = 0; i < personasWithActivity.length; i++) {
93
+ const persona = personasWithActivity[i];
94
+ const isLast = i === personasWithActivity.length - 1;
95
+
96
+ console.log(`[ceremony] Queuing exposure for ${persona.display_name} (${i + 1}/${personasWithActivity.length})${isLast ? " (last)" : ""}`);
97
+ queueExposurePhase(persona.id, state, options);
98
+ }
99
+
100
+ const duration = Date.now() - startTime;
101
+ console.log(`[ceremony] Exposure phase queued in ${duration}ms`);
102
+
103
+ // Check immediately — if zero messages were queued (no unextracted messages for any persona),
104
+ // this will see an empty queue and proceed directly to Decay → Expire.
105
+ handleCeremonyProgress(state);
106
+ }
107
+
108
+ /**
109
+ * Queue all extraction scans for a persona's unextracted messages.
110
+ * Called during ceremony with ceremony_progress option to flag queue items.
111
+ */
112
+ function queueExposurePhase(personaId: string, state: StateManager, options?: ExtractionOptions): void {
113
+ const persona = state.persona_getById(personaId);
114
+ if (!persona) {
115
+ console.error(`[ceremony:exposure] Persona not found: ${personaId}`);
116
+ return;
117
+ }
118
+
119
+ console.log(`[ceremony:exposure] Starting for ${persona.display_name}`);
120
+
121
+ const allMessages = state.messages_get(personaId);
122
+
123
+ const unextractedFacts = state.messages_getUnextracted(personaId, "f");
124
+ if (unextractedFacts.length > 0) {
125
+ const context: ExtractionContext = {
126
+ personaId,
127
+ personaDisplayName: persona.display_name,
128
+ messages_context: allMessages.filter(m => m.f === true),
129
+ messages_analyze: unextractedFacts,
130
+ extraction_flag: "f",
131
+ };
132
+ queueFactScan(context, state, options);
133
+ }
134
+
135
+ const unextractedTraits = state.messages_getUnextracted(personaId, "r");
136
+ if (unextractedTraits.length > 0) {
137
+ const context: ExtractionContext = {
138
+ personaId,
139
+ personaDisplayName: persona.display_name,
140
+ messages_context: allMessages.filter(m => m.r === true),
141
+ messages_analyze: unextractedTraits,
142
+ extraction_flag: "r",
143
+ };
144
+ queueTraitScan(context, state, options);
145
+ }
146
+
147
+ const unextractedTopics = state.messages_getUnextracted(personaId, "p");
148
+ if (unextractedTopics.length > 0) {
149
+ const context: ExtractionContext = {
150
+ personaId,
151
+ personaDisplayName: persona.display_name,
152
+ messages_context: allMessages.filter(m => m.p === true),
153
+ messages_analyze: unextractedTopics,
154
+ extraction_flag: "p",
155
+ };
156
+ queueTopicScan(context, state, options);
157
+ }
158
+
159
+ const unextractedPeople = state.messages_getUnextracted(personaId, "o");
160
+ if (unextractedPeople.length > 0) {
161
+ const context: ExtractionContext = {
162
+ personaId,
163
+ personaDisplayName: persona.display_name,
164
+ messages_context: allMessages.filter(m => m.o === true),
165
+ messages_analyze: unextractedPeople,
166
+ extraction_flag: "o",
167
+ };
168
+ queuePersonScan(context, state, options);
169
+ }
170
+
171
+ const totalUnextracted = unextractedFacts.length + unextractedTraits.length + unextractedTopics.length + unextractedPeople.length;
172
+ if (totalUnextracted > 0) {
173
+ console.log(`[ceremony:exposure] Queued human extraction scans (f:${unextractedFacts.length}, r:${unextractedTraits.length}, p:${unextractedTopics.length}, o:${unextractedPeople.length})`);
174
+ }
175
+
176
+ const unextractedForPersonaTopics = state.messages_getUnextracted(personaId, "p");
177
+ if (unextractedForPersonaTopics.length > 0) {
178
+ const personaTopicContext: PersonaTopicContext = {
179
+ personaId,
180
+ personaDisplayName: persona.display_name,
181
+ messages_context: allMessages.filter(m => m.p === true),
182
+ messages_analyze: unextractedForPersonaTopics,
183
+ };
184
+ queuePersonaTopicScan(personaTopicContext, state);
185
+ console.log(`[ceremony:exposure] Queued persona topic scan for ${persona.display_name}`);
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Called after every LLM response that had ceremony_progress in its data,
191
+ * AND at the end of startCeremony (for the zero-messages edge case).
192
+ *
193
+ * If any ceremony_progress items remain in the queue, does nothing — more work pending.
194
+ * If the queue is clear of ceremony items, advances to Decay → Prune → Expire.
195
+ */
196
+ export function handleCeremonyProgress(state: StateManager): void {
197
+ if (state.queue_hasPendingCeremonies()) {
198
+ return; // Still processing exposure scans
199
+ }
200
+
201
+ console.log("[ceremony:progress] All exposure scans complete, advancing to Decay");
202
+
203
+ const personas = state.persona_getAll();
204
+ const activePersonas = personas.filter(p =>
205
+ !p.is_paused &&
206
+ !p.is_archived &&
207
+ !p.is_static
208
+ );
209
+
210
+ const eiIndex = activePersonas.findIndex(p =>
211
+ (p.aliases?.[0] ?? "").toLowerCase() === "ei"
212
+ );
213
+
214
+ // Ei's topics don't change
215
+ if (eiIndex > -1) {
216
+ activePersonas.splice(eiIndex, 1);
217
+ }
218
+
219
+ // Decay phase: apply decay + prune for ALL active personas
220
+ for (const persona of activePersonas) {
221
+ applyDecayPhase(persona.id, state);
222
+ prunePersonaMessages(persona.id, state);
223
+ }
224
+
225
+ // Human ceremony: decay topics + people
226
+ runHumanCeremony(state);
227
+
228
+ // Expire phase: queue LLM calls for each active persona
229
+ // handlePersonaExpire already chains to Explore → DescriptionCheck
230
+ for (const persona of activePersonas) {
231
+ queueExpirePhase(persona.id, state);
232
+ }
233
+
234
+ console.log("[ceremony:progress] Ceremony Decay complete, Expire queued");
235
+ }
236
+
237
+ // =============================================================================
238
+ // DECAY PHASE (synchronous)
239
+ // =============================================================================
240
+
241
+ function applyDecayPhase(personaId: string, state: StateManager): void {
242
+ const persona = state.persona_getById(personaId);
243
+ if (!persona) {
244
+ console.error(`[ceremony:decay] Persona not found: ${personaId}`);
245
+ return;
246
+ }
247
+
248
+ if (persona.topics.length === 0) {
249
+ console.log(`[ceremony:decay] ${persona.display_name} has no topics, skipping decay`);
250
+ return;
251
+ }
252
+
253
+ const now = new Date();
254
+ const human = state.getHuman();
255
+ const K = human.settings?.ceremony?.decay_rate ?? 0.1;
256
+
257
+ let decayedCount = 0;
258
+ const updatedTopics = persona.topics.map((topic: PersonaTopic) => {
259
+ const result = applyDecayToValue(
260
+ topic.exposure_current,
261
+ topic.last_updated,
262
+ now,
263
+ K
264
+ );
265
+
266
+ if (Math.abs(result.newValue - topic.exposure_current) > 0.001) {
267
+ decayedCount++;
268
+ }
269
+
270
+ return {
271
+ ...topic,
272
+ exposure_current: result.newValue,
273
+ last_updated: now.toISOString(),
274
+ };
275
+ });
276
+
277
+ state.persona_update(personaId, {
278
+ topics: updatedTopics,
279
+ last_updated: now.toISOString(),
280
+ });
281
+
282
+ console.log(`[ceremony:decay] Applied decay to ${decayedCount}/${updatedTopics.length} topics for ${persona.display_name}`);
283
+ }
284
+
285
+ // =============================================================================
286
+ // PRUNE PHASE (synchronous, runs as part of Decay)
287
+ // =============================================================================
288
+
289
+ export function prunePersonaMessages(personaId: string, state: StateManager): void {
290
+ // Sort first — injected messages (session update, archive scan) may be out of order.
291
+ state.messages_sort(personaId);
292
+ const messages = state.messages_get(personaId);
293
+ if (messages.length <= MESSAGE_MIN_COUNT) return;
294
+
295
+ const cutoffMs = Date.now() - (MESSAGE_MAX_AGE_DAYS * 24 * 60 * 60 * 1000);
296
+
297
+ // Messages are sorted by timestamp (oldest first from messages_sort)
298
+ const toRemove: string[] = [];
299
+ for (const m of messages) {
300
+ if (messages.length - toRemove.length <= MESSAGE_MIN_COUNT) break;
301
+
302
+ const msgMs = new Date(m.timestamp).getTime();
303
+ if (msgMs >= cutoffMs) break; // Sorted by time, no more old ones
304
+
305
+ const fullyExtracted = m.p && m.r && m.o && m.f;
306
+ if (fullyExtracted) {
307
+ toRemove.push(m.id);
308
+ }
309
+ }
310
+
311
+ if (toRemove.length > 0) {
312
+ state.messages_remove(personaId, toRemove);
313
+ const persona = state.persona_getById(personaId);
314
+ console.log(`[ceremony:prune] Removed ${toRemove.length} old messages from ${persona?.display_name ?? personaId}`);
315
+ }
316
+ }
317
+
318
+ // =============================================================================
319
+ // EXPIRE PHASE (queues LLM calls)
320
+ // =============================================================================
321
+
322
+ export function queueExpirePhase(personaId: string, state: StateManager): void {
323
+ const persona = state.persona_getById(personaId);
324
+ if (!persona) {
325
+ console.error(`[ceremony:expire] Persona not found: ${personaId}`);
326
+ return;
327
+ }
328
+
329
+ console.log(`[ceremony:expire] Queueing for ${persona.display_name}`);
330
+
331
+ if (persona.topics.length === 0) {
332
+ console.log(`[ceremony:expire] ${persona.display_name} has no topics, skipping to description check`);
333
+ queueDescriptionCheck(personaId, state);
334
+ return;
335
+ }
336
+
337
+ const prompt = buildPersonaExpirePrompt({
338
+ persona_name: persona.display_name,
339
+ topics: persona.topics,
340
+ });
341
+
342
+ state.queue_enqueue({
343
+ type: LLMRequestType.JSON,
344
+ priority: LLMPriority.Low,
345
+ system: prompt.system,
346
+ user: prompt.user,
347
+ next_step: LLMNextStep.HandlePersonaExpire,
348
+ data: { personaId, personaDisplayName: persona.display_name },
349
+ });
350
+ }
351
+
352
+ // =============================================================================
353
+ // EXPLORE PHASE (queues LLM calls — chained from handlePersonaExpire in handlers)
354
+ // =============================================================================
355
+
356
+ export function queueExplorePhase(personaId: string, state: StateManager): void {
357
+ const persona = state.persona_getById(personaId);
358
+ if (!persona) {
359
+ console.error(`[ceremony:explore] Persona not found: ${personaId}`);
360
+ queueDescriptionCheck(personaId, state);
361
+ return;
362
+ }
363
+
364
+ console.log(`[ceremony:explore] Queueing for ${persona.display_name}`);
365
+
366
+ const messages = state.messages_get(personaId);
367
+ const recentMessages = messages.slice(-20);
368
+ const themes = extractConversationThemes(recentMessages);
369
+
370
+ const prompt = buildPersonaExplorePrompt({
371
+ persona_name: persona.display_name,
372
+ traits: persona.traits,
373
+ remaining_topics: persona.topics,
374
+ recent_conversation_themes: themes,
375
+ });
376
+
377
+ state.queue_enqueue({
378
+ type: LLMRequestType.JSON,
379
+ priority: LLMPriority.Low,
380
+ system: prompt.system,
381
+ user: prompt.user,
382
+ next_step: LLMNextStep.HandlePersonaExplore,
383
+ data: { personaId, personaDisplayName: persona.display_name },
384
+ });
385
+ }
386
+
387
+ function extractConversationThemes(messages: { content: string; role: string }[]): string[] {
388
+ const humanMessages = messages.filter(m => m.role === "human");
389
+ if (humanMessages.length === 0) return [];
390
+
391
+ const words = humanMessages
392
+ .map(m => m.content.toLowerCase())
393
+ .join(" ")
394
+ .split(/\s+/)
395
+ .filter(w => w.length > 4);
396
+
397
+ const frequency: Record<string, number> = {};
398
+ for (const word of words) {
399
+ frequency[word] = (frequency[word] || 0) + 1;
400
+ }
401
+
402
+ return Object.entries(frequency)
403
+ .filter(([_, count]) => count >= 2)
404
+ .sort((a, b) => b[1] - a[1])
405
+ .slice(0, 5)
406
+ .map(([word]) => word);
407
+ }
408
+
409
+ export function queueDescriptionCheck(personaId: string, state: StateManager): void {
410
+ const persona = state.persona_getById(personaId);
411
+ if (!persona) {
412
+ console.error(`[ceremony:description] Persona not found: ${personaId}`);
413
+ return;
414
+ }
415
+
416
+ console.log(`[ceremony:description] Queueing for ${persona.display_name}`);
417
+
418
+ const prompt = buildDescriptionCheckPrompt({
419
+ persona_name: persona.display_name,
420
+ current_short_description: persona.short_description,
421
+ current_long_description: persona.long_description,
422
+ traits: persona.traits,
423
+ topics: persona.topics,
424
+ });
425
+
426
+ state.queue_enqueue({
427
+ type: LLMRequestType.JSON,
428
+ priority: LLMPriority.Low,
429
+ system: prompt.system,
430
+ user: prompt.user,
431
+ next_step: LLMNextStep.HandleDescriptionCheck,
432
+ data: { personaId, personaDisplayName: persona.display_name },
433
+ });
434
+ }
435
+
436
+ // =============================================================================
437
+ // HUMAN CEREMONY (synchronous — runs during Decay phase)
438
+ // =============================================================================
439
+
440
+ export function runHumanCeremony(state: StateManager): void {
441
+ console.log("[ceremony:human] Running Human ceremony (decay)...");
442
+
443
+ const human = state.getHuman();
444
+ const now = new Date();
445
+ const K = human.settings?.ceremony?.decay_rate ?? 0.1;
446
+
447
+ let topicDecayCount = 0;
448
+ const updatedTopics: Topic[] = human.topics.map(topic => {
449
+ const result = applyDecayToValue(
450
+ topic.exposure_current,
451
+ topic.last_updated,
452
+ now,
453
+ K
454
+ );
455
+
456
+ if (Math.abs(result.newValue - topic.exposure_current) > 0.001) {
457
+ topicDecayCount++;
458
+ }
459
+
460
+ return {
461
+ ...topic,
462
+ exposure_current: result.newValue,
463
+ last_updated: now.toISOString(),
464
+ };
465
+ });
466
+
467
+ let personDecayCount = 0;
468
+ const updatedPeople = human.people.map(person => {
469
+ const result = applyDecayToValue(
470
+ person.exposure_current,
471
+ person.last_updated,
472
+ now,
473
+ K
474
+ );
475
+
476
+ if (Math.abs(result.newValue - person.exposure_current) > 0.001) {
477
+ personDecayCount++;
478
+ }
479
+
480
+ return {
481
+ ...person,
482
+ exposure_current: result.newValue,
483
+ last_updated: now.toISOString(),
484
+ };
485
+ });
486
+
487
+ const lowExposureTopics = updatedTopics.filter(t => t.exposure_current < 0.2);
488
+ const lowExposurePeople = updatedPeople.filter(p => p.exposure_current < 0.2);
489
+
490
+ state.setHuman({
491
+ ...human,
492
+ topics: updatedTopics,
493
+ people: updatedPeople,
494
+ });
495
+
496
+ console.log(`[ceremony:human] Decayed ${topicDecayCount} topics, ${personDecayCount} people`);
497
+ if (lowExposureTopics.length > 0 || lowExposurePeople.length > 0) {
498
+ console.log(`[ceremony:human] Low exposure items: ${lowExposureTopics.length} topics, ${lowExposurePeople.length} people`);
499
+ }
500
+ }
@@ -0,0 +1,138 @@
1
+ import type { Message } from "../types.js";
2
+ import type { ExtractionContext } from "./human-extraction.js";
3
+
4
+ const DEFAULT_MAX_TOKENS = 10000;
5
+ const CHARS_PER_TOKEN = 4;
6
+ const CONTEXT_RATIO = 0.15;
7
+ const ANALYZE_RATIO = 0.85;
8
+ const SYSTEM_PROMPT_BUFFER = 1000;
9
+
10
+ function estimateTokens(text: string): number {
11
+ return Math.ceil(text.length / CHARS_PER_TOKEN);
12
+ }
13
+
14
+ function estimateMessageTokens(messages: Message[]): number {
15
+ return messages.reduce((sum, msg) => sum + estimateTokens(msg.content) + 4, 0);
16
+ }
17
+
18
+ function fitMessagesFromEnd(messages: Message[], maxTokens: number): Message[] {
19
+ const result: Message[] = [];
20
+ let tokens = 0;
21
+
22
+ for (let i = messages.length - 1; i >= 0; i--) {
23
+ const msgTokens = estimateTokens(messages[i].content) + 4;
24
+ if (tokens + msgTokens > maxTokens) break;
25
+ result.unshift(messages[i]);
26
+ tokens += msgTokens;
27
+ }
28
+
29
+ return result;
30
+ }
31
+
32
+ function pullMessagesFromStart(
33
+ messages: Message[],
34
+ startIndex: number,
35
+ maxTokens: number
36
+ ): { pulled: Message[]; nextIndex: number } {
37
+ const pulled: Message[] = [];
38
+ let tokens = 0;
39
+ let i = startIndex;
40
+
41
+ while (i < messages.length) {
42
+ const msgTokens = estimateTokens(messages[i].content) + 4;
43
+ if (tokens + msgTokens > maxTokens && pulled.length > 0) break;
44
+ pulled.push(messages[i]);
45
+ tokens += msgTokens;
46
+ i++;
47
+ }
48
+
49
+ return { pulled, nextIndex: i };
50
+ }
51
+
52
+ export interface ChunkedContextResult {
53
+ chunks: ExtractionContext[];
54
+ totalMessages: number;
55
+ estimatedTokensPerChunk: number;
56
+ }
57
+
58
+ export function chunkExtractionContext(
59
+ context: ExtractionContext,
60
+ maxTokens: number = DEFAULT_MAX_TOKENS
61
+ ): ChunkedContextResult {
62
+ const { personaId, personaDisplayName, messages_context, messages_analyze } = context;
63
+
64
+ if (messages_analyze.length === 0) {
65
+ return {
66
+ chunks: [],
67
+ totalMessages: messages_context.length,
68
+ estimatedTokensPerChunk: 0,
69
+ };
70
+ }
71
+
72
+ const availableTokens = maxTokens - SYSTEM_PROMPT_BUFFER;
73
+ const contextBudget = Math.floor(availableTokens * CONTEXT_RATIO);
74
+ const analyzeBudget = Math.floor(availableTokens * ANALYZE_RATIO);
75
+
76
+ const totalAnalyzeTokens = estimateMessageTokens(messages_analyze);
77
+
78
+ if (totalAnalyzeTokens <= analyzeBudget) {
79
+ const fittedContext = fitMessagesFromEnd(messages_context, contextBudget);
80
+ return {
81
+ chunks: [{
82
+ personaId,
83
+ personaDisplayName,
84
+ messages_context: fittedContext,
85
+ messages_analyze,
86
+ }],
87
+ totalMessages: fittedContext.length + messages_analyze.length,
88
+ estimatedTokensPerChunk: estimateMessageTokens(fittedContext) + totalAnalyzeTokens,
89
+ };
90
+ }
91
+
92
+ const chunks: ExtractionContext[] = [];
93
+ let currentContext = fitMessagesFromEnd(messages_context, contextBudget);
94
+ let analyzeIndex = 0;
95
+
96
+ console.log(`[Chunker] Splitting ${messages_analyze.length} messages (~${totalAnalyzeTokens} tokens) into batches (budget: ${analyzeBudget} tokens/batch)`);
97
+
98
+ while (analyzeIndex < messages_analyze.length) {
99
+ const { pulled, nextIndex } = pullMessagesFromStart(
100
+ messages_analyze,
101
+ analyzeIndex,
102
+ analyzeBudget
103
+ );
104
+
105
+ if (pulled.length === 0) break;
106
+
107
+ chunks.push({
108
+ personaId,
109
+ personaDisplayName,
110
+ messages_context: currentContext,
111
+ messages_analyze: pulled,
112
+ });
113
+
114
+ const chunkTokens = estimateMessageTokens(currentContext) + estimateMessageTokens(pulled);
115
+ console.log(`[Chunker] Batch ${chunks.length}: ${currentContext.length} context + ${pulled.length} analyze msgs (~${chunkTokens} tokens)`);
116
+
117
+ currentContext = fitMessagesFromEnd(pulled, contextBudget);
118
+ analyzeIndex = nextIndex;
119
+ }
120
+
121
+ const avgTokens = chunks.length > 0
122
+ ? Math.floor(chunks.reduce((sum, chunk) =>
123
+ sum + estimateMessageTokens(chunk.messages_context) + estimateMessageTokens(chunk.messages_analyze), 0
124
+ ) / chunks.length)
125
+ : 0;
126
+
127
+ return {
128
+ chunks,
129
+ totalMessages: messages_context.length + messages_analyze.length,
130
+ estimatedTokensPerChunk: avgTokens,
131
+ };
132
+ }
133
+
134
+ export function estimateContextTokens(context: ExtractionContext): number {
135
+ return estimateMessageTokens(context.messages_context) +
136
+ estimateMessageTokens(context.messages_analyze) +
137
+ SYSTEM_PROMPT_BUFFER;
138
+ }