ei-tui 0.1.3 → 0.1.5

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 (44) hide show
  1. package/README.md +36 -35
  2. package/package.json +6 -2
  3. package/src/README.md +85 -1
  4. package/src/cli/README.md +30 -20
  5. package/src/cli/retrieval.ts +5 -17
  6. package/src/cli.ts +69 -0
  7. package/src/core/handlers/index.ts +195 -172
  8. package/src/core/orchestrators/ceremony.ts +4 -4
  9. package/src/core/orchestrators/extraction-chunker.ts +3 -3
  10. package/src/core/processor.ts +245 -77
  11. package/src/core/queue-processor.ts +3 -26
  12. package/src/core/state/checkpoints.ts +4 -0
  13. package/src/core/state/personas.ts +13 -1
  14. package/src/core/state/queue.ts +80 -23
  15. package/src/core/state-manager.ts +36 -10
  16. package/src/core/types.ts +23 -11
  17. package/src/core/utils/crossFind.ts +44 -0
  18. package/src/core/utils/index.ts +4 -0
  19. package/src/integrations/opencode/importer.ts +118 -691
  20. package/src/prompts/heartbeat/check.ts +27 -13
  21. package/src/prompts/heartbeat/ei.ts +65 -136
  22. package/src/prompts/heartbeat/types.ts +47 -17
  23. package/src/prompts/human/item-update.ts +20 -8
  24. package/src/prompts/index.ts +2 -5
  25. package/src/prompts/message-utils.ts +42 -3
  26. package/src/prompts/response/index.ts +13 -6
  27. package/src/prompts/response/sections.ts +65 -12
  28. package/src/prompts/response/types.ts +10 -0
  29. package/tui/README.md +89 -4
  30. package/tui/src/commands/dlq.ts +75 -0
  31. package/tui/src/commands/editor.tsx +1 -1
  32. package/tui/src/commands/queue.ts +77 -0
  33. package/tui/src/components/CommandSuggest.tsx +50 -0
  34. package/tui/src/components/MessageList.tsx +12 -2
  35. package/tui/src/components/PromptInput.tsx +118 -30
  36. package/tui/src/components/Sidebar.tsx +6 -2
  37. package/tui/src/components/StatusBar.tsx +12 -5
  38. package/tui/src/context/ei.tsx +43 -3
  39. package/tui/src/context/keyboard.tsx +90 -2
  40. package/tui/src/util/clipboard.ts +73 -0
  41. package/tui/src/util/yaml-serializers.ts +81 -11
  42. package/src/prompts/validation/ei.ts +0 -93
  43. package/src/prompts/validation/index.ts +0 -6
  44. package/src/prompts/validation/types.ts +0 -22
@@ -1,16 +1,13 @@
1
1
  import type { StateManager } from "../../core/state-manager.js";
2
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
3
  import type { IOpenCodeReader, OpenCodeSession, OpenCodeMessage } from "./types.js";
5
4
  import { UTILITY_AGENTS, AGENT_TO_AGENT_PREFIXES } from "./types.js";
6
5
  import { createOpenCodeReader } from "./reader-factory.js";
7
6
  import { ensureAgentPersona } from "../../core/personas/opencode-agent.js";
8
7
  import {
9
- queueDirectTopicUpdate,
10
8
  queueAllScans,
11
9
  type ExtractionContext,
12
10
  } from "../../core/orchestrators/human-extraction.js";
13
- import { resolveTokenLimit } from "../../core/llm-client.js";
14
11
 
15
12
  // =============================================================================
16
13
  // Constants
@@ -18,48 +15,17 @@ import { resolveTokenLimit } from "../../core/llm-client.js";
18
15
 
19
16
  const OPENCODE_TOPIC_GROUPS = ["General", "Coding", "OpenCode"];
20
17
 
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
18
  // =============================================================================
42
19
  // Export Types
43
20
  // =============================================================================
