twinclaw 1.4.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config/model-catalog.js +91 -0
- package/dist/core/context-assembly.js +49 -0
- package/dist/core/gateway.js +130 -55
- package/dist/core/lane-executor.js +58 -14
- package/dist/core/tool-events.js +53 -0
- package/dist/interfaces/dispatcher.js +44 -2
- package/dist/services/learning-system.js +4 -0
- package/dist/services/memory-extraction.js +111 -0
- package/dist/services/model-router.js +68 -2
- package/package.json +1 -1
|
@@ -297,6 +297,97 @@ export const STATIC_MODEL_CATALOG = [
|
|
|
297
297
|
pricing: 'Free tier available',
|
|
298
298
|
description: 'Fast inference with Groq'
|
|
299
299
|
},
|
|
300
|
+
// Alias entries for twinclaw.json model IDs that differ from catalog IDs
|
|
301
|
+
{
|
|
302
|
+
id: 'groq-qwen-qwen3-32b',
|
|
303
|
+
name: 'Qwen 3 32B (Groq)',
|
|
304
|
+
provider: 'groq',
|
|
305
|
+
model: 'qwen/qwen3-32b',
|
|
306
|
+
contextLength: 32768,
|
|
307
|
+
supportsStreaming: true,
|
|
308
|
+
pricing: 'Free tier available',
|
|
309
|
+
description: 'Fast inference with Groq'
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
id: 'groq-moonshotai-kimi-k2-instruct-0905',
|
|
313
|
+
name: 'Kimi K2 Instruct (Groq)',
|
|
314
|
+
provider: 'groq',
|
|
315
|
+
model: 'moonshotai/kimi-k2-instruct-0905',
|
|
316
|
+
contextLength: 32768,
|
|
317
|
+
supportsStreaming: true,
|
|
318
|
+
pricing: 'Free tier available',
|
|
319
|
+
description: 'Fast inference with Groq'
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
id: 'openrouter-z-ai-glm-4-5-air-free',
|
|
323
|
+
name: 'GLM-4.5 Air (Free)',
|
|
324
|
+
provider: 'openrouter',
|
|
325
|
+
model: 'z-ai/glm-4.5-air:free',
|
|
326
|
+
contextLength: 32000,
|
|
327
|
+
supportsStreaming: true,
|
|
328
|
+
pricing: 'Free',
|
|
329
|
+
description: 'Free model via OpenRouter'
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
id: 'openrouter-stepfun-step-3-5-flash-free',
|
|
333
|
+
name: 'StepFun Flash (Free)',
|
|
334
|
+
provider: 'openrouter',
|
|
335
|
+
model: 'stepfun/step-3.5-flash:free',
|
|
336
|
+
contextLength: 32000,
|
|
337
|
+
supportsStreaming: true,
|
|
338
|
+
pricing: 'Free',
|
|
339
|
+
description: 'Free model via OpenRouter'
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
id: 'openrouter-qwen-qwen3-coder-free',
|
|
343
|
+
name: 'Qwen3 Coder (Free)',
|
|
344
|
+
provider: 'openrouter',
|
|
345
|
+
model: 'qwen/qwen3-coder:free',
|
|
346
|
+
contextLength: 32000,
|
|
347
|
+
supportsStreaming: true,
|
|
348
|
+
pricing: 'Free',
|
|
349
|
+
description: 'Free model via OpenRouter'
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
id: 'openrouter-arcee-ai-trinity-mini-free',
|
|
353
|
+
name: 'Trinity Mini (Free)',
|
|
354
|
+
provider: 'openrouter',
|
|
355
|
+
model: 'arcee-ai/trinity-mini:free',
|
|
356
|
+
contextLength: 32000,
|
|
357
|
+
supportsStreaming: true,
|
|
358
|
+
pricing: 'Free',
|
|
359
|
+
description: 'Free model via OpenRouter'
|
|
360
|
+
},
|
|
361
|
+
{
|
|
362
|
+
id: 'openrouter-arcee-ai-trinity-large-preview-free',
|
|
363
|
+
name: 'Trinity Large Preview (Free)',
|
|
364
|
+
provider: 'openrouter',
|
|
365
|
+
model: 'arcee-ai/trinity-large-preview:free',
|
|
366
|
+
contextLength: 32000,
|
|
367
|
+
supportsStreaming: true,
|
|
368
|
+
pricing: 'Free',
|
|
369
|
+
description: 'Free model via OpenRouter'
|
|
370
|
+
},
|
|
371
|
+
{
|
|
372
|
+
id: 'copilot-gpt-4o',
|
|
373
|
+
name: 'GPT-4o (Copilot)',
|
|
374
|
+
provider: 'copilot',
|
|
375
|
+
model: 'gpt-4o',
|
|
376
|
+
contextLength: 128000,
|
|
377
|
+
supportsStreaming: true,
|
|
378
|
+
pricing: 'Included with Copilot',
|
|
379
|
+
description: 'GitHub Copilot model'
|
|
380
|
+
},
|
|
381
|
+
{
|
|
382
|
+
id: 'copilot-gemini-flash',
|
|
383
|
+
name: 'Gemini Flash (Copilot)',
|
|
384
|
+
provider: 'copilot',
|
|
385
|
+
model: 'gemini-2.0-flash',
|
|
386
|
+
contextLength: 1000000,
|
|
387
|
+
supportsStreaming: true,
|
|
388
|
+
pricing: 'Included with Copilot',
|
|
389
|
+
description: 'GitHub Copilot model'
|
|
390
|
+
},
|
|
300
391
|
// OpenRouter Free Models
|
|
301
392
|
{
|
|
302
393
|
id: 'openrouter-stepfun-flash',
|
|
@@ -1,9 +1,51 @@
|
|
|
1
1
|
import fs from 'fs/promises';
|
|
2
2
|
import path from 'path';
|
|
3
|
+
import os from 'node:os';
|
|
3
4
|
import { getIdentityDir } from '../config/workspace.js';
|
|
5
|
+
import { getConfigValue } from '../config/json-config.js';
|
|
6
|
+
/** Build a short runtime context block (OS, time, model). */
|
|
7
|
+
function buildRuntimeContext() {
|
|
8
|
+
const now = new Date();
|
|
9
|
+
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
10
|
+
const formattedTime = now.toLocaleString('en-US', {
|
|
11
|
+
weekday: 'long',
|
|
12
|
+
year: 'numeric',
|
|
13
|
+
month: 'long',
|
|
14
|
+
day: 'numeric',
|
|
15
|
+
hour: '2-digit',
|
|
16
|
+
minute: '2-digit',
|
|
17
|
+
second: '2-digit',
|
|
18
|
+
timeZoneName: 'short',
|
|
19
|
+
});
|
|
20
|
+
const currentModel = getConfigValue('PRIMARY_MODEL') ?? 'unknown';
|
|
21
|
+
const platform = os.platform();
|
|
22
|
+
const hostname = os.hostname();
|
|
23
|
+
const arch = os.arch();
|
|
24
|
+
const lines = [
|
|
25
|
+
`- **Current Date & Time:** ${formattedTime} (${timezone})`,
|
|
26
|
+
`- **Host:** ${hostname}`,
|
|
27
|
+
`- **OS:** ${platform} (${arch})`,
|
|
28
|
+
`- **Shell:** ${platform === 'win32' ? 'PowerShell' : 'bash'}`,
|
|
29
|
+
`- **Primary Model:** ${currentModel}`,
|
|
30
|
+
];
|
|
31
|
+
return lines.join('\n');
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Known messaging tool names. When the model calls any of these,
|
|
35
|
+
* the assistant's text response should be suppressed to avoid duplicates.
|
|
36
|
+
*/
|
|
37
|
+
export const MESSAGING_TOOL_NAMES = new Set([
|
|
38
|
+
'send_telegram_message',
|
|
39
|
+
'send_whatsapp_message',
|
|
40
|
+
'send_telegram_photo',
|
|
41
|
+
'send_telegram_document',
|
|
42
|
+
'send_whatsapp_image',
|
|
43
|
+
'send_whatsapp_document',
|
|
44
|
+
]);
|
|
4
45
|
export async function assembleContext(additionalRuntimeContext = '') {
|
|
5
46
|
let soul = '';
|
|
6
47
|
let identity = '';
|
|
48
|
+
let tools = '';
|
|
7
49
|
let user = '';
|
|
8
50
|
const identityDir = getIdentityDir();
|
|
9
51
|
const readOptionalFile = async (fileName) => {
|
|
@@ -23,7 +65,9 @@ export async function assembleContext(additionalRuntimeContext = '') {
|
|
|
23
65
|
};
|
|
24
66
|
soul = await readOptionalFile('soul.md');
|
|
25
67
|
identity = await readOptionalFile('identity.md');
|
|
68
|
+
tools = await readOptionalFile('tools.md');
|
|
26
69
|
user = await readOptionalFile('user.md');
|
|
70
|
+
const runtimeBlock = buildRuntimeContext();
|
|
27
71
|
const compiled = `
|
|
28
72
|
You are TwinClaw. Follow your core directives exactly.
|
|
29
73
|
|
|
@@ -31,6 +75,11 @@ ${soul ? `### CORE SOUL & DIRECTIVES\n${soul}` : ''}
|
|
|
31
75
|
|
|
32
76
|
${identity ? `### IDENTITY & PERSONA\n${identity}` : ''}
|
|
33
77
|
|
|
78
|
+
${tools ? `### TOOL USAGE GUIDE\n${tools}` : ''}
|
|
79
|
+
|
|
80
|
+
### RUNTIME ENVIRONMENT
|
|
81
|
+
${runtimeBlock}
|
|
82
|
+
|
|
34
83
|
${user ? `### USER PREFERENCES\n${user}` : ''}
|
|
35
84
|
|
|
36
85
|
${additionalRuntimeContext ? `### ADDITIONAL CONTEXT (RAG MEMORY)\n${additionalRuntimeContext}` : ''}
|
package/dist/core/gateway.js
CHANGED
|
@@ -6,14 +6,17 @@ import { ModelRouter } from '../services/model-router.js';
|
|
|
6
6
|
import { getIdentityDir } from '../config/workspace.js';
|
|
7
7
|
import { indexConversationTurn, retrieveEvidenceAwareMemoryContext, } from '../services/semantic-memory.js';
|
|
8
8
|
import { OrchestrationService } from '../services/orchestration-service.js';
|
|
9
|
-
import { assembleContext } from './context-assembly.js';
|
|
9
|
+
import { assembleContext, MESSAGING_TOOL_NAMES } from './context-assembly.js';
|
|
10
10
|
import { LaneExecutor } from './lane-executor.js';
|
|
11
11
|
import { PolicyEngine } from '../services/policy-engine.js';
|
|
12
12
|
import { logThought } from '../utils/logger.js';
|
|
13
13
|
import { ContextLifecycleOrchestrator } from '../services/context-lifecycle.js';
|
|
14
|
+
import { extractAndStoreMemory } from '../services/memory-extraction.js';
|
|
14
15
|
import { createSelfSetupAgent } from '../agents/self-setup-agent.js';
|
|
15
16
|
const DEFAULT_MAX_TOOL_ROUNDS = 6;
|
|
16
17
|
const DEFAULT_IDENTICAL_TOOL_CALL_LIMIT = 3;
|
|
18
|
+
const DEFAULT_AGENT_TIMEOUT_MS = 300_000; // 5 minutes
|
|
19
|
+
const NO_REPLY_TOKEN = 'NO_REPLY';
|
|
17
20
|
const DEFAULT_DELEGATION_MIN_SCORE = 2;
|
|
18
21
|
const DELEGATION_KEYWORDS = [
|
|
19
22
|
'complex',
|
|
@@ -91,6 +94,8 @@ export class Gateway {
|
|
|
91
94
|
#enableDelegation;
|
|
92
95
|
#delegationMinScore;
|
|
93
96
|
#contextLifecycle;
|
|
97
|
+
#agentTimeoutMs;
|
|
98
|
+
#sessionLocks = new Map();
|
|
94
99
|
#toolPolicy;
|
|
95
100
|
#degradationCounts = new Map();
|
|
96
101
|
#selfSetupAgents = new Map();
|
|
@@ -111,6 +116,7 @@ export class Gateway {
|
|
|
111
116
|
this.#enableDelegation = options.enableDelegation ?? true;
|
|
112
117
|
this.#delegationMinScore = Math.max(1, Number(options.delegationMinScore ?? DEFAULT_DELEGATION_MIN_SCORE));
|
|
113
118
|
this.#contextLifecycle = new ContextLifecycleOrchestrator(options.contextBudgetConfig);
|
|
119
|
+
this.#agentTimeoutMs = options.agentTimeoutMs ?? DEFAULT_AGENT_TIMEOUT_MS;
|
|
114
120
|
this.#toolPolicy = {
|
|
115
121
|
allow: normalizeToolSelectors(options.toolPolicy?.allow),
|
|
116
122
|
deny: normalizeToolSelectors(options.toolPolicy?.deny),
|
|
@@ -247,7 +253,43 @@ export class Gateway {
|
|
|
247
253
|
...(setupPrompt ? [{ role: 'system', content: setupPrompt }] : []),
|
|
248
254
|
{ role: 'user', content: normalizedText },
|
|
249
255
|
];
|
|
250
|
-
|
|
256
|
+
// Session write lock: serialize processText calls per session
|
|
257
|
+
const existingLock = this.#sessionLocks.get(sessionId) ?? Promise.resolve('');
|
|
258
|
+
const runLocked = existingLock.then(async () => {
|
|
259
|
+
return this.#runConversationLoopWithTimeout(sessionId, messages);
|
|
260
|
+
}).catch((err) => {
|
|
261
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
262
|
+
console.error(`[Gateway] Session ${sessionId} error: ${message}`);
|
|
263
|
+
return `Error: ${message}`;
|
|
264
|
+
});
|
|
265
|
+
this.#sessionLocks.set(sessionId, runLocked);
|
|
266
|
+
// Clean up the lock reference once done
|
|
267
|
+
void runLocked.finally(() => {
|
|
268
|
+
if (this.#sessionLocks.get(sessionId) === runLocked) {
|
|
269
|
+
this.#sessionLocks.delete(sessionId);
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
return runLocked;
|
|
273
|
+
}
|
|
274
|
+
/** Wrap the conversation loop with a hard timeout + memory extraction. */
|
|
275
|
+
async #runConversationLoopWithTimeout(sessionId, messages) {
|
|
276
|
+
const loop = this.#agentTimeoutMs > 0
|
|
277
|
+
? Promise.race([
|
|
278
|
+
this.#runConversationLoop(sessionId, messages),
|
|
279
|
+
new Promise((_, reject) => {
|
|
280
|
+
setTimeout(() => {
|
|
281
|
+
reject(new Error(`Agent run timed out after ${Math.round(this.#agentTimeoutMs / 1000)}s. Session: ${sessionId}`));
|
|
282
|
+
}, this.#agentTimeoutMs);
|
|
283
|
+
}),
|
|
284
|
+
])
|
|
285
|
+
: this.#runConversationLoop(sessionId, messages);
|
|
286
|
+
const result = await loop;
|
|
287
|
+
// Fire-and-forget memory extraction after successful conversation
|
|
288
|
+
void extractAndStoreMemory(sessionId, messages, this.#router).catch((err) => {
|
|
289
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
290
|
+
console.warn(`[Gateway] Memory extraction failed for ${sessionId}: ${msg}`);
|
|
291
|
+
});
|
|
292
|
+
return result;
|
|
251
293
|
}
|
|
252
294
|
async #runConversationLoop(sessionId, messages) {
|
|
253
295
|
// Refresh tools at the start of each loop to catch newly connected MCP servers
|
|
@@ -264,6 +306,24 @@ export class Gateway {
|
|
|
264
306
|
tool_calls: assistantMessage.tool_calls,
|
|
265
307
|
});
|
|
266
308
|
if (!assistantMessage.tool_calls || assistantMessage.tool_calls.length === 0) {
|
|
309
|
+
// Empty response guard: detect bare "Done." or trivially empty responses
|
|
310
|
+
// when the model should have used tools but didn't
|
|
311
|
+
const trimmedContent = assistantContent.trim().toLowerCase();
|
|
312
|
+
const isEmptyResponse = !trimmedContent ||
|
|
313
|
+
trimmedContent === 'done.' ||
|
|
314
|
+
trimmedContent === 'done' ||
|
|
315
|
+
trimmedContent === 'ok.' ||
|
|
316
|
+
trimmedContent === 'ok';
|
|
317
|
+
if (isEmptyResponse && round === 0 && this.#tools.length > 0) {
|
|
318
|
+
// First round, tools available, but model gave empty/trivial response.
|
|
319
|
+
// Nudge it to actually use tools instead of giving up.
|
|
320
|
+
console.warn(`[Gateway] Empty response guard triggered: "${assistantContent}" in round ${round}. Nudging model to use tools.`);
|
|
321
|
+
messages.push({
|
|
322
|
+
role: 'system',
|
|
323
|
+
content: 'Your response was empty or trivial. You have tools available — use them to actually perform the task. Do NOT respond with just "Done." unless you have completed concrete actions and shown their results.',
|
|
324
|
+
});
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
267
327
|
// L6: Index the final assistant response and the user's initial prompt together
|
|
268
328
|
// this is moved here from the outer processText method
|
|
269
329
|
const lastUserMsg = [...messages].reverse().find((m) => m.role === 'user');
|
|
@@ -273,7 +333,9 @@ export class Gateway {
|
|
|
273
333
|
});
|
|
274
334
|
}
|
|
275
335
|
await this.#persistTurn(sessionId, 'assistant', assistantContent || '[assistant finished]');
|
|
276
|
-
|
|
336
|
+
// Filter NO_REPLY tokens from final output
|
|
337
|
+
const finalContent = assistantContent.replace(NO_REPLY_TOKEN, '').trim();
|
|
338
|
+
return finalContent || 'Done.';
|
|
277
339
|
}
|
|
278
340
|
await this.#persistTurn(sessionId, 'assistant', assistantContent || '[assistant returned tool calls]');
|
|
279
341
|
const pattern = this.#buildToolCallSignature(assistantMessage.tool_calls);
|
|
@@ -291,10 +353,23 @@ export class Gateway {
|
|
|
291
353
|
return `${diagnostic} Stopping execution to prevent an infinite loop.`;
|
|
292
354
|
}
|
|
293
355
|
const toolResults = await this.#laneExecutor.executeToolCalls(assistantMessage, sessionId, this.#policyEngine);
|
|
356
|
+
// Track if a messaging tool was used in this round
|
|
357
|
+
const calledToolNames = (assistantMessage.tool_calls ?? []).map((tc) => tc.function.name);
|
|
358
|
+
const usedMessagingTool = calledToolNames.some((name) => MESSAGING_TOOL_NAMES.has(name));
|
|
294
359
|
for (const toolMessage of toolResults) {
|
|
295
360
|
messages.push(toolMessage);
|
|
296
361
|
await this.#persistTurn(sessionId, 'tool', toolMessage.content ?? '');
|
|
297
362
|
}
|
|
363
|
+
// NO_REPLY suppression: if a messaging tool was called, inject a
|
|
364
|
+
// hint telling the model not to also produce a text reply.
|
|
365
|
+
if (usedMessagingTool) {
|
|
366
|
+
messages.push({
|
|
367
|
+
role: 'system',
|
|
368
|
+
content: 'You already sent a message via a messaging tool. ' +
|
|
369
|
+
'Do NOT also produce a text reply confirming it — that would duplicate the message. ' +
|
|
370
|
+
`If you have nothing else to do, respond with exactly: ${NO_REPLY_TOKEN}`,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
298
373
|
// Compact messages to fit within budget after tool results are added
|
|
299
374
|
this.#compactConversationMessages(messages);
|
|
300
375
|
}
|
|
@@ -514,58 +589,58 @@ export class Gateway {
|
|
|
514
589
|
return content;
|
|
515
590
|
}
|
|
516
591
|
#getPersonaSetupPrompt() {
|
|
517
|
-
return `
|
|
518
|
-
## First Interaction - Persona Setup
|
|
519
|
-
|
|
520
|
-
This appears to be your first conversation with me. Before we dive into anything, I'd love to take a few minutes to get to know you better so I can be actually helpful (not just generic AI helpful).
|
|
521
|
-
|
|
522
|
-
Think of this as a quick chat where I learn what makes you tick.
|
|
523
|
-
|
|
524
|
-
Start by introducing yourself warmly and asking me 2-3 questions about:
|
|
525
|
-
- What do you do? (Role, industry, company)
|
|
526
|
-
- What are you working on right now?
|
|
527
|
-
- How do you like information presented? (Brief vs detailed, casual vs formal)
|
|
528
|
-
- What frustrates you about AI assistants?
|
|
529
|
-
- What platforms or tools do you use that I might integrate with?
|
|
530
|
-
|
|
531
|
-
After I respond, synthesize what you learn and create/update the user.md file in my identity directory with:
|
|
532
|
-
- User name and how they'd like to be addressed
|
|
533
|
-
- Role/industry/company
|
|
534
|
-
- Communication preferences
|
|
535
|
-
- Current projects or goals
|
|
536
|
-
- Tools and platforms they use
|
|
537
|
-
- Anything they want me to know or avoid
|
|
538
|
-
|
|
539
|
-
The user.md file should follow this format:
|
|
540
|
-
|
|
541
|
-
\`\`\`markdown
|
|
542
|
-
# User Profile
|
|
543
|
-
|
|
544
|
-
## Basic Info
|
|
545
|
-
- **Name:** [what they want to be called]
|
|
546
|
-
- **Role:** [job title/position]
|
|
547
|
-
- **Company/Context:** [where they work/study]
|
|
548
|
-
- **Technical Level:** [beginner/intermediate/expert]
|
|
549
|
-
|
|
550
|
-
## Communication Preferences
|
|
551
|
-
- **Formality:** [casual/professional/mix]
|
|
552
|
-
- **Detail Level:** [brief/comprehensive/contextual]
|
|
553
|
-
- **Tone:** [direct/exploratory/friendly]
|
|
554
|
-
|
|
555
|
-
## Current Context
|
|
556
|
-
- **Active Projects:** [what they're working on]
|
|
557
|
-
- **Goals:** [what they're trying to achieve]
|
|
558
|
-
- **Tools:** [daily tech stack, platforms]
|
|
559
|
-
|
|
560
|
-
## Important to Remember
|
|
561
|
-
- [things they explicitly mention caring about]
|
|
562
|
-
- [frustrations they mention]
|
|
563
|
-
|
|
564
|
-
## Learned Facts
|
|
565
|
-
- [interesting facts from our conversation]
|
|
566
|
-
\`\`\`
|
|
567
|
-
|
|
568
|
-
Tell me once you've saved this. Then we'll be ready to dive in!
|
|
592
|
+
return `
|
|
593
|
+
## First Interaction - Persona Setup
|
|
594
|
+
|
|
595
|
+
This appears to be your first conversation with me. Before we dive into anything, I'd love to take a few minutes to get to know you better so I can be actually helpful (not just generic AI helpful).
|
|
596
|
+
|
|
597
|
+
Think of this as a quick chat where I learn what makes you tick.
|
|
598
|
+
|
|
599
|
+
Start by introducing yourself warmly and asking me 2-3 questions about:
|
|
600
|
+
- What do you do? (Role, industry, company)
|
|
601
|
+
- What are you working on right now?
|
|
602
|
+
- How do you like information presented? (Brief vs detailed, casual vs formal)
|
|
603
|
+
- What frustrates you about AI assistants?
|
|
604
|
+
- What platforms or tools do you use that I might integrate with?
|
|
605
|
+
|
|
606
|
+
After I respond, synthesize what you learn and create/update the user.md file in my identity directory with:
|
|
607
|
+
- User name and how they'd like to be addressed
|
|
608
|
+
- Role/industry/company
|
|
609
|
+
- Communication preferences
|
|
610
|
+
- Current projects or goals
|
|
611
|
+
- Tools and platforms they use
|
|
612
|
+
- Anything they want me to know or avoid
|
|
613
|
+
|
|
614
|
+
The user.md file should follow this format:
|
|
615
|
+
|
|
616
|
+
\`\`\`markdown
|
|
617
|
+
# User Profile
|
|
618
|
+
|
|
619
|
+
## Basic Info
|
|
620
|
+
- **Name:** [what they want to be called]
|
|
621
|
+
- **Role:** [job title/position]
|
|
622
|
+
- **Company/Context:** [where they work/study]
|
|
623
|
+
- **Technical Level:** [beginner/intermediate/expert]
|
|
624
|
+
|
|
625
|
+
## Communication Preferences
|
|
626
|
+
- **Formality:** [casual/professional/mix]
|
|
627
|
+
- **Detail Level:** [brief/comprehensive/contextual]
|
|
628
|
+
- **Tone:** [direct/exploratory/friendly]
|
|
629
|
+
|
|
630
|
+
## Current Context
|
|
631
|
+
- **Active Projects:** [what they're working on]
|
|
632
|
+
- **Goals:** [what they're trying to achieve]
|
|
633
|
+
- **Tools:** [daily tech stack, platforms]
|
|
634
|
+
|
|
635
|
+
## Important to Remember
|
|
636
|
+
- [things they explicitly mention caring about]
|
|
637
|
+
- [frustrations they mention]
|
|
638
|
+
|
|
639
|
+
## Learned Facts
|
|
640
|
+
- [interesting facts from our conversation]
|
|
641
|
+
\`\`\`
|
|
642
|
+
|
|
643
|
+
Tell me once you've saved this. Then we'll be ready to dive in!
|
|
569
644
|
`;
|
|
570
645
|
}
|
|
571
646
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { logToolCall, scrubSensitiveText } from '../utils/logger.js';
|
|
2
2
|
import { getLearningSystem } from '../services/learning-system.js';
|
|
3
3
|
import { getSelfHealingService } from '../services/self-healing.js';
|
|
4
|
+
import { getToolEventBus } from './tool-events.js';
|
|
4
5
|
/** Convert a Skill (from the registry) into the internal Tool format used by LaneExecutor. */
|
|
5
6
|
function skillToTool(skill) {
|
|
6
7
|
return {
|
|
@@ -18,6 +19,17 @@ function skillToTool(skill) {
|
|
|
18
19
|
},
|
|
19
20
|
};
|
|
20
21
|
}
|
|
22
|
+
/** Max characters for a single tool result to prevent context budget exhaustion. */
|
|
23
|
+
const MAX_TOOL_RESULT_CHARS = 16_000;
|
|
24
|
+
/** Truncate oversized tool results to prevent context budget exhaustion. */
|
|
25
|
+
function sanitizeToolResult(content, toolName) {
|
|
26
|
+
if (content.length <= MAX_TOOL_RESULT_CHARS) {
|
|
27
|
+
return content;
|
|
28
|
+
}
|
|
29
|
+
const truncated = content.slice(0, MAX_TOOL_RESULT_CHARS);
|
|
30
|
+
console.warn(`[LaneExecutor] Tool '${toolName}' result truncated: ${content.length} -> ${MAX_TOOL_RESULT_CHARS} chars`);
|
|
31
|
+
return `${truncated}\n\n[... truncated ${content.length - MAX_TOOL_RESULT_CHARS} characters. Result was too large for context window.]`;
|
|
32
|
+
}
|
|
21
33
|
export class LaneExecutor {
|
|
22
34
|
tools = new Map();
|
|
23
35
|
constructor(tools = []) {
|
|
@@ -51,16 +63,6 @@ export class LaneExecutor {
|
|
|
51
63
|
syncFromRegistry(registry) {
|
|
52
64
|
this.syncSkills(registry.list());
|
|
53
65
|
}
|
|
54
|
-
parseArguments(args) {
|
|
55
|
-
try {
|
|
56
|
-
return JSON.parse(args);
|
|
57
|
-
}
|
|
58
|
-
catch (error) {
|
|
59
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
60
|
-
console.warn(`[LaneExecutor] Failed to parse arguments: ${scrubSensitiveText(args)} (${scrubSensitiveText(message)})`);
|
|
61
|
-
return {};
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
66
|
async executeToolCalls(message, sessionId, policyEngine) {
|
|
65
67
|
if (!message.tool_calls || message.tool_calls.length === 0) {
|
|
66
68
|
return [];
|
|
@@ -69,10 +71,24 @@ export class LaneExecutor {
|
|
|
69
71
|
// Lane-Based Execution: Execute tools serially in an await loop
|
|
70
72
|
for (const toolCall of message.tool_calls) {
|
|
71
73
|
const toolName = toolCall.function.name;
|
|
72
|
-
|
|
73
|
-
const tool = this.tools.get(toolName);
|
|
74
|
+
let args;
|
|
74
75
|
let content = '';
|
|
75
|
-
|
|
76
|
+
let parsingFailed = false;
|
|
77
|
+
try {
|
|
78
|
+
args = JSON.parse(toolCall.function.arguments);
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
82
|
+
console.warn(`[LaneExecutor] Failed to parse arguments for ${toolName}: ${scrubSensitiveText(errorMessage)}`);
|
|
83
|
+
content = `Error: Invalid JSON arguments for tool '${toolName}'. Fix syntax.`;
|
|
84
|
+
parsingFailed = true;
|
|
85
|
+
args = {};
|
|
86
|
+
}
|
|
87
|
+
const tool = this.tools.get(toolName);
|
|
88
|
+
if (parsingFailed) {
|
|
89
|
+
await logToolCall(toolName, args, content);
|
|
90
|
+
}
|
|
91
|
+
else if (!tool) {
|
|
76
92
|
console.warn(`[LaneExecutor] Tool not found: ${toolName}`);
|
|
77
93
|
content = `Error: Tool '${toolName}' is not registered or unavailable.`;
|
|
78
94
|
await logToolCall(toolName, args, content);
|
|
@@ -110,12 +126,31 @@ export class LaneExecutor {
|
|
|
110
126
|
await logToolCall(toolName, args, content);
|
|
111
127
|
}
|
|
112
128
|
if (allowed) {
|
|
129
|
+
const startTime = Date.now();
|
|
130
|
+
const eventBus = getToolEventBus();
|
|
131
|
+
eventBus.emitStart({
|
|
132
|
+
sessionId,
|
|
133
|
+
toolName,
|
|
134
|
+
toolCallId: toolCall.id,
|
|
135
|
+
args,
|
|
136
|
+
timestamp: startTime,
|
|
137
|
+
});
|
|
113
138
|
try {
|
|
114
139
|
console.log(`[LaneExecutor] Executing ${toolName} with args: ${scrubSensitiveText(JSON.stringify(args))}`);
|
|
115
140
|
const result = await tool.execute(args);
|
|
116
141
|
content = typeof result === 'string' ? result : JSON.stringify(result);
|
|
117
142
|
await logToolCall(toolName, args, content);
|
|
118
143
|
const success = !content.startsWith('Error');
|
|
144
|
+
const durationMs = Date.now() - startTime;
|
|
145
|
+
eventBus.emitEnd({
|
|
146
|
+
sessionId,
|
|
147
|
+
toolName,
|
|
148
|
+
toolCallId: toolCall.id,
|
|
149
|
+
success,
|
|
150
|
+
durationMs,
|
|
151
|
+
resultPreview: content.slice(0, 200),
|
|
152
|
+
timestamp: Date.now(),
|
|
153
|
+
});
|
|
119
154
|
await this.#recordSkillExecution(toolName, args, success, content);
|
|
120
155
|
}
|
|
121
156
|
catch (error) {
|
|
@@ -124,6 +159,15 @@ export class LaneExecutor {
|
|
|
124
159
|
console.error(`[LaneExecutor] Tool ${toolName} failed: ${sanitizedMessage}`);
|
|
125
160
|
content = `Error executing tool: ${sanitizedMessage}`;
|
|
126
161
|
await logToolCall(toolName, args, content);
|
|
162
|
+
const durationMs = Date.now() - startTime;
|
|
163
|
+
eventBus.emitError({
|
|
164
|
+
sessionId,
|
|
165
|
+
toolName,
|
|
166
|
+
toolCallId: toolCall.id,
|
|
167
|
+
error: sanitizedMessage,
|
|
168
|
+
durationMs,
|
|
169
|
+
timestamp: Date.now(),
|
|
170
|
+
});
|
|
127
171
|
await this.#recordSkillExecution(toolName, args, false, sanitizedMessage);
|
|
128
172
|
}
|
|
129
173
|
}
|
|
@@ -132,7 +176,7 @@ export class LaneExecutor {
|
|
|
132
176
|
role: 'tool',
|
|
133
177
|
tool_call_id: toolCall.id,
|
|
134
178
|
name: toolName,
|
|
135
|
-
content: content,
|
|
179
|
+
content: sanitizeToolResult(content, toolName),
|
|
136
180
|
});
|
|
137
181
|
}
|
|
138
182
|
return results;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
// ── Typed EventEmitter ──────────────────────────────────────────────────────
|
|
3
|
+
class ToolEventBus extends EventEmitter {
|
|
4
|
+
emitStart(event) {
|
|
5
|
+
this.emit('tool:start', event);
|
|
6
|
+
}
|
|
7
|
+
emitEnd(event) {
|
|
8
|
+
this.emit('tool:end', event);
|
|
9
|
+
}
|
|
10
|
+
emitError(event) {
|
|
11
|
+
this.emit('tool:error', event);
|
|
12
|
+
}
|
|
13
|
+
onStart(listener) {
|
|
14
|
+
return this.on('tool:start', listener);
|
|
15
|
+
}
|
|
16
|
+
onEnd(listener) {
|
|
17
|
+
return this.on('tool:end', listener);
|
|
18
|
+
}
|
|
19
|
+
onError(listener) {
|
|
20
|
+
return this.on('tool:error', listener);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** Singleton tool event bus — shared between LaneExecutor (producer) and Dispatcher (consumer). */
|
|
24
|
+
let instance = null;
|
|
25
|
+
export function getToolEventBus() {
|
|
26
|
+
if (!instance) {
|
|
27
|
+
instance = new ToolEventBus();
|
|
28
|
+
// Avoid memory-leak warnings for many listeners (one per platform adapter).
|
|
29
|
+
instance.setMaxListeners(20);
|
|
30
|
+
}
|
|
31
|
+
return instance;
|
|
32
|
+
}
|
|
33
|
+
/** Human-friendly tool name for status messages. */
|
|
34
|
+
export function humanizeToolName(toolName) {
|
|
35
|
+
const map = {
|
|
36
|
+
'read_file': '📄 Reading file',
|
|
37
|
+
'write_file': '✏️ Writing file',
|
|
38
|
+
'edit_file': '✏️ Editing file',
|
|
39
|
+
'list_directory': '📂 Listing directory',
|
|
40
|
+
'directory_tree': '📂 Browsing directory tree',
|
|
41
|
+
'search_files': '🔍 Searching files',
|
|
42
|
+
'exec': '⚡ Running command',
|
|
43
|
+
'web_search': '🌐 Searching the web',
|
|
44
|
+
'web_fetch': '🌐 Fetching webpage',
|
|
45
|
+
'create_entities': '🧠 Saving to memory',
|
|
46
|
+
'search_nodes': '🧠 Searching memory',
|
|
47
|
+
'create_relations': '🧠 Connecting knowledge',
|
|
48
|
+
'send_telegram_message': '💬 Sending Telegram message',
|
|
49
|
+
'send_whatsapp_message': '💬 Sending WhatsApp message',
|
|
50
|
+
'sequentialthinking': '🤔 Thinking step-by-step',
|
|
51
|
+
};
|
|
52
|
+
return map[toolName] ?? `🔧 Using ${toolName}`;
|
|
53
|
+
}
|
|
@@ -4,6 +4,7 @@ import { getDmPairingService, normalizePairingSenderId, } from '../services/dm-p
|
|
|
4
4
|
import { InboundDebounceService } from '../services/inbound-debounce.js';
|
|
5
5
|
import { EmbeddedBlockChunker } from '../services/block-chunker.js';
|
|
6
6
|
import { getConfigValue } from '../config/json-config.js';
|
|
7
|
+
import { getToolEventBus, humanizeToolName } from '../core/tool-events.js';
|
|
7
8
|
function parseIntConfig(key, fallback) {
|
|
8
9
|
const value = getConfigValue(key);
|
|
9
10
|
if (value === undefined || value === null || value === '') {
|
|
@@ -59,6 +60,8 @@ export class Dispatcher {
|
|
|
59
60
|
#debounce;
|
|
60
61
|
#chunker;
|
|
61
62
|
#humanDelayMs;
|
|
63
|
+
/** Maps sessionId → { platform, chatId } for routing tool events to the correct chat. */
|
|
64
|
+
#activeSessions = new Map();
|
|
62
65
|
constructor(telegram, whatsapp, stt, tts, gateway, queue, options = {}) {
|
|
63
66
|
this.#telegram = telegram;
|
|
64
67
|
this.#whatsapp = whatsapp;
|
|
@@ -98,6 +101,8 @@ export class Dispatcher {
|
|
|
98
101
|
this.#telegram.onMessage = (msg) => this.#handleDebounced(msg);
|
|
99
102
|
if (this.#whatsapp)
|
|
100
103
|
this.#whatsapp.onMessage = (msg) => this.#handleDebounced(msg);
|
|
104
|
+
// Subscribe to tool events for real-time status messages
|
|
105
|
+
this.#subscribeToToolEvents();
|
|
101
106
|
}
|
|
102
107
|
/** Expose the queue service for reliability and dead-letter controls. */
|
|
103
108
|
get queue() {
|
|
@@ -127,8 +132,16 @@ export class Dispatcher {
|
|
|
127
132
|
return;
|
|
128
133
|
}
|
|
129
134
|
const normalized = await this.#resolveAudio(message);
|
|
130
|
-
|
|
131
|
-
|
|
135
|
+
// Track session so tool events can route status messages to this chat
|
|
136
|
+
const sessionId = `${normalized.platform}:${normalized.senderId}`;
|
|
137
|
+
this.trackSession(sessionId, normalized.platform, normalized.chatId);
|
|
138
|
+
try {
|
|
139
|
+
const responseText = await this.#gateway.processMessage(normalized);
|
|
140
|
+
await this.#dispatch(normalized, responseText);
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
this.untrackSession(sessionId);
|
|
144
|
+
}
|
|
132
145
|
}
|
|
133
146
|
catch (err) {
|
|
134
147
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
@@ -294,7 +307,36 @@ export class Dispatcher {
|
|
|
294
307
|
/** Tear down all active interface adapters cleanly. */
|
|
295
308
|
shutdown() {
|
|
296
309
|
this.#debounce.clear();
|
|
310
|
+
this.#activeSessions.clear();
|
|
297
311
|
this.#telegram?.stop();
|
|
298
312
|
this.#whatsapp?.stop();
|
|
299
313
|
}
|
|
314
|
+
// ── Tool Event Streaming ────────────────────────────────────────────────────
|
|
315
|
+
/** Subscribe to LaneExecutor tool events and send status messages to the active chat. */
|
|
316
|
+
#subscribeToToolEvents() {
|
|
317
|
+
const eventBus = getToolEventBus();
|
|
318
|
+
eventBus.onStart((event) => {
|
|
319
|
+
const target = this.#activeSessions.get(event.sessionId);
|
|
320
|
+
if (!target)
|
|
321
|
+
return;
|
|
322
|
+
const statusText = `${humanizeToolName(event.toolName)}...`;
|
|
323
|
+
// Fire-and-forget status message — don't await to avoid blocking execution
|
|
324
|
+
this.#queue.enqueue(target.platform, target.chatId, statusText);
|
|
325
|
+
});
|
|
326
|
+
eventBus.onError((event) => {
|
|
327
|
+
const target = this.#activeSessions.get(event.sessionId);
|
|
328
|
+
if (!target)
|
|
329
|
+
return;
|
|
330
|
+
const statusText = `⚠️ ${event.toolName} failed (${Math.round(event.durationMs / 1000)}s): ${event.error.slice(0, 120)}`;
|
|
331
|
+
this.#queue.enqueue(target.platform, target.chatId, statusText);
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
/** Register the active session's routing info so tool events reach the right chat. */
|
|
335
|
+
trackSession(sessionId, platform, chatId) {
|
|
336
|
+
this.#activeSessions.set(sessionId, { platform, chatId });
|
|
337
|
+
}
|
|
338
|
+
/** Remove session tracking after processing completes. */
|
|
339
|
+
untrackSession(sessionId) {
|
|
340
|
+
this.#activeSessions.delete(sessionId);
|
|
341
|
+
}
|
|
300
342
|
}
|
|
@@ -69,10 +69,14 @@ class LearningSystem {
|
|
|
69
69
|
return entry;
|
|
70
70
|
}
|
|
71
71
|
#findSimilar(description) {
|
|
72
|
+
if (!description)
|
|
73
|
+
return undefined;
|
|
72
74
|
const searchTerms = description.toLowerCase().split(/\s+/);
|
|
73
75
|
let bestMatch;
|
|
74
76
|
let bestScore = 0;
|
|
75
77
|
for (const memory of this.#memories.values()) {
|
|
78
|
+
if (!memory.description)
|
|
79
|
+
continue;
|
|
76
80
|
const memTerms = memory.description.toLowerCase().split(/\s+/);
|
|
77
81
|
let score = 0;
|
|
78
82
|
for (const term of searchTerms) {
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { saveMemoryFact } from './db.js';
|
|
2
|
+
import { logThought } from '../utils/logger.js';
|
|
3
|
+
/**
|
|
4
|
+
* Extract persistent facts from a completed conversation and store them in the
|
|
5
|
+
* BM25 FTS5 memory table. Uses a lightweight model call to distill key facts.
|
|
6
|
+
*
|
|
7
|
+
* This runs as a fire-and-forget background task after each conversation loop.
|
|
8
|
+
*/
|
|
9
|
+
export async function extractAndStoreMemory(sessionId, messages, router) {
|
|
10
|
+
try {
|
|
11
|
+
// Only extract from conversations with meaningful content
|
|
12
|
+
const userMessages = messages.filter((m) => m.role === 'user');
|
|
13
|
+
const assistantMessages = messages.filter((m) => m.role === 'assistant');
|
|
14
|
+
if (userMessages.length === 0 || assistantMessages.length === 0) {
|
|
15
|
+
return 0;
|
|
16
|
+
}
|
|
17
|
+
// Build a compact summary of the conversation for extraction
|
|
18
|
+
const conversationSummary = buildConversationSummary(messages);
|
|
19
|
+
if (conversationSummary.length < 50) {
|
|
20
|
+
// Conversation too short to extract meaningful facts from
|
|
21
|
+
return 0;
|
|
22
|
+
}
|
|
23
|
+
const extractionMessages = [
|
|
24
|
+
{
|
|
25
|
+
role: 'system',
|
|
26
|
+
content: EXTRACTION_PROMPT,
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
role: 'user',
|
|
30
|
+
content: conversationSummary,
|
|
31
|
+
},
|
|
32
|
+
];
|
|
33
|
+
// Use model to extract facts — no tools, lightweight call
|
|
34
|
+
const response = await router.createChatCompletion(extractionMessages, [], // no tools
|
|
35
|
+
{ sessionId: `memory-extract:${sessionId}` });
|
|
36
|
+
const rawContent = response.content?.trim() ?? '';
|
|
37
|
+
const facts = parseExtractedFacts(rawContent);
|
|
38
|
+
if (facts.length === 0) {
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
41
|
+
// Store each fact in the FTS5 table
|
|
42
|
+
let stored = 0;
|
|
43
|
+
for (const fact of facts) {
|
|
44
|
+
const taggedFact = `MEMORY: ${fact.fact}${fact.tags.length > 0 ? ` [${fact.tags.join(', ')}]` : ''}`;
|
|
45
|
+
saveMemoryFact(sessionId, taggedFact);
|
|
46
|
+
stored++;
|
|
47
|
+
}
|
|
48
|
+
void logThought(`[MemoryExtraction] Extracted and stored ${stored} facts from session ${sessionId}`);
|
|
49
|
+
return stored;
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
53
|
+
console.warn(`[MemoryExtraction] Failed for session ${sessionId}: ${message}`);
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// ── Extraction Prompt ──────────────────────────────────────────────────────────
|
|
58
|
+
const EXTRACTION_PROMPT = `You are a memory extraction agent. Your job is to extract 0-5 key facts from a conversation that are worth remembering long-term.
|
|
59
|
+
|
|
60
|
+
Rules:
|
|
61
|
+
- Only extract genuinely useful, persistent facts (preferences, names, decisions, technical context).
|
|
62
|
+
- Do NOT extract transient information (greetings, "thank you", temporary states).
|
|
63
|
+
- Do NOT extract information the user explicitly marked as private or temporary.
|
|
64
|
+
- Each fact should be self-contained and understandable without the conversation context.
|
|
65
|
+
- Tags should be 1-3 short keywords for categorization.
|
|
66
|
+
|
|
67
|
+
Respond with ONLY a JSON array. No markdown, no explanation, no code fences.
|
|
68
|
+
Format: [{"fact": "...", "tags": ["..."]}]
|
|
69
|
+
Return [] if nothing notable or the conversation is trivial.`;
|
|
70
|
+
/** Build a compact summary of the conversation for the extraction model. */
|
|
71
|
+
function buildConversationSummary(messages) {
|
|
72
|
+
const relevant = messages.filter((m) => m.role === 'user' || m.role === 'assistant');
|
|
73
|
+
// Cap at last 10 messages to keep context small
|
|
74
|
+
const recent = relevant.slice(-10);
|
|
75
|
+
return recent
|
|
76
|
+
.map((m) => `${m.role.toUpperCase()}: ${(m.content ?? '').slice(0, 500)}`)
|
|
77
|
+
.join('\n\n');
|
|
78
|
+
}
|
|
79
|
+
/** Parse the model's extracted facts from JSON, with lenient error handling. */
|
|
80
|
+
function parseExtractedFacts(raw) {
|
|
81
|
+
if (!raw || raw === '[]') {
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
// Strip markdown code fences if present
|
|
85
|
+
const cleaned = raw
|
|
86
|
+
.replace(/^```(?:json)?\s*/i, '')
|
|
87
|
+
.replace(/\s*```$/i, '')
|
|
88
|
+
.trim();
|
|
89
|
+
try {
|
|
90
|
+
const parsed = JSON.parse(cleaned);
|
|
91
|
+
if (!Array.isArray(parsed)) {
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
return parsed
|
|
95
|
+
.filter((item) => typeof item === 'object' &&
|
|
96
|
+
item !== null &&
|
|
97
|
+
typeof item.fact === 'string' &&
|
|
98
|
+
item.fact !== '')
|
|
99
|
+
.map((item) => ({
|
|
100
|
+
fact: String(item.fact).slice(0, 500),
|
|
101
|
+
tags: Array.isArray(item.tags)
|
|
102
|
+
? item.tags.filter((t) => typeof t === 'string').slice(0, 5)
|
|
103
|
+
: [],
|
|
104
|
+
}))
|
|
105
|
+
.slice(0, 5); // Hard cap at 5 facts per session
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
console.warn(`[MemoryExtraction] Failed to parse JSON: ${cleaned.slice(0, 100)}`);
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -868,6 +868,12 @@ export class ModelRouter {
|
|
|
868
868
|
return 'copilot';
|
|
869
869
|
if (url.includes('api.github.com'))
|
|
870
870
|
return 'github';
|
|
871
|
+
if (url.includes('groq.com'))
|
|
872
|
+
return 'groq';
|
|
873
|
+
if (url.includes('api.openai.com'))
|
|
874
|
+
return 'openai';
|
|
875
|
+
if (url.includes('api.anthropic.com'))
|
|
876
|
+
return 'anthropic';
|
|
871
877
|
return 'unknown';
|
|
872
878
|
}
|
|
873
879
|
getModelCooldownState(modelId) {
|
|
@@ -1043,11 +1049,71 @@ export class ModelRouter {
|
|
|
1043
1049
|
return 0;
|
|
1044
1050
|
});
|
|
1045
1051
|
for (const def of sortedDefinitions) {
|
|
1052
|
+
// Resolve missing model/apiKeyEnvName from the static catalog and provider info
|
|
1053
|
+
let resolvedModel = def.model;
|
|
1054
|
+
let resolvedApiKeyEnvName = def.apiKeyEnvName;
|
|
1055
|
+
if (!resolvedModel || !resolvedApiKeyEnvName) {
|
|
1056
|
+
// Try to find the model in the static catalog by ID
|
|
1057
|
+
const catalogEntry = STATIC_MODEL_CATALOG.find(m => m.id === def.id);
|
|
1058
|
+
if (catalogEntry) {
|
|
1059
|
+
if (!resolvedModel)
|
|
1060
|
+
resolvedModel = catalogEntry.model;
|
|
1061
|
+
if (!resolvedApiKeyEnvName) {
|
|
1062
|
+
const providerInfo = PROVIDER_INFO[catalogEntry.provider];
|
|
1063
|
+
resolvedApiKeyEnvName = providerInfo?.apiKeyEnvName ?? '';
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
// If still missing, infer provider from baseURL and resolve apiKeyEnvName
|
|
1068
|
+
if (!resolvedApiKeyEnvName) {
|
|
1069
|
+
const url = def.baseURL.toLowerCase();
|
|
1070
|
+
if (url.includes('groq.com'))
|
|
1071
|
+
resolvedApiKeyEnvName = 'GROQ_API_KEY';
|
|
1072
|
+
else if (url.includes('openrouter.ai'))
|
|
1073
|
+
resolvedApiKeyEnvName = 'OPENROUTER_API_KEY';
|
|
1074
|
+
else if (url.includes('modal.direct'))
|
|
1075
|
+
resolvedApiKeyEnvName = 'MODAL_API_KEY';
|
|
1076
|
+
else if (url.includes('api.openai.com'))
|
|
1077
|
+
resolvedApiKeyEnvName = 'OPENAI_API_KEY';
|
|
1078
|
+
else if (url.includes('generativelanguage.googleapis.com'))
|
|
1079
|
+
resolvedApiKeyEnvName = 'GEMINI_API_KEY';
|
|
1080
|
+
else if (url.includes('githubcopilot.com'))
|
|
1081
|
+
resolvedApiKeyEnvName = 'GITHUB_TOKEN';
|
|
1082
|
+
else if (url.includes('api.anthropic.com'))
|
|
1083
|
+
resolvedApiKeyEnvName = 'ANTHROPIC_API_KEY';
|
|
1084
|
+
}
|
|
1085
|
+
// If model is still missing, try to derive from the ID (e.g., groq-qwen-qwen3-32b -> qwen/qwen3-32b)
|
|
1086
|
+
if (!resolvedModel) {
|
|
1087
|
+
// Try a fuzzy match against catalog entries by normalizing IDs
|
|
1088
|
+
const normalizedDefId = def.id.toLowerCase();
|
|
1089
|
+
const fuzzyMatch = STATIC_MODEL_CATALOG.find(m => {
|
|
1090
|
+
const normalizedCatalogId = m.id.toLowerCase();
|
|
1091
|
+
return normalizedDefId === normalizedCatalogId ||
|
|
1092
|
+
normalizedDefId.includes(normalizedCatalogId) ||
|
|
1093
|
+
normalizedCatalogId.includes(normalizedDefId);
|
|
1094
|
+
});
|
|
1095
|
+
if (fuzzyMatch) {
|
|
1096
|
+
resolvedModel = fuzzyMatch.model;
|
|
1097
|
+
}
|
|
1098
|
+
else {
|
|
1099
|
+
// Last resort: strip provider prefix and use the rest as model name
|
|
1100
|
+
const parts = def.id.split('-');
|
|
1101
|
+
if (parts.length > 1) {
|
|
1102
|
+
// For IDs like 'groq-qwen-qwen3-32b', try 'qwen/qwen3-32b'
|
|
1103
|
+
// For IDs like 'openrouter-z-ai-glm-4-5-air-free', try 'z-ai/glm-4-5-air:free'
|
|
1104
|
+
resolvedModel = def.id; // Use ID as-is — the API will reject if wrong, triggering fallback
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
if (!resolvedModel) {
|
|
1109
|
+
logThought(`[Router] Skipping definition '${def.id}': unable to resolve model name.`);
|
|
1110
|
+
continue;
|
|
1111
|
+
}
|
|
1046
1112
|
configModels.push({
|
|
1047
1113
|
id: def.id,
|
|
1048
|
-
model:
|
|
1114
|
+
model: resolvedModel,
|
|
1049
1115
|
baseURL: def.baseURL,
|
|
1050
|
-
apiKeyEnvName:
|
|
1116
|
+
apiKeyEnvName: resolvedApiKeyEnvName || '',
|
|
1051
1117
|
});
|
|
1052
1118
|
}
|
|
1053
1119
|
// Even with definitions, add fallback providers in case primary fails
|