twinclaw 1.3.2 → 1.4.1

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.
@@ -6,20 +6,11 @@ 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, readConfig, writeConfig } from '../config/json-config.js';
10
- import { getModelCatalogService } from '../services/model-catalog-service.js';
9
+ import { getWorkspaceDir } from '../config/workspace.js';
10
+ import { getConfigValue } from '../config/json-config.js';
11
11
  const { Client, LocalAuth, MessageMedia } = WAWebJS;
12
12
  const RATE_LIMIT_MS = 1500;
13
- const WHATSAPP_COMMANDS = [
14
- { cmd: '/start', desc: 'Show welcome message and menu' },
15
- { cmd: '/help', desc: 'Show all commands' },
16
- { cmd: '/menu', desc: 'Show menu' },
17
- { cmd: '/status', desc: 'Show gateway status' },
18
- { cmd: '/models', desc: 'Show configured models' },
19
- { cmd: '/keys', desc: 'Show API keys status' },
20
- { cmd: '/channel', desc: 'Show channel status' },
21
- { cmd: '/clear', desc: 'Clear conversation' },
22
- ];
13
+ import { CommandRouter } from '../core/command-router.js';
23
14
  /**
24
15
  * Create MessageMedia from URL
25
16
  */
@@ -39,7 +30,7 @@ async function MessageMediaFromUrl(url, caption, mimeType, filename) {
39
30
  export class WhatsAppHandler {
40
31
  #client;
41
32
  #lastMessageAt = 0;
42
- #authDataPath = path.resolve('memory', 'whatsapp_auth');
33
+ #authDataPath = path.join(getWorkspaceDir(), 'memory', 'whatsapp_auth');
43
34
  #pairingBroadcastEnabled = getConfigValue('WHATSAPP_ENABLE_PAIRING_BROADCAST') === 'true';
44
35
  // Session recovery
45
36
  #connectionState = 'disconnected';
@@ -72,125 +63,9 @@ export class WhatsAppHandler {
72
63
  }
73
64
  // ── Command Handlers ─────────────────────────────────────────────────────────
74
65
  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() : '';
78
- switch (cmd) {
79
- case '/start':
80
- case '/menu':
81
- const menuText = `🎯 *TwinClaw Menu*
82
-
83
- ${WHATSAPP_COMMANDS.map(c => `${c.cmd} - ${c.desc}`).join('\n')}
84
-
85
- _How can I help you today?_
86
- `;
87
- await this.sendText(chatId, menuText);
88
- break;
89
- case '/help':
90
- await this.sendText(chatId, `📋 *Available Commands:*
91
-
92
- ${WHATSAPP_COMMANDS.map(c => `${c.cmd} - ${c.desc}`).join('\n')}
93
-
94
- _Just send me a message and I'll respond!_
95
- `);
96
- break;
97
- case '/status':
98
- await this.sendText(chatId, `✅ *Gateway Status:*
99
-
100
- • Status: Running
101
- • Platform: WhatsApp
102
- • Mode: AI Assistant
103
-
104
- _All systems operational_
105
- `);
106
- break;
107
- case '/models':
108
- case '/model':
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}
160
-
161
- Commands:
162
- • /model list - List available models
163
- • /model set <number> - Set primary model
164
-
165
- Example: /model set 1
166
- `);
167
- }
168
- break;
169
- case '/keys':
170
- case '/key':
171
- await this.sendText(chatId, `🔑 *API Keys*
172
-
173
- Use *twinclaw config* to manage API keys.
174
-
175
- Or run: *twinclaw config* in terminal
176
- `);
177
- break;
178
- case '/channel':
179
- case '/channels':
180
- await this.sendText(chatId, `📱 *Channels*
181
-
182
- WhatsApp: ✅ Connected
183
- Telegram: Use /channels to check
184
-
185
- Run: *twinclaw channels* in terminal
186
- `);
187
- break;
188
- case '/clear':
189
- await this.sendText(chatId, '🗑️ Conversation cleared! Starting fresh.');
190
- break;
191
- default:
192
- await this.sendText(chatId, `Unknown command: ${command}\nType /menu for available commands.`);
193
- }
66
+ const router = new CommandRouter();
67
+ const response = await router.dispatch(fullText || command, 'whatsapp', chatId);
68
+ await this.sendText(chatId, response);
194
69
  }
195
70
  onMessage;
196
71
  async #applyRateLimit() {
@@ -1,12 +1,10 @@
1
1
  import Database from 'better-sqlite3';
2
- import * as sqliteVec from 'sqlite-vec';
3
2
  import path from 'path';
4
3
  import fs from 'fs';
5
4
  import { getConfigValue } from '../config/json-config.js';
6
5
  import { getDatabasePath, ensureWorkspaceSubdirs } from '../config/workspace.js';
7
6
  ensureWorkspaceSubdirs();
8
7
  const DB_PATH = getDatabasePath();
9
- const MEMORY_EMBEDDING_DIM = Number(getConfigValue('MEMORY_EMBEDDING_DIM') ?? '1536') || 1536;
10
8
  const DEFAULT_MEMORY_TOP_K = 5;
11
9
  const MAX_MEMORY_TOP_K = 50;
12
10
  const ALLOW_CROSS_SESSION_MEMORY_FALLBACK = getConfigValue('MEMORY_ALLOW_CROSS_SESSION_FALLBACK')?.trim().toLowerCase() === 'true';
@@ -14,8 +12,6 @@ if (!fs.existsSync(path.dirname(DB_PATH))) {
14
12
  fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
15
13
  }
16
14
  export const db = new Database(DB_PATH);
17
- // Load sqlite-vec C extension
18
- sqliteVec.load(db);
19
15
  db.pragma('foreign_keys = ON');
20
16
  db.exec(`
21
17
  CREATE TABLE IF NOT EXISTS sessions (
@@ -58,11 +54,11 @@ db.exec(`
58
54
  FOREIGN KEY(session_id) REFERENCES sessions(session_id)
59
55
  );
60
56
 
61
- -- Virtual table for vector search
62
- CREATE VIRTUAL TABLE IF NOT EXISTS vec_memory USING vec0(
63
- embedding float[${MEMORY_EMBEDDING_DIM}],
64
- session_id TEXT,
65
- fact_text TEXT
57
+ -- Virtual table for full-text search (FTS5) memory
58
+ CREATE VIRTUAL TABLE IF NOT EXISTS semantic_memory_fts USING fts5(
59
+ session_id UNINDEXED,
60
+ fact_text,
61
+ tokenize="porter unicode61"
66
62
  );
67
63
 
68
64
  CREATE TABLE IF NOT EXISTS reasoning_nodes (
@@ -298,13 +294,6 @@ db.exec(`
298
294
  CREATE INDEX IF NOT EXISTS idx_runtime_budget_events_created
299
295
  ON runtime_budget_events(created_at DESC);
300
296
  `);
301
- function serializeEmbedding(embedding) {
302
- const sqliteVecWithSerializer = sqliteVec;
303
- if (typeof sqliteVecWithSerializer.serializeFloat32 === 'function') {
304
- return sqliteVecWithSerializer.serializeFloat32(embedding);
305
- }
306
- return Buffer.from(new Float32Array(embedding).buffer);
307
- }
308
297
  function normalizeTopK(topK) {
309
298
  if (!Number.isFinite(topK)) {
310
299
  return DEFAULT_MEMORY_TOP_K;
@@ -327,55 +316,26 @@ export function getSessionMessages(sessionId) {
327
316
  const stmt = db.prepare('SELECT * FROM messages WHERE session_id = ? ORDER BY rowid ASC');
328
317
  return stmt.all(sessionId);
329
318
  }
330
- export function saveMemoryEmbedding(sessionId, factText, embedding) {
331
- const stmt = db.prepare('INSERT INTO vec_memory (embedding, session_id, fact_text) VALUES (?, ?, ?)');
332
- const result = stmt.run(serializeEmbedding(embedding), sessionId, factText);
319
+ export function saveMemoryFact(sessionId, factText) {
320
+ const stmt = db.prepare('INSERT INTO semantic_memory_fts (session_id, fact_text) VALUES (?, ?)');
321
+ const result = stmt.run(sessionId, factText);
333
322
  return Number(result.lastInsertRowid);
334
323
  }
335
- export function getNearestMemories(queryEmbedding, topK = 5, currentSessionId) {
336
- const boundedTopK = normalizeTopK(topK);
337
- const matcher = serializeEmbedding(queryEmbedding);
338
- const stmt = db.prepare('SELECT rowid AS memory_rowid, session_id, fact_text, distance FROM vec_memory WHERE embedding MATCH ? ORDER BY distance ASC LIMIT ?');
339
- const rows = stmt.all(matcher, boundedTopK * 3);
340
- if (!currentSessionId) {
341
- return rows.slice(0, boundedTopK);
342
- }
343
- const scoped = rows.filter((row) => row.session_id === currentSessionId);
344
- if (!ALLOW_CROSS_SESSION_MEMORY_FALLBACK) {
345
- return scoped.slice(0, boundedTopK);
346
- }
347
- const global = rows.filter((row) => row.session_id !== currentSessionId);
348
- return [...scoped, ...global].slice(0, boundedTopK);
349
- }
350
- /**
351
- * Keyword-based fallback search when vector embeddings are unavailable.
352
- * Uses simple SQL LIKE matching.
353
- */
354
- export function keywordSearchMemories(query, topK = 5, currentSessionId) {
324
+ export function searchMemoriesFTS(query, topK = 5, currentSessionId) {
355
325
  const boundedTopK = normalizeTopK(topK);
356
- // Simple tokenization: remove common punctuation and split by whitespace
357
- const tokens = query
326
+ const words = query
358
327
  .toLowerCase()
359
328
  .replace(/[^\w\s]/g, ' ')
360
329
  .split(/\s+/)
361
- .filter((t) => t.length > 2);
362
- if (tokens.length === 0) {
330
+ .filter((w) => w.length > 2);
331
+ if (words.length === 0) {
363
332
  return [];
364
333
  }
365
- // Build a query with LIKE for each token (OR logic)
366
- // Distance is mocked as 1.0 for keyword matches
367
- const placeholders = tokens.map(() => 'fact_text LIKE ?').join(' OR ');
368
- const sql = `
369
- SELECT rowid AS memory_rowid, session_id, fact_text, 1.0 AS distance
370
- FROM vec_memory
371
- WHERE ${placeholders}
372
- ORDER BY rowid DESC
373
- LIMIT ?
374
- `;
334
+ // Use natural language query format or standard FTS query
335
+ const ftsQuery = words.map((w) => `"${w}"*`).join(' OR ');
336
+ const stmt = db.prepare('SELECT rowid AS memory_rowid, session_id, fact_text, rank AS distance FROM semantic_memory_fts WHERE semantic_memory_fts MATCH ? ORDER BY rank ASC LIMIT ?');
375
337
  try {
376
- const stmt = db.prepare(sql);
377
- const values = tokens.map((t) => `%${t}%`);
378
- const rows = stmt.all(...values, boundedTopK * 3);
338
+ const rows = stmt.all(ftsQuery, boundedTopK * 3);
379
339
  if (!currentSessionId) {
380
340
  return rows.slice(0, boundedTopK);
381
341
  }
@@ -386,10 +346,10 @@ export function keywordSearchMemories(query, topK = 5, currentSessionId) {
386
346
  const global = rows.filter((row) => row.session_id !== currentSessionId);
387
347
  return [...scoped, ...global].slice(0, boundedTopK);
388
348
  }
389
- catch (error) {
390
- const message = error instanceof Error ? error.message : String(error);
391
- console.error(`[DB] keywordSearchMemories failed (tokens=${tokens.length}, topK=${boundedTopK}, scoped=${currentSessionId ? 'yes' : 'no'}): ${message}`);
392
- throw error;
349
+ catch (err) {
350
+ const message = err instanceof Error ? err.message : String(err);
351
+ console.warn(`[DB] FTS search failed for query '${ftsQuery}': ${message}`);
352
+ return [];
393
353
  }
394
354
  }
395
355
  // Re-export domain-specific DB helpers for backwards compatibility.
@@ -2,6 +2,7 @@ import { randomBytes } from 'node:crypto';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { logThought } from '../utils/logger.js';
5
+ import { getWorkspaceDir } from '../config/workspace.js';
5
6
  const CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
6
7
  const CODE_LENGTH = 8;
7
8
  const DEFAULT_CODE_TTL_MS = 5 * 60 * 1000; // 5 minutes for device pairing
@@ -33,7 +34,7 @@ export class DevicePairingService {
33
34
  constructor(options = {}) {
34
35
  this.#credentialsDir = options.credentialsDir
35
36
  ? path.resolve(options.credentialsDir)
36
- : path.resolve('memory', 'devices');
37
+ : path.join(getWorkspaceDir(), 'memory', 'devices');
37
38
  this.#codeTtlMs = options.codeTtlMs ?? DEFAULT_CODE_TTL_MS;
38
39
  this.#maxPending = options.maxPendingPerChannel ?? DEFAULT_MAX_PENDING;
39
40
  // Load existing devices
@@ -1,6 +1,7 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
+ import { getWorkspaceDir } from '../config/workspace.js';
4
5
  const CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
5
6
  const CODE_LENGTH = 8;
6
7
  const DEFAULT_CODE_TTL_MS = 60 * 60 * 1000;
@@ -66,7 +67,7 @@ export class DmPairingService {
66
67
  constructor(options = {}) {
67
68
  this.#credentialsDir = options.credentialsDir
68
69
  ? path.resolve(options.credentialsDir)
69
- : path.resolve('memory', 'credentials');
70
+ : path.join(getWorkspaceDir(), 'memory', 'credentials');
70
71
  this.#codeTtlMs =
71
72
  Number.isFinite(options.codeTtlMs) && (options.codeTtlMs ?? 0) > 0
72
73
  ? Number(options.codeTtlMs)
@@ -93,6 +93,18 @@ class HooksService {
93
93
  }
94
94
  return results;
95
95
  }
96
+ async executeHook(event, data = {}) {
97
+ const context = {
98
+ event,
99
+ timestamp: new Date().toISOString(),
100
+ data,
101
+ };
102
+ // Use the sessionId if provided in the data payload
103
+ if (typeof data.sessionId === 'string') {
104
+ context.sessionId = data.sessionId;
105
+ }
106
+ return this.runHook(event, context);
107
+ }
96
108
  async #runScript(scriptPath, context) {
97
109
  const fullPath = path.isAbsolute(scriptPath)
98
110
  ? scriptPath
@@ -2,6 +2,7 @@ import cron from 'node-cron';
2
2
  import { logThought } from '../utils/logger.js';
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
+ import { getWorkspaceDir } from '../config/workspace.js';
5
6
  /**
6
7
  * Centralized job scheduler for TwinClaw's proactive execution layer.
7
8
  *
@@ -30,7 +31,8 @@ export class JobScheduler {
30
31
  * @param persistenceDir - Directory to store job persistence files
31
32
  */
32
33
  constructor(persistenceDir) {
33
- this.#persistencePath = path.resolve(persistenceDir || './memory', 'scheduler-jobs.json');
34
+ const workspaceMemoryDir = path.join(getWorkspaceDir(), 'memory');
35
+ this.#persistencePath = path.resolve(persistenceDir || workspaceMemoryDir, 'scheduler-jobs.json');
34
36
  this.#loadPersistedJobs();
35
37
  }
36
38
  /** Register a new repeating job. Throws if a job with the same ID already exists. */
@@ -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) {
@@ -290,6 +290,7 @@ export class ModelRouter {
290
290
  if (lastTriedModelId && lastTriedModelId !== config.id) {
291
291
  this.metrics.failoverCount += 1;
292
292
  this.recordEvent('failover', config, `Automatic fallback ${lastTriedModelId} -> ${config.id}.`);
293
+ console.warn(`[ModelRouter] ⚠️ Fallback triggered: Switching from ${lastTriedModelId} to ${config.id}`);
293
294
  }
294
295
  const payload = {
295
296
  model: config.model,
@@ -317,6 +318,7 @@ export class ModelRouter {
317
318
  const retryWaitMs = Math.min(firstAttempt.rateLimitCooldownMs, this.intelligentPacingMaxWaitMs);
318
319
  if (retryWaitMs > 0) {
319
320
  this.recordEvent('cooldown_wait', config, `Intelligent pacing wait ${retryWaitMs}ms before retrying ${config.id}.`);
321
+ console.warn(`[ModelRouter] ⏳ Rate limited. Intelligent pacing: waiting ${retryWaitMs}ms before retrying ${config.id}...`);
320
322
  await this.sleepFn(retryWaitMs);
321
323
  }
322
324
  const retryCooldown = this.getModelCooldownState(config.id);
@@ -400,6 +402,7 @@ export class ModelRouter {
400
402
  if (lastTriedModelId && lastTriedModelId !== config.id) {
401
403
  this.metrics.failoverCount += 1;
402
404
  this.recordEvent('failover', config, `Automatic fallback ${lastTriedModelId} -> ${config.id}.`);
405
+ console.warn(`[ModelRouter] ⚠️ Streaming Fallback triggered: Switching from ${lastTriedModelId} to ${config.id}`);
403
406
  }
404
407
  const payload = {
405
408
  model: config.model,
@@ -865,6 +868,12 @@ export class ModelRouter {
865
868
  return 'copilot';
866
869
  if (url.includes('api.github.com'))
867
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';
868
877
  return 'unknown';
869
878
  }
870
879
  getModelCooldownState(modelId) {
@@ -1040,11 +1049,71 @@ export class ModelRouter {
1040
1049
  return 0;
1041
1050
  });
1042
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
+ }
1043
1112
  configModels.push({
1044
1113
  id: def.id,
1045
- model: def.model,
1114
+ model: resolvedModel,
1046
1115
  baseURL: def.baseURL,
1047
- apiKeyEnvName: def.apiKeyEnvName,
1116
+ apiKeyEnvName: resolvedApiKeyEnvName || '',
1048
1117
  });
1049
1118
  }
1050
1119
  // Even with definitions, add fallback providers in case primary fails
@@ -1,8 +1,8 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { chunkText, EmbeddingService } from './embedding-service.js';
3
- import { getNearestMemories, keywordSearchMemories, saveMemoryEmbedding, } from './db.js';
2
+ import { chunkText } from './embedding-service.js';
3
+ import { searchMemoriesFTS, saveMemoryFact, } from './db.js';
4
4
  import { getMemoryProvenanceRows, getReasoningEvidenceExpansion, getReasoningNodesByClaimKey, linkMemoryProvenance, upsertReasoningEdge, upsertReasoningNode, } from './db-reasoning.js';
5
- const embeddingService = new EmbeddingService();
5
+ import { getHooksService } from './hooks.js';
6
6
  const NEGATION_PATTERN = /\b(no|not|never|without|cannot|can't|won't|dont|don't|didnt|didn't|isnt|isn't|arent|aren't|wasnt|wasn't|weren't)\b/i;
7
7
  const CLAIM_KEY_STOPWORDS = /\b(a|an|the|and|or|to|of|for|in|on|at|with|is|are|was|were|be|been|being|do|does|did|this|that|it|as|by|from|no|not|never|without|cannot|cant|wont)\b/g;
8
8
  const TASK_HINT_PATTERN = /\b(todo|task|implement|fix|build|create|refactor|ship|deploy)\b/i;
@@ -20,11 +20,7 @@ export async function indexConversationTurn(sessionId, role, content) {
20
20
  const limitedChunks = chunks.slice(0, 2);
21
21
  for (const chunk of limitedChunks) {
22
22
  const taggedChunk = `${role.toUpperCase()}: ${chunk}`;
23
- const embedding = await embeddingService.embedText(taggedChunk);
24
- if (!embedding) {
25
- continue;
26
- }
27
- const memoryRowId = saveMemoryEmbedding(sessionId, taggedChunk, embedding);
23
+ const memoryRowId = saveMemoryFact(sessionId, taggedChunk);
28
24
  const node = buildReasoningNode(sessionId, role, taggedChunk);
29
25
  upsertReasoningNode(node);
30
26
  linkMemoryProvenance(memoryRowId, node.nodeId, sessionId);
@@ -41,6 +37,11 @@ export async function indexConversationTurn(sessionId, role, content) {
41
37
  previousNodeId = node.nodeId;
42
38
  reconcileClaimRelations(node);
43
39
  }
40
+ // Trigger proactive context alignment hook
41
+ const hooks = getHooksService();
42
+ hooks.executeHook('afterIndexTurn', { sessionId, role, content }).catch((err) => {
43
+ console.warn(`[SemanticMemory] Hook execution failed for afterIndexTurn:`, err);
44
+ });
44
45
  }
45
46
  catch (err) {
46
47
  const message = err instanceof Error ? err.message : String(err);
@@ -48,24 +49,13 @@ export async function indexConversationTurn(sessionId, role, content) {
48
49
  }
49
50
  }
50
51
  export async function retrieveEvidenceAwareMemoryContext(sessionId, prompt, topK = 5) {
51
- const embedding = await embeddingService.embedText(prompt);
52
52
  const candidateLimit = Math.max(topK * 3, topK);
53
- let nearest;
54
- let fallbackUsed = false;
55
- if (embedding) {
56
- nearest = getNearestMemories(embedding, candidateLimit, sessionId);
57
- }
58
- else {
59
- nearest = keywordSearchMemories(prompt, candidateLimit, sessionId);
60
- fallbackUsed = true;
61
- }
53
+ const nearest = searchMemoriesFTS(prompt, candidateLimit, sessionId);
62
54
  if (nearest.length === 0) {
63
55
  return {
64
56
  context: '',
65
57
  diagnostics: [
66
- fallbackUsed
67
- ? 'Keyword-fallback memory retrieval found no candidates for the active session scope.'
68
- : 'Memory retrieval found no nearest vector candidates for the active session scope.'
58
+ 'Memory retrieval found no FTS candidates for the active session scope.'
69
59
  ],
70
60
  conflictCount: 0,
71
61
  hitCount: 0,
@@ -93,9 +83,7 @@ export async function retrieveEvidenceAwareMemoryContext(sessionId, prompt, topK
93
83
  return {
94
84
  context: `Retrieved evidence-backed memories:\n${lines.join('\n')}${conflictNote}`,
95
85
  diagnostics: [
96
- fallbackUsed
97
- ? `Keyword-fallback memory retrieval ranked ${nearest.length} candidates down to ${ranked.length} items.`
98
- : `Hybrid memory retrieval ranked ${nearest.length} vector candidates down to ${ranked.length} evidence-backed items.`,
86
+ `FTS memory retrieval ranked ${nearest.length} candidates down to ${ranked.length} evidence-backed items.`,
99
87
  `Graph traversal depth=${MAX_GRAPH_TRAVERSAL_DEPTH} collected ${expandedEdges.length} relation edge(s).`,
100
88
  conflictCount > 0
101
89
  ? `Detected contradiction signals in ${conflictCount} candidate(s).`
@@ -207,7 +195,9 @@ function buildGraphRelationCounts(edges) {
207
195
  }
208
196
  function scoreCandidate(sessionId, factText, memoryRowId, distance, options) {
209
197
  const provenance = options.provenance;
210
- const vectorScore = 1 / (1 + Math.max(0, distance));
198
+ // SQLite FTS5 bm25 rank is negative. More negative means better match.
199
+ const ftsRelevance = Math.max(0, -distance);
200
+ const ftsScore = Math.min(1, ftsRelevance * 0.15); // Scale BM25 to 0.0 - 1.0 range
211
201
  const graphCounts = provenance
212
202
  ? options.graphRelationCounts.get(provenance.nodeId) ?? {
213
203
  supports: 0,
@@ -223,7 +213,7 @@ function scoreCandidate(sessionId, factText, memoryRowId, distance, options) {
223
213
  const relationScore = Math.min(1, supports * 0.2 + depends * 0.11 + derived * 0.08);
224
214
  const recencyScore = estimateRecencyScore(provenance?.updatedAt);
225
215
  const contradictionPenalty = Math.min(0.35, contradicts * 0.08);
226
- const score = vectorScore * 0.62 + relationScore * 0.26 + recencyScore * 0.12 - contradictionPenalty;
216
+ const score = ftsScore * 0.62 + relationScore * 0.26 + recencyScore * 0.12 - contradictionPenalty;
227
217
  return {
228
218
  sessionId,
229
219
  factText,
@@ -5,6 +5,7 @@ import { randomUUID } from 'node:crypto';
5
5
  import { executeProgram, executeShell } from './shell.js';
6
6
  import { logToolCall } from '../utils/logger.js';
7
7
  import { validatePathInWorkspace } from '../config/workspace.js';
8
+ import { getPersonaEditorTool } from '../tools/persona-editor.js';
8
9
  function buildReadFileSkill() {
9
10
  return {
10
11
  name: 'fs.read',
@@ -1038,6 +1039,39 @@ function buildVenvSkill() {
1038
1039
  },
1039
1040
  };
1040
1041
  }
1042
+ function buildPersonaEditorSkill() {
1043
+ return {
1044
+ name: 'persona.edit',
1045
+ group: 'group:core',
1046
+ aliases: ['edit_persona', 'update_user_profile', 'update_soul'],
1047
+ description: 'Programmatically edit the agent\'s identity (soul.md) or user profile (user.md).',
1048
+ parameters: {
1049
+ type: 'object',
1050
+ properties: {
1051
+ target: { type: 'string', enum: ['user', 'soul'] },
1052
+ operation: { type: 'string', enum: ['append', 'replace', 'clear'] },
1053
+ content: { type: 'string' },
1054
+ },
1055
+ required: ['target', 'operation'],
1056
+ },
1057
+ async execute(input) {
1058
+ if (input.operation !== 'clear' && typeof input.content !== 'string') {
1059
+ return { ok: false, output: 'Content must be provided for append/replace operations' };
1060
+ }
1061
+ const request = input;
1062
+ const editor = getPersonaEditorTool();
1063
+ const result = await editor.editPersona({
1064
+ target: request.target,
1065
+ operation: request.operation,
1066
+ content: request.content || '',
1067
+ });
1068
+ return {
1069
+ ok: result.success,
1070
+ output: result.message,
1071
+ };
1072
+ },
1073
+ };
1074
+ }
1041
1075
  export function createBuiltinSkills() {
1042
1076
  return [
1043
1077
  buildReadFileSkill(),
@@ -1073,5 +1107,6 @@ export function createBuiltinSkills() {
1073
1107
  buildDefenderSkill(),
1074
1108
  buildPowerShellISESkill(),
1075
1109
  buildVenvSkill(),
1110
+ buildPersonaEditorSkill(),
1076
1111
  ];
1077
1112
  }