lynkr 2.0.0 → 3.0.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.
@@ -424,6 +424,156 @@ async function invokeAzureOpenAI(body) {
424
424
  }
425
425
  }
426
426
 
427
+ async function invokeOpenAI(body) {
428
+ if (!config.openai?.apiKey) {
429
+ throw new Error("OpenAI API key is not configured.");
430
+ }
431
+
432
+ const {
433
+ convertAnthropicToolsToOpenRouter,
434
+ convertAnthropicMessagesToOpenRouter
435
+ } = require("./openrouter-utils");
436
+
437
+ const endpoint = config.openai.endpoint || "https://api.openai.com/v1/chat/completions";
438
+ const headers = {
439
+ "Authorization": `Bearer ${config.openai.apiKey}`,
440
+ "Content-Type": "application/json",
441
+ };
442
+
443
+ // Add organization header if configured
444
+ if (config.openai.organization) {
445
+ headers["OpenAI-Organization"] = config.openai.organization;
446
+ }
447
+
448
+ // Convert messages and handle system message
449
+ const messages = convertAnthropicMessagesToOpenRouter(body.messages || []);
450
+
451
+ // Anthropic uses separate 'system' field, OpenAI needs it as first message
452
+ if (body.system) {
453
+ messages.unshift({
454
+ role: "system",
455
+ content: body.system
456
+ });
457
+ }
458
+
459
+ const openAIBody = {
460
+ model: config.openai.model || "gpt-4o",
461
+ messages,
462
+ temperature: body.temperature ?? 0.7,
463
+ max_tokens: body.max_tokens ?? 4096,
464
+ top_p: body.top_p ?? 1.0,
465
+ stream: body.stream ?? false
466
+ };
467
+
468
+ // Add tools - inject standard tools if client didn't send any (passthrough mode)
469
+ let toolsToSend = body.tools;
470
+ let toolsInjected = false;
471
+
472
+ if (!Array.isArray(toolsToSend) || toolsToSend.length === 0) {
473
+ // Client didn't send tools (likely passthrough mode) - inject standard Claude Code tools
474
+ toolsToSend = STANDARD_TOOLS;
475
+ toolsInjected = true;
476
+ logger.info({
477
+ injectedToolCount: STANDARD_TOOLS.length,
478
+ injectedToolNames: STANDARD_TOOLS.map(t => t.name),
479
+ reason: "Client did not send tools (passthrough mode)"
480
+ }, "=== INJECTING STANDARD TOOLS (OpenAI) ===");
481
+ }
482
+
483
+ if (Array.isArray(toolsToSend) && toolsToSend.length > 0) {
484
+ openAIBody.tools = convertAnthropicToolsToOpenRouter(toolsToSend);
485
+ openAIBody.parallel_tool_calls = true; // Enable parallel tool calling
486
+ openAIBody.tool_choice = "auto"; // Let the model decide when to use tools
487
+ logger.info({
488
+ toolCount: toolsToSend.length,
489
+ toolNames: toolsToSend.map(t => t.name),
490
+ toolsInjected
491
+ }, "=== SENDING TOOLS TO OPENAI ===");
492
+ }
493
+
494
+ logger.info({
495
+ endpoint,
496
+ model: openAIBody.model,
497
+ hasTools: !!openAIBody.tools,
498
+ toolCount: openAIBody.tools?.length || 0,
499
+ temperature: openAIBody.temperature,
500
+ max_tokens: openAIBody.max_tokens,
501
+ }, "=== OPENAI REQUEST ===");
502
+
503
+ return performJsonRequest(endpoint, { headers, body: openAIBody }, "OpenAI");
504
+ }
505
+
506
+ async function invokeLlamaCpp(body) {
507
+ if (!config.llamacpp?.endpoint) {
508
+ throw new Error("llama.cpp endpoint is not configured.");
509
+ }
510
+
511
+ const {
512
+ convertAnthropicToolsToOpenRouter,
513
+ convertAnthropicMessagesToOpenRouter
514
+ } = require("./openrouter-utils");
515
+
516
+ const endpoint = `${config.llamacpp.endpoint}/v1/chat/completions`;
517
+ const headers = {
518
+ "Content-Type": "application/json",
519
+ };
520
+
521
+ // Add API key if configured (for secured llama.cpp servers)
522
+ if (config.llamacpp.apiKey) {
523
+ headers["Authorization"] = `Bearer ${config.llamacpp.apiKey}`;
524
+ }
525
+
526
+ // Convert messages to OpenAI format
527
+ const messages = convertAnthropicMessagesToOpenRouter(body.messages || []);
528
+
529
+ // Handle system message
530
+ if (body.system) {
531
+ messages.unshift({ role: "system", content: body.system });
532
+ }
533
+
534
+ const llamacppBody = {
535
+ messages,
536
+ temperature: body.temperature ?? 0.7,
537
+ max_tokens: body.max_tokens ?? 4096,
538
+ top_p: body.top_p ?? 1.0,
539
+ stream: body.stream ?? false
540
+ };
541
+
542
+ // Inject standard tools if client didn't send any
543
+ let toolsToSend = body.tools;
544
+ let toolsInjected = false;
545
+
546
+ if (!Array.isArray(toolsToSend) || toolsToSend.length === 0) {
547
+ toolsToSend = STANDARD_TOOLS;
548
+ toolsInjected = true;
549
+ logger.info({
550
+ injectedToolCount: STANDARD_TOOLS.length,
551
+ injectedToolNames: STANDARD_TOOLS.map(t => t.name),
552
+ reason: "Client did not send tools (passthrough mode)"
553
+ }, "=== INJECTING STANDARD TOOLS (llama.cpp) ===");
554
+ }
555
+
556
+ if (Array.isArray(toolsToSend) && toolsToSend.length > 0) {
557
+ llamacppBody.tools = convertAnthropicToolsToOpenRouter(toolsToSend);
558
+ llamacppBody.tool_choice = "auto";
559
+ logger.info({
560
+ toolCount: toolsToSend.length,
561
+ toolNames: toolsToSend.map(t => t.name),
562
+ toolsInjected
563
+ }, "=== SENDING TOOLS TO LLAMA.CPP ===");
564
+ }
565
+
566
+ logger.info({
567
+ endpoint,
568
+ hasTools: !!llamacppBody.tools,
569
+ toolCount: llamacppBody.tools?.length || 0,
570
+ temperature: llamacppBody.temperature,
571
+ max_tokens: llamacppBody.max_tokens,
572
+ }, "=== LLAMA.CPP REQUEST ===");
573
+
574
+ return performJsonRequest(endpoint, { headers, body: llamacppBody }, "llama.cpp");
575
+ }
576
+
427
577
  async function invokeModel(body, options = {}) {
428
578
  const { determineProvider, isFallbackEnabled, getFallbackProvider } = require("./routing");
429
579
  const metricsCollector = getMetricsCollector();
@@ -463,6 +613,10 @@ async function invokeModel(body, options = {}) {
463
613
  return await invokeOllama(body);
464
614
  } else if (initialProvider === "openrouter") {
465
615
  return await invokeOpenRouter(body);
616
+ } else if (initialProvider === "openai") {
617
+ return await invokeOpenAI(body);
618
+ } else if (initialProvider === "llamacpp") {
619
+ return await invokeLlamaCpp(body);
466
620
  }
467
621
  return await invokeDatabricks(body);
468
622
  });
