ei-tui 1.0.1 → 1.2.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 (61) hide show
  1. package/README.md +3 -1
  2. package/package.json +2 -21
  3. package/src/cli/README.md +42 -14
  4. package/src/cli/mcp.ts +237 -0
  5. package/src/cli.ts +17 -51
  6. package/src/core/handlers/dedup.ts +4 -15
  7. package/src/core/handlers/document-segmentation.ts +2 -3
  8. package/src/core/handlers/heartbeat.ts +5 -10
  9. package/src/core/handlers/human-extraction.ts +6 -0
  10. package/src/core/handlers/human-matching.ts +53 -10
  11. package/src/core/handlers/index.ts +2 -0
  12. package/src/core/handlers/knowledge-synthesis.ts +50 -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/llm-client.ts +53 -7
  19. package/src/core/message-manager.ts +2 -4
  20. package/src/core/orchestrators/ceremony.ts +44 -13
  21. package/src/core/orchestrators/human-extraction.ts +38 -1
  22. package/src/core/orchestrators/index.ts +1 -0
  23. package/src/core/processor.ts +192 -41
  24. package/src/core/prompt-context-builder.ts +1 -0
  25. package/src/core/queue-manager.ts +10 -0
  26. package/src/core/queue-processor.ts +13 -4
  27. package/src/core/state-manager.ts +35 -0
  28. package/src/core/tools/builtin/fetch-memory.ts +92 -0
  29. package/src/core/tools/builtin/fetch-message.ts +123 -0
  30. package/src/core/tools/builtin/find-memory.ts +99 -0
  31. package/src/core/tools/index.ts +88 -5
  32. package/src/core/tools/types.ts +1 -1
  33. package/src/core/types/data-items.ts +1 -1
  34. package/src/core/types/entities.ts +7 -1
  35. package/src/core/types/enums.ts +1 -0
  36. package/src/core/types/integrations.ts +3 -1
  37. package/src/integrations/claude-code/importer.ts +6 -0
  38. package/src/integrations/cursor/importer.ts +6 -0
  39. package/src/integrations/document/unsource.ts +5 -3
  40. package/src/integrations/opencode/importer.ts +13 -1
  41. package/src/integrations/persona-history/importer.ts +12 -1
  42. package/src/prompts/ceremony/dedup.ts +3 -3
  43. package/src/prompts/ceremony/people-rewrite.ts +2 -2
  44. package/src/prompts/ceremony/topic-rewrite.ts +2 -2
  45. package/src/prompts/ceremony/types.ts +1 -1
  46. package/src/prompts/human/person-scan.ts +17 -0
  47. package/src/prompts/human/types.ts +4 -0
  48. package/src/prompts/index.ts +3 -0
  49. package/src/prompts/response/sections.ts +14 -7
  50. package/src/prompts/response/types.ts +1 -0
  51. package/src/prompts/synthesis/index.ts +101 -0
  52. package/src/prompts/synthesis/types.ts +26 -0
  53. package/tui/src/commands/generate.tsx +98 -0
  54. package/tui/src/commands/unsource.tsx +17 -10
  55. package/tui/src/components/GeneratedDocsOverlay.tsx +136 -0
  56. package/tui/src/components/PromptInput.tsx +2 -0
  57. package/tui/src/context/ei.tsx +49 -2
  58. package/tui/src/util/logger.ts +22 -2
  59. package/tui/src/util/provider-detection.ts +5 -2
  60. package/tui/src/util/yaml-provider.ts +2 -8
  61. package/src/core/tools/builtin/read-memory.ts +0 -70
