ei-tui 0.9.4 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +22 -3
  2. package/package.json +5 -1
  3. package/src/README.md +9 -25
  4. package/src/core/handlers/document-segmentation.ts +113 -0
  5. package/src/core/handlers/human-extraction.ts +16 -16
  6. package/src/core/handlers/index.ts +2 -0
  7. package/src/core/handlers/rewrite.ts +13 -9
  8. package/src/core/heartbeat-manager.ts +2 -2
  9. package/src/core/llm-client.ts +66 -6
  10. package/src/core/message-manager.ts +20 -18
  11. package/src/core/orchestrators/ceremony.ts +83 -40
  12. package/src/core/orchestrators/human-extraction.ts +5 -1
  13. package/src/core/persona-manager.ts +4 -0
  14. package/src/core/processor.ts +90 -1
  15. package/src/core/queue-manager.ts +35 -0
  16. package/src/core/queue-processor.ts +13 -13
  17. package/src/core/state/queue.ts +9 -1
  18. package/src/core/state-manager.ts +10 -6
  19. package/src/core/types/entities.ts +15 -0
  20. package/src/core/types/enums.ts +1 -0
  21. package/src/core/types/integrations.ts +2 -0
  22. package/src/core/types/llm.ts +9 -0
  23. package/src/integrations/document/chunker.ts +88 -0
  24. package/src/integrations/document/importer.ts +82 -0
  25. package/src/integrations/document/index.ts +2 -0
  26. package/src/integrations/document/invoice.ts +63 -0
  27. package/src/integrations/document/types.ts +16 -0
  28. package/src/integrations/document/unsource.ts +164 -0
  29. package/src/integrations/persona-history/importer.ts +197 -0
  30. package/src/integrations/persona-history/index.ts +3 -0
  31. package/src/integrations/persona-history/types.ts +7 -0
  32. package/src/prompts/ceremony/dedup.ts +7 -3
  33. package/src/prompts/ceremony/index.ts +2 -1
  34. package/src/prompts/ceremony/people-rewrite.ts +190 -0
  35. package/src/prompts/ceremony/{rewrite.ts → topic-rewrite.ts} +103 -78
  36. package/src/prompts/human/person-scan.ts +13 -4
  37. package/src/prompts/human/topic-scan.ts +16 -2
  38. package/src/prompts/human/topic-update.ts +36 -4
  39. package/src/prompts/human/types.ts +1 -0
  40. package/src/storage/indexed.ts +4 -0
  41. package/src/storage/interface.ts +1 -0
  42. package/src/storage/local.ts +4 -0
  43. package/src/templates/emmett.ts +49 -0
  44. package/tui/README.md +25 -2
  45. package/tui/src/app.tsx +9 -6
  46. package/tui/src/commands/delete.tsx +7 -1
  47. package/tui/src/commands/import.tsx +30 -0
  48. package/tui/src/commands/unsource.tsx +115 -0
  49. package/tui/src/components/PromptInput.tsx +4 -0
  50. package/tui/src/components/WelcomeOverlay.tsx +58 -32
  51. package/tui/src/context/ei.tsx +80 -60
  52. package/tui/src/index.tsx +14 -0
  53. package/tui/src/storage/file.ts +11 -5
  54. package/tui/src/util/e2e-flags.ts +4 -3
  55. package/tui/src/util/help-content.ts +20 -0
  56. package/tui/src/util/logger.ts +1 -1
  57. package/tui/src/util/provider-detection.ts +251 -0
  58. package/tui/src/util/yaml-human.ts +7 -1
@@ -1,4 +1,4 @@
1
- import { LLMRequestType, LLMPriority, LLMNextStep, RoomMode, ContextStatus, type CeremonyConfig, type PersonaTopic, type Topic, type DataItemBase } from "../types.js";
1
+ import { LLMRequestType, LLMPriority, LLMNextStep, RoomMode, ContextStatus, type CeremonyConfig, type PersonaTopic, type Topic } from "../types.js";
2
2
  import type { StateManager } from "../state-manager.js";
3
3
  import { normalizeRoomMessages } from "../handlers/utils.js";
4
4
  import { applyDecayToValue } from "../utils/index.js";
@@ -12,7 +12,9 @@ import {
12
12
  } from "./human-extraction.js";
13
13
  import { queuePersonaTopicRating, type PersonaTopicContext, type PersonaTopicOptions } from "./persona-topics.js";
14
14
  import { getRoomVisibleMessages, queueRoomHumanExtraction } from "./room-extraction.js";
15
- import { buildRewriteScanPrompt, type RewriteItemType } from "../../prompts/ceremony/index.js";
15
+ import { type RewriteItemType } from "../../prompts/ceremony/index.js";
16
+ import { buildPersonRewriteScanPrompt } from "../../prompts/ceremony/people-rewrite.js";
17
+ import { buildTopicRewriteScanPrompt } from "../../prompts/ceremony/topic-rewrite.js";
16
18
  import { buildReflectionCriticPrompt } from "../../prompts/reflection/index.js";
17
19
  import { getModelForPersona } from "../heartbeat-manager.js";
18
20
 