@@ -538,6 +692,10 @@ async function invokeModel(body, options = {}) {
538
692
  return await invokeAzureAnthropic(body);
539
693
  } else if (fallbackProvider === "openrouter") {
540
694
  return await invokeOpenRouter(body);
695
+ } else if (fallbackProvider === "openai") {
696
+ return await invokeOpenAI(body);
697
+ } else if (fallbackProvider === "llamacpp") {
698
+ return await invokeLlamaCpp(body);
541
699
  }
542
700
  return await invokeDatabricks(body);
543
701
  });
@@ -52,7 +52,7 @@ function determineProvider(payload) {
52
52
  return "ollama";
53
53
  }
54
54
 
55
- // Moderate tool count → OpenRouter or Azure OpenAI (if configured and fallback enabled)
55
+ // Moderate tool count → OpenRouter, OpenAI, or Azure OpenAI (if configured and fallback enabled)
56
56
  if (toolCount < maxToolsForOpenRouter && isFallbackEnabled()) {
57
57
  if (config.openrouter?.apiKey) {
58
58
  logger.debug(
@@ -60,12 +60,24 @@ function determineProvider(payload) {
60
60
  "Routing to OpenRouter (moderate tools)"
61
61
  );
62
62
  return "openrouter";
63
+ } else if (config.openai?.apiKey) {
64
+ logger.debug(
65
+ { toolCount, maxToolsForOllama, maxToolsForOpenRouter, decision: "openai" },
66
+ "Routing to OpenAI (moderate tools)"
67
+ );
68
+ return "openai";
63
69
  } else if (config.azureOpenAI?.apiKey) {
64
70
  logger.debug(
65
71
  { toolCount, maxToolsForOllama, maxToolsForOpenRouter, decision: "azure-openai" },
66
72
  "Routing to Azure OpenAI (moderate tools)"
67
73
  );
68
74
  return "azure-openai";
75
+ } else if (config.llamacpp?.endpoint) {
76
+ logger.debug(
77
+ { toolCount, maxToolsForOllama, maxToolsForOpenRouter, decision: "llamacpp" },
78
+ "Routing to llama.cpp (moderate tools)"
79
+ );
80
+ return "llamacpp";
69
81
  }
70
82
  }
71
83
 
@@ -62,7 +62,7 @@ function resolveConfigPath(targetPath) {
62
62
  return path.resolve(normalised);
63
63
  }
64
64
 
65
- const SUPPORTED_MODEL_PROVIDERS = new Set(["databricks", "azure-anthropic", "ollama", "openrouter", "azure-openai"]);
65
+ const SUPPORTED_MODEL_PROVIDERS = new Set(["databricks", "azure-anthropic", "ollama", "openrouter", "azure-openai", "openai", "llamacpp"]);
66
66
  const rawModelProvider = (process.env.MODEL_PROVIDER ?? "databricks").toLowerCase();
67
67
  const modelProvider = SUPPORTED_MODEL_PROVIDERS.has(rawModelProvider)
68
68
  ? rawModelProvider
@@ -90,6 +90,17 @@ const azureOpenAIApiKey = process.env.AZURE_OPENAI_API_KEY?.trim() || null;
90
90
  const azureOpenAIDeployment = process.env.AZURE_OPENAI_DEPLOYMENT?.trim() || "gpt-4o";
91
91
  const azureOpenAIApiVersion = process.env.AZURE_OPENAI_API_VERSION?.trim() || "2024-08-01-preview";
92
92
 
93
+ // OpenAI configuration
94
+ const openAIApiKey = process.env.OPENAI_API_KEY?.trim() || null;
95
+ const openAIModel = process.env.OPENAI_MODEL?.trim() || "gpt-4o";
96
+ const openAIEndpoint = process.env.OPENAI_ENDPOINT?.trim() || "https://api.openai.com/v1/chat/completions";
97
+ const openAIOrganization = process.env.OPENAI_ORGANIZATION?.trim() || null;
98
+
99
+ // llama.cpp configuration
100
+ const llamacppEndpoint = process.env.LLAMACPP_ENDPOINT?.trim() || "http://localhost:8080";
101
+ const llamacppModel = process.env.LLAMACPP_MODEL?.trim() || "default";
102
+ const llamacppTimeout = Number.parseInt(process.env.LLAMACPP_TIMEOUT_MS ?? "120000", 10);
103
+ const llamacppApiKey = process.env.LLAMACPP_API_KEY?.trim() || null;
93
104
 
94
105
  // Hybrid routing configuration
95
106
  const preferOllama = process.env.PREFER_OLLAMA === "true";
@@ -112,6 +123,18 @@ if (!["server", "client", "passthrough"].includes(toolExecutionMode)) {
112
123
  );
113
124
  }
