ei-tui 0.1.17 → 0.1.18
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/package.json +1 -1
- package/src/core/embedding-service.ts +11 -2
- package/src/core/handlers/index.ts +12 -0
- package/src/core/llm-client.ts +26 -6
- package/src/core/orchestrators/human-extraction.ts +36 -28
- package/src/core/processor.ts +293 -5
- package/src/core/queue-processor.ts +150 -11
- package/src/core/state-manager.ts +121 -0
- package/src/core/tools/builtin/file-read.ts +58 -0
- package/src/core/tools/builtin/read-memory.ts +54 -0
- package/src/core/tools/builtin/web-search.ts +89 -0
- package/src/core/tools/index.ts +186 -0
- package/src/core/tools/types.ts +27 -0
- package/src/core/types.ts +53 -1
- package/src/integrations/opencode/importer.ts +9 -71
- package/src/prompts/response/index.ts +1 -1
- package/src/prompts/response/sections.ts +1 -1
- package/tui/README.md +1 -0
- package/tui/src/commands/tools.tsx +44 -0
- package/tui/src/components/PromptInput.tsx +2 -0
- package/tui/src/components/ToolkitListOverlay.tsx +178 -0
- package/tui/src/context/ei.tsx +28 -2
- package/tui/src/util/persona-editor.tsx +8 -4
- package/tui/src/util/toolkit-editor.tsx +83 -0
- package/tui/src/util/yaml-serializers.ts +128 -7
package/package.json
CHANGED
|
@@ -201,8 +201,17 @@ function createBunService(): EmbeddingService {
|
|
|
201
201
|
if (embedderPromise) return embedderPromise;
|
|
202
202
|
|
|
203
203
|
embedderPromise = (async () => {
|
|
204
|
-
const mod = await
|
|
205
|
-
|
|
204
|
+
const [mod, os, path] = await Promise.all([
|
|
205
|
+
import(/* @vite-ignore */ FASTEMBED_MODULE),
|
|
206
|
+
import('os'),
|
|
207
|
+
import('path'),
|
|
208
|
+
]);
|
|
209
|
+
// Use EI_DATA_PATH if set, otherwise fall back to ~/.local/share/ei/embeddings.
|
|
210
|
+
// Must be absolute so the cache is stable regardless of cwd.
|
|
211
|
+
const cacheDir = process.env.EI_DATA_PATH
|
|
212
|
+
? path.join(process.env.EI_DATA_PATH, 'embeddings')
|
|
213
|
+
: path.join(os.homedir(), '.local', 'share', 'ei', 'embeddings');
|
|
214
|
+
embedder = await mod.FlagEmbedding.init({ model: mod.EmbeddingModel.AllMiniLML6V2, cacheDir });
|
|
206
215
|
return embedder;
|
|
207
216
|
})();
|
|
208
217
|
|
|
@@ -1117,6 +1117,17 @@ function handlePersonaTopicUpdate(response: LLMResponse, state: StateManager): v
|
|
|
1117
1117
|
|
|
1118
1118
|
|
|
1119
1119
|
|
|
1120
|
+
/**
|
|
1121
|
+
* handleToolSynthesis — second LLM call in the tool flow.
|
|
1122
|
+
* The QueueProcessor already injected tool history into messages and got the
|
|
1123
|
+
* final persona response. Parse and store it exactly like handlePersonaResponse.
|
|
1124
|
+
*/
|
|
1125
|
+
function handleToolSynthesis(response: LLMResponse, state: StateManager): void {
|
|
1126
|
+
console.log(`[handleToolSynthesis] Routing to handlePersonaResponse`);
|
|
1127
|
+
handlePersonaResponse(response, state);
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
|
|
1120
1131
|
export const handlers: Record<LLMNextStep, ResponseHandler> = {
|
|
1121
1132
|
handlePersonaResponse,
|
|
1122
1133
|
handlePersonaGeneration,
|
|
@@ -1137,4 +1148,5 @@ export const handlers: Record<LLMNextStep, ResponseHandler> = {
|
|
|
1137
1148
|
handlePersonaExpire,
|
|
1138
1149
|
handlePersonaExplore,
|
|
1139
1150
|
handleDescriptionCheck,
|
|
1151
|
+
handleToolSynthesis,
|
|
1140
1152
|
};
|
package/src/core/llm-client.ts
CHANGED
|
@@ -17,11 +17,17 @@ export interface ResolvedModel {
|
|
|
17
17
|
export interface LLMCallOptions {
|
|
18
18
|
signal?: AbortSignal;
|
|
19
19
|
temperature?: number;
|
|
20
|
+
/** OpenAI-compatible tools array. When present and non-empty, sent with tool_choice: "auto". */
|
|
21
|
+
tools?: Record<string, unknown>[];
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
export interface LLMRawResponse {
|
|
23
25
|
content: string | null;
|
|
24
26
|
finishReason: string | null;
|
|
27
|
+
/** Raw tool_calls array from the API response, present when finishReason is "tool_calls". */
|
|
28
|
+
rawToolCalls?: unknown[];
|
|
29
|
+
/** The full assistant message object (needed to inject into history for the tool loop). */
|
|
30
|
+
assistantMessage?: Record<string, unknown>;
|
|
25
31
|
}
|
|
26
32
|
|
|
27
33
|
let llmCallCount = 0;
|
|
@@ -170,14 +176,21 @@ export async function callLLMRaw(
|
|
|
170
176
|
headers["anthropic-dangerous-direct-browser-access"] = "true";
|
|
171
177
|
}
|
|
172
178
|
|
|
179
|
+
const requestBody: Record<string, unknown> = {
|
|
180
|
+
model,
|
|
181
|
+
messages: finalMessages,
|
|
182
|
+
temperature,
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
if (options.tools && options.tools.length > 0) {
|
|
186
|
+
requestBody.tools = options.tools;
|
|
187
|
+
requestBody.tool_choice = "auto";
|
|
188
|
+
}
|
|
189
|
+
|
|
173
190
|
const response = await fetch(`${normalizedBaseURL}/chat/completions`, {
|
|
174
191
|
method: "POST",
|
|
175
192
|
headers,
|
|
176
|
-
body: JSON.stringify(
|
|
177
|
-
model,
|
|
178
|
-
messages: finalMessages,
|
|
179
|
-
temperature,
|
|
180
|
-
}),
|
|
193
|
+
body: JSON.stringify(requestBody),
|
|
181
194
|
signal,
|
|
182
195
|
});
|
|
183
196
|
|
|
@@ -189,9 +202,16 @@ export async function callLLMRaw(
|
|
|
189
202
|
const data = await response.json();
|
|
190
203
|
const choice = data.choices?.[0];
|
|
191
204
|
|
|
205
|
+
const assistantMessage = choice?.message as Record<string, unknown> | undefined;
|
|
206
|
+
const rawToolCalls = Array.isArray(choice?.message?.tool_calls)
|
|
207
|
+
? (choice.message.tool_calls as unknown[])
|
|
208
|
+
: undefined;
|
|
209
|
+
|
|
192
210
|
return {
|
|
193
|
-
content: choice?.message?.content ?? null,
|
|
211
|
+
content: (choice?.message?.content as string | null) ?? null,
|
|
194
212
|
finishReason: choice?.finish_reason ?? null,
|
|
213
|
+
rawToolCalls,
|
|
214
|
+
assistantMessage,
|
|
195
215
|
};
|
|
196
216
|
}
|
|
197
217
|
|
|
@@ -281,11 +281,14 @@ export async function queueItemMatch(
|
|
|
281
281
|
break;
|
|
282
282
|
}
|
|
283
283
|
|
|
284
|
+
// Traits are personality patterns — they must only match against other traits.
|
|
285
|
+
// Non-trait candidates (facts, topics, people) must never absorb trait content,
|
|
286
|
+
// and trait candidates must never cross-match into facts/topics/people.
|
|
284
287
|
const allItemsWithEmbeddings = [
|
|
285
|
-
...human.facts.map(f => ({ ...f, data_type: "fact" as DataItemType })),
|
|
288
|
+
...(dataType !== "trait" ? human.facts.map(f => ({ ...f, data_type: "fact" as DataItemType })) : []),
|
|
286
289
|
...human.traits.map(t => ({ ...t, data_type: "trait" as DataItemType })),
|
|
287
|
-
...human.topics.map(t => ({ ...t, data_type: "topic" as DataItemType })),
|
|
288
|
-
...human.people.map(p => ({ ...p, data_type: "person" as DataItemType })),
|
|
290
|
+
...(dataType !== "trait" ? human.topics.map(t => ({ ...t, data_type: "topic" as DataItemType })) : []),
|
|
291
|
+
...(dataType !== "trait" ? human.people.map(p => ({ ...p, data_type: "person" as DataItemType })) : []),
|
|
289
292
|
].filter(item => item.embedding && item.embedding.length > 0);
|
|
290
293
|
|
|
291
294
|
let topKItems: Array<{
|
|
@@ -321,15 +324,18 @@ export async function queueItemMatch(
|
|
|
321
324
|
}
|
|
322
325
|
|
|
323
326
|
if (topKItems.length === 0) {
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
327
|
+
|
|
328
|
+
console.log(`[queueItemMatch] No embeddings available, using filtered items (dataType=${dataType})`);
|
|
329
|
+
|
|
330
|
+
if (dataType !== "trait") {
|
|
331
|
+
for (const fact of human.facts) {
|
|
332
|
+
topKItems.push({
|
|
333
|
+
data_type: "fact",
|
|
334
|
+
data_id: fact.id,
|
|
335
|
+
data_name: fact.name,
|
|
336
|
+
data_description: dataType === "fact" ? fact.description : truncateDescription(fact.description),
|
|
337
|
+
});
|
|
338
|
+
}
|
|
333
339
|
}
|
|
334
340
|
|
|
335
341
|
for (const trait of human.traits) {
|
|
@@ -341,22 +347,24 @@ export async function queueItemMatch(
|
|
|
341
347
|
});
|
|
342
348
|
}
|
|
343
349
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
350
|
+
if (dataType !== "trait") {
|
|
351
|
+
for (const topic of human.topics) {
|
|
352
|
+
topKItems.push({
|
|
353
|
+
data_type: "topic",
|
|
354
|
+
data_id: topic.id,
|
|
355
|
+
data_name: topic.name,
|
|
356
|
+
data_description: dataType === "topic" ? topic.description : truncateDescription(topic.description),
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
for (const person of human.people) {
|
|
361
|
+
topKItems.push({
|
|
362
|
+
data_type: "person",
|
|
363
|
+
data_id: person.id,
|
|
364
|
+
data_name: person.name,
|
|
365
|
+
data_description: dataType === "person" ? person.description : truncateDescription(person.description),
|
|
366
|
+
});
|
|
367
|
+
}
|
|
360
368
|
}
|
|
361
369
|
}
|
|
362
370
|
|
package/src/core/processor.ts
CHANGED
|
@@ -25,6 +25,8 @@ import {
|
|
|
25
25
|
type StorageState,
|
|
26
26
|
type StateConflictResolution,
|
|
27
27
|
type StateConflictData,
|
|
28
|
+
type ToolDefinition,
|
|
29
|
+
type ToolProvider,
|
|
28
30
|
} from "./types.js";
|
|
29
31
|
import type { Storage } from "../storage/interface.js";
|
|
30
32
|
import { remoteSync } from "../storage/remote.js";
|
|
@@ -57,6 +59,8 @@ import { EI_WELCOME_MESSAGE, EI_PERSONA_DEFINITION } from "../templates/welcome.
|
|
|
57
59
|
import { getEmbeddingService, findTopK, needsEmbeddingUpdate, needsQuoteEmbeddingUpdate, computeDataItemEmbedding, computeQuoteEmbedding } from "./embedding-service.js";
|
|
58
60
|
import { ContextStatus as ContextStatusEnum } from "./types.js";
|
|
59
61
|
import { buildChatMessageContent } from "../prompts/message-utils.js";
|
|
62
|
+
import { registerReadMemoryExecutor, registerFileReadExecutor } from "./tools/index.js";
|
|
63
|
+
import { createReadMemoryExecutor } from "./tools/builtin/read-memory.js";
|
|
60
64
|
|
|
61
65
|
// =============================================================================
|
|
62
66
|
// EMBEDDING STRIPPING - Remove embeddings from data items before returning to FE
|
|
@@ -208,6 +212,14 @@ export class Processor {
|
|
|
208
212
|
if (!this.stateManager.hasExistingData() || this.stateManager.persona_getAll().length === 0) {
|
|
209
213
|
await this.bootstrapFirstRun();
|
|
210
214
|
}
|
|
215
|
+
// Seed built-in tool providers and tools if absent
|
|
216
|
+
this.bootstrapTools();
|
|
217
|
+
// Register read_memory executor (injected to avoid circular deps)
|
|
218
|
+
registerReadMemoryExecutor(createReadMemoryExecutor(this.searchHumanData.bind(this)));
|
|
219
|
+
// file_read is Node-only — dynamic import to keep node:fs/promises out of the web bundle
|
|
220
|
+
if (this.isTUI) {
|
|
221
|
+
registerFileReadExecutor();
|
|
222
|
+
}
|
|
211
223
|
this.running = true;
|
|
212
224
|
console.log(`[Processor ${this.instanceId}] initialized, starting loop`);
|
|
213
225
|
this.runLoop();
|
|
@@ -251,6 +263,145 @@ export class Processor {
|
|
|
251
263
|
this.interface.onMessageAdded?.(eiEntity.id);
|
|
252
264
|
}
|
|
253
265
|
|
|
266
|
+
/**
|
|
267
|
+
* Seed built-in tool providers and tools if they don't exist yet.
|
|
268
|
+
* Called on every startup (after state load/restore) — safe to call repeatedly.
|
|
269
|
+
* New builtins added in future releases will be seeded automatically.
|
|
270
|
+
*/
|
|
271
|
+
private bootstrapTools(): void {
|
|
272
|
+
const now = new Date().toISOString();
|
|
273
|
+
|
|
274
|
+
// --- Ei built-in provider ---
|
|
275
|
+
if (!this.stateManager.tools_getProviderById("ei")) {
|
|
276
|
+
const eiProvider: ToolProvider = {
|
|
277
|
+
id: "ei",
|
|
278
|
+
name: "ei",
|
|
279
|
+
display_name: "Ei Built-ins",
|
|
280
|
+
description: "Built-in tools that ship with Ei. No external API needed.",
|
|
281
|
+
builtin: true,
|
|
282
|
+
config: {},
|
|
283
|
+
enabled: true,
|
|
284
|
+
created_at: now,
|
|
285
|
+
};
|
|
286
|
+
this.stateManager.tools_addProvider(eiProvider);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// read_memory tool
|
|
290
|
+
if (!this.stateManager.tools_getByName("read_memory")) {
|
|
291
|
+
this.stateManager.tools_add({
|
|
292
|
+
id: crypto.randomUUID(),
|
|
293
|
+
provider_id: "ei",
|
|
294
|
+
name: "read_memory",
|
|
295
|
+
display_name: "Read Memory",
|
|
296
|
+
description: "Search your personal memory for relevant facts, traits, topics, people, or quotes. Use this when you need information about the user that may not be in the current conversation.",
|
|
297
|
+
input_schema: {
|
|
298
|
+
type: "object",
|
|
299
|
+
properties: {
|
|
300
|
+
query: { type: "string", description: "What to search for in memory" },
|
|
301
|
+
types: {
|
|
302
|
+
type: "array",
|
|
303
|
+
items: { type: "string", enum: ["fact", "trait", "topic", "person", "quote"] },
|
|
304
|
+
description: "Limit search to specific memory types (default: all types)"
|
|
305
|
+
},
|
|
306
|
+
limit: { type: "number", description: "Max results to return (default: 10, max: 20)" },
|
|
307
|
+
},
|
|
308
|
+
required: ["query"],
|
|
309
|
+
},
|
|
310
|
+
runtime: "any",
|
|
311
|
+
builtin: true,
|
|
312
|
+
enabled: true,
|
|
313
|
+
created_at: now,
|
|
314
|
+
max_calls_per_interaction: 3,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// file_read tool (TUI only)
|
|
319
|
+
if (!this.stateManager.tools_getByName("file_read")) {
|
|
320
|
+
this.stateManager.tools_add({
|
|
321
|
+
id: crypto.randomUUID(),
|
|
322
|
+
provider_id: "ei",
|
|
323
|
+
name: "file_read",
|
|
324
|
+
display_name: "Read File",
|
|
325
|
+
description: "Read the contents of a file from the local filesystem. Only available in the TUI.",
|
|
326
|
+
input_schema: {
|
|
327
|
+
type: "object",
|
|
328
|
+
properties: {
|
|
329
|
+
path: { type: "string", description: "Absolute or relative path to the file" },
|
|
330
|
+
},
|
|
331
|
+
required: ["path"],
|
|
332
|
+
},
|
|
333
|
+
runtime: "node",
|
|
334
|
+
builtin: true,
|
|
335
|
+
enabled: true,
|
|
336
|
+
created_at: now,
|
|
337
|
+
max_calls_per_interaction: 5,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// --- Tavily Search provider ---
|
|
342
|
+
if (!this.stateManager.tools_getProviderById("tavily")) {
|
|
343
|
+
const tavilyProvider: ToolProvider = {
|
|
344
|
+
id: "tavily",
|
|
345
|
+
name: "tavily",
|
|
346
|
+
display_name: "Tavily Search",
|
|
347
|
+
description: "Browser-compatible web search. Requires a Tavily API key (free tier: 1000 requests/month).",
|
|
348
|
+
builtin: true,
|
|
349
|
+
config: { api_key: '' }, // user fills in their Tavily API key via Settings → Toolkits
|
|
350
|
+
enabled: false, // disabled until user adds API key
|
|
351
|
+
created_at: now,
|
|
352
|
+
};
|
|
353
|
+
this.stateManager.tools_addProvider(tavilyProvider);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// tavily_web_search
|
|
357
|
+
if (!this.stateManager.tools_getByName("tavily_web_search")) {
|
|
358
|
+
this.stateManager.tools_add({
|
|
359
|
+
id: crypto.randomUUID(),
|
|
360
|
+
provider_id: "tavily",
|
|
361
|
+
name: "tavily_web_search",
|
|
362
|
+
display_name: "Web Search",
|
|
363
|
+
description: "Search the web using Tavily. Use for current events, fact verification, or any topic that benefits from up-to-date information.",
|
|
364
|
+
input_schema: {
|
|
365
|
+
type: "object",
|
|
366
|
+
properties: {
|
|
367
|
+
query: { type: "string", description: "Search query" },
|
|
368
|
+
max_results: { type: "number", description: "Number of results (default: 5, max: 10)" },
|
|
369
|
+
},
|
|
370
|
+
required: ["query"],
|
|
371
|
+
},
|
|
372
|
+
runtime: "any",
|
|
373
|
+
builtin: true,
|
|
374
|
+
enabled: true,
|
|
375
|
+
created_at: now,
|
|
376
|
+
max_calls_per_interaction: 3,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// tavily_news_search
|
|
381
|
+
if (!this.stateManager.tools_getByName("tavily_news_search")) {
|
|
382
|
+
this.stateManager.tools_add({
|
|
383
|
+
id: crypto.randomUUID(),
|
|
384
|
+
provider_id: "tavily",
|
|
385
|
+
name: "tavily_news_search",
|
|
386
|
+
display_name: "News Search",
|
|
387
|
+
description: "Search recent news articles using Tavily. Use for current events and recent developments.",
|
|
388
|
+
input_schema: {
|
|
389
|
+
type: "object",
|
|
390
|
+
properties: {
|
|
391
|
+
query: { type: "string", description: "News search query" },
|
|
392
|
+
max_results: { type: "number", description: "Number of results (default: 5, max: 10)" },
|
|
393
|
+
},
|
|
394
|
+
required: ["query"],
|
|
395
|
+
},
|
|
396
|
+
runtime: "any",
|
|
397
|
+
builtin: true,
|
|
398
|
+
enabled: true,
|
|
399
|
+
created_at: now,
|
|
400
|
+
max_calls_per_interaction: 3,
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
254
405
|
async stop(): Promise<void> {
|
|
255
406
|
console.log(`[Processor ${this.instanceId}] stop() called, running=${this.running}, stopped=${this.stopped}`);
|
|
256
407
|
this.stopped = true;
|
|
@@ -368,6 +519,17 @@ export class Processor {
|
|
|
368
519
|
this.interface.onMessageProcessing?.(personaId);
|
|
369
520
|
}
|
|
370
521
|
|
|
522
|
+
const toolNextSteps = new Set([
|
|
523
|
+
LLMNextStep.HandlePersonaResponse,
|
|
524
|
+
LLMNextStep.HandleHeartbeatCheck,
|
|
525
|
+
LLMNextStep.HandleEiHeartbeat,
|
|
526
|
+
]);
|
|
527
|
+
const toolPersonaId = personaId ?? (request.next_step === LLMNextStep.HandleEiHeartbeat ? "ei" : undefined);
|
|
528
|
+
const tools = (toolNextSteps.has(request.next_step) && toolPersonaId)
|
|
529
|
+
? this.stateManager.tools_getForPersona(toolPersonaId, this.isTUI)
|
|
530
|
+
: [];
|
|
531
|
+
console.log(`[Tools] Dispatch for ${request.next_step} persona=${toolPersonaId ?? "none"}: ${tools.length} tool(s) attached`);
|
|
532
|
+
|
|
371
533
|
this.queueProcessor.start(request, async (response) => {
|
|
372
534
|
this.currentRequest = null;
|
|
373
535
|
await this.handleResponse(response);
|
|
@@ -378,6 +540,8 @@ export class Processor {
|
|
|
378
540
|
accounts: this.stateManager.getHuman().settings?.accounts,
|
|
379
541
|
messageFetcher: (pName) => this.fetchMessagesForLLM(pName),
|
|
380
542
|
rawMessageFetcher: (pName) => this.stateManager.messages_get(pName),
|
|
543
|
+
tools: tools.length > 0 ? tools : undefined,
|
|
544
|
+
onEnqueue: (req) => this.stateManager.queue_enqueue(req),
|
|
381
545
|
});
|
|
382
546
|
|
|
383
547
|
this.interface.onQueueStateChanged?.("busy");
|
|
@@ -433,7 +597,13 @@ export class Processor {
|
|
|
433
597
|
const timeSinceHeartbeat = now - lastHeartbeat;
|
|
434
598
|
|
|
435
599
|
if (timeSinceHeartbeat >= heartbeatDelay) {
|
|
436
|
-
this.
|
|
600
|
+
const history = this.stateManager.messages_get(persona.id);
|
|
601
|
+
const contextWindowHours = persona.context_window_hours ?? DEFAULT_CONTEXT_WINDOW_HOURS;
|
|
602
|
+
const contextHistory = filterMessagesForContext(history, persona.context_boundary, contextWindowHours);
|
|
603
|
+
const trailing = this.countTrailingPersonaMessages(contextHistory);
|
|
604
|
+
if (trailing < 3) {
|
|
605
|
+
this.queueHeartbeatCheck(persona.id);
|
|
606
|
+
}
|
|
437
607
|
}
|
|
438
608
|
}
|
|
439
609
|
}
|
|
@@ -517,7 +687,7 @@ export class Processor {
|
|
|
517
687
|
if (result.sessionsProcessed > 0) {
|
|
518
688
|
console.log(
|
|
519
689
|
`[Processor] OpenCode sync complete: ${result.sessionsProcessed} sessions, ` +
|
|
520
|
-
`${result.
|
|
690
|
+
`${result.messagesImported} messages imported, ` +
|
|
521
691
|
`${result.extractionScansQueued} extraction scans queued`
|
|
522
692
|
);
|
|
523
693
|
}
|
|
@@ -624,14 +794,32 @@ export class Processor {
|
|
|
624
794
|
}, []);
|
|
625
795
|
}
|
|
626
796
|
|
|
797
|
+
/**
|
|
798
|
+
* Count consecutive conversational messages the persona sent at the end of history
|
|
799
|
+
* without a human reply. Used to prevent heartbeat spam when the user is away.
|
|
800
|
+
*/
|
|
801
|
+
private countTrailingPersonaMessages(history: Message[]): number {
|
|
802
|
+
let count = 0;
|
|
803
|
+
for (let i = history.length - 1; i >= 0; i--) {
|
|
804
|
+
const msg = history[i];
|
|
805
|
+
if (msg.role === 'human') break;
|
|
806
|
+
if (msg.role === 'system' && msg.verbal_response && msg.silence_reason === undefined) {
|
|
807
|
+
count++;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
return count;
|
|
811
|
+
}
|
|
812
|
+
|
|
627
813
|
private async queueHeartbeatCheck(personaId: string): Promise<void> {
|
|
628
814
|
const persona = this.stateManager.persona_getById(personaId);
|
|
629
815
|
if (!persona) return;
|
|
630
816
|
this.stateManager.persona_update(personaId, { last_heartbeat: new Date().toISOString() });
|
|
631
817
|
const human = this.stateManager.getHuman();
|
|
632
818
|
const history = this.stateManager.messages_get(personaId);
|
|
819
|
+
const contextWindowHours = persona.context_window_hours ?? DEFAULT_CONTEXT_WINDOW_HOURS;
|
|
820
|
+
const contextHistory = filterMessagesForContext(history, persona.context_boundary, contextWindowHours);
|
|
633
821
|
if (personaId === "ei") {
|
|
634
|
-
await this.queueEiHeartbeat(human,
|
|
822
|
+
await this.queueEiHeartbeat(human, contextHistory);
|
|
635
823
|
return;
|
|
636
824
|
}
|
|
637
825
|
|
|
@@ -651,7 +839,7 @@ export class Processor {
|
|
|
651
839
|
topics: sortByEngagementGap(filteredHuman.topics).slice(0, 5),
|
|
652
840
|
people: sortByEngagementGap(filteredHuman.people).slice(0, 5),
|
|
653
841
|
},
|
|
654
|
-
recent_history:
|
|
842
|
+
recent_history: contextHistory.slice(-10),
|
|
655
843
|
inactive_days: inactiveDays,
|
|
656
844
|
};
|
|
657
845
|
|
|
@@ -816,6 +1004,14 @@ export class Processor {
|
|
|
816
1004
|
return;
|
|
817
1005
|
}
|
|
818
1006
|
|
|
1007
|
+
// Tool-phase complete: tools were executed and HandleToolSynthesis was enqueued.
|
|
1008
|
+
// The persona message will arrive when synthesis completes — nothing to handle here.
|
|
1009
|
+
if (response.finish_reason === "tool_calls_enqueued") {
|
|
1010
|
+
console.log(`[Processor] tool_calls_enqueued for ${response.request.next_step} — awaiting HandleToolSynthesis`);
|
|
1011
|
+
this.stateManager.queue_complete(response.request.id);
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
819
1015
|
const handler = handlers[response.request.next_step as LLMNextStep];
|
|
820
1016
|
if (!handler) {
|
|
821
1017
|
const errorMsg = `No handler for ${response.request.next_step}`;
|
|
@@ -831,7 +1027,10 @@ export class Processor {
|
|
|
831
1027
|
await handler(response, this.stateManager);
|
|
832
1028
|
this.stateManager.queue_complete(response.request.id);
|
|
833
1029
|
|
|
834
|
-
if (
|
|
1030
|
+
if (
|
|
1031
|
+
response.request.next_step === LLMNextStep.HandlePersonaResponse ||
|
|
1032
|
+
response.request.next_step === LLMNextStep.HandleToolSynthesis
|
|
1033
|
+
) {
|
|
835
1034
|
// Always notify FE - even without content, user's message was "read" by the persona
|
|
836
1035
|
const personaId = response.request.data.personaId as string;
|
|
837
1036
|
if (personaId) {
|
|
@@ -959,6 +1158,7 @@ export class Processor {
|
|
|
959
1158
|
groups_visible: input.groups_visible ?? [DEFAULT_GROUP],
|
|
960
1159
|
traits: [],
|
|
961
1160
|
topics: [],
|
|
1161
|
+
tools: input.tools && input.tools.length > 0 ? input.tools : undefined,
|
|
962
1162
|
is_paused: false,
|
|
963
1163
|
is_archived: false,
|
|
964
1164
|
is_static: false,
|
|
@@ -1655,6 +1855,94 @@ export class Processor {
|
|
|
1655
1855
|
});
|
|
1656
1856
|
}
|
|
1657
1857
|
|
|
1858
|
+
// ============================================================================
|
|
1859
|
+
// TOOL PROVIDER API
|
|
1860
|
+
// ============================================================================
|
|
1861
|
+
|
|
1862
|
+
getToolProviderList(): ToolProvider[] {
|
|
1863
|
+
return this.stateManager.tools_getProviders();
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
getToolProvider(id: string): ToolProvider | null {
|
|
1867
|
+
return this.stateManager.tools_getProviderById(id);
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
async addToolProvider(provider: Omit<ToolProvider, 'id' | 'created_at'>): Promise<string> {
|
|
1871
|
+
const id = crypto.randomUUID();
|
|
1872
|
+
const now = new Date().toISOString();
|
|
1873
|
+
const newProvider: ToolProvider = { ...provider, id, created_at: now };
|
|
1874
|
+
this.stateManager.tools_addProvider(newProvider);
|
|
1875
|
+
this.interface.onToolProviderAdded?.();
|
|
1876
|
+
return id;
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
async updateToolProvider(id: string, updates: Partial<Omit<ToolProvider, 'id' | 'created_at'>>): Promise<boolean> {
|
|
1880
|
+
const result = this.stateManager.tools_updateProvider(id, updates);
|
|
1881
|
+
if (result) this.interface.onToolProviderUpdated?.(id);
|
|
1882
|
+
return result;
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
async removeToolProvider(id: string): Promise<boolean> {
|
|
1886
|
+
// Cascade: unassign all tools from this provider from all personas before removing
|
|
1887
|
+
const providerTools = this.stateManager.tools_getAll().filter(t => t.provider_id === id);
|
|
1888
|
+
const providerToolIds = new Set(providerTools.map(t => t.id));
|
|
1889
|
+
if (providerToolIds.size > 0) {
|
|
1890
|
+
const personas = this.stateManager.persona_getAll();
|
|
1891
|
+
for (const persona of personas) {
|
|
1892
|
+
if (persona.tools?.some(tid => providerToolIds.has(tid))) {
|
|
1893
|
+
await this.stateManager.persona_update(persona.id, {
|
|
1894
|
+
tools: persona.tools.filter(tid => !providerToolIds.has(tid)),
|
|
1895
|
+
});
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
const result = this.stateManager.tools_removeProvider(id);
|
|
1900
|
+
if (result) this.interface.onToolProviderRemoved?.();
|
|
1901
|
+
return result;
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
// ============================================================================
|
|
1905
|
+
// TOOL API
|
|
1906
|
+
// ============================================================================
|
|
1907
|
+
|
|
1908
|
+
getToolList(): ToolDefinition[] {
|
|
1909
|
+
return this.stateManager.tools_getAll();
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
getTool(id: string): ToolDefinition | null {
|
|
1913
|
+
return this.stateManager.tools_getById(id);
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
async addTool(tool: Omit<ToolDefinition, 'id' | 'created_at'>): Promise<string> {
|
|
1917
|
+
const id = crypto.randomUUID();
|
|
1918
|
+
const now = new Date().toISOString();
|
|
1919
|
+
const newTool: ToolDefinition = { ...tool, id, created_at: now };
|
|
1920
|
+
this.stateManager.tools_add(newTool);
|
|
1921
|
+
this.interface.onToolAdded?.();
|
|
1922
|
+
return id;
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
async updateTool(id: string, updates: Partial<Omit<ToolDefinition, 'id' | 'created_at'>>): Promise<boolean> {
|
|
1926
|
+
const result = this.stateManager.tools_update(id, updates);
|
|
1927
|
+
if (result) this.interface.onToolUpdated?.(id);
|
|
1928
|
+
return result;
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
async removeTool(id: string): Promise<boolean> {
|
|
1932
|
+
// Remove this tool from all persona tool lists before deleting
|
|
1933
|
+
const personas = this.stateManager.persona_getAll();
|
|
1934
|
+
for (const persona of personas) {
|
|
1935
|
+
if (persona.tools?.includes(id)) {
|
|
1936
|
+
await this.stateManager.persona_update(persona.id, {
|
|
1937
|
+
tools: persona.tools.filter(t => t !== id),
|
|
1938
|
+
});
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
const result = this.stateManager.tools_remove(id);
|
|
1942
|
+
if (result) this.interface.onToolRemoved?.();
|
|
1943
|
+
return result;
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1658
1946
|
// ============================================================================
|
|
1659
1947
|
// DEBUG / TESTING UTILITIES
|
|
1660
1948
|
// ============================================================================
|