ei-tui 1.0.0 → 1.1.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.
@@ -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 => {
@@ -144,7 +148,7 @@ export async function handleTopicUpdate(response: LLMResponse, state: StateManag
144
148
  exposure_current: calculateExposureCurrent(exposureImpact, existingTopic?.exposure_current ?? 0),
145
149
  exposure_desired: result.exposure_desired ?? 0.5,
146
150
  last_updated: now,
147
- learned_on: isNewItem ? now : existingTopic?.learned_on,
151
+ learned_on: isNewItem ? earliestMessageTimestamp : existingTopic?.learned_on,
148
152
  last_mentioned: now,
149
153
  learned_by: isNewItem ? primaryId : existingTopic?.learned_by,
150
154
  last_changed_by: primaryId,
@@ -168,6 +172,26 @@ export async function handleTopicUpdate(response: LLMResponse, state: StateManag
168
172
  console.log(`[handleTopicUpdate] ${isNewItem ? "Created" : "Updated"} topic "${resolvedName}"`);
169
173
  }
170
174
 
175
+ function ensureEiPersonaHasNickname(identifiers: PersonIdentifier[], state: StateManager): PersonIdentifier[] {
176
+ const eiPersonaId = identifiers.find(i => i.type === 'Ei Persona')?.value;
177
+ if (!eiPersonaId) return identifiers;
178
+
179
+ const persona = state.persona_getById(eiPersonaId);
180
+ if (!persona) return identifiers;
181
+
182
+ const hasNickname = identifiers.some(i => i.type === 'Nickname' && i.value === persona.display_name);
183
+ if (hasNickname) return identifiers;
184
+
185
+ const withoutPrimary = identifiers.map(i =>
186
+ i.type === 'Ei Persona' ? { ...i, is_primary: undefined } : i
187
+ ).map(({ is_primary, ...rest }) => is_primary ? { ...rest, is_primary } : rest);
188
+
189
+ return [
190
+ { type: 'Nickname', value: persona.display_name, is_primary: true as const },
191
+ ...withoutPrimary.map(i => i.type === 'Ei Persona' ? { type: i.type, value: i.value } : i),
192
+ ];
193
+ }
194
+
171
195
  export async function handlePersonUpdate(response: LLMResponse, state: StateManager): Promise<void> {
172
196
  const result = response.parsed as (PersonUpdateResult & {
173
197
  identifiers?: PersonIdentifier[];
@@ -194,6 +218,10 @@ export async function handlePersonUpdate(response: LLMResponse, state: StateMana
194
218
  const primaryId = personaIds[0] ?? personaId;
195
219
 
196
220
  const now = new Date().toISOString();
221
+ const { messages_analyze } = resolveMessageWindow(response, state);
222
+ const earliestMessageTimestamp = messages_analyze.length > 0
223
+ ? messages_analyze.reduce((a, b) => a.timestamp < b.timestamp ? a : b).timestamp
224
+ : now;
197
225
  const human = state.getHuman();
198
226
 
199
227
  const resolveItemId = (): string => {
@@ -264,7 +292,7 @@ export async function handlePersonUpdate(response: LLMResponse, state: StateMana
264
292
  deduped.push(id);
265
293
  }
266
294
  }
267
- resolvedIdentifiers = deduped;
295
+ resolvedIdentifiers = ensureEiPersonaHasNickname(deduped, state);
268
296
  } else {
269
297
  const base = [...(existingPerson?.identifiers ?? [])];
270
298
  const sanitizedToAdd = sanitizeEiPersonaIdentifiers(
@@ -279,12 +307,16 @@ export async function handlePersonUpdate(response: LLMResponse, state: StateMana
279
307
  base.push({ type: id.type, value: id.value, ...(id.is_primary ? { is_primary: id.is_primary } : {}) });
280
308
  }
281
309
  }
282
- resolvedIdentifiers = base;
310
+ resolvedIdentifiers = ensureEiPersonaHasNickname(base, state);
283
311
  }
284
312
 
313
+ const personName = resolvedIdentifiers.find(i => i.is_primary && i.type !== 'Ei Persona')?.value
314
+ ?? resolvedIdentifiers.find(i => i.type !== 'Ei Persona')?.value
315
+ ?? candidateName;
316
+
285
317
  const person: Person = {
286
318
  id: itemId,
287
- name: candidateName,
319
+ name: personName,
288
320
  description: resolvedDescription,
289
321
  sentiment: resolvedSentiment,
290
322
  relationship: result.relationship ?? candidateRelationship ?? existingPerson?.relationship ?? "Unknown",
@@ -293,7 +325,7 @@ export async function handlePersonUpdate(response: LLMResponse, state: StateMana
293
325
  identifiers: resolvedIdentifiers,
294
326
  validated_date: isNewItem ? '' : (existingPerson?.validated_date ?? ''),
295
327
  last_updated: now,
296
- learned_on: isNewItem ? now : existingPerson?.learned_on,
328
+ learned_on: isNewItem ? earliestMessageTimestamp : existingPerson?.learned_on,
297
329
  last_mentioned: now,
298
330
  learned_by: isNewItem ? primaryId : existingPerson?.learned_by,
299
331
  last_changed_by: primaryId,
@@ -323,14 +355,13 @@ function normalizeText(text: string): string {
323
355
  .replace(/[\u2018\u2019\u0060\u00B4]/g, "'") // curly single, backtick, acute accent
324
356
  .replace(/[\u2014\u2013\u2012]/g, '-') // em-dash, en-dash, figure dash
325
357
  .replace(/\u00A0/g, ' ') // non-breaking space
326
- .replace(/[\u2000-\u200F]/g, ' '); // unicode space variants
358
+ .replace(/[\u2000-\u200F]/g, ' ') // unicode space variants
359
+ .replace(/[*_`~]/g, ''); // Markdown emphasis/code chars
327
360
  }
328
361
 
329
362
  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
363
  return text
364
+ .replace(/[*_`~]/g, ' ') // Markdown chars (kept by \w, must strip explicitly)
334
365
  .replace(/[^\w\s]/gu, ' ') // replace non-word, non-space with space
335
366
  .replace(/\s+/g, ' ') // collapse multiple spaces
336
367
  .trim()
@@ -406,6 +437,10 @@ async function validateAndStoreQuotes(
406
437
  if (!candidates || candidates.length === 0) return;
407
438
 
408
439
  for (const candidate of candidates) {
440
+ if (!candidate.text) {
441
+ console.warn('[extraction] Skipping quote candidate with missing text field');
442
+ continue;
443
+ }
409
444
  let found = false;
410
445
  for (const message of messages) {
411
446
  const msgText = getMessageText(message);
@@ -511,7 +546,7 @@ async function validateAndStoreQuotes(
511
546
  break;
512
547
  }
513
548
  if (!found) {
514
- console.warn(`[extraction] Quote not found in messages (both levels), skipping: "${candidate.text?.slice(0, 50)}..."`);
549
+ console.warn(`[extraction] Quote not found in messages (both levels), skipping: "${candidate.text}"`);
515
550
  }
516
551
  }
517
552
  }
@@ -2,6 +2,36 @@ import type { ChatMessage, ProviderAccount, ModelConfig } from "./types.js";
2
2
  const DEFAULT_TOKEN_LIMIT = 8192;
3
3
  const DEFAULT_MAX_OUTPUT_TOKENS = 8000;
4
4
 
5
+ // Lazy verbose network dump — only active when EI_DEBUG_NETWORK_VERBOSE=1.
6
+ // Uses dynamic import so the web bundle never pulls in node:fs.
7
+ async function writeNetworkDump(
8
+ callNumber: number,
9
+ nextStep: string,
10
+ meta: { model: string; provider: string; latency_ms: number; status_code: number; tokens_in: number; tokens_out: number },
11
+ request: unknown,
12
+ response: unknown
13
+ ): Promise<void> {
14
+ const dataPath = (typeof process !== "undefined" && process.env?.EI_DATA_PATH) ||
15
+ (typeof Bun !== "undefined" && (Bun as Record<string, unknown>).env && ((Bun as { env: Record<string, string> }).env.EI_DATA_PATH));
16
+ if (!dataPath) return;
17
+
18
+ try {
19
+ const { mkdirSync, writeFileSync } = await import("node:fs");
20
+ const { join } = await import("node:path");
21
+ const logsDir = join(dataPath as string, "logs");
22
+ mkdirSync(logsDir, { recursive: true });
23
+
24
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
25
+ const safeName = nextStep.replace(/[^a-zA-Z0-9_-]/g, "_");
26
+ const filename = join(logsDir, `${timestamp}_call${callNumber}_${safeName}.json`);
27
+
28
+ const payload = JSON.stringify({ meta, request, response }, null, 2);
29
+ writeFileSync(filename, payload);
30
+ } catch {
31
+ // Silent — verbose dump failures must never crash the main path
32
+ }
33
+ }
34
+
5
35
  export interface ProviderConfig {
6
36
  baseURL: string;
7
37
  apiKey: string;
@@ -22,6 +52,8 @@ export interface LLMCallOptions {
22
52
  tools?: Record<string, unknown>[];
23
53
  /** Fire-and-forget callback invoked after a successful response to increment usage counters. */
24
54
  onUsageUpdate?: (modelId: string, usage: { calls: number; tokens_in: number; tokens_out: number }) => void;
55
+ /** Queue step name passed through to EI_DEBUG_NETWORK_VERBOSE file dumps. */
56
+ nextStep?: string;
25
57
  }
26
58
 
27
59
  export interface LLMRawResponse {
@@ -212,7 +244,7 @@ function logTokenLimit(model: string, source: string, tokens: number): void {
212
244
  if (source === "default") {
213
245
  console.warn(`[TokenLimit] Unknown model "${model}" — using conservative default (${DEFAULT_TOKEN_LIMIT})`);
214
246
  } else {
215
- console.log(`[TokenLimit] ${model}: ${source} → ${tokens} tokens (extraction budget: ${budget})`);
247
+ console.debug(`[TokenLimit] ${model}: ${source} → ${tokens} tokens (extraction budget: ${budget})`);
216
248
  }
217
249
  }
218
250
 
@@ -226,7 +258,7 @@ export async function callLLMRaw(
226
258
  ): Promise<LLMRawResponse> {
227
259
  llmCallCount++;
228
260
 
229
- const { signal, temperature = 0.7, onUsageUpdate } = options;
261
+ const { signal, temperature = 0.7, onUsageUpdate, nextStep = "unknown" } = options;
230
262
 
231
263
  if (signal?.aborted) {
232
264
  throw new Error("LLM call aborted");
@@ -251,7 +283,9 @@ export async function callLLMRaw(
251
283
 
252
284
  const totalChars = finalMessages.reduce((sum, m) => sum + (m.content?.length ?? 0), 0);
253
285
  const estimatedTokens = Math.ceil(totalChars / 4);
254
- console.log(`[LLM] Call #${llmCallCount} - ~${estimatedTokens} tokens (${totalChars} chars)`);
286
+ const modelLabel = model ?? "default";
287
+ console.log(`[LLM] Call #${llmCallCount} — ${config.name}:${modelLabel}, ~${estimatedTokens} tokens est.`);
288
+ const _llmCallStart = Date.now();
255
289
 
256
290
  const normalizedBaseURL = config.baseURL.replace(/\/+$/, "");
257
291
 
@@ -275,14 +309,15 @@ export async function callLLMRaw(
275
309
 
276
310
  if (modelConfig?.thinking_budget !== undefined) {
277
311
  if (modelConfig.thinking_budget === 0) {
278
- // Universal kill switch works on Ollama, LM Studio, and all OpenAI-compat providers.
279
- requestBody.reasoning_effort = "none";
312
+ // Universal kill switch across all known providers. Non-conflicting each reads
313
+ // whichever field it understands and ignores the rest.
314
+ requestBody.reasoning_effort = "none"; // Ollama, OpenAI-compat
315
+ requestBody.enable_thinking = false; // Rapid-MLX
280
316
  } else {
281
- // Pass both signals: providers that honor the token budget get it (Qwen3 via Ollama,
282
- // Anthropic), providers that reduce thinking to on/off use reasoning_effort as the
283
- // on-signal (Gemma4 via Ollama/LM Studio). Non-conflicting — each provider reads
284
- // whichever field it understands.
317
+ // Pass all on-signals: providers that honor the token budget get it (Qwen3, Anthropic),
318
+ // providers that reduce thinking to on/off use reasoning_effort or enable_thinking.
285
319
  requestBody.reasoning_effort = "high";
320
+ requestBody.enable_thinking = true;
286
321
  requestBody.think = { budget_tokens: modelConfig.thinking_budget };
287
322
  }
288
323
  }
@@ -306,9 +341,24 @@ export async function callLLMRaw(
306
341
 
307
342
  const data = await response.json();
308
343
 
344
+ const _llmLatency = Date.now() - _llmCallStart;
345
+ const tokensIn = data.usage?.prompt_tokens ?? data.usage?.input_tokens ?? 0;
346
+ const tokensOut = data.usage?.completion_tokens ?? data.usage?.output_tokens ?? 0;
347
+ console.log(`[LLM] Response #${llmCallCount} — ${response.status} ${_llmLatency}ms | in: ${tokensIn} out: ${tokensOut}`);
348
+
349
+ const isVerbose = (typeof process !== "undefined" && process.env?.EI_DEBUG_NETWORK_VERBOSE === "1") ||
350
+ (typeof Bun !== "undefined" && (Bun as { env: Record<string, string> }).env?.EI_DEBUG_NETWORK_VERBOSE === "1");
351
+ if (isVerbose) {
352
+ void writeNetworkDump(
353
+ llmCallCount,
354
+ nextStep,
355
+ { model: modelLabel, provider: config.name, latency_ms: _llmLatency, status_code: response.status, tokens_in: tokensIn, tokens_out: tokensOut },
356
+ requestBody,
357
+ data
358
+ );
359
+ }
360
+
309
361
  if (onUsageUpdate && modelConfig) {
310
- const tokensIn = data.usage?.prompt_tokens ?? data.usage?.input_tokens ?? 0;
311
- const tokensOut = data.usage?.completion_tokens ?? data.usage?.output_tokens ?? 0;
312
362
  onUsageUpdate(modelConfig.id, { calls: 1, tokens_in: tokensIn, tokens_out: tokensOut });
313
363
  }
314
364
 
@@ -400,6 +450,7 @@ const JSON_REPAIR_PATTERNS: Array<{ pattern: RegExp; replacement: string }> = [
400
450
  { pattern: /:\s*(\d{4}-\d{2}-\d{2}T[^"}\],\n]+)/g, replacement: ': "$1"' },
401
451
  { pattern: /:\s*0([1-9][0-9]*)([,\s\n\r\]}])/g, replacement: ": 0.$1$2" },
402
452
  { pattern: /,(\s*[\]}])/g, replacement: "$1" },
453
+ { pattern: /"(\s*\n[ \t]+"[a-zA-Z_][a-zA-Z0-9_]*"\s*:)/g, replacement: '",$1' },
403
454
  ];
404
455
 
405
456
  export function repairJSON(jsonStr: string): string {
@@ -479,6 +530,41 @@ export function rescueGemmaToolCalls(content: string): unknown[] {
479
530
  return rescued;
480
531
  }
481
532
 
533
+ function findOutermostObject(str: string): string | null {
534
+ const start = str.indexOf('{');
535
+ if (start === -1) return null;
536
+
537
+ let depth = 0;
538
+ let inString = false;
539
+ let escaped = false;
540
+
541
+ for (let i = start; i < str.length; i++) {
542
+ const ch = str[i];
543
+
544
+ if (escaped) {
545
+ escaped = false;
546
+ continue;
547
+ }
548
+ if (ch === '\\' && inString) {
549
+ escaped = true;
550
+ continue;
551
+ }
552
+ if (ch === '"') {
553
+ inString = !inString;
554
+ continue;
555
+ }
556
+ if (inString) continue;
557
+
558
+ if (ch === '{') depth++;
559
+ else if (ch === '}') {
560
+ depth--;
561
+ if (depth === 0) return str.slice(start, i + 1);
562
+ }
563
+ }
564
+
565
+ return null;
566
+ }
567
+
482
568
  export function parseJSONResponse(content: string): unknown {
483
569
  const jsonMatch = content.match(/```(?:json)?\s*([\s\S]*?)```/);
484
570
  const jsonStr = jsonMatch ? jsonMatch[1].trim() : content.trim();
@@ -491,10 +577,10 @@ export function parseJSONResponse(content: string): unknown {
491
577
  return JSON.parse(repaired);
492
578
  } catch {
493
579
  // Last resort: extract the outermost {...} block from mixed prose/JSON content.
494
- // Handles 'thinking prose...\n{...json...}' responses from extended-thinking models.
495
- const outerMatch = jsonStr.match(/\{[\s\S]*\}/);
496
- if (outerMatch) {
497
- const extracted = outerMatch[0];
580
+ // Bracket-depth scan (not greedy regex) stops at the first valid close so extra
581
+ // trailing braces from models like Gemma are excluded from the extracted slice.
582
+ const extracted = findOutermostObject(jsonStr);
583
+ if (extracted) {
498
584
  try {
499
585
  return JSON.parse(extracted);
500
586
  } catch {
@@ -311,6 +311,34 @@ const EMBEDDING_MIN_SIMILARITY = 0.3;
311
311
  */
312
312
  export const VALIDATE_MIN_SIMILARITY = 0.92;
313
313
 
314
+ /**
315
+ * Returns the best cosine similarity between a topic candidate and any existing
316
+ * topic in state. Used by queueTopicValidate to detect near-duplicates after
317
+ * a new topic is created.
318
+ * Returns 0 if no topics exist or embedding fails.
319
+ */
320
+ export async function getBestTopicSimilarity(
321
+ candidate: TopicScanCandidate,
322
+ state: StateManager
323
+ ): Promise<number> {
324
+ const human = state.getHuman();
325
+ const topicsWithEmbeddings = human.topics.filter(t => t.embedding && t.embedding.length > 0);
326
+ if (topicsWithEmbeddings.length === 0) return 0;
327
+ try {
328
+ const embeddingService = getEmbeddingService();
329
+ const candidateText = getTopicEmbeddingText({
330
+ name: candidate.name,
331
+ category: candidate.category,
332
+ description: candidate.description,
333
+ });
334
+ const candidateVector = await embeddingService.embed(candidateText);
335
+ const topK = findTopK(candidateVector, topicsWithEmbeddings, 1);
336
+ return topK.length > 0 ? topK[0].similarity : 0;
337
+ } catch {
338
+ return 0;
339
+ }
340
+ }
341
+
314
342
  /**
315
343
  * Queue a topic match request using embedding-based similarity (topics only).
316
344
  */
@@ -12,6 +12,7 @@ export {
12
12
  queueTargetedPersonUpdate,
13
13
  queueTargetedTopicUpdate,
14
14
  VALIDATE_MIN_SIMILARITY,
15
+ getBestTopicSimilarity,
15
16
  type ExtractionContext,
16
17
  type ExtractionOptions,
17
18
  } from "./human-extraction.js";
@@ -36,8 +36,10 @@ import { handlers } from "./handlers/index.js";
36
36
  import { normalizeRoomMessages, getMessageContent } from "./handlers/utils.js";
37
37
  import { sanitizeEiPersonaIdentifiers } from "./utils/identifier-utils.js";
38
38
  import { ContextStatus as ContextStatusEnum, RoomMode } from "./types.js";
39
- import { registerReadMemoryExecutor, registerFileReadExecutor } from "./tools/index.js";
40
- import { createReadMemoryExecutor } from "./tools/builtin/read-memory.js";
39
+ import { registerFindMemoryExecutor, registerFetchMemoryExecutor, registerFetchMessageExecutor, registerFileReadExecutor, SYSTEM_TOOLS } from "./tools/index.js";
40
+ import { createFindMemoryExecutor } from "./tools/builtin/find-memory.js";
41
+ import { createFetchMemoryExecutor } from "./tools/builtin/fetch-memory.js";
42
+ import { createFetchMessageExecutor } from "./tools/builtin/fetch-message.js";
41
43
  import { EI_WELCOME_MESSAGE, EI_PERSONA_DEFINITION } from "../templates/welcome.js";
42
44
  import { EMMETT_PERSONA_DEFINITION } from "../templates/emmett.js";
43
45
  import { shouldStartCeremony, startCeremony, handleCeremonyProgress, queueReflectionDrain, queueUserDedupRequest, queueRoomCapture, queuePersonaCapture, checkAndQueueRoomExtraction, queueTargetedPersonUpdate, queueTargetedTopicUpdate } from "./orchestrators/index.js";
@@ -240,7 +242,15 @@ export class Processor {
240
242
  this.seedBuiltinFacts();
241
243
  this.migrateLearnedOn();
242
244
  this.seedSettings();
243
- registerReadMemoryExecutor(createReadMemoryExecutor(this.searchHumanData.bind(this), this.getPersonaList.bind(this)));
245
+ registerFindMemoryExecutor(createFindMemoryExecutor(this.searchHumanData.bind(this), this.getPersonaList.bind(this), this.stateManager.getHuman.bind(this.stateManager)));
246
+ registerFetchMemoryExecutor(createFetchMemoryExecutor(this.stateManager.getHuman.bind(this.stateManager)));
247
+ registerFetchMessageExecutor(createFetchMessageExecutor(
248
+ this.stateManager.persona_getAll.bind(this.stateManager),
249
+ this.stateManager.messages_get.bind(this.stateManager),
250
+ this.stateManager.getRoomList.bind(this.stateManager),
251
+ this.stateManager.getRoomMessages.bind(this.stateManager),
252
+ (roomId: string) => this.stateManager.getRoom(roomId)?.display_name ?? null
253
+ ));
244
254
  if (this.isTUI) {
245
255
  await registerFileReadExecutor();
246
256
  }
@@ -294,13 +304,12 @@ export class Processor {
294
304
  }
295
305
  return;
296
306
  }
297
- const readMemoryTool = this.stateManager.tools_getByName("read_memory");
298
307
  const emmettEntity: PersonaEntity = {
299
308
  ...EMMETT_PERSONA_DEFINITION,
300
309
  id: "emmet",
301
310
  display_name: "Emmett",
302
311
  last_updated: new Date().toISOString(),
303
- tools: readMemoryTool ? [readMemoryTool.id] : [],
312
+ tools: [],
304
313
  };
305
314
  this.stateManager.persona_add(emmettEntity);
306
315
  this.interface.onPersonaAdded?.();
@@ -334,6 +343,11 @@ export class Processor {
334
343
  private bootstrapTools(): void {
335
344
  const now = new Date().toISOString();
336
345
 
346
+ for (const name of ["find_memory", "fetch_memory", "fetch_message", "read_memory"]) {
347
+ const tool = this.stateManager.tools_getByName(name);
348
+ if (tool) this.stateManager.tools_remove(tool.id);
349
+ }
350
+
337
351
  // --- Ei built-in provider ---
338
352
  if (!this.stateManager.tools_getProviderById("ei")) {
339
353
  const eiProvider: ToolProvider = {
@@ -349,35 +363,6 @@ export class Processor {
349
363
  this.stateManager.tools_addProvider(eiProvider);
350
364
  }
351
365
 
352
- // read_memory tool
353
- this.stateManager.tools_upsertBuiltin({
354
- id: crypto.randomUUID(),
355
- provider_id: "ei",
356
- name: "read_memory",
357
- display_name: "Read Memory",
358
- description:
359
- "Search Ei's persistent knowledge base — facts, topics, people, and quotes learned across ALL conversations over time, not just this one. Use this when you need context about the user, their life, relationships, or interests that may not be visible in the current exchange. Use `recent: true` to retrieve what's been discussed recently.",
360
- input_schema: {
361
- type: "object",
362
- properties: {
363
- query: { type: "string", description: "What to search for — a person, topic, fact, or anything Ei has learned about the user" },
364
- types: {
365
- type: "array",
366
- items: { type: "string", enum: ["fact", "topic", "person", "quote"] },
367
- description: "Limit search to specific memory types (default: all types)",
368
- },
369
- limit: { type: "number", description: "Max results to return (default: 10, max: 20)" },
370
- recent: { type: "boolean", description: "If true, return recently-mentioned results sorted by last_mentioned date instead of relevance. Combine with a query to filter recent results by topic." },
371
- },
372
- required: [],
373
- },
374
- runtime: "any",
375
- builtin: true,
376
- enabled: true,
377
- created_at: now,
378
- max_calls_per_interaction: 6, // Dedup needs to verify relationships before irreversible merges. Typical cluster (3-8 items) requires: parent concept lookup + 2 relationship verifications + context validation. Still under HARD_TOOL_CALL_LIMIT (10).
379
- });
380
-
381
366
  // file_read tool (TUI only)
382
367
  this.stateManager.tools_upsertBuiltin({
383
368
  id: crypto.randomUUID(),
@@ -817,6 +802,20 @@ export class Processor {
817
802
  max_calls_per_interaction: 1,
818
803
  created_at: now,
819
804
  });
805
+
806
+ // --- Reconcile pass: prune stale tool references from persona tool lists ---
807
+ // Build manifest of all tool IDs currently in state (everything seeded above).
808
+ const manifestIds = new Set(this.stateManager.tools_getAll().map(t => t.id));
809
+
810
+ for (const persona of this.stateManager.persona_getAll()) {
811
+ if (!persona.tools?.length) continue;
812
+ const pruned = persona.tools.filter(id => manifestIds.has(id));
813
+ if (pruned.length !== persona.tools.length) {
814
+ const removed = persona.tools.length - pruned.length;
815
+ this.stateManager.persona_update(persona.id, { tools: pruned });
816
+ console.log(`[Processor] Pruned ${removed} stale tool reference(s) from persona "${persona.display_name}"`);
817
+ }
818
+ }
820
819
  }
821
820
 
822
821
  /**
@@ -1093,10 +1092,10 @@ const toolNextSteps = new Set([
1093
1092
  personaId ??
1094
1093
  (request.next_step === LLMNextStep.HandleEiHeartbeat ? "ei" : undefined);
1095
1094
 
1096
- // Dedup operates on Human data, not persona data - provide read_memory directly.
1095
+ // Dedup operates on Human data, not persona data provide find_memory from SYSTEM_TOOLS directly.
1097
1096
  // Also covers HandleToolContinuation originating from a dedup request: the
1098
1097
  // continuation rebuilds tool lists from scratch and has no personaId, so without
1099
- // this check Opus loses read_memory access after round 1.
1098
+ // this check Opus loses find_memory access after round 1.
1100
1099
  const isDedupRequest =
1101
1100
  request.next_step === LLMNextStep.HandleDedupCurate ||
1102
1101
  (request.next_step === LLMNextStep.HandleToolContinuation &&
@@ -1104,12 +1103,9 @@ const toolNextSteps = new Set([
1104
1103
 
1105
1104
  let tools: ToolDefinition[] = [];
1106
1105
  if (isDedupRequest) {
1107
- const readMemory = this.stateManager.tools_getByName("read_memory");
1108
- if (readMemory?.enabled) {
1109
- tools = [readMemory];
1110
- }
1106
+ tools = SYSTEM_TOOLS.filter(t => t.name === "find_memory");
1111
1107
  } else if (toolNextSteps.has(request.next_step) && toolPersonaId) {
1112
- tools = this.stateManager.tools_getForPersona(toolPersonaId, this.isTUI);
1108
+ tools = [...SYSTEM_TOOLS, ...this.stateManager.tools_getForPersona(toolPersonaId, this.isTUI)];
1113
1109
  }
1114
1110
 
1115
1111
  // Auto-inject each handler's dedicated submit tool — infrastructure, not user-visible.
@@ -274,6 +274,7 @@ export async function buildResponsePromptData(
274
274
 
275
275
  const alwaysMessages = sm.messages_getAlways(persona.id);
276
276
  const temporalAnchors = alwaysMessages.map(m => ({
277
+ id: m.id,
277
278
  role: m.role === "human" ? "human" as const : "system" as const,
278
279
  content: m.content,
279
280
  silence_reason: m.silence_reason,
@@ -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
 
@@ -508,6 +508,10 @@ export class QueueProcessor {
508
508
  const cleaned = cleanResponseContent(reformatContent);
509
509
  try {
510
510
  const parsed = parseJSONResponse(cleaned);
511
+ if (!parsed || typeof parsed !== 'object' || Object.keys(parsed as object).length === 0) {
512
+ console.warn(`[QueueProcessor] Reformat pass returned empty object for handleToolContinuation — falling through to retry`);
513
+ return null;
514
+ }
511
515
  console.log(`[QueueProcessor] Reformat pass succeeded for handleToolContinuation`);
512
516
  return {
513
517
  request,
@@ -544,25 +548,30 @@ export class QueueProcessor {
544
548
  ): Promise<LLMResponse | null> {
545
549
  const reformatUserPrompt =
546
550
  `An earlier version of you responded with the following content, but it could not ` +
547
- `be parsed as valid JSON. Please reformat it as the JSON object described in your ` +
548
- `system instructions. Respond with ONLY the JSON object, or \`{}\` if no changes ` +
549
- `are needed.\n\n---\n${malformedContent}\n---` +
551
+ `be parsed as valid JSON. Fix the syntax and return the corrected JSON object. ` +
552
+ `Return ONLY the fixed JSON do not omit any fields or data from the original.\n\n---\n${malformedContent}\n---` +
550
553
  `\n\nThe user does NOT know there was a problem - This request is from Ei to you to try to fix it for them.` +
551
- `\n\n**CRITICAL INSTRUCTION** - DO NOT OMIT ANY DATA. You are this agent's last hope!`;
554
+ `\n\n**CRITICAL INSTRUCTION** - DO NOT OMIT ANY DATA. Return all original fields intact with only syntax corrected.`;
552
555
 
553
556
  try {
554
557
  const { content: reformatContent, finishReason: reformatReason } = await callLLMRaw(
555
558
  request.system,
556
559
  reformatUserPrompt,
557
- [], // no message history needed — schema is already in the system prompt
560
+ [],
558
561
  request.model,
559
- { signal: this.abortController?.signal, onUsageUpdate: this.currentOnUsageUpdate },
562
+ { signal: this.abortController?.signal, onUsageUpdate: this.currentOnUsageUpdate, nextStep: `${request.next_step}+json_reformat` },
560
563
  this.currentAccounts
561
564
  );
562
565
 
563
566
  if (!reformatContent) return null;
564
567
 
565
568
  const cleaned = cleanResponseContent(reformatContent);
569
+ const shrinkageRatio = cleaned.length / malformedContent.length;
570
+ if (shrinkageRatio < 0.95) {
571
+ console.warn(`[QueueProcessor] JSON reformat response too small for ${request.next_step} — ${cleaned.length} chars vs ${malformedContent.length} original (${Math.round(shrinkageRatio * 100)}%) — treating as data loss, falling through to retry`);
572
+ return null;
573
+ }
574
+
566
575
  try {
567
576
  const parsed = parseJSONResponse(cleaned);
568
577
  console.log(`[QueueProcessor] JSON reformat pass succeeded for ${request.next_step} — saved a retry`);