44
21
 
45
- interface SessionAgentMessages {
46
- sessionId: string;
47
- agentName: string;
48
- personaId?: string;
49
- messages: OpenCodeMessage[];
50
- }
51
-
52
22
  export interface ImportResult {
53
23
  sessionsProcessed: number;
54
24
  topicsCreated: number;
55
25
  topicsUpdated: number;
56
26
  messagesImported: number;
57
- messagesPruned: number;
58
27
  personasCreated: string[];
59
- topicUpdatesQueued: number;
60
28
  extractionScansQueued: number;
61
- partialSessionsFound: number;
62
- archiveScansQueued: number;
63
29
  }
64
30
 
65
31
  export interface OpenCodeImporterOptions {
@@ -81,7 +47,7 @@ function convertToEiMessage(ocMsg: OpenCodeMessage): Message {
81
47
  return {
82
48
  id: ocMsg.id,
83
49
  role: ocMsg.role === "user" ? "human" : "system",
84
- content: ocMsg.content,
50
+ verbal_response: ocMsg.content,
85
51
  timestamp: ocMsg.timestamp,
86
52
  read: true,
87
53
  context_status: "default" as ContextStatus,
@@ -107,19 +73,25 @@ function filterRelevantMessages(messages: OpenCodeMessage[]): OpenCodeMessage[]
107
73
  });
108
74
  }
109
75
 
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
76
  // =============================================================================
118
77
  // Main Import Function
119
78
  // =============================================================================
120
79
 
80
+ /**
81
+ * Import one OpenCode session per call.
82
+ *
83
+ * Flow:
84
+ * 1. Ensure topics exist for all primary sessions (always, cheaply).
85
+ * 2. Find the next unprocessed session after extraction_point.
86
+ * 3. Write all messages for that session to their persona(s) — archived,
87
+ * messages cleared first. Messages before last_imported are pre-marked
88
+ * [p,r,o,f]=true; newer messages are unmarked and queued for extraction.
89
+ * 4. Advance extraction_point to session.time.updated.
90
+ *
91
+ * The processor gate (queue_length() === 0) ensures we never pile onto a
92
+ * backed-up queue.
93
+ */
121
94
  export async function importOpenCodeSessions(
122
- since: Date,
123
95
  options: OpenCodeImporterOptions
124
96
  ): Promise<ImportResult> {
125
97
  const { stateManager, interface: eiInterface } = options;
@@ -130,17 +102,13 @@ export async function importOpenCodeSessions(
130
102
  topicsCreated: 0,
131
103
  topicsUpdated: 0,
132
104
  messagesImported: 0,
133
- messagesPruned: 0,
134
105
  personasCreated: [],
135
- topicUpdatesQueued: 0,
136
106
  extractionScansQueued: 0,
137
- partialSessionsFound: 0,
138
- archiveScansQueued: 0,
139
107
  };
140
108
 
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.
109
+ // ─── Step 1: Ensure topics exist for ALL primary sessions ─────────────
110
+ // Always runs (cheap), so session titles stay current regardless of
111
+ // whether we process messages this cycle.
144
112
  const allSessions = await reader.getSessionsUpdatedSince(new Date(0));
145
113
  const primarySessions = allSessions.filter(s => !s.parentId);
146
114
 
@@ -150,656 +118,136 @@ export async function importOpenCodeSessions(
150
118
  else if (topicResult === "updated") result.topicsUpdated++;
151
119
  }
152
120
 
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) {
121
+ if (result.topicsCreated > 0 || result.topicsUpdated > 0) {
356
122
  eiInterface?.onHumanUpdated?.();
357
123
  }
358
124
 
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
- // =============================================================================
125
+ // ─── Step 2: Find next unprocessed session ────────────────────────────
126
+ const human = stateManager.getHuman();
127
+ const processedSessions = human.settings?.opencode?.processed_sessions ?? {};
373
128
 
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()
129
+ // Sessions sorted oldest-first; find first unprocessed or updated-since-last-import
130
+ const sortedSessions = [...primarySessions].sort(
131
+ (a, b) => a.time.updated - b.time.updated
398
132
  );
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);
133
+ let targetSession: OpenCodeSession | null = null;
134
+ for (const session of sortedSessions) {
135
+ const lastImported = processedSessions[session.id];
136
+ if (!lastImported) {
137
+ targetSession = session;
138
+ break;
139
+ }
140
+ if (session.time.updated > new Date(lastImported).getTime()) {
141
+ targetSession = session;
142
+ break;
415
143
  }
416
144
  }
