ei-tui 1.1.0 → 1.3.0

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 (66) hide show
  1. package/README.md +16 -0
  2. package/package.json +2 -23
  3. package/src/cli/README.md +12 -2
  4. package/src/cli/mcp.ts +12 -4
  5. package/src/cli/retrieval.ts +162 -0
  6. package/src/cli.ts +7 -1
  7. package/src/core/handlers/dedup.ts +4 -15
  8. package/src/core/handlers/document-segmentation.ts +5 -7
  9. package/src/core/handlers/heartbeat.ts +5 -10
  10. package/src/core/handlers/human-matching.ts +8 -0
  11. package/src/core/handlers/index.ts +2 -0
  12. package/src/core/handlers/knowledge-synthesis.ts +48 -0
  13. package/src/core/handlers/persona-generation.ts +4 -8
  14. package/src/core/handlers/persona-response.ts +3 -4
  15. package/src/core/handlers/persona-topics.ts +2 -4
  16. package/src/core/handlers/rewrite.ts +26 -9
  17. package/src/core/handlers/rooms.ts +6 -12
  18. package/src/core/heartbeat-manager.ts +10 -0
  19. package/src/core/llm-client.ts +13 -3
  20. package/src/core/message-manager.ts +2 -4
  21. package/src/core/orchestrators/ceremony.ts +45 -22
  22. package/src/core/orchestrators/human-extraction.ts +10 -1
  23. package/src/core/processor.ts +275 -7
  24. package/src/core/queue-manager.ts +10 -0
  25. package/src/core/state-manager.ts +35 -0
  26. package/src/core/tools/builtin/fetch-memory.ts +6 -6
  27. package/src/core/tools/builtin/fetch-message.ts +27 -1
  28. package/src/core/tools/builtin/find-memory.ts +11 -3
  29. package/src/core/tools/index.ts +3 -3
  30. package/src/core/tools/types.ts +1 -1
  31. package/src/core/types/data-items.ts +1 -1
  32. package/src/core/types/entities.ts +7 -1
  33. package/src/core/types/enums.ts +1 -0
  34. package/src/core/types/integrations.ts +3 -1
  35. package/src/core/types/llm.ts +0 -9
  36. package/src/core/utils/message-id.ts +114 -0
  37. package/src/integrations/claude-code/importer.ts +12 -5
  38. package/src/integrations/cursor/importer.ts +12 -5
  39. package/src/integrations/document/importer.ts +1 -1
  40. package/src/integrations/document/unsource.ts +11 -14
  41. package/src/integrations/opencode/importer.ts +19 -6
  42. package/src/integrations/opencode/json-reader.ts +65 -0
  43. package/src/integrations/opencode/sqlite-reader.ts +33 -0
  44. package/src/integrations/opencode/types.ts +8 -0
  45. package/src/integrations/persona-history/importer.ts +9 -0
  46. package/src/prompts/ceremony/people-rewrite.ts +2 -2
  47. package/src/prompts/ceremony/topic-rewrite.ts +2 -2
  48. package/src/prompts/heartbeat/check.ts +5 -2
  49. package/src/prompts/heartbeat/ei.ts +7 -0
  50. package/src/prompts/heartbeat/types.ts +5 -0
  51. package/src/prompts/index.ts +3 -0
  52. package/src/prompts/response/sections.ts +30 -16
  53. package/src/prompts/room/sections.ts +28 -6
  54. package/src/prompts/synthesis/index.ts +101 -0
  55. package/src/prompts/synthesis/types.ts +26 -0
  56. package/src/prompts/trait-utils.ts +33 -0
  57. package/tui/README.md +2 -0
  58. package/tui/src/commands/generate.tsx +98 -0
  59. package/tui/src/commands/unsource.tsx +17 -10
  60. package/tui/src/components/GeneratedDocsOverlay.tsx +136 -0
  61. package/tui/src/components/PromptInput.tsx +2 -0
  62. package/tui/src/context/ei.tsx +49 -2
  63. package/tui/src/util/help-content.ts +11 -0
  64. package/tui/src/util/logger.ts +22 -2
  65. package/tui/src/util/provider-detection.ts +5 -2
  66. package/tui/src/util/yaml-provider.ts +2 -8
