levix-bot 2.2.1 → 2.3.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.
@@ -0,0 +1,666 @@
1
+ // The openai and anthropic providers: the same agent aiAgent.cjs runs on
2
+ // Gemini, walked over two plain HTTP APIs.
3
+ //
4
+ // Why hand-rolled and not an SDK: the OpenAI side has to be OPENAI-COMPATIBLE,
5
+ // not OpenAI — the whole point of the provider is that the operator points
6
+ // `openai_base_url` at whichever server they have (OpenAI, OpenRouter, Groq,
7
+ // Ollama, LM Studio), and every SDK along that path hides or hard-codes the
8
+ // base URL in its own way. Two `fetch` calls with no dependencies between them
9
+ // are easier to keep honest than a dependency that fights the feature.
10
+ //
11
+ // What is deliberately NOT here:
12
+ // * Google Search grounding — that is a Gemini built-in tool; these APIs
13
+ // have no such concept. (Levix's own web_search tool still travels, so the
14
+ // model can still look things up.)
15
+ // * Media. There is no Files API to upload to on this path, so a message's
16
+ // media parts become a one-line note and the text is what gets answered.
17
+ // `!stt` and `!generate` stay on Gemini for the same reason.
18
+ //
19
+ // Storage does not change: chat history stays in the Gemini parts format on
20
+ // disk (single canonical shape, so the operator can switch providers in the
21
+ // panel without invalidating a single conversation), and each adapter here
22
+ // translates on the way out and back. Tool calls follow the same round trip:
23
+ // a provider's native tool_call / tool_use id is echoed into the canonical
24
+ // functionCall/functionResponse pair, so a conversation survives being moved
25
+ // from one provider to another mid-flight.
26
+ //
27
+ // The loop, narration, budget and history trimming are the aiAgent.cjs ones —
28
+ // runAgent() dispatches here and hands over the already-built system
29
+ // instruction and already-trimmed history.
30
+
31
+ const logger = require("../utils/logger.cjs");
32
+ const settings = require("../config/settings.cjs");
33
+ const { toolDeclarations, describeCall, runTool } = require("./aiTools.cjs");
34
+
35
+ // Shown in place of a media part, on the wire and in the canonical history.
36
+ const MEDIA_NOTE = "[تم إرفاق ملف/وسائط في الرسالة]";
37
+
38
+ const API_TIMEOUT_MS = 120000;
39
+
40
+ // ===========================================================================
41
+ // adapters: everything that differs between the two wire formats
42
+ // ===========================================================================
43
+
44
+ const ADAPTERS = {
45
+ openai: {
46
+ label: "OpenAI-compatible",
47
+ keySetting: "openai_api_key",
48
+ modelSetting: "openai_model",
49
+ baseUrlSetting: "openai_base_url",
50
+ defaultBaseUrl: "https://api.openai.com/v1",
51
+ tools: openaiTools,
52
+ systemMessage: (systemInstruction) => [
53
+ { role: "system", content: systemInstruction },
54
+ ],
55
+ userMessage: (text) => ({ role: "user", content: text }),
56
+ /** Canonical history -> chat-completions messages. */
57
+ historyToMessages: historyToOpenAI,
58
+ /** Raw parsed response body -> { text, calls, assistantMessage }. */
59
+ parseResponse: parseOpenAIResponse,
60
+ /** One tool result as a message the API pairs with the call by id. */
61
+ toolResultMessages: (calls, results) =>
62
+ calls.map((call, i) => ({
63
+ role: "tool",
64
+ tool_call_id: call.id,
65
+ content: JSON.stringify(results[i] ?? {}),
66
+ })),
67
+ },
68
+
69
+ anthropic: {
70
+ label: "Anthropic",
71
+ keySetting: "anthropic_api_key",
72
+ modelSetting: "anthropic_model",
73
+ baseUrlSetting: "anthropic_base_url",
74
+ defaultBaseUrl: "https://api.anthropic.com",
75
+ tools: anthropicTools,
76
+ // Anthropic takes the system prompt as a top-level field, not a message.
77
+ systemMessage: () => [],
78
+ userMessage: (text) => ({
79
+ role: "user",
80
+ content: [{ type: "text", text }],
81
+ }),
82
+ historyToMessages: historyToAnthropic,
83
+ parseResponse: parseAnthropicResponse,
84
+ // Tool results ride in a user message as tool_result blocks, which is why
85
+ // this also has to say which role carries them.
86
+ toolResultRole: "user",
87
+ toolResultMessages: (calls, results) => [
88
+ {
89
+ role: "user",
90
+ content: calls.map((call, i) => ({
91
+ type: "tool_result",
92
+ tool_use_id: call.id,
93
+ content: JSON.stringify(results[i] ?? {}),
94
+ })),
95
+ },
96
+ ],
97
+ },
98
+ };
99
+
100
+ function adapterFor(provider) {
101
+ const adapter = ADAPTERS[provider];
102
+ if (!adapter) throw new Error(`مزود غير معروف: ${provider}`);
103
+ return adapter;
104
+ }
105
+
106
+ /**
107
+ * The settings key holding the API key of whichever provider is active.
108
+ *
109
+ * Gemini is deliberately not in ADAPTERS — its loop lives in aiAgent.cjs and
110
+ * needs no wire adapter — so the key lookup is its own total map over all
111
+ * three providers, default included. This runs inside the /stats handler and
112
+ * in front of every AI command, so a provider the map does not know must be
113
+ * the only thing that throws here.
114
+ */
115
+ const PROVIDER_KEY_SETTINGS = Object.freeze({
116
+ gemini: "gemini_api_key",
117
+ openai: "openai_api_key",
118
+ anthropic: "anthropic_api_key",
119
+ });
120
+
121
+ function activeProviderKeySetting(provider = settings.get("ai_provider")) {
122
+ const keySetting = PROVIDER_KEY_SETTINGS[provider];
123
+ if (!keySetting) throw new Error(`مزود غير معروف: ${provider}`);
124
+ return keySetting;
125
+ }
126
+
127
+ // ===========================================================================
128
+ // schema translation — Gemini Schema (uppercase `Type`) -> JSON Schema
129
+ // ===========================================================================
130
+
131
+ // aiTools.cjs declares its parameters in Gemini's dialect because that is what
132
+ // the Gemini path consumes natively. Both other formats take ordinary JSON
133
+ // Schema, whose only real difference is lowercase type names and a few fields
134
+ // Gemini invented that other servers reject.
135
+ const TYPE_MAP = {
136
+ string: "string",
137
+ number: "number",
138
+ integer: "integer",
139
+ boolean: "boolean",
140
+ array: "array",
141
+ object: "object",
142
+ };
143
+
144
+ function toJsonSchema(schema) {
145
+ if (Array.isArray(schema)) return schema.map(toJsonSchema);
146
+ if (!schema || typeof schema !== "object") return schema;
147
+
148
+ const out = {};
149
+ for (const [key, value] of Object.entries(schema)) {
150
+ if (key === "type") {
151
+ const name = String(value).toLowerCase();
152
+ out.type = TYPE_MAP[name] || name;
153
+ } else if (key === "properties" && value && typeof value === "object") {
154
+ out.properties = {};
155
+ for (const [name, sub] of Object.entries(value)) {
156
+ out.properties[name] = toJsonSchema(sub);
157
+ }
158
+ } else if (key === "items") {
159
+ out.items = toJsonSchema(value);
160
+ } else if (key === "anyOf") {
161
+ out.anyOf = toJsonSchema(value);
162
+ } else if (
163
+ [
164
+ "description",
165
+ "enum",
166
+ "required",
167
+ "format",
168
+ "minimum",
169
+ "maximum",
170
+ "default",
171
+ ].includes(key)
172
+ ) {
173
+ out[key] = value;
174
+ }
175
+ // Everything else (nullable, ...) is Gemini-specific and dropped rather
176
+ // than sent to a server that might 400 on an unknown field.
177
+ }
178
+ return out;
179
+ }
180
+
181
+ /** Levix's tool declarations in chat-completions `tools` shape. */
182
+ function openaiTools() {
183
+ return toolDeclarations()[0].functionDeclarations.map((declaration) => ({
184
+ type: "function",
185
+ function: {
186
+ name: declaration.name,
187
+ description: declaration.description,
188
+ parameters: toJsonSchema(declaration.parameters),
189
+ },
190
+ }));
191
+ }
192
+
193
+ /** Levix's tool declarations in Messages-API `tools` shape. */
194
+ function anthropicTools() {
195
+ return toolDeclarations()[0].functionDeclarations.map((declaration) => ({
196
+ name: declaration.name,
197
+ description: declaration.description,
198
+ input_schema: toJsonSchema(declaration.parameters),
199
+ }));
200
+ }
201
+
202
+ // ===========================================================================
203
+ // history translation — canonical Gemini parts -> provider messages
204
+ // ===========================================================================
205
+
206
+ function partsText(parts) {
207
+ return (parts || [])
208
+ .map((part) => {
209
+ if (part?.text) return part.text;
210
+ // Media on this path is a note, never bytes: there is no upload API to
211
+ // send it through (see the header comment).
212
+ if (part?.fileData || part?.inlineData) return MEDIA_NOTE;
213
+ return "";
214
+ })
215
+ .filter(Boolean)
216
+ .join("\n");
217
+ }
218
+
219
+ function safeJsonArgs(raw) {
220
+ if (raw == null) return {};
221
+ if (typeof raw === "object") return raw;
222
+ try {
223
+ const parsed = JSON.parse(raw);
224
+ return parsed && typeof parsed === "object" ? parsed : {};
225
+ } catch {
226
+ return {};
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Canonical -> chat-completions.
232
+ *
233
+ * `callIds` closes the loop for history written by the GEMINI path: Gemini
234
+ * function calls carry no id, but a `tool` message is required to name the
235
+ * `tool_call_id` it answers, so the assistant turn mints one and the map hands
236
+ * the same value to the matching functionResponse.
237
+ */
238
+ function historyToOpenAI(history) {
239
+ const messages = [];
240
+ const callIds = new Map();
241
+
242
+ (history || []).forEach((turn, turnIndex) => {
243
+ if (!turn || !Array.isArray(turn.parts)) return;
244
+ const role = turn.role === "model" ? "assistant" : "user";
245
+ const calls = turn.parts
246
+ .filter((part) => part?.functionCall)
247
+ .map((part) => part.functionCall);
248
+ const responses = turn.parts
249
+ .filter((part) => part?.functionResponse)
250
+ .map((part) => part.functionResponse);
251
+ const text = partsText(turn.parts);
252
+
253
+ if (calls.length) {
254
+ const toolCalls = calls.map((call, i) => {
255
+ const id = call.id || `call_${turnIndex}_${i}`;
256
+ if (call.name) callIds.set(call.name, id);
257
+ return {
258
+ id,
259
+ type: "function",
260
+ function: { name: call.name, arguments: JSON.stringify(call.args || {}) },
261
+ };
262
+ });
263
+ messages.push({
264
+ role: "assistant",
265
+ content: text || null,
266
+ tool_calls: toolCalls,
267
+ });
268
+ return;
269
+ }
270
+
271
+ if (responses.length) {
272
+ for (const response of responses) {
273
+ messages.push({
274
+ role: "tool",
275
+ tool_call_id: response.id || callIds.get(response.name) || `call_${turnIndex}`,
276
+ content: JSON.stringify(response.response ?? {}),
277
+ });
278
+ }
279
+ if (text) messages.push({ role: "user", content: text });
280
+ return;
281
+ }
282
+
283
+ if (text) messages.push({ role, content: text });
284
+ });
285
+
286
+ return messages;
287
+ }
288
+
289
+ /**
290
+ * Canonical -> Messages API. Content is always a block array, consecutive
291
+ * same-role turns are merged (the API requires strict alternation), and a
292
+ * tool_result block must land in a user turn — which is exactly where the
293
+ * canonical format already keeps functionResponse parts.
294
+ */
295
+ function historyToAnthropic(history) {
296
+ const messages = [];
297
+ const callIds = new Map();
298
+
299
+ const push = (role, blocks) => {
300
+ if (!blocks.length) return;
301
+ const last = messages[messages.length - 1];
302
+ if (last && last.role === role) last.content.push(...blocks);
303
+ else messages.push({ role, content: blocks });
304
+ };
305
+
306
+ (history || []).forEach((turn, turnIndex) => {
307
+ if (!turn || !Array.isArray(turn.parts)) return;
308
+ const role = turn.role === "model" ? "assistant" : "user";
309
+ const blocks = [];
310
+
311
+ for (const part of turn.parts) {
312
+ if (part?.text) {
313
+ blocks.push({ type: "text", text: part.text });
314
+ } else if (part?.functionCall) {
315
+ const id = part.functionCall.id || `call_${turnIndex}_${blocks.length}`;
316
+ if (part.functionCall.name) callIds.set(part.functionCall.name, id);
317
+ blocks.push({
318
+ type: "tool_use",
319
+ id,
320
+ name: part.functionCall.name,
321
+ input: part.functionCall.args || {},
322
+ });
323
+ } else if (part?.functionResponse) {
324
+ blocks.push({
325
+ type: "tool_result",
326
+ tool_use_id:
327
+ part.functionResponse.id ||
328
+ callIds.get(part.functionResponse.name) ||
329
+ `call_${turnIndex}`,
330
+ content: JSON.stringify(part.functionResponse.response ?? {}),
331
+ });
332
+ } else if (part?.fileData || part?.inlineData) {
333
+ blocks.push({ type: "text", text: MEDIA_NOTE });
334
+ }
335
+ }
336
+
337
+ push(role, blocks);
338
+ });
339
+
340
+ // The API refuses a conversation that opens on an assistant turn.
341
+ while (messages.length && messages[0].role !== "user") messages.shift();
342
+ return messages;
343
+ }
344
+
345
+ // ===========================================================================
346
+ // response parsing
347
+ // ===========================================================================
348
+
349
+ function parseOpenAIResponse(payload) {
350
+ const message = payload?.choices?.[0]?.message || {};
351
+
352
+ const content = Array.isArray(message.content)
353
+ ? message.content.map((block) => block?.text || "").join("\n")
354
+ : typeof message.content === "string"
355
+ ? message.content
356
+ : "";
357
+
358
+ const calls = (message.tool_calls || [])
359
+ .map((toolCall, i) => ({
360
+ id: toolCall.id || `call_${i}`,
361
+ name: toolCall.function?.name,
362
+ args: safeJsonArgs(toolCall.function?.arguments),
363
+ }))
364
+ .filter((call) => call.name);
365
+
366
+ return {
367
+ text: content.trim(),
368
+ calls,
369
+ // Echoed verbatim so the tool messages can pair on the same ids.
370
+ assistantMessage: {
371
+ role: "assistant",
372
+ content: message.content ?? null,
373
+ ...(calls.length ? { tool_calls: message.tool_calls } : {}),
374
+ },
375
+ };
376
+ }
377
+
378
+ function parseAnthropicResponse(payload) {
379
+ const blocks = (payload?.content || []).filter(
380
+ (block) => block?.type === "text" || block?.type === "tool_use"
381
+ );
382
+
383
+ const calls = blocks
384
+ .filter((block) => block.type === "tool_use")
385
+ .map((block) => ({
386
+ id: block.id || `call_${blocks.indexOf(block)}`,
387
+ name: block.name,
388
+ args: block.input && typeof block.input === "object" ? block.input : {},
389
+ }));
390
+
391
+ return {
392
+ text: blocks
393
+ .filter((block) => block.type === "text")
394
+ .map((block) => block.text || "")
395
+ .join("\n")
396
+ .trim(),
397
+ calls,
398
+ // tool_use blocks must be echoed with their ids for the tool_result
399
+ // pairing; text goes back too, exactly as the API expects.
400
+ assistantMessage: { role: "assistant", content: blocks },
401
+ };
402
+ }
403
+
404
+ // ===========================================================================
405
+ // the wire
406
+ // ===========================================================================
407
+
408
+ async function callApi({ url, headers, body, label }) {
409
+ const controller = new AbortController();
410
+ const timer = setTimeout(() => controller.abort(), API_TIMEOUT_MS);
411
+
412
+ let response;
413
+ try {
414
+ response = await fetch(url, {
415
+ method: "POST",
416
+ headers: { "Content-Type": "application/json", ...headers },
417
+ body: JSON.stringify(body),
418
+ signal: controller.signal,
419
+ });
420
+ } catch (err) {
421
+ throw new Error(
422
+ err?.name === "AbortError"
423
+ ? `${label} API request timed out after ${API_TIMEOUT_MS / 1000}s`
424
+ : `${label} API request failed: ${err?.message || err}`
425
+ );
426
+ } finally {
427
+ clearTimeout(timer);
428
+ }
429
+
430
+ let payload = {};
431
+ try {
432
+ payload = await response.json();
433
+ } catch {
434
+ // A non-JSON body (a proxy error page, say) still gets the status below.
435
+ }
436
+
437
+ if (!response.ok) {
438
+ const message =
439
+ payload?.error?.message || payload?.error || response.statusText || "unknown error";
440
+ const error = new Error(`${label} API request failed (${response.status}): ${message}`);
441
+ error.status = response.status;
442
+ throw error;
443
+ }
444
+
445
+ return payload;
446
+ }
447
+
448
+ function requestFor(adapter, provider, { baseUrl, apiKey, model, systemInstruction, messages, tools }) {
449
+ if (provider === "anthropic") {
450
+ return {
451
+ url: `${baseUrl}/v1/messages`,
452
+ headers: {
453
+ "x-api-key": apiKey,
454
+ "anthropic-version": "2023-06-01",
455
+ },
456
+ body: {
457
+ model,
458
+ max_tokens: 2048,
459
+ ...(systemInstruction ? { system: systemInstruction } : {}),
460
+ messages,
461
+ ...(tools.length ? { tools, tool_choice: { type: "auto" } } : {}),
462
+ },
463
+ };
464
+ }
465
+
466
+ return {
467
+ url: `${baseUrl}/chat/completions`,
468
+ headers: { Authorization: `Bearer ${apiKey}` },
469
+ body: {
470
+ model,
471
+ messages: [...adapter.systemMessage(systemInstruction), ...messages],
472
+ ...(tools.length ? { tools, tool_choice: "auto" } : {}),
473
+ temperature: 0.7,
474
+ },
475
+ };
476
+ }
477
+
478
+ function withTimeout(promise, ms, label) {
479
+ let timer;
480
+ return Promise.race([
481
+ promise,
482
+ new Promise((_, reject) => {
483
+ timer = setTimeout(
484
+ () => reject(new Error(`${label} تأخرت أكتر من ${Math.round(ms / 1000)} ثانية`)),
485
+ ms
486
+ );
487
+ }),
488
+ ]).finally(() => clearTimeout(timer));
489
+ }
490
+
491
+ // ===========================================================================
492
+ // the loop — same shape as aiAgent.runAgent, different wire format
493
+ // ===========================================================================
494
+
495
+ /**
496
+ * Run one agent turn on a non-Gemini provider.
497
+ *
498
+ * @param {string} provider - "openai" | "anthropic"
499
+ * @param {object} options
500
+ * @param {Array} options.parts - the current message (text parts; media arrives as notes)
501
+ * @param {string} options.systemInstruction - built by aiAgent.buildSystemInstruction
502
+ * @param {Array} [options.history] - canonical history, already trimmed
503
+ * @param {object} [options.status] - live status message for narration
504
+ * @param {object} [options.context] - handed to the tools
505
+ * @param {boolean} [options.useTools=true]
506
+ * @returns {Promise<{text: string, history: Array, toolCalls: Array, steps: number, sources: Array, searchOffered: boolean}>}
507
+ */
508
+ async function runProviderAgent(provider, {
509
+ parts,
510
+ systemInstruction,
511
+ history = [],
512
+ status = null,
513
+ context = {},
514
+ useTools = true,
515
+ maxSteps = null,
516
+ } = {}) {
517
+ const adapter = adapterFor(provider);
518
+
519
+ const apiKey = settings.get(adapter.keySetting);
520
+ if (!apiKey) throw new Error(`${adapter.label} API key is not configured`);
521
+
522
+ const stepBudget = maxSteps ?? settings.get("ai_max_tool_steps");
523
+ const model = settings.get(adapter.modelSetting);
524
+ const baseUrl = String(
525
+ settings.get(adapter.baseUrlSetting) || adapter.defaultBaseUrl
526
+ ).replace(/\/+$/, "");
527
+
528
+ const turnText = partsText(parts);
529
+
530
+ // The provider-native conversation, and the canonical one that comes back
531
+ // for storage. Both start from the trimmed history, then gain the current
532
+ // user turn — exactly what the Gemini path's Chat class does internally.
533
+ const messages = adapter.historyToMessages(history);
534
+ messages.push(adapter.userMessage(turnText));
535
+
536
+ const canonical = [...(Array.isArray(history) ? history : [])];
537
+ canonical.push({
538
+ role: "user",
539
+ parts: (parts || []).filter((part) => part?.text || part?.fileData || part?.inlineData),
540
+ });
541
+
542
+ const tools = useTools ? adapter.tools() : [];
543
+ const toolCalls = [];
544
+ let steps = 0;
545
+
546
+ const send = () => {
547
+ const request = requestFor(adapter, provider, {
548
+ baseUrl,
549
+ apiKey,
550
+ model,
551
+ systemInstruction,
552
+ messages,
553
+ tools,
554
+ });
555
+ return callApi({ ...request, label: adapter.label });
556
+ };
557
+
558
+ let response = await send();
559
+ while (true) {
560
+ const parsed = adapter.parseResponse(response);
561
+
562
+ if (parsed.text || parsed.calls.length) {
563
+ canonical.push({
564
+ role: "model",
565
+ parts: [
566
+ ...(parsed.text ? [{ text: parsed.text }] : []),
567
+ ...parsed.calls.map((call) => ({
568
+ functionCall: {
569
+ // The provider's own id, so a mid-conversation provider switch
570
+ // keeps tool call/result pairs intact.
571
+ ...(call.id ? { id: call.id } : {}),
572
+ name: call.name,
573
+ args: call.args,
574
+ },
575
+ })),
576
+ ],
577
+ });
578
+ }
579
+ messages.push(parsed.assistantMessage);
580
+
581
+ if (!parsed.calls.length) {
582
+ return finish({ text: parsed.text, canonical, toolCalls, steps });
583
+ }
584
+ if (steps >= stepBudget) {
585
+ // Same contract as the Gemini loop: the budget answer, not a lie.
586
+ return finish({
587
+ text:
588
+ parsed.text ||
589
+ "شغّلت الأدوات المتاحة بس مقدرتش أوصل لإجابة نهائية. جرّب تسأل بصيغة أوضح.",
590
+ canonical,
591
+ toolCalls,
592
+ steps,
593
+ });
594
+ }
595
+
596
+ steps += 1;
597
+
598
+ if (status) {
599
+ const line = parsed.calls
600
+ .map((call) => describeCall(call.name, call.args))
601
+ .join("\n");
602
+ await status.update(line);
603
+ }
604
+
605
+ const results = [];
606
+ for (const call of parsed.calls) {
607
+ toolCalls.push({ name: call.name, args: call.args });
608
+ logger.info({ tool: call.name, chatId: context.chatId }, "[aiAgent] tool call");
609
+
610
+ let toolResult;
611
+ try {
612
+ toolResult = await withTimeout(
613
+ runTool(call.name, call.args, context),
614
+ settings.get("ai_tool_timeout_ms"),
615
+ call.name
616
+ );
617
+ } catch (err) {
618
+ toolResult = { error: err?.message || String(err) };
619
+ }
620
+ results.push(toolResult);
621
+ }
622
+
623
+ const resultMessages = adapter.toolResultMessages(parsed.calls, results);
624
+ messages.push(...resultMessages);
625
+ // The canonical shape keeps one user turn carrying every functionResponse,
626
+ // which is also what adapterFor("openai") expects to translate back.
627
+ canonical.push({
628
+ role: "user",
629
+ parts: parsed.calls.map((call, i) => ({
630
+ functionResponse: {
631
+ ...(call.id ? { id: call.id } : {}),
632
+ name: call.name,
633
+ response: results[i],
634
+ },
635
+ })),
636
+ });
637
+
638
+ if (status) await status.update("🤖 بجهّز الرد...");
639
+ response = await send();
640
+ }
641
+ }
642
+
643
+ function finish({ text, canonical, toolCalls, steps }) {
644
+ return {
645
+ text,
646
+ history: canonical,
647
+ toolCalls,
648
+ steps,
649
+ // Grounding is a Gemini feature; these providers never have sources.
650
+ sources: [],
651
+ searchOffered: false,
652
+ };
653
+ }
654
+
655
+ module.exports = {
656
+ runProviderAgent,
657
+ activeProviderKeySetting,
658
+ toJsonSchema,
659
+ openaiTools,
660
+ anthropicTools,
661
+ historyToOpenAI,
662
+ historyToAnthropic,
663
+ parseOpenAIResponse,
664
+ parseAnthropicResponse,
665
+ ADAPTERS,
666
+ };
@@ -3,7 +3,7 @@
3
3
  // Why files and not a table
4
4
  // -------------------------
5
5
  // The operator asked for "save it to your memory" to end up in a `.md` file
6
- // per chat (plus one global file) — the same idea as an agent's CLAUDE.md.
6
+ // per chat (plus one global file) — the same idea as an agent's AGENTS.md.
7
7
  // Markdown keeps the memory:
8
8
  // * human readable and hand-editable (open the file, fix a line, done),
9
9
  // * diffable / backup-able without a DB dump,