ei-tui 0.1.16 → 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/cli/retrieval.ts +2 -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 +300 -9
- 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 +137 -9
package/package.json
CHANGED
package/src/cli/retrieval.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { StorageState, Quote, Fact, Trait, Person, Topic } from "../core/types";
|
|
2
|
+
import { decodeAllEmbeddings } from "../storage/embeddings";
|
|
2
3
|
import { crossFind } from "../core/utils/index.ts";
|
|
3
4
|
import { join } from "path";
|
|
4
5
|
import { readFile } from "fs/promises";
|
|
@@ -21,7 +22,7 @@ export async function loadLatestState(): Promise<StorageState | null> {
|
|
|
21
22
|
for (const file of [STATE_FILE, BACKUP_FILE]) {
|
|
22
23
|
try {
|
|
23
24
|
const text = await readFile(join(dataPath, file), "utf-8");
|
|
24
|
-
if (text) return JSON.parse(text) as StorageState;
|
|
25
|
+
if (text) return decodeAllEmbeddings(JSON.parse(text) as StorageState);
|
|
25
26
|
} catch {
|
|
26
27
|
continue;
|
|
27
28
|
}
|
|
@@ -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;
|
|
@@ -292,11 +443,14 @@ export class Processor {
|
|
|
292
443
|
const result = await remoteSync.sync(state);
|
|
293
444
|
|
|
294
445
|
if (!result.success) {
|
|
295
|
-
//
|
|
296
|
-
// Do NOT
|
|
297
|
-
//
|
|
446
|
+
// Sync failed (e.g. 429, network error, 412 etag mismatch).
|
|
447
|
+
// Do NOT stop() — leave the processor loop running so the user can
|
|
448
|
+
// keep using the TUI and retry /quit later.
|
|
449
|
+
// Do NOT moveToBackup — leave state.json intact so next boot can
|
|
450
|
+
// detect primary + remote → conflict resolution if needed.
|
|
298
451
|
console.log(`[Processor ${this.instanceId}] Remote sync failed: ${result.error}`);
|
|
299
|
-
|
|
452
|
+
// Reset the import abort controller so imports can resume normally.
|
|
453
|
+
this.importAbortController = new AbortController();
|
|
300
454
|
this.interface.onSaveAndExitFinish?.();
|
|
301
455
|
return { success: false, error: result.error };
|
|
302
456
|
}
|
|
@@ -365,6 +519,17 @@ export class Processor {
|
|
|
365
519
|
this.interface.onMessageProcessing?.(personaId);
|
|
366
520
|
}
|
|
367
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
|
+
|
|
368
533
|
this.queueProcessor.start(request, async (response) => {
|
|
369
534
|
this.currentRequest = null;
|
|
370
535
|
await this.handleResponse(response);
|
|
@@ -375,6 +540,8 @@ export class Processor {
|
|
|
375
540
|
accounts: this.stateManager.getHuman().settings?.accounts,
|
|
376
541
|
messageFetcher: (pName) => this.fetchMessagesForLLM(pName),
|
|
377
542
|
rawMessageFetcher: (pName) => this.stateManager.messages_get(pName),
|
|
543
|
+
tools: tools.length > 0 ? tools : undefined,
|
|
544
|
+
onEnqueue: (req) => this.stateManager.queue_enqueue(req),
|
|
378
545
|
});
|
|
379
546
|
|
|
380
547
|
this.interface.onQueueStateChanged?.("busy");
|
|
@@ -430,7 +597,13 @@ export class Processor {
|
|
|
430
597
|
const timeSinceHeartbeat = now - lastHeartbeat;
|
|
431
598
|
|
|
432
599
|
if (timeSinceHeartbeat >= heartbeatDelay) {
|
|
433
|
-
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
|
+
}
|
|
434
607
|
}
|
|
435
608
|
}
|
|
436
609
|
}
|
|
@@ -514,7 +687,7 @@ export class Processor {
|
|
|
514
687
|
if (result.sessionsProcessed > 0) {
|
|
515
688
|
console.log(
|
|
516
689
|
`[Processor] OpenCode sync complete: ${result.sessionsProcessed} sessions, ` +
|
|
517
|
-
`${result.
|
|
690
|
+
`${result.messagesImported} messages imported, ` +
|
|
518
691
|
`${result.extractionScansQueued} extraction scans queued`
|
|
519
692
|
);
|
|
520
693
|
}
|
|
@@ -621,14 +794,32 @@ export class Processor {
|
|
|
621
794
|
}, []);
|
|
622
795
|
}
|
|
623
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
|
+
|
|
624
813
|
private async queueHeartbeatCheck(personaId: string): Promise<void> {
|
|
625
814
|
const persona = this.stateManager.persona_getById(personaId);
|
|
626
815
|
if (!persona) return;
|
|
627
816
|
this.stateManager.persona_update(personaId, { last_heartbeat: new Date().toISOString() });
|
|
628
817
|
const human = this.stateManager.getHuman();
|
|
629
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);
|
|
630
821
|
if (personaId === "ei") {
|
|
631
|
-
await this.queueEiHeartbeat(human,
|
|
822
|
+
await this.queueEiHeartbeat(human, contextHistory);
|
|
632
823
|
return;
|
|
633
824
|
}
|
|
634
825
|
|
|
@@ -648,7 +839,7 @@ export class Processor {
|
|
|
648
839
|
topics: sortByEngagementGap(filteredHuman.topics).slice(0, 5),
|
|
649
840
|
people: sortByEngagementGap(filteredHuman.people).slice(0, 5),
|
|
650
841
|
},
|
|
651
|
-
recent_history:
|
|
842
|
+
recent_history: contextHistory.slice(-10),
|
|
652
843
|
inactive_days: inactiveDays,
|
|
653
844
|
};
|
|
654
845
|
|
|
@@ -813,6 +1004,14 @@ export class Processor {
|
|
|
813
1004
|
return;
|
|
814
1005
|
}
|
|
815
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
|
+
|
|
816
1015
|
const handler = handlers[response.request.next_step as LLMNextStep];
|
|
817
1016
|
if (!handler) {
|
|
818
1017
|
const errorMsg = `No handler for ${response.request.next_step}`;
|
|
@@ -828,7 +1027,10 @@ export class Processor {
|
|
|
828
1027
|
await handler(response, this.stateManager);
|
|
829
1028
|
this.stateManager.queue_complete(response.request.id);
|
|
830
1029
|
|
|
831
|
-
if (
|
|
1030
|
+
if (
|
|
1031
|
+
response.request.next_step === LLMNextStep.HandlePersonaResponse ||
|
|
1032
|
+
response.request.next_step === LLMNextStep.HandleToolSynthesis
|
|
1033
|
+
) {
|
|
832
1034
|
// Always notify FE - even without content, user's message was "read" by the persona
|
|
833
1035
|
const personaId = response.request.data.personaId as string;
|
|
834
1036
|
if (personaId) {
|
|
@@ -956,6 +1158,7 @@ export class Processor {
|
|
|
956
1158
|
groups_visible: input.groups_visible ?? [DEFAULT_GROUP],
|
|
957
1159
|
traits: [],
|
|
958
1160
|
topics: [],
|
|
1161
|
+
tools: input.tools && input.tools.length > 0 ? input.tools : undefined,
|
|
959
1162
|
is_paused: false,
|
|
960
1163
|
is_archived: false,
|
|
961
1164
|
is_static: false,
|
|
@@ -1652,6 +1855,94 @@ export class Processor {
|
|
|
1652
1855
|
});
|
|
1653
1856
|
}
|
|
1654
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
|
+
|
|
1655
1946
|
// ============================================================================
|
|
1656
1947
|
// DEBUG / TESTING UTILITIES
|
|
1657
1948
|
// ============================================================================
|