@@ -12,8 +12,7 @@ export function handlePersonaTopicRating(response: LLMResponse, state: StateMana
12
12
  const personaId = response.request.data.personaId as string;
13
13
  const personaDisplayName = response.request.data.personaDisplayName as string;
14
14
  if (!personaId || !personaDisplayName) {
15
- console.error("[handlePersonaTopicRating] Missing personaId or personaDisplayName in request data");
16
- return;
15
+ throw new Error("[handlePersonaTopicRating] Missing personaId or personaDisplayName in request data");
17
16
  }
18
17
 
19
18
  const result = response.parsed as PersonaTopicRatingResult | undefined;
@@ -35,8 +34,7 @@ export function handlePersonaTopicRating(response: LLMResponse, state: StateMana
35
34
 
36
35
  const persona = state.persona_getById(personaId);
37
36
  if (!persona) {
38
- console.error(`[handlePersonaTopicRating] Persona not found: ${personaDisplayName}`);
39
- return;
37
+ throw new Error(`[handlePersonaTopicRating] Persona not found: ${personaDisplayName}`);
40
38
  }
41
39
 
42
40
  const now = new Date().toISOString();
@@ -20,6 +20,8 @@ import { getEmbeddingService, getItemEmbeddingText } from "../embedding-service.
20
20
 
21
21
  import { searchHumanData } from "../human-data-manager.js";
22
22
 
23
+ const MIN_REWRITE_FLOOR = 750;
24
+
23
25
  /**
24
26
  * handleRewriteScan — Phase 1 of Rewrite.
25
27
  * LLM returns an array of subject strings found in the bloated item.
@@ -31,20 +33,25 @@ export async function handleRewriteScan(response: LLMResponse, state: StateManag
31
33
  const rewriteModel = response.request.data.rewriteModel as string;
32
34
 
33
35
  if (!itemId || !itemType) {
34
- console.error("[handleRewriteScan] Missing itemId or itemType in request data");
35
- return;
36
+ throw new Error("[handleRewriteScan] Missing itemId or itemType in request data");
36
37
  }
37
38
 
38
39
  const subjects = response.parsed as RewriteScanResult | undefined;
39
40
  if (!subjects || !Array.isArray(subjects) || subjects.length === 0) {
40
- console.log(`[handleRewriteScan] No extra subjects found for ${itemType} "${itemId}" — marking rewrite_checked`);
41
+ console.log(`[handleRewriteScan] No extra subjects found for ${itemType} "${itemId}" — setting rewrite_length_floor`);
41
42
  const human = state.getHuman();
42
43
  if (itemType === "topic") {
43
44
  const topic = human.topics.find(t => t.id === itemId);
44
- if (topic) state.human_topic_upsert({ ...topic, rewrite_checked: true });
45
+ if (topic) state.human_topic_upsert({
46
+ ...topic,
47
+ rewrite_length_floor: Math.max(MIN_REWRITE_FLOOR, Math.ceil((topic.description?.length ?? 0) * 1.1)),
48
+ });
45
49
  } else if (itemType === "person") {
46
50
  const person = human.people.find(p => p.id === itemId);
47
- if (person) state.human_person_upsert({ ...person, rewrite_checked: true });
51
+ if (person) state.human_person_upsert({
52
+ ...person,
53
+ rewrite_length_floor: Math.max(MIN_REWRITE_FLOOR, Math.ceil((person.description?.length ?? 0) * 1.1)),
54
+ });
48
55
  }
49
56
  return;
50
57
  }
@@ -111,8 +118,7 @@ export async function handleRewriteRewrite(response: LLMResponse, state: StateMa
111
118
  const itemType = response.request.data.itemType as RewriteItemType;
112
119
 
113
120
  if (!itemId || !itemType) {
114
- console.error("[handleRewriteRewrite] Missing itemId or itemType in request data");
115
- return;
121
+ throw new Error("[handleRewriteRewrite] Missing itemId or itemType in request data");
116
122
  }
117
123
 
118
124
  const result = response.parsed as RewriteResult | undefined;
@@ -171,6 +177,7 @@ export async function handleRewriteRewrite(response: LLMResponse, state: StateMa
171
177
  console.warn(`[handleRewriteRewrite] Failed to compute embedding for existing ${resolvedType} "${item.name}":`, err);
172
178
  }
173
179
 
180
+ const existingFloor = Math.max(MIN_REWRITE_FLOOR, Math.ceil(item.description.length * 1.1));
174
181
  switch (resolvedType) {
175
182
  case "topic": {
176
183
  const existing = human.topics.find(t => t.id === item.id)!;
@@ -181,6 +188,7 @@ export async function handleRewriteRewrite(response: LLMResponse, state: StateMa
181
188
  sentiment: item.sentiment ?? existing.sentiment,
182
189
  last_updated: now,
183
190
  embedding,
191
+ rewrite_length_floor: existingFloor,
184
192
  });
185
193
  break;
186
194
  }
@@ -193,6 +201,7 @@ export async function handleRewriteRewrite(response: LLMResponse, state: StateMa
193
201
  sentiment: item.sentiment ?? existing.sentiment,
194
202
  last_updated: now,
195
203
  embedding,
204
+ rewrite_length_floor: existingFloor,
196
205
  });
197
206
  break;
198
207
  }
@@ -216,6 +225,7 @@ export async function handleRewriteRewrite(response: LLMResponse, state: StateMa
216
225
  console.warn(`[handleRewriteRewrite] Failed to compute embedding for new ${item.type} "${item.name}":`, err);
217
226
  }
218
227
 
228
+ const newFloor = Math.max(MIN_REWRITE_FLOOR, Math.ceil(item.description.length * 1.1));
219
229
  const baseFields = {
220
230
  id: crypto.randomUUID(),
221
231
  name: item.name,
@@ -227,6 +237,7 @@ export async function handleRewriteRewrite(response: LLMResponse, state: StateMa
227
237
  persona_groups: unionGroups,
228
238
  interested_personas: unionPersonas,
229
239
  embedding,
240
+ rewrite_length_floor: newFloor,
230
241
  };
231
242
 
232
243
  switch (item.type) {
@@ -267,10 +278,16 @@ export async function handleRewriteRewrite(response: LLMResponse, state: StateMa
267
278
  const updatedHuman = state.getHuman();
268
279
  if (itemType === "topic") {
269
280
  const original = updatedHuman.topics.find(t => t.id === itemId);
270
- if (original) state.human_topic_upsert({ ...original, rewrite_checked: true });
281
+ if (original) state.human_topic_upsert({
282
+ ...original,
283
+ rewrite_length_floor: Math.max(MIN_REWRITE_FLOOR, Math.ceil((original.description?.length ?? 0) * 1.1)),
284
+ });
271
285
  } else if (itemType === "person") {
272
286
  const original = updatedHuman.people.find(p => p.id === itemId);
273
- if (original) state.human_person_upsert({ ...original, rewrite_checked: true });
287
+ if (original) state.human_person_upsert({
288
+ ...original,
289
+ rewrite_length_floor: Math.max(MIN_REWRITE_FLOOR, Math.ceil((original.description?.length ?? 0) * 1.1)),
290
+ });
274
291
  }
275
292
 
276
293
  console.log(`[handleRewriteRewrite] Complete for ${itemType} "${itemId}": ${existingCount} existing updated, ${newCount} new created`);
@@ -17,8 +17,7 @@ export function handleRoomResponse(response: LLMResponse, state: StateManager):
17
17
  const parentMessageId = response.request.data.parentMessageId as string | null ?? null;
18
18
 
19
19
  if (!roomId || !personaId) {
20
- console.error("[handleRoomResponse] Missing roomId or personaId in request data");
21
- return;
20
+ throw new Error("[handleRoomResponse] Missing roomId or personaId in request data");
22
21
  }
23
22
 
24
23
  const now = new Date().toISOString();
@@ -111,19 +110,16 @@ export async function handleRoomJudge(response: LLMResponse, state: StateManager
111
110
  const judgeDisplayName = response.request.data.judgePersonaDisplayName as string;
112
111
 
113
112
  if (!roomId) {
114
- console.error("[handleRoomJudge] Missing roomId in request data");
115
- return;
113
+ throw new Error("[handleRoomJudge] Missing roomId in request data");
116
114
  }
117
115
 
118
116
  if (!response.parsed) {
119
- console.error(`[handleRoomJudge] No parsed result from judge ${judgeDisplayName}`);
120
- return;
117
+ throw new Error(`[handleRoomJudge] No parsed result from judge ${judgeDisplayName}`);
121
118
  }
122
119
 
123
120
  const result = response.parsed as RoomJudgeResult;
124
121
  if (!result.winner_message_id) {
125
- console.error(`[handleRoomJudge] Judge ${judgeDisplayName} returned no winner_message_id`);
126
- return;
122
+ throw new Error(`[handleRoomJudge] Judge ${judgeDisplayName} returned no winner_message_id`);
127
123
  }
128
124
 
129
125
  const judgePersonaId = response.request.data.judgePersonaId as string;
@@ -131,16 +127,14 @@ export async function handleRoomJudge(response: LLMResponse, state: StateManager
131
127
  const allMessages = state.getRoomMessages(roomId);
132
128
  const winner = allMessages.find(m => m.id === result.winner_message_id);
133
129
  if (!winner) {
134
- console.error(`[handleRoomJudge] Winner message ${result.winner_message_id} not found in room ${roomId}`);
135
- return;
130
+ throw new Error(`[handleRoomJudge] Winner message ${result.winner_message_id} not found in room ${roomId}`);
136
131
  }
137
132
 
138
133
  const verdictParentId = winner.parent_id;
139
134
 
140
135
  const ok = state.setRoomActiveNode(roomId, result.winner_message_id);
141
136
  if (!ok) {
142
- console.error(`[handleRoomJudge] Could not set active node ${result.winner_message_id} in room ${roomId}`);
143
- return;
137
+ throw new Error(`[handleRoomJudge] Could not set active node ${result.winner_message_id} in room ${roomId}`);
144
138
  }
145
139
 
146
140
  const losers = allMessages
@@ -168,6 +168,16 @@ export async function queueEiHeartbeat(
168
168
  });
169
169
  }
170
170
 
171
+ // Ei's own pending reflection — separate item type so she can introspect on herself
172
+ const eiPersona = personas.find((p) => p.id === "ei");
173
+ if (eiPersona?.pending_update?.critique) {
174
+ items.push({
175
+ id: eiPersona.id,
176
+ type: "Self Reflection Alert",
177
+ critique: eiPersona.pending_update.critique,
178
+ });
179
+ }
180
+
171
181
  const personasWithPendingUpdate = personas.filter(
172
182
  (p) => !p.is_archived && !p.is_paused && !p.is_static && p.id !== "ei" && p.pending_update?.critique
173
183
  );
@@ -76,7 +76,17 @@ export interface LLMRawResponse {
76
76
 
77
77
  let llmCallCount = 0;
78
78
 
79
-
79
+ function resolveApiKey(raw: string | undefined): string {
80
+ if (!raw || !raw.startsWith("$")) return raw ?? "";
81
+ const varName = raw.slice(1);
82
+ const resolved =
83
+ (typeof Bun !== "undefined" && (Bun as { env: Record<string, string> }).env?.[varName]) ||
84
+ (typeof process !== "undefined" && process.env?.[varName]);
85
+ if (!resolved) {
86
+ throw new Error(`Provider API key references env var $${varName}, but it is not set.`);
87
+ }
88
+ return resolved;
89
+ }
80
90
 
81
91
  function isGuid(str: string): boolean {
82
92
  return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(str);
@@ -90,7 +100,7 @@ function buildResolvedModel(account: ProviderAccount, model: ModelConfig): Resol
90
100
  config: {
91
101
  name: account.name,
92
102
  baseURL: account.url,
93
- apiKey: account.api_key || "",
103
+ apiKey: resolveApiKey(account.api_key),
94
104
  },
95
105
  extraHeaders: account.extra_headers,
96
106
  };
@@ -171,7 +181,7 @@ export function resolveModel(modelSpec?: string, accounts?: ProviderAccount[]):
171
181
  config: {
172
182
  name: matchingAccount.name,
173
183
  baseURL: matchingAccount.url,
174
- apiKey: matchingAccount.api_key || "",
184
+ apiKey: resolveApiKey(matchingAccount.api_key),
175
185
  },
176
186
  extraHeaders: matchingAccount.extra_headers,
177
187
  };
@@ -255,8 +255,6 @@ export function checkAndQueueHumanExtraction(
255
255
  const unextractedPeople = sm.messages_getUnextracted(personaId, "p", undefined, "exclude");
256
256
  const peopleThreshold = Math.min(EXTRACTION_TAPER_CAP, human.people.length);
257
257
  if (unextractedPeople.length > 0 && unextractedPeople.length >= peopleThreshold) {
258
- const personaForScan = sm.persona_getById(personaId);
259
- const personScanOptions = personaForScan?.pending_update ? { reflection_progress: 1 } : undefined;
260
258
  const context: ExtractionContext = {
261
259
  personaId,
262
260
  channelDisplayName: personaDisplayName,
@@ -264,9 +262,9 @@ export function checkAndQueueHumanExtraction(
264
262
  messages_analyze: unextractedPeople,
265
263
  extraction_flag: "p",
266
264
  };
267
- queuePersonScan(context, sm, personScanOptions);
265
+ queuePersonScan(context, sm);
268
266
  console.log(
269
- `[Processor] Human Seed extraction: people (threshold: ${peopleThreshold}, unextracted: ${unextractedPeople.length}${personScanOptions ? ", reflection_progress=1" : ""})`
267
+ `[Processor] Human Seed extraction: people (threshold: ${peopleThreshold}, unextracted: ${unextractedPeople.length})`
270
268
  );
271
269
  }
272
270
  }
@@ -137,10 +137,7 @@ function queueExposurePhase(personaId: string, state: StateManager, options?: Ex
137
137
  messages_analyze: unextractedPeople,
138
138
  extraction_flag: "p",
139
139
  };
140
- const personScanOptions = persona.pending_update
141
- ? { ...options, reflection_progress: 1 }
142
- : options;
143
- queuePersonScan(context, state, personScanOptions);
140
+ queuePersonScan(context, state, options);
144
141
  }
145
142
 
146
143
  const totalUnextracted = unextractedFacts.length + unextractedTopics.length + unextractedPeople.length;
@@ -267,14 +264,6 @@ export function handleCeremonyProgress(state: StateManager, lastPhase: number):
267
264
  !p.is_static
268
265
  );
269
266
 
270
- const eiIndex = activePersonas.findIndex(p =>
271
- (p.aliases?.[0] ?? "").toLowerCase() === "ei"
272
- );
273
-
274
- // Ei's topics don't change
275
- if (eiIndex > -1) {
276
- activePersonas.splice(eiIndex, 1);
277
- }
278
267
  // Decay phase: apply decay + prune for ALL active personas
279
268
  for (const persona of activePersonas) {
280
269
  applyDecayPhase(persona.id, state);
@@ -287,7 +276,7 @@ export function handleCeremonyProgress(state: StateManager, lastPhase: number):
287
276
  // Person Rewrite phase (phase 4): scan bloated Person records, extract Topics from them.
288
277
  // Gated via ceremony_progress so Topic Rewrite can run after — Topics created here
289
278
  // need to be visible before Topic Rewrite snapshots the threshold.
290
- queuePersonRewritePhase(state);
279
+ queuePersonRewritePhase(state, { ceremonyProgress: 4 });
291
280
 
292
281
  // Zero-work guard: if no person rewrites queued, advance to topic rewrite immediately
293
282
  if (!state.queue_hasPendingCeremonies()) {
@@ -482,7 +471,7 @@ export function queueReflectionDrain(personaId: string, state: StateManager): vo
482
471
  messages_analyze: unextractedPeople,
483
472
  extraction_flag: "p",
484
473
  };
485
- queuePersonScan(context, state, { reflection_progress: 1 });
474
+ queuePersonScan(context, state);
486
475
  console.log(`[reflection:drain] Queued Person scan for ${persona.display_name} (${unextractedPeople.length} messages) — clears on completion`);
487
476
  }
488
477
 
@@ -490,7 +479,7 @@ function getRewriteModel(state: StateManager): string | undefined {
490
479
  return state.getHuman().settings?.rewrite_model;
491
480
  }
492
481
 
493
- export function queuePersonRewritePhase(state: StateManager): void {
482
+ export function queuePersonRewritePhase(state: StateManager, options?: { ceremonyProgress?: number }): void {
494
483
  const rewriteModel = getRewriteModel(state);
495
484
  if (!rewriteModel) {
496
485
  console.log("[ceremony:rewrite] rewrite_model not set — skipping person rewrite phase");
@@ -498,13 +487,30 @@ export function queuePersonRewritePhase(state: StateManager): void {
498
487
  }
499
488
 
500
489
  const human = state.getHuman();
501
- const personsToScan = human.people.filter(person => {
490
+ const allCandidates = human.people.filter(person => {
502
491
  const isPersonaLinked = (person.identifiers ?? []).some(
503
492
  i => i.type.toLowerCase() === 'ei persona'
504
493
  );
505
494
  return !isPersonaLinked
506
- && (person.description?.length ?? 0) > REWRITE_DESCRIPTION_THRESHOLD
507
- && !person.rewrite_checked;
495
+ && (person.description?.length ?? 0) > REWRITE_DESCRIPTION_THRESHOLD;
496
+ });
497
+
498
+ const alreadyChecked = allCandidates.filter(p => {
499
+ const descLen = p.description?.length ?? 0;
500
+ return p.rewrite_length_floor !== undefined && descLen < p.rewrite_length_floor;
501
+ });
502
+ if (alreadyChecked.length > 0) {
503
+ for (const person of alreadyChecked) {
504
+ console.log(
505
+ `[ceremony:rewrite] Person "${person.name}" is ${person.description?.length ?? 0} chars ` +
506
+ `(floor: ${person.rewrite_length_floor}) — already reviewed, skipping`
507
+ );
508
+ }
509
+ }
510
+
511
+ const personsToScan = allCandidates.filter(p => {
512
+ if (p.rewrite_length_floor === undefined) return true;
513
+ return (p.description?.length ?? 0) >= p.rewrite_length_floor;
508
514
  });
509
515
 
510
516
  if (personsToScan.length === 0) {
@@ -527,7 +533,7 @@ export function queuePersonRewritePhase(state: StateManager): void {
527
533
  itemId: person.id,
528
534
  itemType: "person" as RewriteItemType,
529
535
  rewriteModel,
530
- ceremony_progress: 4,
536
+ ...(options?.ceremonyProgress !== undefined && { ceremony_progress: options.ceremonyProgress }),
531
537
  },
532
538
  });
533
539
  }
@@ -543,11 +549,28 @@ export function queueTopicRewritePhase(state: StateManager): void {
543
549
  }
544
550
 
545
551
  const human = state.getHuman();
546
- const topicsToScan = human.topics.filter(topic =>
552
+ const allCandidateTopics = human.topics.filter(topic =>
547
553
  (topic.description?.length ?? 0) > REWRITE_DESCRIPTION_THRESHOLD
548
- && !topic.rewrite_checked
549
554
  );
550
555
 
556
+ const alreadyCheckedTopics = allCandidateTopics.filter(t => {
557
+ const descLen = t.description?.length ?? 0;
558
+ return t.rewrite_length_floor !== undefined && descLen < t.rewrite_length_floor;
559
+ });
560
+ if (alreadyCheckedTopics.length > 0) {
561
+ for (const topic of alreadyCheckedTopics) {
562
+ console.log(
563
+ `[ceremony:rewrite] Topic "${topic.name}" is ${topic.description?.length ?? 0} chars ` +
564
+ `(floor: ${topic.rewrite_length_floor}) — already reviewed, skipping`
565
+ );
566
+ }
567
+ }
568
+
569
+ const topicsToScan = allCandidateTopics.filter(t => {
570
+ if (t.rewrite_length_floor === undefined) return true;
571
+ return (t.description?.length ?? 0) >= t.rewrite_length_floor;
572
+ });
573
+
551
574
  if (topicsToScan.length === 0) {
552
575
  console.log("[ceremony:rewrite] No topics above threshold — skipping topic rewrite phase");
553
576
  return;
@@ -592,7 +615,7 @@ function queueEventSummaryForAll(state: StateManager, options?: ExtractionOption
592
615
 
593
616
  function queueReflectionPhase(state: StateManager): void {
594
617
  const personas = state.persona_getAll().filter(p =>
595
- !p.is_paused && !p.is_archived && !p.is_static && p.id !== "ei"
618
+ !p.is_paused && !p.is_archived && !p.is_static
596
619
  );
597
620
 
598
621
  let queued = 0;
@@ -195,6 +195,15 @@ export function queuePersonScan(context: ExtractionContext, state: StateManager,
195
195
 
196
196
  if (chunks.length === 0) return 0;
197
197
 
198
+ // If the persona has a pending_update (reflection in progress), gate person
199
+ // scans so handleHumanPersonScan won't queue updates for persona-linked people.
200
+ // This prevents importers and other callers from bypassing the reflection lock
201
+ // — they don't know about pending_update, so we enforce it here centrally.
202
+ const persona = state.persona_getById(context.personaId);
203
+ const effectiveOptions: ExtractionOptions | undefined = persona?.pending_update
204
+ ? { ...options, reflection_progress: 1 }
205
+ : options;
206
+
198
207
  // Pre-mark messages before enqueuing — prevents duplicate scans if the
199
208
  // queue check fires again during LLM latency (100ms loop × 5s call = 50 dupes)
200
209
  for (const chunk of chunks) {
@@ -225,7 +234,7 @@ export function queuePersonScan(context: ExtractionContext, state: StateManager,
225
234
  user: prompt.user,
226
235
  next_step: LLMNextStep.HandleHumanPersonScan,
227
236
  data: {
228
- ...options,
237
+ ...effectiveOptions,
229
238
  personaId: chunk.personaId,
230
239
  personaDisplayName: chunk.channelDisplayName,
231
240
  analyze_from_timestamp: getAnalyzeFromTimestamp(chunk),