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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ei-tui",
3
- "version": "0.1.17",
3
+ "version": "0.1.18",
4
4
  "author": "Flare576",
5
5
  "repository": {
6
6
  "type": "git",
@@ -201,8 +201,17 @@ function createBunService(): EmbeddingService {
201
201
  if (embedderPromise) return embedderPromise;
202
202
 
203
203
  embedderPromise = (async () => {
204
- const mod = await import(/* @vite-ignore */ FASTEMBED_MODULE);
205
- embedder = await mod.FlagEmbedding.init({ model: mod.EmbeddingModel.AllMiniLML6V2 });
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
  };
@@ -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
- console.log(`[queueItemMatch] No embeddings available, using all ${human.facts.length + human.traits.length + human.topics.length + human.people.length} items`);
325
-
326
- for (const fact of human.facts) {
327
- topKItems.push({
328
- data_type: "fact",
329
- data_id: fact.id,
330
- data_name: fact.name,
331
- data_description: dataType === "fact" ? fact.description : truncateDescription(fact.description),
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
- for (const topic of human.topics) {
345
- topKItems.push({
346
- data_type: "topic",
347
- data_id: topic.id,
348
- data_name: topic.name,
349
- data_description: dataType === "topic" ? topic.description : truncateDescription(topic.description),
350
- });
351
- }
352
-
353
- for (const person of human.people) {
354
- topKItems.push({
355
- data_type: "person",
356
- data_id: person.id,
357
- data_name: person.name,
358
- data_description: dataType === "person" ? person.description : truncateDescription(person.description),
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
 
@@ -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.queueHeartbeatCheck(persona.id);
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.topicsCreated} topics created, ${result.messagesImported} messages imported, ` +
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, history);
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: history.slice(-10),
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 (response.request.next_step === LLMNextStep.HandlePersonaResponse) {
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
  // ============================================================================