114
125
 
126
+ // Memory system configuration (Titans-inspired long-term memory)
127
+ const memoryEnabled = process.env.MEMORY_ENABLED !== "false"; // default true
128
+ const memoryRetrievalLimit = Number.parseInt(process.env.MEMORY_RETRIEVAL_LIMIT ?? "5", 10);
129
+ const memorySurpriseThreshold = Number.parseFloat(process.env.MEMORY_SURPRISE_THRESHOLD ?? "0.3");
130
+ const memoryMaxAgeDays = Number.parseInt(process.env.MEMORY_MAX_AGE_DAYS ?? "90", 10);
131
+ const memoryMaxCount = Number.parseInt(process.env.MEMORY_MAX_COUNT ?? "10000", 10);
132
+ const memoryIncludeGlobal = process.env.MEMORY_INCLUDE_GLOBAL !== "false"; // default true
133
+ const memoryInjectionFormat = (process.env.MEMORY_INJECTION_FORMAT ?? "system").toLowerCase();
134
+ const memoryExtractionEnabled = process.env.MEMORY_EXTRACTION_ENABLED !== "false"; // default true
135
+ const memoryDecayEnabled = process.env.MEMORY_DECAY_ENABLED !== "false"; // default true
136
+ const memoryDecayHalfLifeDays = Number.parseInt(process.env.MEMORY_DECAY_HALF_LIFE ?? "30", 10);
137
+
115
138
  // Only require Databricks credentials if it's the primary provider or used as fallback
