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,896 @@
1
+ import type { StateManager } from "../../core/state-manager.js";
2
+ import type { Ei_Interface, Topic, Message, ContextStatus } from "../../core/types.js";
3
+ import { MESSAGE_MIN_COUNT, MESSAGE_MAX_AGE_DAYS } from "../../core/types.js";
4
+ import type { IOpenCodeReader, OpenCodeSession, OpenCodeMessage } from "./types.js";
5
+ import { UTILITY_AGENTS, AGENT_TO_AGENT_PREFIXES } from "./types.js";
6
+ import { createOpenCodeReader } from "./reader-factory.js";
7
+ import { ensureAgentPersona } from "../../core/personas/opencode-agent.js";
8
+ import {
9
+ queueDirectTopicUpdate,
10
+ queueAllScans,
11
+ type ExtractionContext,
12
+ } from "../../core/orchestrators/human-extraction.js";
13
+ import { resolveTokenLimit } from "../../core/llm-client.js";
14
+
15
+ // =============================================================================
16
+ // Constants
17
+ // =============================================================================
18
+
19
+ const OPENCODE_TOPIC_GROUPS = ["General", "Coding", "OpenCode"];
20
+
21
+ /** Max extraction calls per archive scan cycle (bounds queue flooding). */
22
+ const ARCHIVE_SCAN_MAX_CALLS = 50;
23
+ const CHARS_PER_TOKEN = 4;
24
+ const MIN_EXTRACTION_TOKENS = 10000;
25
+ const EXTRACTION_BUDGET_RATIO = 0.75;
26
+
27
+ // =============================================================================
28
+ // Transient Types (used only during import analysis, never persisted)
29
+ // =============================================================================
30
+
31
+ interface MiniMessage {
32
+ id: string;
33
+ timestamp: string;
34
+ }
35
+
36
+ interface ExternalMessage extends MiniMessage {
37
+ isExternal: true;
38
+ sessionId: string;
39
+ }
40
+
41
+ // =============================================================================
42
+ // Export Types
43
+ // =============================================================================
44
+
45
+ interface SessionAgentMessages {
46
+ sessionId: string;
47
+ agentName: string;
48
+ personaId?: string;
49
+ messages: OpenCodeMessage[];
50
+ }
51
+
52
+ export interface ImportResult {
53
+ sessionsProcessed: number;
54
+ topicsCreated: number;
55
+ topicsUpdated: number;
56
+ messagesImported: number;
57
+ messagesPruned: number;
58
+ personasCreated: string[];
59
+ topicUpdatesQueued: number;
60
+ extractionScansQueued: number;
61
+ partialSessionsFound: number;
62
+ archiveScansQueued: number;
63
+ }
64
+
65
+ export interface OpenCodeImporterOptions {
66
+ stateManager: StateManager;
67
+ interface?: Ei_Interface;
68
+ reader?: IOpenCodeReader;
69
+ }
70
+
71
+ // =============================================================================
72
+ // Utility Functions
73
+ // =============================================================================
74
+
75
+ function isAgentToAgentMessage(content: string): boolean {
76
+ const trimmed = content.trimStart();
77
+ return AGENT_TO_AGENT_PREFIXES.some(prefix => trimmed.startsWith(prefix));
78
+ }
79
+
80
+ function convertToEiMessage(ocMsg: OpenCodeMessage): Message {
81
+ return {
82
+ id: ocMsg.id,
83
+ role: ocMsg.role === "user" ? "human" : "system",
84
+ content: ocMsg.content,
85
+ timestamp: ocMsg.timestamp,
86
+ read: true,
87
+ context_status: "default" as ContextStatus,
88
+ };
89
+ }
90
+
91
+ /** Convert OC message to Ei Message with all extraction flags pre-set. */
92
+ function convertToPreMarkedEiMessage(ocMsg: OpenCodeMessage): Message {
93
+ return {
94
+ ...convertToEiMessage(ocMsg),
95
+ f: true,
96
+ r: true,
97
+ p: true,
98
+ o: true,
99
+ };
100
+ }
101
+
102
+ function filterRelevantMessages(messages: OpenCodeMessage[]): OpenCodeMessage[] {
103
+ return messages.filter(msg => {
104
+ if (UTILITY_AGENTS.includes(msg.agent as typeof UTILITY_AGENTS[number])) return false;
105
+ if (isAgentToAgentMessage(msg.content)) return false;
106
+ return true;
107
+ });
108
+ }
109
+
110
+ function estimateTokensForMessages(messages: OpenCodeMessage[]): number {
111
+ return messages.reduce(
112
+ (sum, msg) => sum + Math.ceil(msg.content.length / CHARS_PER_TOKEN) + 4,
113
+ 0
114
+ );
115
+ }
116
+
117
+ // =============================================================================
118
+ // Main Import Function
119
+ // =============================================================================
120
+
121
+ export async function importOpenCodeSessions(
122
+ since: Date,
123
+ options: OpenCodeImporterOptions
124
+ ): Promise<ImportResult> {
125
+ const { stateManager, interface: eiInterface } = options;
126
+ const reader = options.reader ?? await createOpenCodeReader();
127
+
128
+ const result: ImportResult = {
129
+ sessionsProcessed: 0,
130
+ topicsCreated: 0,
131
+ topicsUpdated: 0,
132
+ messagesImported: 0,
133
+ messagesPruned: 0,
134
+ personasCreated: [],
135
+ topicUpdatesQueued: 0,
136
+ extractionScansQueued: 0,
137
+ partialSessionsFound: 0,
138
+ archiveScansQueued: 0,
139
+ };
140
+
141
+ // ─── Step 1: Pull ALL sessions → Verify/Write topics ─────────────────
142
+ // new Date(0) ensures we always see all sessions for topic verification,
143
+ // regardless of when last sync was.
144
+ const allSessions = await reader.getSessionsUpdatedSince(new Date(0));
145
+ const primarySessions = allSessions.filter(s => !s.parentId);
146
+
147
+ for (const session of primarySessions) {
148
+ const topicResult = await ensureSessionTopic(session, reader, stateManager);
149
+ if (topicResult === "created") result.topicsCreated++;
150
+ else if (topicResult === "updated") result.topicsUpdated++;
151
+ }
152
+
153
+ // ─── Step 2: Pull messages since last_sync, group by agent ───────────
154
+ const sinceMs = since.getTime();
155
+ const updatedSessions = primarySessions.filter(s => s.time.updated > sinceMs);
156
+
157
+ console.log(
158
+ `[OpenCode] Found ${primarySessions.length} total sessions, ` +
159
+ `${updatedSessions.length} updated since ${since.toISOString()}`
160
+ );
161
+
162
+ const agentsForPersona = new Set<string>();
163
+ const sessionAgentBatches: SessionAgentMessages[] = [];
164
+
165
+ for (const session of updatedSessions) {
166
+ result.sessionsProcessed++;
167
+ const messages = await reader.getMessagesForSession(session.id, since);
168
+ const relevant = filterRelevantMessages(messages);
169
+ const messagesByAgent = new Map<string, OpenCodeMessage[]>();
170
+
171
+ for (const msg of relevant) {
172
+ agentsForPersona.add(msg.agent);
173
+ const existing = messagesByAgent.get(msg.agent) ?? [];
174
+ existing.push(msg);
175
+ messagesByAgent.set(msg.agent, existing);
176
+ }
177
+
178
+ for (const [agentName, agentMessages] of messagesByAgent) {
179
+ sessionAgentBatches.push({
180
+ sessionId: session.id,
181
+ agentName,
182
+ messages: agentMessages,
183
+ });
184
+ }
185
+ }
186
+
187
+ // ─── Steps 3-8: Only run if we have new messages to process ──────────
188
+ let isFirstImport = false;
189
+
190
+ if (sessionAgentBatches.length > 0) {
191
+ // ─── Step 3: Ensure personas exist ─────────────────────────────────
192
+ const agentNameToPersonaId = new Map<string, string>();
193
+
194
+ for (const agentName of agentsForPersona) {
195
+ let existing = stateManager.persona_getByName(agentName);
196
+ if (!existing) {
197
+ existing = await ensureAgentPersona(agentName, {
198
+ stateManager,
199
+ interface: eiInterface,
200
+ reader,
201
+ });
202
+ result.personasCreated.push(agentName);
203
+ }
204
+ agentNameToPersonaId.set(agentName, existing.id);
205
+ }
206
+
207
+ for (const batch of sessionAgentBatches) {
208
+ batch.personaId = agentNameToPersonaId.get(batch.agentName);
209
+ }
210
+
211
+ // Build reverse mapping: personaId → agent names
212
+ const personaIdToAgentNames = new Map<string, Set<string>>();
213
+ for (const [agentName, personaId] of agentNameToPersonaId) {
214
+ const names = personaIdToAgentNames.get(personaId) ?? new Set();
215
+ names.add(agentName);
216
+ personaIdToAgentNames.set(personaId, names);
217
+ }
218
+
219
+ // ─── Steps 4-5: Merge/Dedup/Prune per persona ─────────────────────
220
+ const batchesByPersona = new Map<string, SessionAgentMessages[]>();
221
+ for (const batch of sessionAgentBatches) {
222
+ if (!batch.personaId) continue;
223
+ const existing = batchesByPersona.get(batch.personaId) ?? [];
224
+ existing.push(batch);
225
+ batchesByPersona.set(batch.personaId, existing);
226
+ }
227
+
228
+ // Track surviving new messages per persona for partial session detection
229
+ const survivingNewByPersona = new Map<string, OpenCodeMessage[]>();
230
+
231
+ for (const [personaId, personaBatches] of batchesByPersona) {
232
+ // Combine all new OC messages for this persona
233
+ const allNew: OpenCodeMessage[] = [];
234
+ for (const batch of personaBatches) {
235
+ allNew.push(...batch.messages);
236
+ }
237
+
238
+ // Get existing persona messages for dedup + pruning analysis
239
+ const existingMessages = stateManager.messages_get(personaId);
240
+ const existingIds = new Set(existingMessages.map(m => m.id));
241
+
242
+ // Dedup: only messages not already in state
243
+ const genuinelyNew = allNew.filter(m => !existingIds.has(m.id));
244
+ if (genuinelyNew.length === 0) continue;
245
+
246
+ // Build merged list for pruning analysis
247
+ const merged: (MiniMessage | ExternalMessage)[] = [
248
+ ...existingMessages.map(m => ({ id: m.id, timestamp: m.timestamp })),
249
+ ...genuinelyNew.map(m => ({
250
+ id: m.id,
251
+ timestamp: m.timestamp,
252
+ isExternal: true as const,
253
+ sessionId: m.sessionId,
254
+ })),
255
+ ];
256
+
257
+ // Prune
258
+ const keptIds = pruneImportMessages(merged, existingMessages);
259
+ const keptSet = new Set(keptIds);
260
+
261
+ // ─── Step 6: Write to persona state ────────────────────────────
262
+ const survivingNew: OpenCodeMessage[] = [];
263
+ for (const ocMsg of genuinelyNew) {
264
+ if (keptSet.has(ocMsg.id)) {
265
+ stateManager.messages_append(personaId, convertToEiMessage(ocMsg));
266
+ survivingNew.push(ocMsg);
267
+ result.messagesImported++;
268
+ }
269
+ }
270
+
271
+ const prunedExistingIds = existingMessages
272
+ .filter(m => !keptSet.has(m.id))
273
+ .map(m => m.id);
274
+ if (prunedExistingIds.length > 0) {
275
+ stateManager.messages_remove(personaId, prunedExistingIds);
276
+ result.messagesPruned += prunedExistingIds.length;
277
+ }
278
+
279
+ stateManager.messages_sort(personaId);
280
+ stateManager.persona_update(personaId, {
281
+ last_activity: new Date().toISOString(),
282
+ });
283
+ eiInterface?.onMessageAdded?.(personaId);
284
+
285
+ if (survivingNew.length > 0) {
286
+ survivingNewByPersona.set(personaId, survivingNew);
287
+ }
288
+ }
289
+
290
+ if (result.topicsCreated > 0 || result.topicsUpdated > 0) {
291
+ eiInterface?.onHumanUpdated?.();
292
+ }
293
+
294
+ // ─── Step 7: Detect partial sessions → SessionUpdate ─────────────
295
+ for (const [personaId, survivingMsgs] of survivingNewByPersona) {
296
+ const agentNames = personaIdToAgentNames.get(personaId) ?? new Set();
297
+ const persona = stateManager.persona_getById(personaId);
298
+ if (!persona) continue;
299
+
300
+ // Group surviving new messages by session
301
+ const bySession = new Map<string, OpenCodeMessage[]>();
302
+ for (const msg of survivingMsgs) {
303
+ const existing = bySession.get(msg.sessionId) ?? [];
304
+ existing.push(msg);
305
+ bySession.set(msg.sessionId, existing);
306
+ }
307
+
308
+ for (const [sessionId] of bySession) {
309
+ const isPartial = await checkPartialSession(
310
+ personaId, sessionId, agentNames, reader, stateManager
311
+ );
312
+ if (isPartial) {
313
+ result.partialSessionsFound++;
314
+ const scans = await processSessionUpdate(
315
+ personaId, persona.display_name, sessionId, agentNames,
316
+ reader, stateManager
317
+ );
318
+ result.extractionScansQueued += scans;
319
+ }
320
+ }
321
+ }
322
+
323
+ // ─── Step 8: Queue topic updates + extraction scans ──────────────
324
+ isFirstImport = initializeExtractionPointIfNeeded(
325
+ sessionAgentBatches, stateManager
326
+ );
327
+
328
+ if (result.messagesImported > 0) {
329
+ // Topic description updates for sessions with new messages
330
+ result.topicUpdatesQueued = queueTopicUpdatesForBatches(
331
+ sessionAgentBatches, stateManager
332
+ );
333
+
334
+ if (isFirstImport) {
335
+ // First import: all 4 extraction types on ALL surviving messages
336
+ result.extractionScansQueued += queueAllExtractionsForAllMessages(
337
+ batchesByPersona, stateManager
338
+ );
339
+ console.log(
340
+ `[OpenCode] First import: queued extraction scans for ` +
341
+ `${batchesByPersona.size} persona(s)`
342
+ );
343
+ } else {
344
+ // Normal sync: all 4 extraction types on newly imported messages
345
+ result.extractionScansQueued += queueExtractionsForNewMessages(
346
+ batchesByPersona, stateManager
347
+ );
348
+ }
349
+
350
+ console.log(
351
+ `[OpenCode] Queued ${result.topicUpdatesQueued} topic updates, ` +
352
+ `${result.extractionScansQueued} extraction scans`
353
+ );
354
+ }
355
+ } else if (result.topicsCreated > 0 || result.topicsUpdated > 0) {
356
+ eiInterface?.onHumanUpdated?.();
357
+ }
358
+
359
+ // ─── Step 9: Archive scan (skip on first import to avoid queue flood) ─
360
+ if (!isFirstImport) {
361
+ const archiveResult = await processArchiveScan(
362
+ stateManager, reader, eiInterface
363
+ );
364
+ result.archiveScansQueued = archiveResult.scansQueued;
365
+ }
366
+
367
+ return result;
368
+ }
369
+
370
+ // =============================================================================
371
+ // Pruning
372
+ // =============================================================================
373
+
374
+ /**
375
+ * Determine which messages to keep after merging existing + new external messages.
376
+ *
377
+ * Rules:
378
+ * - Always keep at least minMessages (even if they're ancient)
379
+ * - Remove messages older than maxAgeDays IF:
380
+ * - External (never in state → safe to drop), OR
381
+ * - Fully extracted ([p,r,o,f] all true → knowledge already captured)
382
+ * - Messages that are old but NOT external and NOT fully extracted are KEPT
383
+ * (they still have knowledge to extract)
384
+ *
385
+ * @returns Array of message IDs to keep
386
+ */
387
+ export function pruneImportMessages(
388
+ merged: (MiniMessage | ExternalMessage)[],
389
+ existingMessages: Message[],
390
+ minMessages: number = MESSAGE_MIN_COUNT,
391
+ maxAgeDays: number = MESSAGE_MAX_AGE_DAYS
392
+ ): string[] {
393
+ if (merged.length <= minMessages) return merged.map(m => m.id);
394
+
395
+ const cutoffMs = Date.now() - (maxAgeDays * 24 * 60 * 60 * 1000);
396
+ const sorted = [...merged].sort(
397
+ (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
398
+ );
399
+
400
+ const existingById = new Map(existingMessages.map(m => [m.id, m]));
401
+
402
+ const toRemove: string[] = [];
403
+ for (const m of sorted) {
404
+ if (merged.length - toRemove.length <= minMessages) break;
405
+
406
+ const isOld = new Date(m.timestamp).getTime() < cutoffMs;
407
+ if (!isOld) break; // Sorted by time — no more old messages after this
408
+
409
+ const isExternal = "isExternal" in m && (m as ExternalMessage).isExternal;
410
+ const existing = existingById.get(m.id);
411
+ const fullyExtracted = existing?.f && existing?.r && existing?.o && existing?.p;
412
+
413
+ if (isExternal || fullyExtracted) {
414
+ toRemove.push(m.id);
415
+ }
416
+ }
417
+
418
+ const removeSet = new Set(toRemove);
419
+ return merged.filter(m => !removeSet.has(m.id)).map(m => m.id);
420
+ }
421
+
422
+ // =============================================================================
423
+ // Partial Session Detection & SessionUpdate
424
+ // =============================================================================
425
+
426
+ /**
427
+ * Check if a session is "partial" — some messages in state but not all.
428
+ * This happens when an old session gets new messages ("necro session").
429
+ */
430
+ async function checkPartialSession(
431
+ personaId: string,
432
+ sessionId: string,
433
+ agentNames: Set<string>,
434
+ reader: IOpenCodeReader,
435
+ stateManager: StateManager
436
+ ): Promise<boolean> {
437
+ const allSessionMsgs = await reader.getMessagesForSession(sessionId);
438
+ const relevantMsgs = filterRelevantMessages(
439
+ allSessionMsgs.filter(m => agentNames.has(m.agent))
440
+ );
441
+
442
+ const personaMsgs = stateManager.messages_get(personaId);
443
+ const stateIds = new Set(personaMsgs.map(m => m.id));
444
+
445
+ return relevantMsgs.some(m => !stateIds.has(m.id));
446
+ }
447
+
448
+ /**
449
+ * Handle a partial session by injecting missing messages and queuing extraction.
450
+ *
451
+ * Old (missing) messages are injected with [p,r,o,f]=true — they serve as
452
+ * context only and are already "fully extracted" so ceremony can prune them.
453
+ * The new messages (already in state, NOT pre-marked) go to messages_analyze
454
+ * for actual extraction.
455
+ */
456
+ async function processSessionUpdate(
457
+ personaId: string,
458
+ personaDisplayName: string,
459
+ sessionId: string,
460
+ agentNames: Set<string>,
461
+ reader: IOpenCodeReader,
462
+ stateManager: StateManager
463
+ ): Promise<number> {
464
+ const allSessionMsgs = await reader.getMessagesForSession(sessionId);
465
+ const relevantMsgs = filterRelevantMessages(
466
+ allSessionMsgs.filter(m => agentNames.has(m.agent))
467
+ );
468
+
469
+ if (relevantMsgs.length === 0) return 0;
470
+
471
+ // Find which messages are missing from state
472
+ const personaMsgs = stateManager.messages_get(personaId);
473
+ const stateIds = new Set(personaMsgs.map(m => m.id));
474
+ const missingMsgs = relevantMsgs.filter(m => !stateIds.has(m.id));
475
+
476
+ // Inject missing messages PRE-MARKED as fully extracted.
477
+ // They're context only — ceremony will prune them (old + [p,r,o,f]=true).
478
+ for (const ocMsg of missingMsgs) {
479
+ stateManager.messages_append(personaId, convertToPreMarkedEiMessage(ocMsg));
480
+ }
481
+ stateManager.messages_sort(personaId);
482
+
483
+ console.log(
484
+ `[OpenCode] SessionUpdate: injected ${missingMsgs.length} pre-marked ` +
485
+ `context messages for session ${sessionId}`
486
+ );
487
+
488
+ // Build extraction context:
489
+ // - context = old injected messages (pre-marked, provide session background)
490
+ // - analyze = new messages already in state (need actual extraction)
491
+ const allInState = stateManager.messages_get(personaId);
492
+ const sessionMsgIds = new Set(relevantMsgs.map(m => m.id));
493
+ const missingIds = new Set(missingMsgs.map(m => m.id));
494
+
495
+ const contextMsgs = allInState.filter(
496
+ m => sessionMsgIds.has(m.id) && missingIds.has(m.id)
497
+ );
498
+ const analyzeMsgs = allInState.filter(
499
+ m => sessionMsgIds.has(m.id) && !missingIds.has(m.id)
500
+ );
501
+
502
+ if (analyzeMsgs.length === 0) return 0;
503
+
504
+ const context: ExtractionContext = {
505
+ personaId,
506
+ personaDisplayName,
507
+ messages_context: contextMsgs,
508
+ messages_analyze: analyzeMsgs,
509
+ };
510
+
511
+ queueAllScans(context, stateManager);
512
+
513
+ const human = stateManager.getHuman();
514
+ const topic = human.topics.find(t => t.id === sessionId);
515
+ if (topic) {
516
+ queueDirectTopicUpdate(topic, context, stateManager);
517
+ }
518
+
519
+ return 4;
520
+ }
521
+
522
+ // =============================================================================
523
+ // Archive Scan
524
+ // =============================================================================
525
+
526
+ /**
527
+ * Process old sessions from SQLite for extraction.
528
+ *
529
+ * Replaces gradual-extraction.ts entirely. Reads sessions between
530
+ * extraction_point and the 14-day cutoff, injects their messages into
531
+ * persona state, and queues all 4 extraction types.
532
+ *
533
+ * Unlike SessionUpdate, archive messages are NOT pre-marked — they need
534
+ * actual extraction. The queue-empty gate on ceremony (in ceremony.ts)
535
+ * prevents premature pruning while extraction is pending.
536
+ *
537
+ * Bounded by a token budget to prevent queue flooding.
538
+ */
539
+ async function processArchiveScan(
540
+ stateManager: StateManager,
541
+ reader: IOpenCodeReader,
542
+ eiInterface?: Ei_Interface
543
+ ): Promise<{ scansQueued: number; newExtractionPoint: string | null }> {
544
+ const human = stateManager.getHuman();
545
+ const extractionPoint = human.settings?.opencode?.extraction_point;
546
+
547
+ if (!extractionPoint || extractionPoint === "done") {
548
+ return { scansQueued: 0, newExtractionPoint: null };
549
+ }
550
+
551
+ const extractionPointMs = new Date(extractionPoint).getTime();
552
+ const cutoffMs = Date.now() - (MESSAGE_MAX_AGE_DAYS * 24 * 60 * 60 * 1000);
553
+
554
+ if (extractionPointMs >= cutoffMs) {
555
+ // Archive scan caught up to the primary window
556
+ updateExtractionPoint(stateManager, "done");
557
+ console.log(`[OpenCode] Archive scan complete — caught up to primary window`);
558
+ return { scansQueued: 0, newExtractionPoint: "done" };
559
+ }
560
+
561
+ const sessions = await reader.getSessionsInRange(
562
+ new Date(extractionPointMs),
563
+ new Date(cutoffMs)
564
+ );
565
+
566
+ if (sessions.length === 0) {
567
+ // No sessions in archive range — advance to cutoff
568
+ const newPoint = new Date(cutoffMs).toISOString();
569
+ updateExtractionPoint(stateManager, newPoint);
570
+ return { scansQueued: 0, newExtractionPoint: newPoint };
571
+ }
572
+
573
+ // Sort chronologically (getSessionsInRange should return ASC, but ensure)
574
+ sessions.sort((a, b) => a.time.updated - b.time.updated);
575
+
576
+ // Build set of all message IDs currently in state for fast lookups
577
+ const openCodePersonas = stateManager.persona_getAll()
578
+ .filter(p => p.group_primary === "OpenCode");
579
+ const allStateMessageIds = new Set<string>();
580
+ for (const persona of openCodePersonas) {
581
+ for (const m of stateManager.messages_get(persona.id)) {
582
+ allStateMessageIds.add(m.id);
583
+ }
584
+ }
585
+
586
+ let tokenBudget = 0;
587
+ const modelTokenLimit = resolveTokenLimit(human.settings?.default_model, human.settings?.accounts);
588
+ const perCallBudget = Math.max(MIN_EXTRACTION_TOKENS, Math.floor(modelTokenLimit * EXTRACTION_BUDGET_RATIO));
589
+ const tokenLimit = ARCHIVE_SCAN_MAX_CALLS * perCallBudget;
590
+ let scansQueued = 0;
591
+ let lastProcessed: OpenCodeSession | null = null;
592
+
593
+ for (const session of sessions) {
594
+ const allMsgs = await reader.getMessagesForSession(session.id);
595
+ const relevant = filterRelevantMessages(allMsgs);
596
+
597
+ // Skip sessions whose messages are already in state
598
+ if (relevant.some(m => allStateMessageIds.has(m.id))) {
599
+ lastProcessed = session;
600
+ continue;
601
+ }
602
+
603
+ if (relevant.length === 0) {
604
+ lastProcessed = session;
605
+ continue;
606
+ }
607
+
608
+ // Token budget check
609
+ const sessionTokens = estimateTokensForMessages(relevant);
610
+ tokenBudget += sessionTokens;
611
+
612
+ // Group by agent → persona
613
+ const byAgent = new Map<string, OpenCodeMessage[]>();
614
+ for (const msg of relevant) {
615
+ const existing = byAgent.get(msg.agent) ?? [];
616
+ existing.push(msg);
617
+ byAgent.set(msg.agent, existing);
618
+ }
619
+
620
+ for (const [agentName, agentMsgs] of byAgent) {
621
+ // Resolve persona (create if needed — unlikely for archive but safe)
622
+ let persona = stateManager.persona_getByName(agentName);
623
+ if (!persona) {
624
+ persona = await ensureAgentPersona(agentName, {
625
+ stateManager,
626
+ interface: eiInterface,
627
+ reader,
628
+ });
629
+ }
630
+
631
+ // Inject messages into persona state (NOT pre-marked — need extraction)
632
+ for (const ocMsg of agentMsgs) {
633
+ stateManager.messages_append(persona.id, convertToEiMessage(ocMsg));
634
+ allStateMessageIds.add(ocMsg.id);
635
+ }
636
+ stateManager.messages_sort(persona.id);
637
+
638
+ // Build extraction context from the injected messages
639
+ const injectedMsgs = stateManager.messages_get(persona.id)
640
+ .filter(m => agentMsgs.some(am => am.id === m.id));
641
+
642
+ const context: ExtractionContext = {
643
+ personaId: persona.id,
644
+ personaDisplayName: persona.display_name,
645
+ messages_context: [],
646
+ messages_analyze: injectedMsgs,
647
+ };
648
+
649
+ queueAllScans(context, stateManager);
650
+ scansQueued += 4;
651
+
652
+ const topic = human.topics.find(t => t.id === session.id);
653
+ if (topic) {
654
+ queueDirectTopicUpdate(topic, context, stateManager);
655
+ }
656
+ }
657
+
658
+ lastProcessed = session;
659
+
660
+ if (tokenBudget >= tokenLimit) {
661
+ console.log(
662
+ `[OpenCode] Archive scan: token budget reached after ${scansQueued} scans`
663
+ );
664
+ break;
665
+ }
666
+ }
667
+
668
+ // Advance extraction_point
669
+ if (lastProcessed) {
670
+ const newPoint = new Date(lastProcessed.time.updated).toISOString();
671
+ updateExtractionPoint(stateManager, newPoint);
672
+ console.log(
673
+ `[OpenCode] Archive scan: ${scansQueued} scans queued, ` +
674
+ `extraction_point → ${newPoint}`
675
+ );
676
+ return { scansQueued, newExtractionPoint: newPoint };
677
+ }
678
+
679
+ return { scansQueued: 0, newExtractionPoint: null };
680
+ }
681
+
682
+ // =============================================================================
683
+ // Extraction Queueing
684
+ // =============================================================================
685
+
686
+ /**
687
+ * Queue topic description updates for sessions with new messages.
688
+ * Finds batch messages in persona state and builds ExtractionContext.
689
+ */
690
+ function queueTopicUpdatesForBatches(
691
+ batches: SessionAgentMessages[],
692
+ stateManager: StateManager
693
+ ): number {
694
+ const human = stateManager.getHuman();
695
+ let totalChunks = 0;
696
+
697
+ for (const batch of batches) {
698
+ if (!batch.personaId) continue;
699
+
700
+ const topic = human.topics.find(t => t.id === batch.sessionId);
701
+ if (!topic) continue;
702
+
703
+ const persona = stateManager.persona_getById(batch.personaId);
704
+ if (!persona) continue;
705
+
706
+ const allMessages = stateManager.messages_get(batch.personaId);
707
+ const batchMessageIds = new Set(batch.messages.map(m => m.id));
708
+
709
+ const analyzeStartIndex = allMessages.findIndex(m => batchMessageIds.has(m.id));
710
+ if (analyzeStartIndex === -1) continue;
711
+
712
+ const context: ExtractionContext = {
713
+ personaId: batch.personaId,
714
+ personaDisplayName: persona.display_name,
715
+ messages_context: allMessages.slice(0, analyzeStartIndex),
716
+ messages_analyze: allMessages.filter(m => batchMessageIds.has(m.id)),
717
+ };
718
+
719
+ if (context.messages_analyze.length === 0) continue;
720
+
721
+ totalChunks += queueDirectTopicUpdate(topic, context, stateManager);
722
+ }
723
+
724
+ return totalChunks;
725
+ }
726
+
727
+ /**
728
+ * Queue all 4 extraction types for newly imported messages (normal sync).
729
+ * Groups by persona to avoid duplicate scan queueing.
730
+ */
731
+ function queueExtractionsForNewMessages(
732
+ batchesByPersona: Map<string, SessionAgentMessages[]>,
733
+ stateManager: StateManager
734
+ ): number {
735
+ let scansQueued = 0;
736
+
737
+ for (const [personaId, personaBatches] of batchesByPersona) {
738
+ const persona = stateManager.persona_getById(personaId);
739
+ if (!persona) continue;
740
+
741
+ const allMessages = stateManager.messages_get(personaId);
742
+
743
+ // Combine all batch message IDs for this persona
744
+ const batchMessageIds = new Set<string>();
745
+ for (const batch of personaBatches) {
746
+ for (const m of batch.messages) {
747
+ batchMessageIds.add(m.id);
748
+ }
749
+ }
750
+
751
+ // Find the new messages that are actually in state (survived pruning)
752
+ const analyzeMessages = allMessages.filter(m => batchMessageIds.has(m.id));
753
+ if (analyzeMessages.length === 0) continue;
754
+
755
+ const analyzeStartIndex = allMessages.findIndex(m => batchMessageIds.has(m.id));
756
+ const contextMessages = analyzeStartIndex > 0
757
+ ? allMessages.slice(0, analyzeStartIndex)
758
+ : [];
759
+
760
+ const context: ExtractionContext = {
761
+ personaId,
762
+ personaDisplayName: persona.display_name,
763
+ messages_context: contextMessages,
764
+ messages_analyze: analyzeMessages,
765
+ };
766
+
767
+ queueAllScans(context, stateManager);
768
+ scansQueued += 4;
769
+ }
770
+
771
+ return scansQueued;
772
+ }
773
+
774
+ /**
775
+ * Queue all 4 extraction types on ALL surviving messages for every persona.
776
+ * Used on first import — the "fun" moment where extraction kicks off.
777
+ */
778
+ function queueAllExtractionsForAllMessages(
779
+ batchesByPersona: Map<string, SessionAgentMessages[]>,
780
+ stateManager: StateManager
781
+ ): number {
782
+ let scansQueued = 0;
783
+
784
+ for (const [personaId] of batchesByPersona) {
785
+ const persona = stateManager.persona_getById(personaId);
786
+ if (!persona) continue;
787
+
788
+ const allMessages = stateManager.messages_get(personaId);
789
+ if (allMessages.length === 0) continue;
790
+
791
+ const context: ExtractionContext = {
792
+ personaId,
793
+ personaDisplayName: persona.display_name,
794
+ messages_context: [],
795
+ messages_analyze: allMessages,
796
+ };
797
+
798
+ queueAllScans(context, stateManager);
799
+ scansQueued += 4;
800
+ }
801
+
802
+ return scansQueued;
803
+ }
804
+
805
+ // =============================================================================
806
+ // Topic Management
807
+ // =============================================================================
808
+
809
+ async function ensureSessionTopic(
810
+ session: OpenCodeSession,
811
+ reader: IOpenCodeReader,
812
+ stateManager: StateManager
813
+ ): Promise<"created" | "updated" | "unchanged"> {
814
+ const human = stateManager.getHuman();
815
+ const existingTopic = human.topics.find((t) => t.id === session.id);
816
+
817
+ const firstAgent = await reader.getFirstAgent(session.id);
818
+ const learnedBy = firstAgent ?? "build";
819
+
820
+ if (existingTopic) {
821
+ if (existingTopic.name !== session.title) {
822
+ const updatedTopic: Topic = {
823
+ ...existingTopic,
824
+ name: session.title,
825
+ last_updated: new Date().toISOString(),
826
+ };
827
+ stateManager.human_topic_upsert(updatedTopic);
828
+ return "updated";
829
+ }
830
+ return "unchanged";
831
+ }
832
+
833
+ const newTopic: Topic = {
834
+ id: session.id,
835
+ name: session.title,
836
+ description: "",
837
+ sentiment: 0,
838
+ exposure_current: 0.5,
839
+ exposure_desired: 0.3,
840
+ persona_groups: OPENCODE_TOPIC_GROUPS,
841
+ learned_by: learnedBy,
842
+ last_updated: new Date().toISOString(),
843
+ };
844
+
845
+ stateManager.human_topic_upsert(newTopic);
846
+ return "created";
847
+ }
848
+
849
+ // =============================================================================
850
+ // State Helpers
851
+ // =============================================================================
852
+
853
+ function initializeExtractionPointIfNeeded(
854
+ batches: SessionAgentMessages[],
855
+ stateManager: StateManager
856
+ ): boolean {
857
+ const human = stateManager.getHuman();
858
+ const existingPoint = human.settings?.opencode?.extraction_point;
859
+
860
+ if (existingPoint) return false;
861
+
862
+ let earliestTimestamp: number | null = null;
863
+
864
+ for (const batch of batches) {
865
+ for (const msg of batch.messages) {
866
+ const msgMs = new Date(msg.timestamp).getTime();
867
+ if (earliestTimestamp === null || msgMs < earliestTimestamp) {
868
+ earliestTimestamp = msgMs;
869
+ }
870
+ }
871
+ }
872
+
873
+ if (earliestTimestamp === null) return false;
874
+
875
+ const extractionPoint = new Date(earliestTimestamp).toISOString();
876
+ updateExtractionPoint(stateManager, extractionPoint);
877
+ console.log(`[OpenCode] Initialized extraction_point to ${extractionPoint}`);
878
+ return true;
879
+ }
880
+
881
+ function updateExtractionPoint(
882
+ stateManager: StateManager,
883
+ newPoint: string
884
+ ): void {
885
+ const human = stateManager.getHuman();
886
+ stateManager.setHuman({
887
+ ...human,
888
+ settings: {
889
+ ...human.settings,
890
+ opencode: {
891
+ ...human.settings?.opencode,
892
+ extraction_point: newPoint,
893
+ },
894
+ },
895
+ });
896
+ }