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.
- package/README.md +3 -1
- package/package.json +3 -1
- package/src/cli/README.md +42 -14
- package/src/cli/mcp.ts +237 -0
- package/src/cli.ts +17 -51
- package/src/core/handlers/human-extraction.ts +22 -16
- package/src/core/handlers/human-matching.ts +45 -10
- package/src/core/llm-client.ts +101 -15
- package/src/core/orchestrators/human-extraction.ts +28 -0
- package/src/core/orchestrators/index.ts +1 -0
- package/src/core/processor.ts +37 -41
- package/src/core/prompt-context-builder.ts +1 -0
- package/src/core/queue-processor.ts +26 -17
- package/src/core/state-manager.ts +6 -6
- package/src/core/tools/builtin/fetch-memory.ts +92 -0
- package/src/core/tools/builtin/fetch-message.ts +123 -0
- package/src/core/tools/builtin/find-memory.ts +99 -0
- package/src/core/tools/index.ts +88 -5
- package/src/integrations/persona-history/importer.ts +3 -1
- package/src/prompts/ceremony/dedup.ts +3 -3
- package/src/prompts/ceremony/types.ts +1 -1
- package/src/prompts/human/person-scan.ts +17 -0
- package/src/prompts/human/types.ts +4 -0
- package/src/prompts/response/sections.ts +14 -7
- package/src/prompts/response/types.ts +1 -0
- package/tui/README.md +3 -2
- package/tui/src/util/logger.ts +1 -1
- 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 => {
|
|
@@ -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 ?
|
|
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:
|
|
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 ?
|
|
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, ' ')
|
|
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
|
|
549
|
+
console.warn(`[extraction] Quote not found in messages (both levels), skipping: "${candidate.text}"`);
|
|
515
550
|
}
|
|
516
551
|
}
|
|
517
552
|
}
|
package/src/core/llm-client.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
|
279
|
-
|
|
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
|
|
282
|
-
//
|
|
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
|
-
//
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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
|
*/
|
package/src/core/processor.ts
CHANGED
|
@@ -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 {
|
|
40
|
-
import {
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
300
|
+
console.debug(`[${personaName} Heartbeat] LLM call - tools offered: ${openAITools.length} (${activeTools.map(t => t.name).join(', ') || 'none'})`);
|
|
301
301
|
} else {
|
|
302
|
-
console.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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,
|
|
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.
|
|
548
|
-
`
|
|
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.
|
|
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
|
-
[],
|
|
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`);
|