@@ -51,7 +53,7 @@ export function shouldStartCeremony(config: CeremonyConfig, state: StateManager,
51
53
  * Start the ceremony by queuing Exposure scans for all active personas with recent activity.
52
54
  *
53
55
  * IMPORTANT: Sets last_ceremony FIRST to prevent re-triggering from the processor loop.
54
- * The actual Decay → PruneExpire Explore phases happen later via handleCeremonyProgress
56
+ * The actual Decay → Person Rewrite Topic Rewrite phases happen later via handleCeremonyProgress
55
57
  * once all exposure scans have completed.
56
58
  */
57
59
  export function startCeremony(state: StateManager): void {
@@ -167,7 +169,7 @@ function queueExposurePhase(personaId: string, state: StateManager, options?: Ex
167
169
  * AND at the end of startCeremony (for the zero-messages edge case).
168
170
  *
169
171
  * If any ceremony_progress items remain in the queue, does nothing — more work pending.
170
- * Phase 1: Dedup → Phase 2: Expose → Phase 3: EventSummary → Decay → Expire
172
+ * Phase 1: Dedup → Phase 2: Expose → Phase 3: EventSummary → Decay → Phase 4: Person Rewrite → Topic Rewrite (fire-and-forget)
171
173
  */
172
174
  export function handleCeremonyProgress(state: StateManager, lastPhase: number): void {
173
175
  if (state.queue_hasPendingCeremonies()) {
@@ -236,6 +238,12 @@ export function handleCeremonyProgress(state: StateManager, lastPhase: number):
236
238
  return;
237
239
  }
238
240
 
241
+ if (lastPhase === 4) {
242
+ console.log("[ceremony:progress] Person Rewrite complete, starting Topic Rewrite");
243
+ queueTopicRewritePhase(state);
244
+ return;
245
+ }
246
+
239
247
  if (lastPhase === 2) {
240
248
  console.log("[ceremony:progress] Expose complete, starting EventSummary phase");
241
249
  const options: ExtractionOptions = { ceremony_progress: 3 };
@@ -249,7 +257,7 @@ export function handleCeremonyProgress(state: StateManager, lastPhase: number):
249
257
  return;
250
258
  }
251
259
 
252
- // Phase 3 (EventSummary) complete → advance to Decay/Prune/Expire/Explore
260
+ // Phase 3 (EventSummary) complete → advance to Decay/Prune then Person Rewrite (phase 4)
253
261
  console.log("[ceremony:progress] EventSummary complete, advancing to Decay");
254
262
 
255
263
  const personas = state.persona_getAll();
@@ -276,8 +284,16 @@ export function handleCeremonyProgress(state: StateManager, lastPhase: number):
276
284
  // Human ceremony: decay topics + people
277
285
  runHumanCeremony(state);
278
286
 
279
- // Rewrite phase: fire-and-forget scans for bloated human data items
280
- queueRewritePhase(state);
287
+ // Person Rewrite phase (phase 4): scan bloated Person records, extract Topics from them.
288
+ // Gated via ceremony_progress so Topic Rewrite can run after — Topics created here
289
+ // need to be visible before Topic Rewrite snapshots the threshold.
290
+ queuePersonRewritePhase(state);
291
+
292
+ // Zero-work guard: if no person rewrites queued, advance to topic rewrite immediately
293
+ if (!state.queue_hasPendingCeremonies()) {
294
+ console.log("[ceremony:progress] No person rewrite work, advancing to Topic Rewrite");
295
+ handleCeremonyProgress(state, 4);
296
+ }
281
297
 
282
298
  // Reflection phase: fire-and-forget critic calls for persona person records above threshold
283
299
  queueReflectionPhase(state);
@@ -441,15 +457,6 @@ export function runHumanCeremony(state: StateManager): void {
441
457
 
442
458
  const REWRITE_DESCRIPTION_THRESHOLD = 750;
443
459
 
444
- /**
445
- * Queue Phase 1 "scan" for every human data item whose description exceeds the
446
- * threshold. Gated on rewrite_model being set in HumanSettings.
447
- *
448
- * Fire-and-forget: no ceremony_progress, no blocking. Expire/Explore proceed
449
- * immediately since they only touch persona topics (zero overlap with human data).
450
- * Phase 2 items enqueue at Normal priority, naturally processing before more
451
- * Low-priority Phase 1 scans.
452
- */
453
460
  /**
454
461
  * Forces an unconditional, threshold-bypassing Person scan on Apply/Dismiss.
455
462
  * Cannot be replaced by checkAndQueueHumanExtraction — that function gates on
@@ -479,41 +486,77 @@ export function queueReflectionDrain(personaId: string, state: StateManager): vo
479
486
  console.log(`[reflection:drain] Queued Person scan for ${persona.display_name} (${unextractedPeople.length} messages) — clears on completion`);
480
487
  }
481
488
 
482
- export function queueRewritePhase(state: StateManager): void {
483
- const human = state.getHuman();
484
- const rewriteModel = human.settings?.rewrite_model;
489
+ function getRewriteModel(state: StateManager): string | undefined {
490
+ return state.getHuman().settings?.rewrite_model;
491
+ }
485
492
 
493
+ export function queuePersonRewritePhase(state: StateManager): void {
494
+ const rewriteModel = getRewriteModel(state);
486
495
  if (!rewriteModel) {
487
- console.log("[ceremony:rewrite] rewrite_model not set — skipping rewrite phase");
496
+ console.log("[ceremony:rewrite] rewrite_model not set — skipping person rewrite phase");
488
497
  return;
489
498
  }
490
499
 
491
- const itemsToScan: Array<{ item: DataItemBase; type: RewriteItemType }> = [];
492
-
493
- for (const topic of human.topics) {
494
- if ((topic.description?.length ?? 0) > REWRITE_DESCRIPTION_THRESHOLD && !topic.rewrite_checked) {
495
- itemsToScan.push({ item: topic, type: "topic" });
496
- }
497
- }
498
- for (const person of human.people) {
500
+ const human = state.getHuman();
501
+ const personsToScan = human.people.filter(person => {
499
502
  const isPersonaLinked = (person.identifiers ?? []).some(
500
503
  i => i.type.toLowerCase() === 'ei persona'
501
504
  );
502
- if (!isPersonaLinked && (person.description?.length ?? 0) > REWRITE_DESCRIPTION_THRESHOLD && !person.rewrite_checked) {
503
- itemsToScan.push({ item: person, type: "person" });
504
- }
505
+ return !isPersonaLinked
506
+ && (person.description?.length ?? 0) > REWRITE_DESCRIPTION_THRESHOLD
507
+ && !person.rewrite_checked;
508
+ });
509
+
510
+ if (personsToScan.length === 0) {
511
+ console.log("[ceremony:rewrite] No persons above threshold — skipping person rewrite phase");
512
+ return;
513
+ }
514
+
515
+ console.log(`[ceremony:rewrite] Found ${personsToScan.length} person(s) above ${REWRITE_DESCRIPTION_THRESHOLD} chars — queueing person rewrite scans`);
516
+
517
+ for (const person of personsToScan) {
518
+ const prompt = buildPersonRewriteScanPrompt({ item: person, itemType: "person" });
519
+ state.queue_enqueue({
520
+ type: LLMRequestType.JSON,
521
+ priority: LLMPriority.Low,
522
+ system: prompt.system,
523
+ user: prompt.user,
524
+ next_step: LLMNextStep.HandleRewriteScan,
525
+ model: rewriteModel,
526
+ data: {
527
+ itemId: person.id,
528
+ itemType: "person" as RewriteItemType,
529
+ rewriteModel,
530
+ ceremony_progress: 4,
531
+ },
532
+ });
505
533
  }
506
534
 
507
- if (itemsToScan.length === 0) {
508
- console.log("[ceremony:rewrite] No items above threshold — nothing to rewrite");
535
+ console.log(`[ceremony:rewrite] Queued ${personsToScan.length} person rewrite scan(s)`);
536
+ }
537
+
538
+ export function queueTopicRewritePhase(state: StateManager): void {
539
+ const rewriteModel = getRewriteModel(state);
540
+ if (!rewriteModel) {
541
+ console.log("[ceremony:rewrite] rewrite_model not set — skipping topic rewrite phase");
509
542
  return;
510
543
  }
511
544
 
512
- console.log(`[ceremony:rewrite] Found ${itemsToScan.length} item(s) above ${REWRITE_DESCRIPTION_THRESHOLD} chars — queueing Phase 1 scans`);
545
+ const human = state.getHuman();
546
+ const topicsToScan = human.topics.filter(topic =>
547
+ (topic.description?.length ?? 0) > REWRITE_DESCRIPTION_THRESHOLD
548
+ && !topic.rewrite_checked
549
+ );
550
+
551
+ if (topicsToScan.length === 0) {
552
+ console.log("[ceremony:rewrite] No topics above threshold — skipping topic rewrite phase");
553
+ return;
554
+ }
513
555
 
514
- for (const { item, type } of itemsToScan) {
515
- const prompt = buildRewriteScanPrompt({ item, itemType: type });
556
+ console.log(`[ceremony:rewrite] Found ${topicsToScan.length} topic(s) above ${REWRITE_DESCRIPTION_THRESHOLD} chars — queueing topic rewrite scans`);
516
557
 
558
+ for (const topic of topicsToScan) {
559
+ const prompt = buildTopicRewriteScanPrompt({ item: topic, itemType: "topic" });
517
560
  state.queue_enqueue({
518
561
  type: LLMRequestType.JSON,
519
562
  priority: LLMPriority.Low,
@@ -522,14 +565,14 @@ export function queueRewritePhase(state: StateManager): void {
522
565
  next_step: LLMNextStep.HandleRewriteScan,
523
566
  model: rewriteModel,
524
567
  data: {
525
- itemId: item.id,
526
- itemType: type,
527
- rewriteModel, // pass through so Phase 1 handler can queue Phase 2 with the same model
568
+ itemId: topic.id,
569
+ itemType: "topic" as RewriteItemType,
570
+ rewriteModel,
528
571
  },
529
572
  });
530
573
  }
531
574
 
532
- console.log(`[ceremony:rewrite] Queued ${itemsToScan.length} Phase 1 scan(s) at Low priority`);
575
+ console.log(`[ceremony:rewrite] Queued ${topicsToScan.length} topic rewrite scan(s)`);
533
576
  }
534
577
 
535
578
  function queueEventSummaryForAll(state: StateManager, options?: ExtractionOptions): void {
@@ -164,6 +164,7 @@ export function queueTopicScan(context: ExtractionContext, state: StateManager,
164
164
  messages_context: chunk.messages_context,
165
165
  messages_analyze: chunk.messages_analyze,
166
166
  participant_context: buildParticipantContext(context.personaId, state),
167
+ technical_context: (context.sources?.length ?? 0) > 0,
167
168
  });
168
169
 
169
170
  state.queue_enqueue({
@@ -275,6 +276,7 @@ export function queueDirectTopicUpdate(
275
276
  messages_analyze: chunk.messages_analyze,
276
277
  persona_name: chunk.channelDisplayName,
277
278
  participant_context: buildParticipantContext(context.personaId, state),
279
+ technical_context: (context.sources?.length ?? 0) > 0,
278
280
  });
279
281
 
280
282
  state.queue_enqueue({
@@ -291,6 +293,7 @@ export function queueDirectTopicUpdate(
291
293
  existingItemId: topic.id,
292
294
  analyze_from_timestamp: getAnalyzeFromTimestamp(chunk),
293
295
  extraction_model: extractionModel,
296
+ sources: context.sources,
294
297
  },
295
298
  });
296
299
  }
@@ -306,7 +309,7 @@ const EMBEDDING_MIN_SIMILARITY = 0.3;
306
309
  * Higher than EMBEDDING_MIN_SIMILARITY (0.3) because we need near-duplicates,
307
310
  * not just vague thematic overlap.
308
311
  */
309
- export const VALIDATE_MIN_SIMILARITY = 0.85;
312
+ export const VALIDATE_MIN_SIMILARITY = 0.92;
310
313
 
311
314
  /**
312
315
  * Queue a topic match request using embedding-based similarity (topics only).
@@ -425,6 +428,7 @@ export function queueTopicUpdate(
425
428
  messages_analyze: chunk.messages_analyze,
426
429
  persona_name: chunk.channelDisplayName,
427
430
  participant_context: buildParticipantContext(primaryPersonaId, state),
431
+ technical_context: (context.sources?.length ?? 0) > 0,
428
432
  });
429
433
 
430
434
  state.queue_enqueue({
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  RESERVED_PERSONA_NAMES,
3
3
  isReservedPersonaName,
4
+ isReservedPersonaId,
4
5
  type PersonaSummary,
5
6
  type PersonaEntity,
6
7
  type PersonaCreationInput,
@@ -110,6 +111,9 @@ export async function deletePersona(
110
111
  personaId: string,
111
112
  _deleteHumanData: boolean
112
113
  ): Promise<boolean> {
114
+ if (isReservedPersonaId(personaId)) {
115
+ throw new Error(`Cannot delete reserved persona "${personaId}". Use archive instead.`);
116
+ }
113
117
  const persona = sm.persona_getById(personaId);
114
118
  if (!persona) return false;
115
119
  sm.persona_delete(personaId);
@@ -39,7 +39,9 @@ import { ContextStatus as ContextStatusEnum, RoomMode } from "./types.js";
39
39
  import { registerReadMemoryExecutor, registerFileReadExecutor } from "./tools/index.js";
40
40
  import { createReadMemoryExecutor } from "./tools/builtin/read-memory.js";
41
41
  import { EI_WELCOME_MESSAGE, EI_PERSONA_DEFINITION } from "../templates/welcome.js";
42
+ import { EMMETT_PERSONA_DEFINITION } from "../templates/emmett.js";
42
43
  import { shouldStartCeremony, startCeremony, handleCeremonyProgress, queueReflectionDrain, queueUserDedupRequest, queueRoomCapture, queuePersonaCapture, checkAndQueueRoomExtraction, queueTargetedPersonUpdate, queueTargetedTopicUpdate } from "./orchestrators/index.js";
44
+ import { finishDocumentBatch } from "./handlers/document-segmentation.js";
43
45
  import { BUILT_IN_FACTS } from "./constants/built-in-facts.js";
44
46
  import { DEFAULT_SEED_TRAITS } from "./constants/seed-traits.js";
45
47
 
@@ -132,6 +134,8 @@ import {
132
134
  markAllRoomMessagesRead,
133
135
  } from "./room-manager.js";
134
136
  import type { RoomCreationInput, RoomEntity, RoomMessage, RoomSummary } from "./types.js";
137
+ import { previewUnsource as _previewUnsource } from "../integrations/document/unsource.js";
138
+ import type { UnsourcePreview, UnsourceResult } from "../integrations/document/unsource.js";
135
139
 
136
140
  const DEFAULT_LOOP_INTERVAL_MS = 100;
137
141
  const DEFAULT_OPENCODE_POLLING_MS = 60000;
@@ -282,6 +286,46 @@ export class Processor {
282
286
  this.interface.onMessageAdded?.(eiEntity.id);
283
287
  }
284
288
 
289
+ private bootstrapEmmett(): void {
290
+ const existing = this.stateManager.persona_getById("emmet");
291
+ if (existing) {
292
+ if (existing.is_archived) {
293
+ this.stateManager.persona_unarchive("emmet");
294
+ }
295
+ return;
296
+ }
297
+ const readMemoryTool = this.stateManager.tools_getByName("read_memory");
298
+ const emmettEntity: PersonaEntity = {
299
+ ...EMMETT_PERSONA_DEFINITION,
300
+ id: "emmet",
301
+ display_name: "Emmett",
302
+ last_updated: new Date().toISOString(),
303
+ tools: readMemoryTool ? [readMemoryTool.id] : [],
304
+ };
305
+ this.stateManager.persona_add(emmettEntity);
306
+ this.interface.onPersonaAdded?.();
307
+ }
308
+
309
+ async importDocument(content: string, filename: string): Promise<import("../integrations/document/types.js").DocumentImportResult> {
310
+ this.bootstrapEmmett();
311
+ const { importDocument } = await import("../integrations/document/importer.js");
312
+ return importDocument({
313
+ stateManager: this.stateManager,
314
+ interface: this.interface,
315
+ content,
316
+ filename,
317
+ });
318
+ }
319
+
320
+ getUnsourcePreview(sourceTag: string): UnsourcePreview {
321
+ return _previewUnsource(sourceTag, this.stateManager);
322
+ }
323
+
324
+ async executeUnsource(preview: UnsourcePreview): Promise<UnsourceResult> {
325
+ const { executeUnsource } = await import("../integrations/document/unsource.js");
326
+ return executeUnsource(preview, this.stateManager);
327
+ }
328
+
285
329
  /**
286
330
  * Seed built-in tool providers and tools if they don't exist yet.
287
331
  * Called on every startup (after state load/restore) — safe to call repeatedly.
@@ -1168,6 +1212,15 @@ const toolNextSteps = new Set([
1168
1212
  await this.checkAndSyncCursor(human, now);
1169
1213
  }
1170
1214
 
1215
+ if (
1216
+ this.isTUI &&
1217
+ human.settings?.personaHistory?.integration &&
1218
+ !human.settings.personaHistory.complete &&
1219
+ this.stateManager.queue_length() === 0
1220
+ ) {
1221
+ await this.checkAndSyncPersonaHistory(human);
1222
+ }
1223
+
1171
1224
  if (human.settings?.ceremony && shouldStartCeremony(human.settings.ceremony, this.stateManager)) {
1172
1225
  if (human.settings?.sync && remoteSync.isConfigured()) {
1173
1226
  const state = this.stateManager.getStorageState();
@@ -1180,7 +1233,7 @@ const toolNextSteps = new Set([
1180
1233
  }
1181
1234
 
1182
1235
  for (const persona of this.stateManager.persona_getAll()) {
1183
- if (persona.is_paused || persona.is_archived) continue;
1236
+ if (persona.is_paused || persona.is_archived || persona.is_static) continue;
1184
1237
 
1185
1238
  const defaultHeartbeatMs = this.stateManager.getHuman().settings?.default_heartbeat_ms ?? 1800000;
1186
1239
  const heartbeatDelay = persona.heartbeat_delay_ms ?? defaultHeartbeatMs;
@@ -1408,6 +1461,32 @@ const toolNextSteps = new Set([
1408
1461
  });
1409
1462
  }
1410
1463
 
1464
+ private personaHistoryImportInProgress = false;
1465
+
1466
+ private async checkAndSyncPersonaHistory(_human: HumanEntity): Promise<void> {
1467
+ if (this.personaHistoryImportInProgress) return;
1468
+
1469
+ this.personaHistoryImportInProgress = true;
1470
+ import("../integrations/persona-history/importer.js")
1471
+ .then(({ importPersonaHistory }) =>
1472
+ importPersonaHistory({ stateManager: this.stateManager })
1473
+ )
1474
+ .then((result) => {
1475
+ if (result.scansQueued > 0) {
1476
+ console.log(
1477
+ `[Processor] PersonaHistory: ${result.scansQueued} scans queued` +
1478
+ (result.complete ? " — import complete" : "")
1479
+ );
1480
+ }
1481
+ })
1482
+ .catch((err) => {
1483
+ console.warn(`[Processor] PersonaHistory sync failed:`, err);
1484
+ })
1485
+ .finally(() => {
1486
+ this.personaHistoryImportInProgress = false;
1487
+ });
1488
+ }
1489
+
1411
1490
  private augmentRoomRequest(request: LLMRequest): LLMRequest {
1412
1491
  if (request.next_step !== LLMNextStep.HandleRoomResponse) return request;
1413
1492
 
@@ -1675,6 +1754,16 @@ const toolNextSteps = new Set([
1675
1754
  if (typeof response.request.data.ceremony_progress === "number") {
1676
1755
  handleCeremonyProgress(this.stateManager, response.request.data.ceremony_progress);
1677
1756
  }
1757
+
1758
+ if (response.request.next_step === LLMNextStep.HandleDocumentSegmentation) {
1759
+ const batchId = response.request.data.batchId as string;
1760
+ const filename = response.request.data.filename as string;
1761
+ if (batchId && !this.stateManager.queue_hasPendingDocumentSegments(batchId)) {
1762
+ finishDocumentBatch(batchId, filename, this.stateManager);
1763
+ this.interface.onMessageAdded?.("emmet");
1764
+ this.interface.onHumanUpdated?.();
1765
+ }
1766
+ }
1678
1767
  } catch (err) {
1679
1768
  const errorMsg = err instanceof Error ? err.message : String(err);
1680
1769
  const result = this.stateManager.queue_fail(response.request.id, errorMsg);
@@ -18,6 +18,39 @@ export async function resumeQueue(sm: StateManager): Promise<void> {
18
18
  }
19
19
 
20
20
  export async function getQueueStatus(sm: StateManager): Promise<QueueStatus> {
21
+ const activeItems = sm.queue_getAllActiveItems();
22
+ const segmentationItems = activeItems.filter(
23
+ r => r.next_step === LLMNextStep.HandleDocumentSegmentation
24
+ );
25
+
26
+ const batchMap = new Map<string, { filename: string; count: number }>();
27
+ for (const item of segmentationItems) {
28
+ const { batchId, filename } = item.data as { batchId: string; filename: string };
29
+ if (!batchId || !filename) continue;
30
+ const existing = batchMap.get(batchId);
31
+ if (existing) {
32
+ existing.count++;
33
+ } else {
34
+ batchMap.set(batchId, { filename, count: 1 });
35
+ }
36
+ }
37
+
38
+ const pending_documents = batchMap.size > 0
39
+ ? Array.from(batchMap.entries()).map(([batchId, { filename, count }]) => ({ batchId, filename, count }))
40
+ : undefined;
41
+
42
+ const extractingSet = new Set<string>();
43
+ for (const item of activeItems) {
44
+ const sources = item.data.sources as string[] | undefined;
45
+ if (!Array.isArray(sources)) continue;
46
+ for (const s of sources) {
47
+ if (typeof s === "string" && s.startsWith("import:document:")) {
48
+ extractingSet.add(s.slice("import:document:".length));
49
+ }
50
+ }
51
+ }
52
+ const extracting_documents = extractingSet.size > 0 ? Array.from(extractingSet) : undefined;
53
+
21
54
  return {
22
55
  state: sm.queue_isPaused()
23
56
  ? "paused"
@@ -27,6 +60,8 @@ export async function getQueueStatus(sm: StateManager): Promise<QueueStatus> {
27
60
  pending_count: sm.queue_length(),
28
61
  dlq_count: sm.queue_dlqLength(),
29
62
  embedding_warning: sm.embedding_getWarning() || undefined,
63
+ pending_documents,
64
+ extracting_documents,
30
65
  };
31
66
  }
32
67
 
@@ -200,7 +200,7 @@ export class QueueProcessor {
200
200
  hydratedUser,
201
201
  messages,
202
202
  request.model,
203
- { signal: this.abortController?.signal, tools: openAITools, onUsageUpdate: this.currentOnUsageUpdate },
203
+ { signal: this.abortController?.signal, tools: openAITools, onUsageUpdate: this.currentOnUsageUpdate, nextStep: `${request.data.originalNextStep ?? request.next_step}+tool_continuation` },
204
204
  this.currentAccounts
205
205
  );
206
206
 
@@ -219,7 +219,7 @@ export class QueueProcessor {
219
219
  if (!args.should_respond && args.content) {
220
220
  args.should_respond = true;
221
221
  }
222
- console.log(`[QueueProcessor] submit tool "${submitCall.name}" called — returning arguments as parsed response`);
222
+ console.debug(`[QueueProcessor] submit tool "${submitCall.name}" called — returning arguments as parsed response`);
223
223
  return {
224
224
  request,
225
225
  success: true,
@@ -297,9 +297,9 @@ export class QueueProcessor {
297
297
  const isHeartbeat = request.next_step === LLMNextStep.HandleHeartbeatCheck || request.next_step === LLMNextStep.HandleEiHeartbeat;
298
298
  if (isHeartbeat) {
299
299
  const personaName = request.data.personaDisplayName as string | undefined ?? 'Ei';
300
- console.log(`[${personaName} Heartbeat] LLM call - tools offered: ${openAITools.length} (${activeTools.map(t => t.name).join(', ') || 'none'})`);
300
+ console.debug(`[${personaName} Heartbeat] LLM call - tools offered: ${openAITools.length} (${activeTools.map(t => t.name).join(', ') || 'none'})`);
301
301
  } else {
302
- console.log(`[QueueProcessor] LLM call for ${request.next_step}, tools=${openAITools.length}`);
302
+ console.debug(`[QueueProcessor] LLM call for ${request.next_step}, tools=${openAITools.length}`);
303
303
  }
304
304
 
305
305
  const { content, finishReason, rawToolCalls, assistantMessage, thinking } = await callLLMRaw(
@@ -307,18 +307,18 @@ export class QueueProcessor {
307
307
  hydratedUser,
308
308
  messages,
309
309
  request.model,
310
- { signal: this.abortController?.signal, tools: openAITools, onUsageUpdate: this.currentOnUsageUpdate },
310
+ { signal: this.abortController?.signal, tools: openAITools, onUsageUpdate: this.currentOnUsageUpdate, nextStep: request.next_step },
311
311
  this.currentAccounts
312
312
  );
313
313
  if (thinking) {
314
- console.log(`[QueueProcessor] Extended thinking on ${request.next_step} (${thinking.length} chars) — TODO(#13): stream to TUI`);
314
+ console.debug(`[QueueProcessor] Extended thinking on ${request.next_step} (${thinking.length} chars) — TODO(#13): stream to TUI`);
315
315
  }
316
316
 
317
317
  // =========================================================================
318
318
  // Tool call path: execute tools, enqueue HandleToolContinuation, done.
319
319
  // =========================================================================
320
320
  if (finishReason === "tool_calls" && rawToolCalls?.length) {
321
- console.log(`[QueueProcessor] finish_reason=tool_calls — executing tools, will enqueue HandleToolContinuation`);
321
+ console.debug(`[QueueProcessor] finish_reason=tool_calls — executing tools, will enqueue HandleToolContinuation`);
322
322
 
323
323
  const toolCalls = parseToolCalls(rawToolCalls);
324
324
  if (toolCalls.length === 0) {
@@ -364,7 +364,7 @@ export class QueueProcessor {
364
364
  });
365
365
  }
366
366
 
367
- console.log(`[QueueProcessor] Tool execution complete: ${results.length} result(s). Enqueueing HandleToolContinuation.`);
367
+ console.debug(`[QueueProcessor] Tool execution complete: ${results.length} result(s). Enqueueing HandleToolContinuation.`);
368
368
 
369
369
  if (this.currentOnEnqueue) {
370
370
  this.currentOnEnqueue({
@@ -412,7 +412,7 @@ export class QueueProcessor {
412
412
  // =========================================================================
413
413
  // Normal stop path
414
414
  // =========================================================================
415
- console.log(`[QueueProcessor] finish_reason="${finishReason}" — normal stop`);
415
+ console.debug(`[QueueProcessor] finish_reason="${finishReason}" — normal stop`);
416
416
  return this.handleResponseType(request, content ?? "", finishReason);
417
417
  }
418
418
 
@@ -497,9 +497,9 @@ export class QueueProcessor {
497
497
  const { content: reformatContent, finishReason: reformatReason } = await callLLMRaw(
498
498
  request.system,
499
499
  reformatUserPrompt,
500
- messages, // existing tool history — gives full context without duplicating the ask
500
+ messages,
501
501
  request.model,
502
- { signal: this.abortController?.signal, onUsageUpdate: this.currentOnUsageUpdate },
502
+ { signal: this.abortController?.signal, onUsageUpdate: this.currentOnUsageUpdate, nextStep: `${request.data.originalNextStep ?? request.next_step}+prose_reformat` },
503
503
  this.currentAccounts
504
504
  );
505
505
 
@@ -554,9 +554,9 @@ export class QueueProcessor {
554
554
  const { content: reformatContent, finishReason: reformatReason } = await callLLMRaw(
555
555
  request.system,
556
556
  reformatUserPrompt,
557
- [], // no message history needed — schema is already in the system prompt
557
+ [],
558
558
  request.model,
559
- { signal: this.abortController?.signal, onUsageUpdate: this.currentOnUsageUpdate },
559
+ { signal: this.abortController?.signal, onUsageUpdate: this.currentOnUsageUpdate, nextStep: `${request.next_step}+json_reformat` },
560
560
  this.currentAccounts
561
561
  );
562
562
 
@@ -1,5 +1,5 @@
1
1
  import type { LLMRequest, QueueFailResult } from "../types.js";
2
- import { DLQ_MAX_COUNT, DLQ_MAX_AGE_DAYS } from "../types.js";
2
+ import { DLQ_MAX_COUNT, DLQ_MAX_AGE_DAYS, LLMNextStep } from "../types.js";
3
3
 
4
4
  const BASE_BACKOFF_MS = 2_000;
5
5
  const MAX_BACKOFF_MS = 30_000;
@@ -200,6 +200,14 @@ export class QueueState {
200
200
  return this.queue.some(r => r.state !== "dlq" && typeof r.data.ceremony_progress === "number" && r.data.ceremony_progress > 0);
201
201
  }
202
202
 
203
+ hasPendingDocumentSegments(batchId: string): boolean {
204
+ return this.queue.some(r =>
205
+ r.state !== "dlq" &&
206
+ r.next_step === LLMNextStep.HandleDocumentSegmentation &&
207
+ r.data.batchId === batchId
208
+ );
209
+ }
210
+
203
211
  clear(): number {
204
212
  const count = this.queue.filter(r => r.state !== "dlq").length;
205
213
  this.queue = this.queue.filter(r => r.state === "dlq");
@@ -917,6 +917,10 @@ export class StateManager {
917
917
  return this.queueState.hasPendingCeremonies();
918
918
  }
919
919
 
920
+ queue_hasPendingDocumentSegments(batchId: string): boolean {
921
+ return this.queueState.hasPendingDocumentSegments(batchId);
922
+ }
923
+
920
924
  queue_clear(): number {
921
925
  const result = this.queueState.clear();
922
926
  this.scheduleSave();
@@ -1050,7 +1054,7 @@ export class StateManager {
1050
1054
  tools_getForPersona(personaId: string, isTUI: boolean): ToolDefinition[] {
1051
1055
  const persona = this.personaState.getById(personaId);
1052
1056
  if (!persona?.tools?.length) {
1053
- console.log(`[Tools] tools_getForPersona(${personaId}): persona has no assigned tools`);
1057
+ console.debug(`[Tools] tools_getForPersona(${personaId}): persona has no assigned tools`);
1054
1058
  return [];
1055
1059
  }
1056
1060
  const assignedIds = new Set(persona.tools);
@@ -1073,13 +1077,13 @@ export class StateManager {
1073
1077
  if (result.length < assignedIds.size) {
1074
1078
  for (const id of assignedIds) {
1075
1079
  const tool = this.tools.find(t => t.id === id);
1076
- if (!tool) { console.log(`[Tools] tools_getForPersona: assigned tool id=${id} not found in registry`); continue; }
1077
- if (!tool.enabled) { console.log(`[Tools] tools_getForPersona: tool "${tool.name}" is disabled`); continue; }
1078
- if (!enabledProviderIds.has(tool.provider_id)) { console.log(`[Tools] tools_getForPersona: tool "${tool.name}" provider is disabled`); continue; }
1079
- if (!(tool.runtime === "any" || (tool.runtime === "node" && isTUI))) { console.log(`[Tools] tools_getForPersona: tool "${tool.name}" runtime "${tool.runtime}" not available (isTUI=${isTUI})`); continue; }
1080
+ if (!tool) { console.debug(`[Tools] tools_getForPersona: assigned tool id=${id} not found in registry`); continue; }
1081
+ if (!tool.enabled) { console.debug(`[Tools] tools_getForPersona: tool "${tool.name}" is disabled`); continue; }
1082
+ if (!enabledProviderIds.has(tool.provider_id)) { console.debug(`[Tools] tools_getForPersona: tool "${tool.name}" provider is disabled`); continue; }
1083
+ if (!(tool.runtime === "any" || (tool.runtime === "node" && isTUI))) { console.debug(`[Tools] tools_getForPersona: tool "${tool.name}" runtime "${tool.runtime}" not available (isTUI=${isTUI})`); continue; }
1080
1084
  }
1081
1085
  }
1082
- console.log(`[Tools] tools_getForPersona(${personaId}): resolved ${result.length}/${assignedIds.size} tools: [${result.map(t => t.name).join(", ")}]`);
1086
+ console.debug(`[Tools] tools_getForPersona(${personaId}): resolved ${result.length}/${assignedIds.size} tools: [${result.map(t => t.name).join(", ")}]`);
1083
1087
  return result;
1084
1088
  }
1085
1089
 
@@ -20,6 +20,11 @@ export interface OpenCodeSettings {
20
20
  processed_sessions?: Record<string, string>; // sessionId → ISO timestamp of last import
21
21
  }
22
22
 
23
+ export interface DocumentSettings {
24
+ extraction_model?: string;
25
+ processed_documents?: Record<string, string>;
26
+ }
27
+
23
28
  export interface CeremonyConfig {
24
29
  time: string; // "HH:MM" format (e.g., "09:00")
25
30
  last_ceremony?: string; // ISO timestamp
@@ -117,8 +122,10 @@ export interface HumanSettings {
117
122
  backup?: BackupConfig;
118
123
  claudeCode?: import("../../integrations/claude-code/types.js").ClaudeCodeSettings;
119
124
  cursor?: import("../../integrations/cursor/types.js").CursorSettings;
125
+ document?: DocumentSettings;
120
126
  active_theme?: string;
121
127
  custom_themes?: ThemeDefinition[];
128
+ personaHistory?: import("../../integrations/persona-history/types.js").PersonaHistorySettings;
122
129
  }
123
130
 
124
131
  export interface HumanEntity {
@@ -202,3 +209,11 @@ export type ReservedPersonaName = typeof RESERVED_PERSONA_NAMES[number];
202
209
  export function isReservedPersonaName(name: string): boolean {
203
210
  return RESERVED_PERSONA_NAMES.includes(name.toLowerCase() as ReservedPersonaName);
204
211
  }
212
+
213
+ // Reserved persona IDs (built-in system personas that cannot be deleted)
214
+ export const RESERVED_PERSONA_IDS = ["ei", "emmet"] as const;
215
+ export type ReservedPersonaId = typeof RESERVED_PERSONA_IDS[number];
216
+
217
+ export function isReservedPersonaId(id: string): boolean {
218
+ return (RESERVED_PERSONA_IDS as readonly string[]).includes(id);
219
+ }
@@ -52,6 +52,7 @@ export enum LLMNextStep {
52
52
  HandlePersonaPreview = "handlePersonaPreview",
53
53
  HandleTopicValidate = "handleTopicValidate",
54
54
  HandleReflectionCritic = "handleReflectionCritic",
55
+ HandleDocumentSegmentation = "handleDocumentSegmentation",
55
56
  }
56
57
 
57
58
  export enum ProviderType {
@@ -75,6 +75,8 @@ export interface QueueStatus {
75
75
  current_operation?: string;
76
76
  /** True when the embedding service failed and topic/person matching fell back to recent items. */
77
77
  embedding_warning?: boolean;
78
+ pending_documents?: Array<{ batchId: string; filename: string; count: number }>;
79
+ extracting_documents?: string[];
78
80
  }
79
81
 
80
82
  export interface EiError {