@@ -85,6 +85,10 @@ export async function handleTopicUpdate(response: LLMResponse, state: StateManag
85
85
  const primaryId = personaIds[0] ?? personaId;
86
86
 
87
87
  const now = new Date().toISOString();
88
+ const { messages_analyze } = resolveMessageWindow(response, state);
89
+ const earliestMessageTimestamp = messages_analyze.length > 0
90
+ ? messages_analyze.reduce((a, b) => a.timestamp < b.timestamp ? a : b).timestamp
91
+ : now;
88
92
  const human = state.getHuman();
89
93
 
90
94
  const resolveItemId = (): string => {
@@ -135,6 +139,8 @@ export async function handleTopicUpdate(response: LLMResponse, state: StateManag
135
139
  ? incomingSources
136
140
  : [...new Set([...(existingTopic?.sources ?? []), ...incomingSources])];
137
141
 
142
+ const newDescLen = resolvedDescription?.length ?? 0;
143
+ const existingFloor = existingTopic?.rewrite_length_floor;
138
144
  const topic: Topic = {
139
145
  id: itemId,
140
146
  name: resolvedName,
@@ -144,7 +150,7 @@ export async function handleTopicUpdate(response: LLMResponse, state: StateManag
144
150
  exposure_current: calculateExposureCurrent(exposureImpact, existingTopic?.exposure_current ?? 0),
145
151
  exposure_desired: result.exposure_desired ?? 0.5,
146
152
  last_updated: now,
147
- learned_on: isNewItem ? now : existingTopic?.learned_on,
153
+ learned_on: isNewItem ? earliestMessageTimestamp : existingTopic?.learned_on,
148
154
  last_mentioned: now,
149
155
  learned_by: isNewItem ? primaryId : existingTopic?.learned_by,
150
156
  last_changed_by: primaryId,
@@ -152,6 +158,7 @@ export async function handleTopicUpdate(response: LLMResponse, state: StateManag
152
158
  sources: sources.length > 0 ? sources : undefined,
153
159
  persona_groups: personaGroupsMerged,
154
160
  embedding,
161
+ rewrite_length_floor: existingFloor !== undefined && newDescLen < existingFloor ? existingFloor : undefined,
155
162
  };
156
163
  state.human_topic_upsert(topic);
157
164
 
@@ -168,6 +175,26 @@ export async function handleTopicUpdate(response: LLMResponse, state: StateManag
168
175
  console.log(`[handleTopicUpdate] ${isNewItem ? "Created" : "Updated"} topic "${resolvedName}"`);
169
176
  }
170
177
 
178
+ function ensureEiPersonaHasNickname(identifiers: PersonIdentifier[], state: StateManager): PersonIdentifier[] {
179
+ const eiPersonaId = identifiers.find(i => i.type === 'Ei Persona')?.value;
180
+ if (!eiPersonaId) return identifiers;
181
+
182
+ const persona = state.persona_getById(eiPersonaId);
183
+ if (!persona) return identifiers;
184
+
185
+ const hasNickname = identifiers.some(i => i.type === 'Nickname' && i.value === persona.display_name);
186
+ if (hasNickname) return identifiers;
187
+
188
+ const withoutPrimary = identifiers.map(i =>
189
+ i.type === 'Ei Persona' ? { ...i, is_primary: undefined } : i
190
+ ).map(({ is_primary, ...rest }) => is_primary ? { ...rest, is_primary } : rest);
191
+
192
+ return [
193
+ { type: 'Nickname', value: persona.display_name, is_primary: true as const },
194
+ ...withoutPrimary.map(i => i.type === 'Ei Persona' ? { type: i.type, value: i.value } : i),
195
+ ];
196
+ }
197
+
171
198
  export async function handlePersonUpdate(response: LLMResponse, state: StateManager): Promise<void> {
172
199
  const result = response.parsed as (PersonUpdateResult & {
173
200
  identifiers?: PersonIdentifier[];
@@ -194,6 +221,10 @@ export async function handlePersonUpdate(response: LLMResponse, state: StateMana
194
221
  const primaryId = personaIds[0] ?? personaId;
195
222
 
196
223
  const now = new Date().toISOString();
224
+ const { messages_analyze } = resolveMessageWindow(response, state);
225
+ const earliestMessageTimestamp = messages_analyze.length > 0
226
+ ? messages_analyze.reduce((a, b) => a.timestamp < b.timestamp ? a : b).timestamp
227
+ : now;
197
228
  const human = state.getHuman();
198
229
 
199
230
  const resolveItemId = (): string => {
@@ -264,7 +295,7 @@ export async function handlePersonUpdate(response: LLMResponse, state: StateMana
264
295
  deduped.push(id);
265
296
  }
266
297
  }
267
- resolvedIdentifiers = deduped;
298
+ resolvedIdentifiers = ensureEiPersonaHasNickname(deduped, state);
268
299
  } else {
269
300
  const base = [...(existingPerson?.identifiers ?? [])];
270
301
  const sanitizedToAdd = sanitizeEiPersonaIdentifiers(
@@ -279,12 +310,16 @@ export async function handlePersonUpdate(response: LLMResponse, state: StateMana
279
310
  base.push({ type: id.type, value: id.value, ...(id.is_primary ? { is_primary: id.is_primary } : {}) });
280
311
  }
281
312
  }
282
- resolvedIdentifiers = base;
313
+ resolvedIdentifiers = ensureEiPersonaHasNickname(base, state);
283
314
  }
284
315
 
316
+ const personName = resolvedIdentifiers.find(i => i.is_primary && i.type !== 'Ei Persona')?.value
317
+ ?? resolvedIdentifiers.find(i => i.type !== 'Ei Persona')?.value
318
+ ?? candidateName;
319
+
285
320
  const person: Person = {
286
321
  id: itemId,
287
- name: candidateName,
322
+ name: personName,
288
323
  description: resolvedDescription,
289
324
  sentiment: resolvedSentiment,
290
325
  relationship: result.relationship ?? candidateRelationship ?? existingPerson?.relationship ?? "Unknown",
@@ -293,7 +328,7 @@ export async function handlePersonUpdate(response: LLMResponse, state: StateMana
293
328
  identifiers: resolvedIdentifiers,
294
329
  validated_date: isNewItem ? '' : (existingPerson?.validated_date ?? ''),
295
330
  last_updated: now,
296
- learned_on: isNewItem ? now : existingPerson?.learned_on,
331
+ learned_on: isNewItem ? earliestMessageTimestamp : existingPerson?.learned_on,
297
332
  last_mentioned: now,
298
333
  learned_by: isNewItem ? primaryId : existingPerson?.learned_by,
299
334
  last_changed_by: primaryId,
@@ -301,6 +336,11 @@ export async function handlePersonUpdate(response: LLMResponse, state: StateMana
301
336
  sources: personSources.length > 0 ? personSources : undefined,
302
337
  persona_groups: personaGroupsMerged,
303
338
  embedding,
339
+ rewrite_length_floor: (() => {
340
+ const floor = existingPerson?.rewrite_length_floor;
341
+ const newLen = resolvedDescription?.length ?? 0;
342
+ return floor !== undefined && newLen < floor ? floor : undefined;
343
+ })(),
304
344
  };
305
345
  state.human_person_upsert(person);
306
346
 
@@ -323,14 +363,13 @@ function normalizeText(text: string): string {
323
363
  .replace(/[\u2018\u2019\u0060\u00B4]/g, "'") // curly single, backtick, acute accent
324
364
  .replace(/[\u2014\u2013\u2012]/g, '-') // em-dash, en-dash, figure dash
325
365
  .replace(/\u00A0/g, ' ') // non-breaking space
326
- .replace(/[\u2000-\u200F]/g, ' '); // unicode space variants
366
+ .replace(/[\u2000-\u200F]/g, ' ') // unicode space variants
367
+ .replace(/[*_`~]/g, ''); // Markdown emphasis/code chars
327
368
  }
328
369
 
329
370
  function stripPunctuation(text: string): string {
330
- // Remove characters LLMs commonly mangle, keep spaces and alphanumeric
331
- // Strip: punctuation, unicode punctuation variants, curly quotes, dashes, etc.
332
- // Keep: letters, digits, spaces
333
371
  return text
372
+ .replace(/[*_`~]/g, ' ') // Markdown chars (kept by \w, must strip explicitly)
334
373
  .replace(/[^\w\s]/gu, ' ') // replace non-word, non-space with space
335
374
  .replace(/\s+/g, ' ') // collapse multiple spaces
336
375
  .trim()
@@ -406,6 +445,10 @@ async function validateAndStoreQuotes(
406
445
  if (!candidates || candidates.length === 0) return;
407
446
 
408
447
  for (const candidate of candidates) {
448
+ if (!candidate.text) {
449
+ console.warn('[extraction] Skipping quote candidate with missing text field');
450
+ continue;
451
+ }
409
452
  let found = false;
410
453
  for (const message of messages) {
411
454
  const msgText = getMessageText(message);
@@ -511,7 +554,7 @@ async function validateAndStoreQuotes(
511
554
  break;
512
555
  }
513
556
  if (!found) {
514
- console.warn(`[extraction] Quote not found in messages (both levels), skipping: "${candidate.text?.slice(0, 50)}..."`);
557
+ console.warn(`[extraction] Quote not found in messages (both levels), skipping: "${candidate.text}"`);
515
558
  }
516
559
  }
517
560
  }
@@ -16,6 +16,7 @@ import { handleDedupCurate } from "./dedup.js";
16
16
  import { handleRoomResponse, handleRoomJudge } from "./rooms.js";
17
17
  import { handlePersonaPreview } from "./persona-preview.js";
18
18
  import { handleDocumentSegmentation } from "./document-segmentation.js";
19
+ import { handleKnowledgeSynthesis } from "./knowledge-synthesis.js";
19
20
 
20
21
  export const handlers: Record<LLMNextStep, ResponseHandler> = {
21
22
  handlePersonaResponse,
@@ -43,4 +44,5 @@ export const handlers: Record<LLMNextStep, ResponseHandler> = {
43
44
  [LLMNextStep.HandleTopicValidate]: handleDedupCurate,
44
45
  [LLMNextStep.HandleReflectionCritic]: handleReflectionCritic,
45
46
  [LLMNextStep.HandleDocumentSegmentation]: handleDocumentSegmentation,
47
+ [LLMNextStep.HandleKnowledgeSynthesis]: handleKnowledgeSynthesis,
46
48
  };
@@ -0,0 +1,50 @@
1
+ import { ContextStatus } from "../types.js";
2
+ import type { LLMResponse, Message } from "../types.js";
3
+ import type { StateManager } from "../state-manager.js";
4
+
5
+ export function handleKnowledgeSynthesis(response: LLMResponse, state: StateManager): void {
6
+ const { slug, subject } = response.request.data as {
7
+ slug: string;
8
+ subject: string;
9
+ };
10
+
11
+ if (!slug || !subject) {
12
+ throw new Error("[handleKnowledgeSynthesis] Missing slug or subject in request data");
13
+ }
14
+
15
+ const content = response.content?.trim() ?? "";
16
+ if (!content) {
17
+ throw new Error(`[handleKnowledgeSynthesis] Empty or null response content for slug "${slug}"`);
18
+ }
19
+
20
+ const now = new Date().toISOString();
21
+ const sourceTag = `generate:document:${slug}`;
22
+
23
+ const message: Message = {
24
+ id: crypto.randomUUID(),
25
+ role: "system",
26
+ content,
27
+ timestamp: now,
28
+ read: true,
29
+ context_status: ContextStatus.Always,
30
+ external: true,
31
+ source_tag: sourceTag,
32
+ };
33
+
34
+ state.messages_append("emmet", message);
35
+
36
+ const updatedHuman = state.getHuman();
37
+ state.setHuman({
38
+ ...updatedHuman,
39
+ settings: {
40
+ ...updatedHuman.settings,
41
+ document: {
42
+ ...updatedHuman.settings?.document,
43
+ processed_documents: {
44
+ ...(updatedHuman.settings?.document?.processed_documents ?? {}),
45
+ [slug]: { created_at: now, type: "generated", subject },
46
+ },
47
+ },
48
+ },
49
+ });
50
+ }
@@ -12,8 +12,7 @@ export function handlePersonaGeneration(response: LLMResponse, state: StateManag
12
12
  const personaId = response.request.data.personaId as string;
13
13
  const personaDisplayName = response.request.data.personaDisplayName as string;
14
14
  if (!personaId) {
15
- console.error("[handlePersonaGeneration] No personaId in request data");
16
- return;
15
+ throw new Error("[handlePersonaGeneration] No personaId in request data");
17
16
  }
18
17
 
19
18
  const result = response.parsed as PersonaGenerationResult | undefined;
@@ -115,14 +114,12 @@ export function handlePersonaTraitExtraction(response: LLMResponse, state: State
115
114
  const personaId = response.request.data.personaId as string;
116
115
  const personaDisplayName = response.request.data.personaDisplayName as string;
117
116
  if (!personaId) {
118
- console.error("[handlePersonaTraitExtraction] No personaId in request data");
119
- return;
117
+ throw new Error("[handlePersonaTraitExtraction] No personaId in request data");
120
118
  }
121
119
 
122
120
  const result = response.parsed as TraitResult[] | undefined;
123
121
  if (!result || !Array.isArray(result)) {
124
- console.error("[handlePersonaTraitExtraction] Invalid parsed result");
125
- return;
122
+ throw new Error("[handlePersonaTraitExtraction] Invalid parsed result");
126
123
  }
127
124
 
128
125
  if (result.length === 0) {
@@ -131,8 +128,7 @@ export function handlePersonaTraitExtraction(response: LLMResponse, state: State
131
128
 
132
129
  const persona = state.persona_getById(personaId);
133
130
  if (!persona) {
134
- console.error(`[handlePersonaTraitExtraction] Persona ${personaId} not found`);
135
- return;
131
+ throw new Error(`[handlePersonaTraitExtraction] Persona ${personaId} not found`);
136
132
  }
137
133
 
138
134
  const now = new Date().toISOString();
@@ -15,8 +15,7 @@ export function handlePersonaResponse(response: LLMResponse, state: StateManager
15
15
  const personaId = response.request.data.personaId as string;
16
16
  const personaDisplayName = response.request.data.personaDisplayName as string;
17
17
  if (!personaId) {
18
- console.error("[handlePersonaResponse] No personaId in request data");
19
- return;
18
+ throw new Error("[handlePersonaResponse] No personaId in request data");
20
19
  }
21
20
 
22
21
  state.messages_markPendingAsRead(personaId);
@@ -108,7 +107,7 @@ export function handleToolContinuation(response: LLMResponse, state: StateManage
108
107
  const originalStep = response.request.data.originalNextStep as LLMNextStep | undefined;
109
108
 
110
109
  if (!originalStep) {
111
- console.error(`[handleToolContinuation] No originalNextStep in data, falling back to handlePersonaResponse`);
110
+ console.warn(`[handleToolContinuation] No originalNextStep in data, falling back to handlePersonaResponse`);
112
111
  handlePersonaResponse(response, state);
113
112
  return;
114
113
  }
@@ -118,7 +117,7 @@ export function handleToolContinuation(response: LLMResponse, state: StateManage
118
117
  const handler = handlers[originalStep];
119
118
 
120
119
  if (!handler) {
121
- console.error(`[handleToolContinuation] No handler found for ${originalStep}, falling back to handlePersonaResponse`);
120
+ console.warn(`[handleToolContinuation] No handler found for ${originalStep}, falling back to handlePersonaResponse`);
122
121
  handlePersonaResponse(response, state);
123
122
  return;
124
123
  }
@@ -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
@@ -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
  };
@@ -450,6 +460,7 @@ const JSON_REPAIR_PATTERNS: Array<{ pattern: RegExp; replacement: string }> = [
450
460
  { pattern: /:\s*(\d{4}-\d{2}-\d{2}T[^"}\],\n]+)/g, replacement: ': "$1"' },
451
461
  { pattern: /:\s*0([1-9][0-9]*)([,\s\n\r\]}])/g, replacement: ": 0.$1$2" },
452
462
  { pattern: /,(\s*[\]}])/g, replacement: "$1" },
463
+ { pattern: /"(\s*\n[ \t]+"[a-zA-Z_][a-zA-Z0-9_]*"\s*:)/g, replacement: '",$1' },
453
464
  ];
454
465
 
455
466
  export function repairJSON(jsonStr: string): string {
@@ -529,6 +540,41 @@ export function rescueGemmaToolCalls(content: string): unknown[] {
529
540
  return rescued;
530
541
  }
531
542
 
543
+ function findOutermostObject(str: string): string | null {
544
+ const start = str.indexOf('{');
545
+ if (start === -1) return null;
546
+
547
+ let depth = 0;
548
+ let inString = false;
549
+ let escaped = false;
550
+
551
+ for (let i = start; i < str.length; i++) {
552
+ const ch = str[i];
553
+
554
+ if (escaped) {
555
+ escaped = false;
556
+ continue;
557
+ }
558
+ if (ch === '\\' && inString) {
559
+ escaped = true;
560
+ continue;
561
+ }
562
+ if (ch === '"') {
563
+ inString = !inString;
564
+ continue;
565
+ }
566
+ if (inString) continue;
567
+
568
+ if (ch === '{') depth++;
569
+ else if (ch === '}') {
570
+ depth--;
571
+ if (depth === 0) return str.slice(start, i + 1);
572
+ }
573
+ }
574
+
575
+ return null;
576
+ }
577
+
532
578
  export function parseJSONResponse(content: string): unknown {
533
579
  const jsonMatch = content.match(/```(?:json)?\s*([\s\S]*?)```/);
534
580
  const jsonStr = jsonMatch ? jsonMatch[1].trim() : content.trim();
@@ -541,10 +587,10 @@ export function parseJSONResponse(content: string): unknown {
541
587
  return JSON.parse(repaired);
542
588
  } catch {
543
589
  // Last resort: extract the outermost {...} block from mixed prose/JSON content.
544
- // Handles 'thinking prose...\n{...json...}' responses from extended-thinking models.
545
- const outerMatch = jsonStr.match(/\{[\s\S]*\}/);
546
- if (outerMatch) {
547
- const extracted = outerMatch[0];
590
+ // Bracket-depth scan (not greedy regex) stops at the first valid close so extra
591
+ // trailing braces from models like Gemma are excluded from the extracted slice.
592
+ const extracted = findOutermostObject(jsonStr);
593
+ if (extracted) {
548
594
  try {
549
595
  return JSON.parse(extracted);
550
596
  } catch {
@@ -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
  }