twinclaw 1.2.9 → 1.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.
- package/dist/agents/self-setup-agent.js +281 -0
- package/dist/config/json-config.js +3 -0
- package/dist/config/model-catalog.js +83 -0
- package/dist/core/gateway.js +90 -0
- package/dist/core/onboarding.js +3 -1
- package/dist/index.js +3 -0
- package/dist/interfaces/telegram_handler.js +51 -6
- package/dist/interfaces/whatsapp_handler.js +64 -8
- package/dist/services/model-catalog-service.js +163 -8
- package/dist/services/model-router.js +35 -1
- package/dist/services/user-memory.js +192 -0
- package/package.json +1 -1
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
var _a;
|
|
2
|
+
import { logThought } from '../utils/logger.js';
|
|
3
|
+
import { checkFirstInteraction, loadUserMemory, saveUserMemory, getDefaultUserMemory, } from '../services/user-memory.js';
|
|
4
|
+
export class SelfSetupAgent {
|
|
5
|
+
#userId;
|
|
6
|
+
#phase = 'detecting';
|
|
7
|
+
#discoveryAnswers = {};
|
|
8
|
+
#discoveryQuestionIndex = 0;
|
|
9
|
+
#messageCount = 0;
|
|
10
|
+
static DISCOVERY_QUESTIONS = [
|
|
11
|
+
{
|
|
12
|
+
category: 'professional',
|
|
13
|
+
question: "What do you do? (Role, company, industry)",
|
|
14
|
+
field: 'role',
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
category: 'professional',
|
|
18
|
+
question: "What are you working on right now? (Current projects)",
|
|
19
|
+
field: 'currentProject',
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
category: 'professional',
|
|
23
|
+
question: "What tools do you use daily? (Tech stack, platforms)",
|
|
24
|
+
field: 'tools',
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
category: 'personal',
|
|
28
|
+
question: "What do you like to do outside of work?",
|
|
29
|
+
field: 'interests',
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
category: 'communication',
|
|
33
|
+
question: "How do you like information presented? (Brief vs detailed, casual vs formal)",
|
|
34
|
+
field: 'communicationPreference',
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
category: 'communication',
|
|
38
|
+
question: "What frustrates you about AI assistants?",
|
|
39
|
+
field: 'frustrations',
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
category: 'goals',
|
|
43
|
+
question: "What are you trying to achieve? (Short and long term)",
|
|
44
|
+
field: 'goals',
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
category: 'technical',
|
|
48
|
+
question: "How technical should I be with you?",
|
|
49
|
+
field: 'technicalLevel',
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
constructor(userId) {
|
|
53
|
+
this.#userId = userId;
|
|
54
|
+
}
|
|
55
|
+
get userId() {
|
|
56
|
+
return this.#userId;
|
|
57
|
+
}
|
|
58
|
+
get phase() {
|
|
59
|
+
return this.#phase;
|
|
60
|
+
}
|
|
61
|
+
isComplete() {
|
|
62
|
+
return this.#phase === 'completed';
|
|
63
|
+
}
|
|
64
|
+
processFirstMessage(userMessage) {
|
|
65
|
+
this.#messageCount++;
|
|
66
|
+
if (this.#phase === 'detecting') {
|
|
67
|
+
return this.#startWarmIntro();
|
|
68
|
+
}
|
|
69
|
+
if (this.#phase === 'warm_intro') {
|
|
70
|
+
return this.#handleWarmIntroResponse(userMessage);
|
|
71
|
+
}
|
|
72
|
+
if (this.#phase === 'discovery') {
|
|
73
|
+
return this.#handleDiscoveryResponse(userMessage);
|
|
74
|
+
}
|
|
75
|
+
if (this.#phase === 'synthesis') {
|
|
76
|
+
return this.#handleSynthesisResponse(userMessage);
|
|
77
|
+
}
|
|
78
|
+
if (this.#phase === 'completed') {
|
|
79
|
+
return {
|
|
80
|
+
text: "Setup is complete! How can I help you today?",
|
|
81
|
+
phase: this.#phase,
|
|
82
|
+
isComplete: true,
|
|
83
|
+
needsMoreInfo: false,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
text: "I'm not sure what to do next. Let me start over.",
|
|
88
|
+
phase: this.#phase,
|
|
89
|
+
isComplete: false,
|
|
90
|
+
needsMoreInfo: true,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
#startWarmIntro() {
|
|
94
|
+
this.#phase = 'warm_intro';
|
|
95
|
+
logThought(`[SelfSetupAgent] Starting warm intro for user: ${this.#userId}`);
|
|
96
|
+
return {
|
|
97
|
+
text: `Hey! I'm noticing this is our first conversation. 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).
|
|
98
|
+
|
|
99
|
+
Think of this as a quick coffee chat where I learn what makes you tick. Cool?
|
|
100
|
+
|
|
101
|
+
To start - what do you do, and what kind of thing are you looking to build?`,
|
|
102
|
+
phase: this.#phase,
|
|
103
|
+
isComplete: false,
|
|
104
|
+
needsMoreInfo: true,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
#handleWarmIntroResponse(userMessage) {
|
|
108
|
+
this.#discoveryAnswers.role = userMessage;
|
|
109
|
+
this.#phase = 'discovery';
|
|
110
|
+
this.#discoveryQuestionIndex = 0;
|
|
111
|
+
return this.#askNextDiscoveryQuestion();
|
|
112
|
+
}
|
|
113
|
+
#askNextDiscoveryQuestion() {
|
|
114
|
+
const question = _a.DISCOVERY_QUESTIONS[this.#discoveryQuestionIndex];
|
|
115
|
+
if (!question) {
|
|
116
|
+
return this.#moveToSynthesis();
|
|
117
|
+
}
|
|
118
|
+
const questionText = question.question;
|
|
119
|
+
return {
|
|
120
|
+
text: questionText,
|
|
121
|
+
phase: this.#phase,
|
|
122
|
+
isComplete: false,
|
|
123
|
+
needsMoreInfo: true,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
#handleDiscoveryResponse(userMessage) {
|
|
127
|
+
const question = _a.DISCOVERY_QUESTIONS[this.#discoveryQuestionIndex];
|
|
128
|
+
if (question) {
|
|
129
|
+
const field = question.field;
|
|
130
|
+
this.#discoveryAnswers[field] = userMessage;
|
|
131
|
+
}
|
|
132
|
+
this.#discoveryQuestionIndex++;
|
|
133
|
+
if (this.#discoveryQuestionIndex >= _a.DISCOVERY_QUESTIONS.length) {
|
|
134
|
+
return this.#moveToSynthesis();
|
|
135
|
+
}
|
|
136
|
+
return this.#askNextDiscoveryQuestion();
|
|
137
|
+
}
|
|
138
|
+
#moveToSynthesis() {
|
|
139
|
+
this.#phase = 'synthesis';
|
|
140
|
+
const answers = this.#discoveryAnswers;
|
|
141
|
+
const synthesis = this.#buildSynthesis(answers);
|
|
142
|
+
return {
|
|
143
|
+
text: `${synthesis}
|
|
144
|
+
|
|
145
|
+
Did I get that right? Anything I should adjust or add?`,
|
|
146
|
+
phase: this.#phase,
|
|
147
|
+
isComplete: false,
|
|
148
|
+
needsMoreInfo: true,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
#buildSynthesis(answers) {
|
|
152
|
+
const insights = [];
|
|
153
|
+
if (answers.role) {
|
|
154
|
+
insights.push(`You're working as ${answers.role}`);
|
|
155
|
+
}
|
|
156
|
+
if (answers.currentProject) {
|
|
157
|
+
insights.push(`currently focused on ${answers.currentProject}`);
|
|
158
|
+
}
|
|
159
|
+
if (answers.tools && answers.tools.length > 0) {
|
|
160
|
+
insights.push(`using tools like ${answers.tools}`);
|
|
161
|
+
}
|
|
162
|
+
if (answers.interests) {
|
|
163
|
+
insights.push(`into ${answers.interests} outside work`);
|
|
164
|
+
}
|
|
165
|
+
let preference = 'contextual';
|
|
166
|
+
if (answers.communicationPreference) {
|
|
167
|
+
if (answers.communicationPreference.includes('brief')) {
|
|
168
|
+
preference = 'brief';
|
|
169
|
+
}
|
|
170
|
+
else if (answers.communicationPreference.includes('detail')) {
|
|
171
|
+
preference = 'comprehensive';
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
insights.push(`prefer ${preference} communication`);
|
|
175
|
+
if (answers.goals) {
|
|
176
|
+
insights.push(`your goals include ${answers.goals}`);
|
|
177
|
+
}
|
|
178
|
+
const synthesis = insights.join('. ');
|
|
179
|
+
return `Alright, here's what I'm picking up:\n- ${insights.join('\n- ')}`;
|
|
180
|
+
}
|
|
181
|
+
#handleSynthesisResponse(userMessage) {
|
|
182
|
+
const lowerResponse = userMessage.toLowerCase();
|
|
183
|
+
if (lowerResponse.includes('yes') || lowerResponse.includes('right') || lowerResponse.includes('correct') || lowerResponse.includes('yep')) {
|
|
184
|
+
return this.#completeSetup();
|
|
185
|
+
}
|
|
186
|
+
return this.#completeSetup();
|
|
187
|
+
}
|
|
188
|
+
#completeSetup() {
|
|
189
|
+
this.#phase = 'file_creation';
|
|
190
|
+
const memory = this.#buildUserMemory();
|
|
191
|
+
saveUserMemory(this.#userId, memory);
|
|
192
|
+
this.#phase = 'completed';
|
|
193
|
+
logThought(`[SelfSetupAgent] Completed setup for user: ${this.#userId}`);
|
|
194
|
+
return {
|
|
195
|
+
text: `Cool, I've saved all this so I don't forget. I created a few quick reference files:
|
|
196
|
+
- Your profile & preferences
|
|
197
|
+
- How you like me to communicate
|
|
198
|
+
- What you're currently working on
|
|
199
|
+
- Quick facts I can reference
|
|
200
|
+
|
|
201
|
+
These will help me be consistent and actually useful. I'll update them as I learn more about you.
|
|
202
|
+
|
|
203
|
+
We're all set! From now on, I'll:
|
|
204
|
+
✓ Remember our conversations and what matters to you
|
|
205
|
+
✓ Adapt my style to what works for you
|
|
206
|
+
✓ Keep track of your projects and goals
|
|
207
|
+
✓ Update my notes as things change
|
|
208
|
+
|
|
209
|
+
If I ever seem off-base or forget something important, just call me out. Ready to jump in?`,
|
|
210
|
+
phase: this.#phase,
|
|
211
|
+
isComplete: true,
|
|
212
|
+
needsMoreInfo: false,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
#buildUserMemory() {
|
|
216
|
+
const defaultMemory = getDefaultUserMemory();
|
|
217
|
+
const answers = this.#discoveryAnswers;
|
|
218
|
+
defaultMemory.profile.identity.role = answers.role;
|
|
219
|
+
defaultMemory.profile.identity.technicalLevel = this.#mapTechnicalLevel(answers.technicalLevel);
|
|
220
|
+
defaultMemory.profile.professionalBackground.currentFocus = answers.currentProject;
|
|
221
|
+
if (answers.tools) {
|
|
222
|
+
defaultMemory.profile.professionalBackground.toolsAndPlatforms = Array.isArray(answers.tools)
|
|
223
|
+
? answers.tools
|
|
224
|
+
: [answers.tools];
|
|
225
|
+
}
|
|
226
|
+
defaultMemory.profile.personalContext.interests = answers.interests ? [answers.interests] : [];
|
|
227
|
+
if (answers.communicationPreference) {
|
|
228
|
+
defaultMemory.profile.communicationStyle.detailLevel = this.#mapDetailLevel(answers.communicationPreference);
|
|
229
|
+
}
|
|
230
|
+
if (answers.frustrations) {
|
|
231
|
+
defaultMemory.profile.communicationStyle.frustrations = Array.isArray(answers.frustrations)
|
|
232
|
+
? answers.frustrations
|
|
233
|
+
: [answers.frustrations];
|
|
234
|
+
}
|
|
235
|
+
if (answers.goals) {
|
|
236
|
+
defaultMemory.profile.goals.shortTerm = [answers.goals];
|
|
237
|
+
}
|
|
238
|
+
return defaultMemory;
|
|
239
|
+
}
|
|
240
|
+
#mapTechnicalLevel(level) {
|
|
241
|
+
if (!level)
|
|
242
|
+
return undefined;
|
|
243
|
+
const lower = level.toLowerCase();
|
|
244
|
+
if (lower.includes('expert') || lower.includes('advanced'))
|
|
245
|
+
return 'expert';
|
|
246
|
+
if (lower.includes('beginner') || lower.includes('new'))
|
|
247
|
+
return 'beginner';
|
|
248
|
+
return 'intermediate';
|
|
249
|
+
}
|
|
250
|
+
#mapDetailLevel(pref) {
|
|
251
|
+
if (!pref)
|
|
252
|
+
return undefined;
|
|
253
|
+
const lower = pref.toLowerCase();
|
|
254
|
+
if (lower.includes('brief') || lower.includes('short'))
|
|
255
|
+
return 'brief';
|
|
256
|
+
if (lower.includes('detail') || lower.includes('comprehensive'))
|
|
257
|
+
return 'comprehensive';
|
|
258
|
+
return 'contextual';
|
|
259
|
+
}
|
|
260
|
+
cancel() {
|
|
261
|
+
this.#phase = 'cancelled';
|
|
262
|
+
logThought(`[SelfSetupAgent] Setup cancelled for user: ${this.#userId}`);
|
|
263
|
+
}
|
|
264
|
+
getState() {
|
|
265
|
+
return {
|
|
266
|
+
phase: this.#phase,
|
|
267
|
+
messageCount: this.#messageCount,
|
|
268
|
+
answersCount: Object.keys(this.#discoveryAnswers).length,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
_a = SelfSetupAgent;
|
|
273
|
+
export function createSelfSetupAgent(userId) {
|
|
274
|
+
if (!checkFirstInteraction(userId)) {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
return new SelfSetupAgent(userId);
|
|
278
|
+
}
|
|
279
|
+
export function getExistingUserMemory(userId) {
|
|
280
|
+
return loadUserMemory(userId);
|
|
281
|
+
}
|
|
@@ -631,6 +631,9 @@ export function getConfigValue(key, sensitive = false) {
|
|
|
631
631
|
case 'GITHUB_TOKEN':
|
|
632
632
|
jsonValue = config.models.githubToken;
|
|
633
633
|
break;
|
|
634
|
+
case 'MODEL_DEFINITIONS':
|
|
635
|
+
jsonValue = JSON.stringify(config.models.definitions ?? []);
|
|
636
|
+
break;
|
|
634
637
|
case 'TELEGRAM_BOT_TOKEN':
|
|
635
638
|
jsonValue = config.messaging.telegram.botToken;
|
|
636
639
|
break;
|
|
@@ -265,6 +265,89 @@ export const STATIC_MODEL_CATALOG = [
|
|
|
265
265
|
pricing: 'Included with Copilot',
|
|
266
266
|
description: 'GitHub Copilot model',
|
|
267
267
|
})),
|
|
268
|
+
// Modal Custom Models
|
|
269
|
+
{
|
|
270
|
+
id: 'modal-zai-glm-5-fp8',
|
|
271
|
+
name: 'GLM-5-FP8',
|
|
272
|
+
provider: 'modal',
|
|
273
|
+
model: 'zai-org/GLM-5-FP8',
|
|
274
|
+
contextLength: 128000,
|
|
275
|
+
supportsStreaming: true,
|
|
276
|
+
pricing: 'Custom endpoint',
|
|
277
|
+
description: 'Custom Modal model'
|
|
278
|
+
},
|
|
279
|
+
// Groq Models
|
|
280
|
+
{
|
|
281
|
+
id: 'groq-qwen3-32b',
|
|
282
|
+
name: 'Qwen 3 32B',
|
|
283
|
+
provider: 'groq',
|
|
284
|
+
model: 'qwen/qwen3-32b',
|
|
285
|
+
contextLength: 32768,
|
|
286
|
+
supportsStreaming: true,
|
|
287
|
+
pricing: 'Free tier available',
|
|
288
|
+
description: 'Fast inference with Groq'
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
id: 'groq-kimi-k2',
|
|
292
|
+
name: 'Kimi K2 Instruct',
|
|
293
|
+
provider: 'groq',
|
|
294
|
+
model: 'moonshotai/kimi-k2-instruct-0905',
|
|
295
|
+
contextLength: 32768,
|
|
296
|
+
supportsStreaming: true,
|
|
297
|
+
pricing: 'Free tier available',
|
|
298
|
+
description: 'Fast inference with Groq'
|
|
299
|
+
},
|
|
300
|
+
// OpenRouter Free Models
|
|
301
|
+
{
|
|
302
|
+
id: 'openrouter-stepfun-flash',
|
|
303
|
+
name: 'StepFun Flash (Free)',
|
|
304
|
+
provider: 'openrouter',
|
|
305
|
+
model: 'stepfun/step-3.5-flash:free',
|
|
306
|
+
contextLength: 32000,
|
|
307
|
+
supportsStreaming: true,
|
|
308
|
+
pricing: 'Free',
|
|
309
|
+
description: 'Free model via OpenRouter'
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
id: 'openrouter-trinity-large',
|
|
313
|
+
name: 'Trinity Large (Free)',
|
|
314
|
+
provider: 'openrouter',
|
|
315
|
+
model: 'arcee-ai/trinity-large-preview:free',
|
|
316
|
+
contextLength: 32000,
|
|
317
|
+
supportsStreaming: true,
|
|
318
|
+
pricing: 'Free',
|
|
319
|
+
description: 'Free model via OpenRouter'
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
id: 'openrouter-trinity-mini',
|
|
323
|
+
name: 'Trinity Mini (Free)',
|
|
324
|
+
provider: 'openrouter',
|
|
325
|
+
model: 'arcee-ai/trinity-mini:free',
|
|
326
|
+
contextLength: 32000,
|
|
327
|
+
supportsStreaming: true,
|
|
328
|
+
pricing: 'Free',
|
|
329
|
+
description: 'Free model via OpenRouter'
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
id: 'openrouter-qwen-vl',
|
|
333
|
+
name: 'Qwen VL (Free)',
|
|
334
|
+
provider: 'openrouter',
|
|
335
|
+
model: 'qwen/qwen3-vl-30b-a3b-thinking',
|
|
336
|
+
contextLength: 32000,
|
|
337
|
+
supportsStreaming: true,
|
|
338
|
+
pricing: 'Free',
|
|
339
|
+
description: 'Free vision model via OpenRouter'
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
id: 'openrouter-gpt-oss',
|
|
343
|
+
name: 'GPT OSS (Free)',
|
|
344
|
+
provider: 'openrouter',
|
|
345
|
+
model: 'openai/gpt-oss-120b:free',
|
|
346
|
+
contextLength: 32000,
|
|
347
|
+
supportsStreaming: true,
|
|
348
|
+
pricing: 'Free',
|
|
349
|
+
description: 'Free model via OpenRouter'
|
|
350
|
+
},
|
|
268
351
|
// GitHub Models catalog entries can also be fetched dynamically
|
|
269
352
|
// Modal Custom Models (user-specific - will be added dynamically)
|
|
270
353
|
];
|
package/dist/core/gateway.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'path';
|
|
2
4
|
import { createSession, getSessionMessages, saveMessage } from '../services/db.js';
|
|
3
5
|
import { ModelRouter } from '../services/model-router.js';
|
|
6
|
+
import { getIdentityDir } from '../config/workspace.js';
|
|
4
7
|
import { indexConversationTurn, retrieveEvidenceAwareMemoryContext, } from '../services/semantic-memory.js';
|
|
5
8
|
import { OrchestrationService } from '../services/orchestration-service.js';
|
|
6
9
|
import { assembleContext } from './context-assembly.js';
|
|
@@ -8,6 +11,7 @@ import { LaneExecutor } from './lane-executor.js';
|
|
|
8
11
|
import { PolicyEngine } from '../services/policy-engine.js';
|
|
9
12
|
import { logThought } from '../utils/logger.js';
|
|
10
13
|
import { ContextLifecycleOrchestrator } from '../services/context-lifecycle.js';
|
|
14
|
+
import { createSelfSetupAgent } from '../agents/self-setup-agent.js';
|
|
11
15
|
const DEFAULT_MAX_TOOL_ROUNDS = 6;
|
|
12
16
|
const DEFAULT_IDENTICAL_TOOL_CALL_LIMIT = 3;
|
|
13
17
|
const DEFAULT_DELEGATION_MIN_SCORE = 2;
|
|
@@ -89,6 +93,7 @@ export class Gateway {
|
|
|
89
93
|
#contextLifecycle;
|
|
90
94
|
#toolPolicy;
|
|
91
95
|
#degradationCounts = new Map();
|
|
96
|
+
#selfSetupAgents = new Map();
|
|
92
97
|
constructor(registry, options = {}) {
|
|
93
98
|
this.#router = options.router ?? new ModelRouter();
|
|
94
99
|
this.#orchestration = options.orchestration ?? new OrchestrationService();
|
|
@@ -163,6 +168,24 @@ export class Gateway {
|
|
|
163
168
|
return 'I could not find any text content to process.';
|
|
164
169
|
}
|
|
165
170
|
const sessionId = `${message.platform}:${message.senderId}`;
|
|
171
|
+
const userId = message.senderId;
|
|
172
|
+
const existingAgent = this.#selfSetupAgents.get(userId);
|
|
173
|
+
if (existingAgent) {
|
|
174
|
+
const response = existingAgent.processFirstMessage(normalizedText);
|
|
175
|
+
if (response.isComplete) {
|
|
176
|
+
this.#selfSetupAgents.delete(userId);
|
|
177
|
+
if (response.isComplete && !response.needsMoreInfo) {
|
|
178
|
+
return response.text;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return response.text;
|
|
182
|
+
}
|
|
183
|
+
const newAgent = createSelfSetupAgent(userId);
|
|
184
|
+
if (newAgent) {
|
|
185
|
+
this.#selfSetupAgents.set(userId, newAgent);
|
|
186
|
+
const response = newAgent.processFirstMessage(normalizedText);
|
|
187
|
+
return response.text;
|
|
188
|
+
}
|
|
166
189
|
return this.processText(sessionId, normalizedText);
|
|
167
190
|
}
|
|
168
191
|
async processText(sessionId, text) {
|
|
@@ -174,6 +197,17 @@ export class Gateway {
|
|
|
174
197
|
const historyRows = getSessionMessages(sessionId);
|
|
175
198
|
const conversationHistory = toConversationHistory(historyRows);
|
|
176
199
|
const historyPlan = this.#contextLifecycle.planHistoryWindow(conversationHistory);
|
|
200
|
+
const isFirstInteraction = historyRows.length === 0;
|
|
201
|
+
let setupPrompt = '';
|
|
202
|
+
if (isFirstInteraction) {
|
|
203
|
+
const userMdPath = path.join(getIdentityDir(), 'user.md');
|
|
204
|
+
try {
|
|
205
|
+
await fs.access(userMdPath);
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
setupPrompt = this.#getPersonaSetupPrompt();
|
|
209
|
+
}
|
|
210
|
+
}
|
|
177
211
|
await this.#persistTurn(sessionId, 'user', normalizedText);
|
|
178
212
|
const memoryRetrieval = await retrieveEvidenceAwareMemoryContext(sessionId, normalizedText, historyPlan.memoryTopK);
|
|
179
213
|
const memoryContext = memoryRetrieval.context;
|
|
@@ -190,6 +224,7 @@ export class Gateway {
|
|
|
190
224
|
const messages = [
|
|
191
225
|
{ role: 'system', content: compactSystemPrompt.content },
|
|
192
226
|
...historyPlan.hotHistory,
|
|
227
|
+
...(setupPrompt ? [{ role: 'system', content: setupPrompt }] : []),
|
|
193
228
|
{ role: 'user', content: normalizedText },
|
|
194
229
|
];
|
|
195
230
|
return this.#runConversationLoop(sessionId, messages);
|
|
@@ -458,4 +493,59 @@ export class Gateway {
|
|
|
458
493
|
}
|
|
459
494
|
return content;
|
|
460
495
|
}
|
|
496
|
+
#getPersonaSetupPrompt() {
|
|
497
|
+
return `
|
|
498
|
+
## First Interaction - Persona Setup
|
|
499
|
+
|
|
500
|
+
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).
|
|
501
|
+
|
|
502
|
+
Think of this as a quick chat where I learn what makes you tick.
|
|
503
|
+
|
|
504
|
+
Start by introducing yourself warmly and asking me 2-3 questions about:
|
|
505
|
+
- What do you do? (Role, industry, company)
|
|
506
|
+
- What are you working on right now?
|
|
507
|
+
- How do you like information presented? (Brief vs detailed, casual vs formal)
|
|
508
|
+
- What frustrates you about AI assistants?
|
|
509
|
+
- What platforms or tools do you use that I might integrate with?
|
|
510
|
+
|
|
511
|
+
After I respond, synthesize what you learn and create/update the user.md file in my identity directory with:
|
|
512
|
+
- User name and how they'd like to be addressed
|
|
513
|
+
- Role/industry/company
|
|
514
|
+
- Communication preferences
|
|
515
|
+
- Current projects or goals
|
|
516
|
+
- Tools and platforms they use
|
|
517
|
+
- Anything they want me to know or avoid
|
|
518
|
+
|
|
519
|
+
The user.md file should follow this format:
|
|
520
|
+
|
|
521
|
+
\`\`\`markdown
|
|
522
|
+
# User Profile
|
|
523
|
+
|
|
524
|
+
## Basic Info
|
|
525
|
+
- **Name:** [what they want to be called]
|
|
526
|
+
- **Role:** [job title/position]
|
|
527
|
+
- **Company/Context:** [where they work/study]
|
|
528
|
+
- **Technical Level:** [beginner/intermediate/expert]
|
|
529
|
+
|
|
530
|
+
## Communication Preferences
|
|
531
|
+
- **Formality:** [casual/professional/mix]
|
|
532
|
+
- **Detail Level:** [brief/comprehensive/contextual]
|
|
533
|
+
- **Tone:** [direct/exploratory/friendly]
|
|
534
|
+
|
|
535
|
+
## Current Context
|
|
536
|
+
- **Active Projects:** [what they're working on]
|
|
537
|
+
- **Goals:** [what they're trying to achieve]
|
|
538
|
+
- **Tools:** [daily tech stack, platforms]
|
|
539
|
+
|
|
540
|
+
## Important to Remember
|
|
541
|
+
- [things they explicitly mention caring about]
|
|
542
|
+
- [frustrations they mention]
|
|
543
|
+
|
|
544
|
+
## Learned Facts
|
|
545
|
+
- [interesting facts from our conversation]
|
|
546
|
+
\`\`\`
|
|
547
|
+
|
|
548
|
+
Tell me once you've saved this. Then we'll be ready to dive in!
|
|
549
|
+
`;
|
|
550
|
+
}
|
|
461
551
|
}
|
package/dist/core/onboarding.js
CHANGED
|
@@ -8,7 +8,7 @@ import { isValidApiPort, isValidE164LikePhone, isValidProviderModelFormat, } fro
|
|
|
8
8
|
import { createSession } from '../services/db.js';
|
|
9
9
|
import { logThought } from '../utils/logger.js';
|
|
10
10
|
import { getGitHubCopilotService } from '../services/github-copilot-service.js';
|
|
11
|
-
import { getModelCatalogService } from '../services/model-catalog-service.js';
|
|
11
|
+
import { getModelCatalogService, initializeModelCatalog } from '../services/model-catalog-service.js';
|
|
12
12
|
import { PROVIDER_INFO } from '../config/model-catalog.js';
|
|
13
13
|
const rl = readline.createInterface({
|
|
14
14
|
input: process.stdin,
|
|
@@ -1214,6 +1214,8 @@ async function restoreConfigSnapshot(configPath, snapshot) {
|
|
|
1214
1214
|
await writeFile(configPath, snapshot, { encoding: 'utf8', mode: 0o600 });
|
|
1215
1215
|
}
|
|
1216
1216
|
export async function runSetupWizard(options = {}) {
|
|
1217
|
+
// Initialize model catalog (fetch GitHub models)
|
|
1218
|
+
await initializeModelCatalog();
|
|
1217
1219
|
const logger = options.logger ?? console;
|
|
1218
1220
|
const configPath = getConfigPath(options.configPathOverride);
|
|
1219
1221
|
const baseConfig = await readConfig(options.configPathOverride);
|
package/dist/index.js
CHANGED
|
@@ -19,6 +19,7 @@ import { SttService } from './services/stt-service.js';
|
|
|
19
19
|
import { TtsService } from './services/tts-service.js';
|
|
20
20
|
import { QueueService } from './services/queue-service.js';
|
|
21
21
|
import { ModelRouter } from './services/model-router.js';
|
|
22
|
+
import { initializeModelCatalog } from './services/model-catalog-service.js';
|
|
22
23
|
import { IncidentManager } from './services/incident-manager.js';
|
|
23
24
|
import { RuntimeBudgetGovernor } from './services/runtime-budget-governor.js';
|
|
24
25
|
import { LocalStateBackupService } from './services/local-state-backup.js';
|
|
@@ -240,6 +241,8 @@ async function main() {
|
|
|
240
241
|
return 'allowlist';
|
|
241
242
|
}
|
|
242
243
|
const runtimeBudgetGovernor = new RuntimeBudgetGovernor();
|
|
244
|
+
// Initialize model catalog (fetch GitHub models in background)
|
|
245
|
+
initializeModelCatalog().catch(err => console.warn('[ModelCatalog] Init failed:', err));
|
|
243
246
|
const modelRouter = new ModelRouter({ budgetGovernor: runtimeBudgetGovernor });
|
|
244
247
|
const gateway = new Gateway(skillRegistry, {
|
|
245
248
|
policyEngine,
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import TelegramBot from 'node-telegram-bot-api';
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { readConfig, writeConfig } from '../config/json-config.js';
|
|
5
|
+
import { getModelCatalogService } from '../services/model-catalog-service.js';
|
|
4
6
|
/** Minimum ms delay between processing successive messages (human-like pacing). */
|
|
5
7
|
const RATE_LIMIT_MS = 1500;
|
|
6
8
|
const TELEGRAM_COMMANDS = [
|
|
@@ -128,20 +130,63 @@ _All systems operational_
|
|
|
128
130
|
break;
|
|
129
131
|
case '/models':
|
|
130
132
|
case '/model':
|
|
131
|
-
if (args.startsWith('
|
|
132
|
-
const
|
|
133
|
-
|
|
133
|
+
if (args.startsWith('list')) {
|
|
134
|
+
const catalog = getModelCatalogService().getAllModels();
|
|
135
|
+
const config = await readConfig();
|
|
136
|
+
const currentPrimary = config.models.primaryModel || 'not set';
|
|
137
|
+
let modelList = `📦 *Available Models*\n\nCurrent: *${currentPrimary}*\n\n`;
|
|
138
|
+
const providerGroups = new Map();
|
|
139
|
+
for (const model of catalog) {
|
|
140
|
+
const models = providerGroups.get(model.provider) || [];
|
|
141
|
+
models.push(model);
|
|
142
|
+
providerGroups.set(model.provider, models);
|
|
143
|
+
}
|
|
144
|
+
let num = 1;
|
|
145
|
+
for (const [provider, models] of providerGroups) {
|
|
146
|
+
modelList += `*${provider.toUpperCase()}:*\n`;
|
|
147
|
+
for (const m of models.slice(0, 5)) {
|
|
148
|
+
const isCurrent = m.model === currentPrimary || m.id === currentPrimary;
|
|
149
|
+
modelList += `${num}. ${m.name}${isCurrent ? ' ✅' : ''}\n`;
|
|
150
|
+
num++;
|
|
151
|
+
}
|
|
152
|
+
if (models.length > 5)
|
|
153
|
+
modelList += ` ...and ${models.length - 5} more\n`;
|
|
154
|
+
modelList += '\n';
|
|
155
|
+
}
|
|
156
|
+
modelList += '_To switch: /model set <number>_';
|
|
157
|
+
await this.#bot.sendMessage(chatId, modelList, { parse_mode: 'Markdown' });
|
|
158
|
+
}
|
|
159
|
+
else if (args.startsWith('set ') && args.length > 4) {
|
|
160
|
+
const modelIdOrNum = args.substring(4).trim();
|
|
161
|
+
const catalog = getModelCatalogService().getAllModels();
|
|
162
|
+
let newModelId = modelIdOrNum;
|
|
163
|
+
if (/^\d+$/.test(modelIdOrNum)) {
|
|
164
|
+
const idx = parseInt(modelIdOrNum, 10) - 1;
|
|
165
|
+
if (idx >= 0 && idx < catalog.length) {
|
|
166
|
+
newModelId = `${catalog[idx].provider}/${catalog[idx].model}`;
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
await this.#bot.sendMessage(chatId, `❌ Invalid model number. Use /model list to see available models.`);
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const config = await readConfig();
|
|
174
|
+
config.models.primaryModel = newModelId;
|
|
175
|
+
await writeConfig(config);
|
|
176
|
+
await this.#bot.sendMessage(chatId, `✅ *Model updated!*\n\nNew primary model: *${newModelId}*\n\nRestart TwinClaw for changes to take effect.`, { parse_mode: 'Markdown' });
|
|
134
177
|
}
|
|
135
178
|
else {
|
|
179
|
+
const config = await readConfig();
|
|
180
|
+
const currentPrimary = config.models.primaryModel || 'not set';
|
|
136
181
|
await this.#bot.sendMessage(chatId, `📦 *Models*
|
|
137
182
|
|
|
138
|
-
|
|
183
|
+
Current: *${currentPrimary}*
|
|
139
184
|
|
|
140
185
|
Commands:
|
|
141
186
|
• /model list - List available models
|
|
142
|
-
• /model set <
|
|
187
|
+
• /model set <number> - Set primary model
|
|
143
188
|
|
|
144
|
-
|
|
189
|
+
Example: /model set 1
|
|
145
190
|
`, { parse_mode: 'Markdown' });
|
|
146
191
|
}
|
|
147
192
|
break;
|
|
@@ -6,7 +6,8 @@ import path from 'node:path';
|
|
|
6
6
|
import fs from 'node:fs/promises';
|
|
7
7
|
import { randomUUID } from 'node:crypto';
|
|
8
8
|
import { logThought } from '../utils/logger.js';
|
|
9
|
-
import { getConfigValue } from '../config/json-config.js';
|
|
9
|
+
import { getConfigValue, readConfig, writeConfig } from '../config/json-config.js';
|
|
10
|
+
import { getModelCatalogService } from '../services/model-catalog-service.js';
|
|
10
11
|
const { Client, LocalAuth, MessageMedia } = WAWebJS;
|
|
11
12
|
const RATE_LIMIT_MS = 1500;
|
|
12
13
|
const WHATSAPP_COMMANDS = [
|
|
@@ -70,8 +71,10 @@ export class WhatsAppHandler {
|
|
|
70
71
|
this.#registerListeners();
|
|
71
72
|
}
|
|
72
73
|
// ── Command Handlers ─────────────────────────────────────────────────────────
|
|
73
|
-
async handleCommand(chatId, command) {
|
|
74
|
-
const
|
|
74
|
+
async handleCommand(chatId, command, fullText) {
|
|
75
|
+
const parts = command.toLowerCase().trim().split(' ');
|
|
76
|
+
const cmd = parts[0];
|
|
77
|
+
const args = fullText ? fullText.substring(cmd.length).trim() : '';
|
|
75
78
|
switch (cmd) {
|
|
76
79
|
case '/start':
|
|
77
80
|
case '/menu':
|
|
@@ -103,12 +106,65 @@ _All systems operational_
|
|
|
103
106
|
break;
|
|
104
107
|
case '/models':
|
|
105
108
|
case '/model':
|
|
106
|
-
|
|
109
|
+
if (args.startsWith('list')) {
|
|
110
|
+
const catalog = getModelCatalogService().getAllModels();
|
|
111
|
+
const config = await readConfig();
|
|
112
|
+
const currentPrimary = config.models.primaryModel || 'not set';
|
|
113
|
+
let modelList = `📦 *Available Models*\n\nCurrent: ${currentPrimary}\n\n`;
|
|
114
|
+
const providerGroups = new Map();
|
|
115
|
+
for (const model of catalog) {
|
|
116
|
+
const models = providerGroups.get(model.provider) || [];
|
|
117
|
+
models.push(model);
|
|
118
|
+
providerGroups.set(model.provider, models);
|
|
119
|
+
}
|
|
120
|
+
let num = 1;
|
|
121
|
+
for (const [provider, models] of providerGroups) {
|
|
122
|
+
modelList += `*${provider.toUpperCase()}:*\n`;
|
|
123
|
+
for (const m of models.slice(0, 5)) {
|
|
124
|
+
const isCurrent = m.model === currentPrimary || m.id === currentPrimary;
|
|
125
|
+
modelList += `${num}. ${m.name}${isCurrent ? ' ✅' : ''}\n`;
|
|
126
|
+
num++;
|
|
127
|
+
}
|
|
128
|
+
if (models.length > 5)
|
|
129
|
+
modelList += ` ...and ${models.length - 5} more\n`;
|
|
130
|
+
modelList += '\n';
|
|
131
|
+
}
|
|
132
|
+
modelList += '_To switch: /model set <number>_';
|
|
133
|
+
await this.sendText(chatId, modelList);
|
|
134
|
+
}
|
|
135
|
+
else if (args.startsWith('set ') && args.length > 4) {
|
|
136
|
+
const modelIdOrNum = args.substring(4).trim();
|
|
137
|
+
const catalog = getModelCatalogService().getAllModels();
|
|
138
|
+
let newModelId = modelIdOrNum;
|
|
139
|
+
if (/^\d+$/.test(modelIdOrNum)) {
|
|
140
|
+
const idx = parseInt(modelIdOrNum, 10) - 1;
|
|
141
|
+
if (idx >= 0 && idx < catalog.length) {
|
|
142
|
+
newModelId = `${catalog[idx].provider}/${catalog[idx].model}`;
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
await this.sendText(chatId, '❌ Invalid model number. Use /model list to see available models.');
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const config = await readConfig();
|
|
150
|
+
config.models.primaryModel = newModelId;
|
|
151
|
+
await writeConfig(config);
|
|
152
|
+
await this.sendText(chatId, `✅ *Model updated!*\n\nNew primary model: ${newModelId}\n\nRestart TwinClaw for changes to take effect.`);
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
const config = await readConfig();
|
|
156
|
+
const currentPrimary = config.models.primaryModel || 'not set';
|
|
157
|
+
await this.sendText(chatId, `📦 *Models*
|
|
158
|
+
|
|
159
|
+
Current: ${currentPrimary}
|
|
107
160
|
|
|
108
|
-
|
|
161
|
+
Commands:
|
|
162
|
+
• /model list - List available models
|
|
163
|
+
• /model set <number> - Set primary model
|
|
109
164
|
|
|
110
|
-
|
|
165
|
+
Example: /model set 1
|
|
111
166
|
`);
|
|
167
|
+
}
|
|
112
168
|
break;
|
|
113
169
|
case '/keys':
|
|
114
170
|
case '/key':
|
|
@@ -246,8 +302,8 @@ Run: *twinclaw channels* in terminal
|
|
|
246
302
|
const text = msg.body;
|
|
247
303
|
// Handle commands
|
|
248
304
|
if (text.trim().startsWith('/')) {
|
|
249
|
-
const command = text.trim()
|
|
250
|
-
await this.handleCommand(msg.from, command);
|
|
305
|
+
const command = text.trim();
|
|
306
|
+
await this.handleCommand(msg.from, command, text.trim());
|
|
251
307
|
return;
|
|
252
308
|
}
|
|
253
309
|
await this.onMessage?.(base);
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { STATIC_MODEL_CATALOG } from '../config/model-catalog.js';
|
|
2
2
|
import { getSecretVaultService } from './secret-vault.js';
|
|
3
|
+
import { getGitHubCopilotService } from './github-copilot-service.js';
|
|
3
4
|
const GITHUB_CATALOG_URL = 'https://models.github.ai/catalog/models';
|
|
5
|
+
const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/models';
|
|
6
|
+
const GROQ_API_URL = 'https://api.groq.com/openai/v1/models';
|
|
4
7
|
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
5
8
|
function isRecord(value) {
|
|
6
9
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
@@ -55,8 +58,134 @@ function toGitHubModelEntry(value) {
|
|
|
55
58
|
}
|
|
56
59
|
return entry;
|
|
57
60
|
}
|
|
61
|
+
const GITHUB_COPILOT_TOKEN_URL = 'https://api.github.com/copilot_internal/v2/token';
|
|
62
|
+
async function exchangeForCopilotToken(githubToken) {
|
|
63
|
+
try {
|
|
64
|
+
const response = await fetch(GITHUB_COPILOT_TOKEN_URL, {
|
|
65
|
+
method: 'GET',
|
|
66
|
+
headers: {
|
|
67
|
+
'Authorization': `token ${githubToken}`,
|
|
68
|
+
'Accept': 'application/json'
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
if (!response.ok) {
|
|
72
|
+
console.warn('[ModelCatalog] Failed to exchange for Copilot token:', response.status);
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
const data = await response.json();
|
|
76
|
+
return data.token || null;
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
console.warn('[ModelCatalog] Error exchanging for Copilot token:', error);
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
async function fetchOpenRouterFreeModels(apiKey) {
|
|
84
|
+
if (!apiKey)
|
|
85
|
+
return [];
|
|
86
|
+
try {
|
|
87
|
+
const response = await fetch(OPENROUTER_API_URL, {
|
|
88
|
+
headers: {
|
|
89
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
90
|
+
'HTTP-Referer': 'https://twinclaw.ai',
|
|
91
|
+
'X-Title': 'TwinClaw'
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
if (!response.ok) {
|
|
95
|
+
console.warn('[ModelCatalog] Failed to fetch OpenRouter models:', response.status);
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
const data = await response.json();
|
|
99
|
+
const models = data.data || [];
|
|
100
|
+
return models
|
|
101
|
+
.filter(m => m.pricing?.prompt === '0' && m.pricing?.completion === '0')
|
|
102
|
+
.map(m => ({
|
|
103
|
+
id: `openrouter-${m.id.replace(/[^a-z0-9]/gi, '-')}`,
|
|
104
|
+
name: m.name || m.id,
|
|
105
|
+
provider: 'openrouter',
|
|
106
|
+
model: m.id,
|
|
107
|
+
contextLength: m.context_length || 32000,
|
|
108
|
+
supportsStreaming: true,
|
|
109
|
+
pricing: 'Free',
|
|
110
|
+
description: m.description || `Free model via OpenRouter (${m.architecture?.modality || 'text'})`
|
|
111
|
+
}));
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
console.warn('[ModelCatalog] Error fetching OpenRouter models:', error);
|
|
115
|
+
return [];
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
async function fetchGroqModels(apiKey) {
|
|
119
|
+
if (!apiKey)
|
|
120
|
+
return [];
|
|
121
|
+
try {
|
|
122
|
+
const response = await fetch(GROQ_API_URL, {
|
|
123
|
+
headers: {
|
|
124
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
125
|
+
'Content-Type': 'application/json'
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
if (!response.ok) {
|
|
129
|
+
console.warn('[ModelCatalog] Failed to fetch Groq models:', response.status);
|
|
130
|
+
return [];
|
|
131
|
+
}
|
|
132
|
+
const data = await response.json();
|
|
133
|
+
const models = data.data || [];
|
|
134
|
+
return models
|
|
135
|
+
.filter(m => m.active)
|
|
136
|
+
.map(m => ({
|
|
137
|
+
id: `groq-${m.id.replace(/[^a-z0-9-]/gi, '-')}`,
|
|
138
|
+
name: m.id,
|
|
139
|
+
provider: 'groq',
|
|
140
|
+
model: m.id,
|
|
141
|
+
contextLength: m.context_window || 32768,
|
|
142
|
+
supportsStreaming: true,
|
|
143
|
+
pricing: 'Free (with rate limits)',
|
|
144
|
+
description: `Free model on Groq (${(m.context_window / 1000).toFixed(0)}K context)`
|
|
145
|
+
}));
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
console.warn('[ModelCatalog] Error fetching Groq models:', error);
|
|
149
|
+
return [];
|
|
150
|
+
}
|
|
151
|
+
}
|
|
58
152
|
class ModelCatalogService {
|
|
59
153
|
githubCache = null;
|
|
154
|
+
openRouterCache = null;
|
|
155
|
+
groqCache = null;
|
|
156
|
+
async fetchProviderModels(forceRefresh = false) {
|
|
157
|
+
const vault = getSecretVaultService();
|
|
158
|
+
const openRouterKey = vault.readSecret('OPENROUTER_API_KEY') || getSecretVaultService().readSecret('OPENROUTER_API_KEY');
|
|
159
|
+
const groqKey = vault.readSecret('GROQ_API_KEY') || getSecretVaultService().readSecret('GROQ_API_KEY');
|
|
160
|
+
// Fetch OpenRouter free models
|
|
161
|
+
if (!forceRefresh && this.openRouterCache) {
|
|
162
|
+
const elapsed = Date.now() - this.openRouterCache.timestamp;
|
|
163
|
+
if (elapsed < CACHE_TTL_MS) {
|
|
164
|
+
// Cache still valid
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
this.openRouterCache = null;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (!this.openRouterCache && openRouterKey) {
|
|
171
|
+
const models = await fetchOpenRouterFreeModels(openRouterKey);
|
|
172
|
+
this.openRouterCache = { data: models, timestamp: Date.now() };
|
|
173
|
+
}
|
|
174
|
+
// Fetch Groq models
|
|
175
|
+
if (!forceRefresh && this.groqCache) {
|
|
176
|
+
const elapsed = Date.now() - this.groqCache.timestamp;
|
|
177
|
+
if (elapsed < CACHE_TTL_MS) {
|
|
178
|
+
// Cache still valid
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
this.groqCache = null;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (!this.groqCache && groqKey) {
|
|
185
|
+
const models = await fetchGroqModels(groqKey);
|
|
186
|
+
this.groqCache = { data: models, timestamp: Date.now() };
|
|
187
|
+
}
|
|
188
|
+
}
|
|
60
189
|
async fetchGitHubModels(forceRefresh = false) {
|
|
61
190
|
if (!forceRefresh && this.githubCache) {
|
|
62
191
|
const elapsed = Date.now() - this.githubCache.timestamp;
|
|
@@ -64,18 +193,39 @@ class ModelCatalogService {
|
|
|
64
193
|
return this.githubCache.data;
|
|
65
194
|
}
|
|
66
195
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
196
|
+
// Try to get Copilot token from GitHubCopilotService
|
|
197
|
+
let copilotToken = null;
|
|
198
|
+
try {
|
|
199
|
+
const copilotService = getGitHubCopilotService();
|
|
200
|
+
const auth = copilotService.getAuth();
|
|
201
|
+
if (auth?.copilotToken) {
|
|
202
|
+
copilotToken = auth.copilotToken;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
// GitHubCopilotService not initialized yet
|
|
207
|
+
}
|
|
208
|
+
// Fallback to GitHub OAuth token from vault
|
|
209
|
+
if (!copilotToken) {
|
|
210
|
+
const vault = getSecretVaultService();
|
|
211
|
+
const githubToken = vault.readSecret('GITHUB_TOKEN');
|
|
212
|
+
if (!githubToken) {
|
|
213
|
+
console.warn('[ModelCatalog] No GitHub token available. Cannot fetch GitHub models.');
|
|
214
|
+
return [];
|
|
215
|
+
}
|
|
216
|
+
// For GitHub OAuth token, we need to exchange it for Copilot token
|
|
217
|
+
copilotToken = await exchangeForCopilotToken(githubToken);
|
|
218
|
+
if (!copilotToken) {
|
|
219
|
+
console.warn('[ModelCatalog] Failed to get Copilot token. Cannot fetch GitHub models.');
|
|
220
|
+
return [];
|
|
221
|
+
}
|
|
72
222
|
}
|
|
73
223
|
try {
|
|
74
224
|
const response = await fetch(GITHUB_CATALOG_URL, {
|
|
75
225
|
method: 'GET',
|
|
76
226
|
headers: {
|
|
77
|
-
'Authorization': `Bearer ${
|
|
78
|
-
'Accept': 'application/vnd.github
|
|
227
|
+
'Authorization': `Bearer ${copilotToken}`,
|
|
228
|
+
'Accept': 'application/vnd.github+json',
|
|
79
229
|
'X-GitHub-Api-Version': '2022-11-28'
|
|
80
230
|
}
|
|
81
231
|
});
|
|
@@ -128,10 +278,14 @@ class ModelCatalogService {
|
|
|
128
278
|
}
|
|
129
279
|
getAllModels() {
|
|
130
280
|
const githubModels = this.getGitHubModelsCatalog();
|
|
131
|
-
|
|
281
|
+
const openRouterModels = this.openRouterCache?.data || [];
|
|
282
|
+
const groqModels = this.groqCache?.data || [];
|
|
283
|
+
return [...STATIC_MODEL_CATALOG, ...githubModels, ...openRouterModels, ...groqModels];
|
|
132
284
|
}
|
|
133
285
|
clearCache() {
|
|
134
286
|
this.githubCache = null;
|
|
287
|
+
this.openRouterCache = null;
|
|
288
|
+
this.groqCache = null;
|
|
135
289
|
}
|
|
136
290
|
slugify(text, fallback = 'model') {
|
|
137
291
|
const slug = text
|
|
@@ -151,4 +305,5 @@ export function getModelCatalogService() {
|
|
|
151
305
|
export async function initializeModelCatalog() {
|
|
152
306
|
const service = getModelCatalogService();
|
|
153
307
|
await service.fetchGitHubModels();
|
|
308
|
+
await service.fetchProviderModels();
|
|
154
309
|
}
|
|
@@ -1009,7 +1009,41 @@ export class ModelRouter {
|
|
|
1009
1009
|
}
|
|
1010
1010
|
loadModelsFromConfig() {
|
|
1011
1011
|
const configModels = [];
|
|
1012
|
-
//
|
|
1012
|
+
// First, check if config.models.definitions has any models (from model picker)
|
|
1013
|
+
// If so, use those instead of hardcoded fallbacks
|
|
1014
|
+
const definitionsJson = getConfigValue('MODEL_DEFINITIONS');
|
|
1015
|
+
if (definitionsJson) {
|
|
1016
|
+
try {
|
|
1017
|
+
const definitions = JSON.parse(definitionsJson);
|
|
1018
|
+
if (definitions && definitions.length > 0) {
|
|
1019
|
+
const primaryModel = getConfigValue('PRIMARY_MODEL') ?? '';
|
|
1020
|
+
// Sort models so primary is first, then the rest
|
|
1021
|
+
const sortedDefinitions = [...definitions].sort((a, b) => {
|
|
1022
|
+
if (a.id === primaryModel)
|
|
1023
|
+
return -1;
|
|
1024
|
+
if (b.id === primaryModel)
|
|
1025
|
+
return 1;
|
|
1026
|
+
return 0;
|
|
1027
|
+
});
|
|
1028
|
+
for (const def of sortedDefinitions) {
|
|
1029
|
+
configModels.push({
|
|
1030
|
+
id: def.id,
|
|
1031
|
+
model: def.model,
|
|
1032
|
+
baseURL: def.baseURL,
|
|
1033
|
+
apiKeyEnvName: def.apiKeyEnvName,
|
|
1034
|
+
});
|
|
1035
|
+
}
|
|
1036
|
+
// If we have models from definitions, return them
|
|
1037
|
+
if (configModels.length > 0) {
|
|
1038
|
+
return configModels;
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
catch {
|
|
1043
|
+
// JSON parse failed, fall through to legacy logic
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
// Legacy logic: Check if PRIMARY_MODEL is set - use config.models.primaryModel if available
|
|
1013
1047
|
const primaryModelId = getConfigValue('PRIMARY_MODEL');
|
|
1014
1048
|
const modalApiKey = getConfigValue('MODAL_API_KEY');
|
|
1015
1049
|
const openRouterApiKey = getConfigValue('OPENROUTER_API_KEY');
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { getWorkspaceDir } from '../config/workspace.js';
|
|
4
|
+
import { logThought } from '../utils/logger.js';
|
|
5
|
+
function getUserMemoryDir(userId) {
|
|
6
|
+
const sanitizedUserId = userId.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
7
|
+
return path.join(getWorkspaceDir(), 'memory', 'users', sanitizedUserId);
|
|
8
|
+
}
|
|
9
|
+
function ensureUserMemoryDir(userId) {
|
|
10
|
+
const dir = getUserMemoryDir(userId);
|
|
11
|
+
if (!fs.existsSync(dir)) {
|
|
12
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
13
|
+
}
|
|
14
|
+
return dir;
|
|
15
|
+
}
|
|
16
|
+
export function hasUserMemory(userId) {
|
|
17
|
+
const dir = getUserMemoryDir(userId);
|
|
18
|
+
const profilePath = path.join(dir, 'user_profile.md');
|
|
19
|
+
return fs.existsSync(profilePath);
|
|
20
|
+
}
|
|
21
|
+
export function loadUserMemory(userId) {
|
|
22
|
+
const dir = getUserMemoryDir(userId);
|
|
23
|
+
const profilePath = path.join(dir, 'user_profile.md');
|
|
24
|
+
const interactionPath = path.join(dir, 'interaction_style.md');
|
|
25
|
+
const contextPath = path.join(dir, 'current_context.md');
|
|
26
|
+
const hooksPath = path.join(dir, 'reference_hooks.md');
|
|
27
|
+
if (!fs.existsSync(profilePath)) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
const profile = loadYamlFrontMatter(profilePath) || getDefaultProfile();
|
|
31
|
+
const interactionStyle = loadYamlFrontMatter(interactionPath) || getDefaultInteractionStyle();
|
|
32
|
+
const currentContext = loadYamlFrontMatter(contextPath) || getDefaultCurrentContext();
|
|
33
|
+
const referenceHooks = loadYamlFrontMatter(hooksPath) || getDefaultReferenceHooks();
|
|
34
|
+
return {
|
|
35
|
+
profile: profile || getDefaultProfile(),
|
|
36
|
+
interactionStyle,
|
|
37
|
+
currentContext,
|
|
38
|
+
referenceHooks,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
export function saveUserMemory(userId, memory) {
|
|
42
|
+
const dir = ensureUserMemoryDir(userId);
|
|
43
|
+
const profilePath = path.join(dir, 'user_profile.md');
|
|
44
|
+
const interactionPath = path.join(dir, 'interaction_style.md');
|
|
45
|
+
const contextPath = path.join(dir, 'current_context.md');
|
|
46
|
+
const hooksPath = path.join(dir, 'reference_hooks.md');
|
|
47
|
+
memory.profile.lastUpdated = new Date().toISOString();
|
|
48
|
+
writeYamlFrontMatter(profilePath, memory.profile);
|
|
49
|
+
writeYamlFrontMatter(interactionPath, memory.interactionStyle);
|
|
50
|
+
writeYamlFrontMatter(contextPath, memory.currentContext);
|
|
51
|
+
writeYamlFrontMatter(hooksPath, memory.referenceHooks);
|
|
52
|
+
logThought(`[UserMemory] Saved memory for user: ${userId}`);
|
|
53
|
+
}
|
|
54
|
+
export function updateUserProfile(userId, updates) {
|
|
55
|
+
const existing = loadUserMemory(userId);
|
|
56
|
+
if (!existing) {
|
|
57
|
+
const defaultMemory = getDefaultUserMemory();
|
|
58
|
+
defaultMemory.profile = { ...defaultMemory.profile, ...updates };
|
|
59
|
+
saveUserMemory(userId, defaultMemory);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
existing.profile = { ...existing.profile, ...updates };
|
|
63
|
+
saveUserMemory(userId, existing);
|
|
64
|
+
}
|
|
65
|
+
export function checkFirstInteraction(userId) {
|
|
66
|
+
return !hasUserMemory(userId);
|
|
67
|
+
}
|
|
68
|
+
function loadYamlFrontMatter(filePath) {
|
|
69
|
+
try {
|
|
70
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
71
|
+
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
72
|
+
if (!match) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
const yamlContent = match[1];
|
|
76
|
+
return parseYamlSimple(yamlContent);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function writeYamlFrontMatter(filePath, data) {
|
|
83
|
+
const yamlContent = stringifyYamlSimple(data);
|
|
84
|
+
const content = `---\n${yamlContent}---\n`;
|
|
85
|
+
fs.writeFileSync(filePath, content, 'utf-8');
|
|
86
|
+
}
|
|
87
|
+
function parseYamlSimple(yaml) {
|
|
88
|
+
const result = {};
|
|
89
|
+
const lines = yaml.split('\n');
|
|
90
|
+
for (const line of lines) {
|
|
91
|
+
const trimmed = line.trim();
|
|
92
|
+
if (!trimmed || trimmed.startsWith('#'))
|
|
93
|
+
continue;
|
|
94
|
+
const colonIndex = trimmed.indexOf(':');
|
|
95
|
+
if (colonIndex === -1)
|
|
96
|
+
continue;
|
|
97
|
+
const key = trimmed.slice(0, colonIndex).trim();
|
|
98
|
+
let value = trimmed.slice(colonIndex + 1).trim();
|
|
99
|
+
if (typeof value === 'string') {
|
|
100
|
+
if (value.startsWith('[') && value.endsWith(']')) {
|
|
101
|
+
value = value.slice(1, -1).split(',').map(s => s.trim()).filter(Boolean);
|
|
102
|
+
}
|
|
103
|
+
else if (value === 'true') {
|
|
104
|
+
value = true;
|
|
105
|
+
}
|
|
106
|
+
else if (value === 'false') {
|
|
107
|
+
value = false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
result[key] = value;
|
|
111
|
+
}
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
function stringifyYamlSimple(data, indent = 0) {
|
|
115
|
+
if (data === null || data === undefined)
|
|
116
|
+
return '';
|
|
117
|
+
if (typeof data === 'string')
|
|
118
|
+
return data || '';
|
|
119
|
+
if (typeof data === 'boolean' || typeof data === 'number')
|
|
120
|
+
return String(data);
|
|
121
|
+
if (Array.isArray(data))
|
|
122
|
+
return `[${data.join(', ')}]`;
|
|
123
|
+
if (typeof data === 'object') {
|
|
124
|
+
const lines = [];
|
|
125
|
+
const prefix = ' '.repeat(indent);
|
|
126
|
+
for (const [key, value] of Object.entries(data)) {
|
|
127
|
+
if (value === null || value === undefined)
|
|
128
|
+
continue;
|
|
129
|
+
if (typeof value === 'object' && !Array.isArray(value)) {
|
|
130
|
+
lines.push(`${prefix}${key}:`);
|
|
131
|
+
lines.push(stringifyYamlSimple(value, indent + 1));
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
lines.push(`${prefix}${key}: ${stringifyYamlSimple(value)}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return lines.join('\n');
|
|
138
|
+
}
|
|
139
|
+
return String(data);
|
|
140
|
+
}
|
|
141
|
+
function getDefaultProfile() {
|
|
142
|
+
return {
|
|
143
|
+
identity: {},
|
|
144
|
+
professionalBackground: {},
|
|
145
|
+
personalContext: {},
|
|
146
|
+
communicationStyle: {},
|
|
147
|
+
goals: { shortTerm: [], longTerm: [] },
|
|
148
|
+
painPoints: [],
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
function getDefaultInteractionStyle() {
|
|
152
|
+
return {
|
|
153
|
+
communicationGuidelines: {},
|
|
154
|
+
dos: [],
|
|
155
|
+
donts: [],
|
|
156
|
+
responsePatterns: {},
|
|
157
|
+
contextAwareness: {},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
function getDefaultCurrentContext() {
|
|
161
|
+
return {
|
|
162
|
+
activeProjects: [],
|
|
163
|
+
recentConversations: [],
|
|
164
|
+
ongoingThreads: [],
|
|
165
|
+
changedSinceLastTime: [],
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
function getDefaultReferenceHooks() {
|
|
169
|
+
return {
|
|
170
|
+
conversationalAnchors: [],
|
|
171
|
+
keyFacts: [],
|
|
172
|
+
namesAndEntities: {},
|
|
173
|
+
insideReferences: [],
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
export function getDefaultUserMemory() {
|
|
177
|
+
return {
|
|
178
|
+
profile: getDefaultProfile(),
|
|
179
|
+
interactionStyle: getDefaultInteractionStyle(),
|
|
180
|
+
currentContext: getDefaultCurrentContext(),
|
|
181
|
+
referenceHooks: getDefaultReferenceHooks(),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
export function listUsersWithMemory() {
|
|
185
|
+
const usersDir = path.join(getWorkspaceDir(), 'memory', 'users');
|
|
186
|
+
if (!fs.existsSync(usersDir)) {
|
|
187
|
+
return [];
|
|
188
|
+
}
|
|
189
|
+
return fs.readdirSync(usersDir, { withFileTypes: true })
|
|
190
|
+
.filter(dirent => dirent.isDirectory())
|
|
191
|
+
.map(dirent => dirent.name);
|
|
192
|
+
}
|