116
139
  if (modelProvider === "databricks" && (!rawBaseUrl || !apiKey)) {
117
140
  throw new Error("Set DATABRICKS_API_BASE and DATABRICKS_API_KEY before starting the proxy.");
@@ -134,6 +157,12 @@ if (modelProvider === "azure-openai" && (!azureOpenAIEndpoint || !azureOpenAIApi
134
157
  );
135
158
  }
136
159
 
160
+ if (modelProvider === "openai" && !openAIApiKey) {
161
+ throw new Error(
162
+ "Set OPENAI_API_KEY before starting the proxy.",
163
+ );
164
+ }
165
+
137
166
  if (modelProvider === "ollama") {
138
167
  try {
139
168
  new URL(ollamaEndpoint);
@@ -142,6 +171,14 @@ if (modelProvider === "ollama") {
142
171
  }
143
172
  }
144
173
 
174
+ if (modelProvider === "llamacpp") {
175
+ try {
176
+ new URL(llamacppEndpoint);
177
+ } catch (err) {
178
+ throw new Error("LLAMACPP_ENDPOINT must be a valid URL (default: http://localhost:8080)");
179
+ }
180
+ }
181
+
145
182
  // Validate hybrid routing configuration
146
183
  if (preferOllama) {
147
184
  if (!ollamaEndpoint) {
@@ -353,6 +390,18 @@ const config = {
353
390
  deployment: azureOpenAIDeployment,
354
391
  apiVersion: azureOpenAIApiVersion
355
392
  },
393
+ openai: {
394
+ apiKey: openAIApiKey,
395
+ model: openAIModel,
396
+ endpoint: openAIEndpoint,
397
+ organization: openAIOrganization,
398
+ },
399
+ llamacpp: {
400
+ endpoint: llamacppEndpoint,
401
+ model: llamacppModel,
402
+ timeout: Number.isNaN(llamacppTimeout) ? 120000 : llamacppTimeout,
403
+ apiKey: llamacppApiKey,
404
+ },
356
405
  modelProvider: {
357
406
  type: modelProvider,
358
407
  defaultModel,
@@ -472,6 +521,24 @@ const config = {
472
521
  },
473
522
  profiles: Array.isArray(testProfiles) ? testProfiles : null,
474
523
  },
524
+ memory: {
525
+ enabled: memoryEnabled,
526
+ retrievalLimit: Number.isNaN(memoryRetrievalLimit) ? 5 : memoryRetrievalLimit,
527
+ surpriseThreshold: Number.isNaN(memorySurpriseThreshold) ? 0.3 : memorySurpriseThreshold,
528
+ maxAgeDays: Number.isNaN(memoryMaxAgeDays) ? 90 : memoryMaxAgeDays,
529
+ maxCount: Number.isNaN(memoryMaxCount) ? 10000 : memoryMaxCount,
530
+ includeGlobalMemories: memoryIncludeGlobal,
531
+ injectionFormat: ["system", "assistant_preamble"].includes(memoryInjectionFormat)
532
+ ? memoryInjectionFormat
533
+ : "system",
534
+ extraction: {
535
+ enabled: memoryExtractionEnabled,
536
+ },
537
+ decay: {
538
+ enabled: memoryDecayEnabled,
539
+ halfLifeDays: Number.isNaN(memoryDecayHalfLifeDays) ? 30 : memoryDecayHalfLifeDays,
540
+ },
541
+ },
475
542
  };
476
543
 
477
544
  module.exports = config;
package/src/db/index.js CHANGED
@@ -218,6 +218,124 @@ db.exec(`
218
218
 
219
219
  CREATE INDEX IF NOT EXISTS idx_test_runs_status
220
220
  ON test_runs(status);
221
+
222
+ -- Memory system tables (Titans-inspired long-term memory)
223
+ CREATE TABLE IF NOT EXISTS memories (
224
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
225
+ session_id TEXT,
226
+ content TEXT NOT NULL,
227
+ type TEXT NOT NULL,
228
+ category TEXT,
229
+ importance REAL DEFAULT 0.5,
230
+ surprise_score REAL DEFAULT 0.0,
231
+ access_count INTEGER DEFAULT 0,
232
+ decay_factor REAL DEFAULT 1.0,
233
+ source_turn_id INTEGER,
234
+ created_at INTEGER NOT NULL,
235
+ updated_at INTEGER NOT NULL,
236
+ last_accessed_at INTEGER,
237
+ metadata TEXT,
238
+ FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE SET NULL
239
+ );
240
+
241
+ CREATE INDEX IF NOT EXISTS idx_memories_session_id
242
+ ON memories(session_id);
243
+
244
+ CREATE INDEX IF NOT EXISTS idx_memories_type
245
+ ON memories(type);
246
+
247
+ CREATE INDEX IF NOT EXISTS idx_memories_category
248
+ ON memories(category);
249
+
250
+ CREATE INDEX IF NOT EXISTS idx_memories_importance
251
+ ON memories(importance DESC);
252
+
253
+ CREATE INDEX IF NOT EXISTS idx_memories_surprise
254
+ ON memories(surprise_score DESC);
255
+
256
+ CREATE INDEX IF NOT EXISTS idx_memories_created_at
257
+ ON memories(created_at DESC);
258
+
259
+ CREATE INDEX IF NOT EXISTS idx_memories_last_accessed
260
+ ON memories(last_accessed_at DESC);
261
+
262
+ -- Full-text search for memories
263
+ CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
264
+ content,
265
+ type,
266
+ category,
267
+ content=memories,
268
+ content_rowid=id,
269
+ tokenize="porter unicode61"
270
+ );
271
+ `)
272
+
273
+ ;
274
+
275
+ // FTS5 triggers to keep search index in sync
276
+ db.exec(`
277
+ CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
278
+ INSERT INTO memories_fts(rowid, content, type, category)
279
+ VALUES (new.id, new.content, new.type, new.category);
280
+ END;
281
+
282
+ CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
283
+ INSERT INTO memories_fts(memories_fts, rowid, content, type, category)
284
+ VALUES ('delete', old.id, old.content, old.type, old.category);
285
+ END;
286
+
287
+ CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN
288
+ INSERT INTO memories_fts(memories_fts, rowid, content, type, category)
289
+ VALUES ('delete', old.id, old.content, old.type, old.category);
290
+ INSERT INTO memories_fts(rowid, content, type, category)
291
+ VALUES (new.id, new.content, new.type, new.category);
292
+ END;
293
+ `);
294
+
295
+ db.exec(`
296
+ -- Memory embeddings for optional semantic search
297
+ CREATE TABLE IF NOT EXISTS memory_embeddings (
298
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
299
+ memory_id INTEGER NOT NULL UNIQUE,
300
+ embedding BLOB NOT NULL,
301
+ model TEXT NOT NULL,
302
+ dimensions INTEGER NOT NULL,
303
+ created_at INTEGER NOT NULL,
304
+ FOREIGN KEY (memory_id) REFERENCES memories(id) ON DELETE CASCADE
305
+ );
306
+
307
+ -- Entity tracking for surprise detection
308
+ CREATE TABLE IF NOT EXISTS memory_entities (
309
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
310
+ entity_type TEXT NOT NULL,
311
+ entity_name TEXT NOT NULL,
312
+ first_seen_at INTEGER NOT NULL,
313
+ last_seen_at INTEGER NOT NULL,
314
+ occurrence_count INTEGER DEFAULT 1,
315
+ properties TEXT,
316
+ UNIQUE(entity_type, entity_name)
317
+ );
318
+
319
+ CREATE INDEX IF NOT EXISTS idx_memory_entities_type_name
320
+ ON memory_entities(entity_type, entity_name);
321
+
322
+ -- Memory associations (graph relationships)
323
+ CREATE TABLE IF NOT EXISTS memory_associations (
324
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
325
+ from_memory_id INTEGER NOT NULL,
326
+ to_memory_id INTEGER NOT NULL,
327
+ relationship TEXT,
328
+ strength REAL DEFAULT 0.5,
329
+ created_at INTEGER NOT NULL,
330
+ FOREIGN KEY (from_memory_id) REFERENCES memories(id) ON DELETE CASCADE,
331
+ FOREIGN KEY (to_memory_id) REFERENCES memories(id) ON DELETE CASCADE
332
+ );
333
+
334
+ CREATE INDEX IF NOT EXISTS idx_memory_associations_from
335
+ ON memory_associations(from_memory_id);
336
+
337
+ CREATE INDEX IF NOT EXISTS idx_memory_associations_to
338
+ ON memory_associations(to_memory_id);
221
339
  `);
222
340
 
223
341
  logger.info({ dbPath }, "SQLite session store initialised");