417
145
 
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));
146
+ if (!targetSession) {
147
+ // Nothing new to process — bump last_sync and return
148
+ console.log(`[OpenCode] All sessions processed, nothing new since extraction_point`);
149
+ return result;
480
150
  }
481
- stateManager.messages_sort(personaId);
482
151
 
483
152
  console.log(
484
- `[OpenCode] SessionUpdate: injected ${missingMsgs.length} pre-marked ` +
485
- `context messages for session ${sessionId}`
153
+ `[OpenCode] Processing session: "${targetSession.title}" ` +
154
+ `(updated: ${new Date(targetSession.time.updated).toISOString()})`
486
155
  );
487
156
 
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;
157
+ // ─── Step 3: Pull and filter messages ────────────────────────────────
158
+ const allMsgs = await reader.getMessagesForSession(targetSession.id);
159
+ const relevant = filterRelevantMessages(allMsgs);
546
160
 
547
- if (!extractionPoint || extractionPoint === "done") {
548
- return { scansQueued: 0, newExtractionPoint: null };
161
+ if (relevant.length === 0) {
162
+ // Empty session mark processed and advance
163
+ updateExtractionState(stateManager, targetSession);
164
+ return result;
549
165
  }
550
166
 
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 };
167
+ // ─── Step 4: Resolve agents → personas, group by persona ID ────────
168
+ // Resolve aliases up front so 'sisyphus' and 'Sisyphus (Ultraworker)'
169
+ // land in the same bucket instead of clobbering each other.
170
+ const byPersonaId = new Map<string, { persona: NonNullable<ReturnType<typeof stateManager.persona_getByName>>; msgs: OpenCodeMessage[] }>();
171
+ for (const msg of relevant) {
172
+ let persona = stateManager.persona_getByName(msg.agent);
173
+ if (!persona) {
174
+ persona = await ensureAgentPersona(msg.agent, {
175
+ stateManager,
176
+ interface: eiInterface,
177
+ reader,
178
+ });
179
+ result.personasCreated.push(msg.agent);
180
+ }
181
+ const bucket = byPersonaId.get(persona.id);
182
+ if (bucket) {
183
+ bucket.msgs.push(msg);
184
+ } else {
185
+ byPersonaId.set(persona.id, { persona, msgs: [msg] });
186
+ }
571
187
  }
572
188
 
573
- // Sort chronologically (getSessionsInRange should return ASC, but ensure)
574
- sessions.sort((a, b) => a.time.updated - b.time.updated);
189
+ const cutoffIso = processedSessions[targetSession.id] ?? null;
190
+ const cutoffMs = cutoffIso ? new Date(cutoffIso).getTime() : null;
575
191
 
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);
192
+ for (const [, { persona, msgs: agentMsgs }] of byPersonaId) {
193
+ // Archive persona (message store only, not a conversational persona)
194
+ if (!persona.is_archived) {
195
+ stateManager.persona_archive(persona.id);
583
196
  }
584
- }
585
197
 
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;
198
+ // Clear existing messages — this persona holds exactly one session at a time
199
+ const existingMsgs = stateManager.messages_get(persona.id);
200
+ if (existingMsgs.length > 0) {
201
+ stateManager.messages_remove(persona.id, existingMsgs.map(m => m.id));
601
202
  }
602
203
 
603
- if (relevant.length === 0) {
604
- lastProcessed = session;
605
- continue;
204
+ // Write messages pre-mark old ones, leave new ones unmarked for extraction
205
+ const toAnalyze: Message[] = [];
206
+ for (const ocMsg of agentMsgs) {
207
+ const msgMs = new Date(ocMsg.timestamp).getTime();
208
+ const isOld = cutoffMs !== null && msgMs < cutoffMs;
209
+ const eiMsg = isOld ? convertToPreMarkedEiMessage(ocMsg) : convertToEiMessage(ocMsg);
210
+ stateManager.messages_append(persona.id, eiMsg);
211
+ result.messagesImported++;
212
+ if (!isOld) toAnalyze.push(eiMsg);
606
213
  }
607
214
 
608
- // Token budget check
609
- const sessionTokens = estimateTokensForMessages(relevant);
610
- tokenBudget += sessionTokens;
215
+ stateManager.messages_sort(persona.id);
216
+ stateManager.persona_update(persona.id, {
217
+ last_activity: new Date().toISOString(),
218
+ });
219
+ eiInterface?.onMessageAdded?.(persona.id);
611
220
 
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));
221
+ // ─── Step 5: Queue extraction for unmarked messages ────────────────
222
+ if (toAnalyze.length > 0) {
223
+ const allInState = stateManager.messages_get(persona.id);
224
+ const analyzeIds = new Set(toAnalyze.map(m => m.id));
225
+ const analyzeStartIndex = allInState.findIndex(m => analyzeIds.has(m.id));
226
+ const contextMsgs = analyzeStartIndex > 0 ? allInState.slice(0, analyzeStartIndex) : [];
641
227
 
642
228
  const context: ExtractionContext = {
643
229
  personaId: persona.id,
644
230
  personaDisplayName: persona.display_name,
645
- messages_context: [],
646
- messages_analyze: injectedMsgs,
231
+ messages_context: contextMsgs,
232
+ messages_analyze: toAnalyze,
647
233
  };
648
234
 
649
235
  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
- }
236
+ result.extractionScansQueued += 4;
749
237
  }
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
238
  }
770
239
 
771
- return scansQueued;
772
- }
240
+ result.sessionsProcessed = 1;
773
241
 
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
- };
242
+ // ─── Step 6: Advance extraction state ────────────────────────────────
243
+ updateExtractionState(stateManager, targetSession);
797
244
 
798
- queueAllScans(context, stateManager);
799
- scansQueued += 4;
800
- }
245
+ console.log(
246
+ `[OpenCode] Session complete: ${result.messagesImported} messages imported, ` +
247
+ `${result.extractionScansQueued} extraction scans queued`
248
+ );
801
249
 
802
- return scansQueued;
250
+ return result;
803
251
  }
804
252
 
805
253
  // =============================================================================
@@ -850,39 +298,17 @@ async function ensureSessionTopic(
850
298
  // State Helpers
851
299
  // =============================================================================
852
300
 
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(
301
+ function updateExtractionState(
882
302
  stateManager: StateManager,
883
- newPoint: string
303
+ session: OpenCodeSession
884
304
  ): void {
885
305
  const human = stateManager.getHuman();
306
+ const newPoint = new Date(session.time.updated).toISOString();
307
+ const processedSessions = {
308
+ ...(human.settings?.opencode?.processed_sessions ?? {}),
309
+ [session.id]: new Date().toISOString(),
310
+ };
311
+
886
312
  stateManager.setHuman({
887
313
  ...human,
888
314
  settings: {
@@ -890,6 +316,7 @@ function updateExtractionPoint(
890
316
  opencode: {
891
317
  ...human.settings?.opencode,
892
318
  extraction_point: newPoint,
319
+ processed_sessions: processedSessions,
893
320
  },
894
321
  },
895
322
  });