neoagent 1.4.1 → 1.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,151 @@
1
+ function clampText(text, maxChars) {
2
+ const str = String(text || '');
3
+ if (str.length <= maxChars) return str;
4
+ return `${str.slice(0, maxChars)}\n...[truncated, ${str.length} chars total]`;
5
+ }
6
+
7
+ function lineExcerpt(text, maxLines = 12, maxChars = 700) {
8
+ const str = String(text || '').trim();
9
+ if (!str) return '';
10
+ return clampText(str.split('\n').slice(0, maxLines).join('\n'), maxChars);
11
+ }
12
+
13
+ function toJsonText(value, maxChars) {
14
+ const raw = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
15
+ return clampText(raw, maxChars);
16
+ }
17
+
18
+ function trimObject(obj) {
19
+ return Object.fromEntries(Object.entries(obj).filter(([, value]) => value !== undefined && value !== null && value !== ''));
20
+ }
21
+
22
+ function clampEnvelope(envelope, hardLimit) {
23
+ const raw = JSON.stringify(envelope);
24
+ if (raw.length <= hardLimit) return raw;
25
+
26
+ const trimmed = { ...envelope };
27
+ if (trimmed.summary) trimmed.summary = clampText(trimmed.summary, Math.max(200, hardLimit - 300));
28
+ if (trimmed.stdout) trimmed.stdout = clampText(trimmed.stdout, Math.max(160, hardLimit - 400));
29
+ if (trimmed.stderr) trimmed.stderr = clampText(trimmed.stderr, Math.max(120, hardLimit - 400));
30
+ if (trimmed.content) trimmed.content = clampText(trimmed.content, Math.max(160, hardLimit - 400));
31
+ if (trimmed.excerpt) trimmed.excerpt = clampText(trimmed.excerpt, Math.max(160, hardLimit - 400));
32
+ if (trimmed.result) trimmed.result = clampText(trimmed.result, Math.max(160, hardLimit - 400));
33
+
34
+ const fallback = JSON.stringify(trimmed);
35
+ if (fallback.length <= hardLimit) return fallback;
36
+ return clampText(fallback, hardLimit);
37
+ }
38
+
39
+ function compactToolResult(toolName, toolArgs = {}, toolResult, options = {}) {
40
+ const softLimit = Math.max(400, Math.min(Number(options.softLimit) || 1200, 2000));
41
+ const hardLimit = Math.max(softLimit, Math.min(Number(options.hardLimit) || 2000, 3000));
42
+
43
+ let envelope;
44
+
45
+ switch (toolName) {
46
+ case 'execute_command':
47
+ envelope = trimObject({
48
+ tool: toolName,
49
+ exitCode: toolResult?.exitCode,
50
+ cwd: toolResult?.cwd || toolArgs.cwd,
51
+ killed: toolResult?.killed || false,
52
+ stdout: lineExcerpt(toolResult?.stdout, 12, Math.floor(softLimit * 0.45)),
53
+ stderr: lineExcerpt(toolResult?.stderr, 8, Math.floor(softLimit * 0.25))
54
+ });
55
+ break;
56
+
57
+ case 'read_file':
58
+ envelope = trimObject({
59
+ tool: toolName,
60
+ path: toolArgs.path,
61
+ startLine: toolArgs.start_line,
62
+ endLine: toolArgs.end_line,
63
+ content: lineExcerpt(toolResult?.content || toolResult, 20, Math.floor(softLimit * 0.7))
64
+ });
65
+ break;
66
+
67
+ case 'search_files':
68
+ envelope = trimObject({
69
+ tool: toolName,
70
+ count: toolResult?.count || toolResult?.matches?.length || 0,
71
+ matches: (toolResult?.matches || []).slice(0, 6).map((match) => trimObject({
72
+ file: match.file,
73
+ line: match.line,
74
+ content: clampText(match.content, 160)
75
+ }))
76
+ });
77
+ break;
78
+
79
+ case 'browser_extract':
80
+ envelope = trimObject({
81
+ tool: toolName,
82
+ selector: toolArgs.selector || 'body',
83
+ attribute: toolArgs.attribute || 'innerText',
84
+ excerpt: lineExcerpt(toolResult?.result || toolResult?.content || toolResult, 18, Math.floor(softLimit * 0.7))
85
+ });
86
+ break;
87
+
88
+ case 'http_request':
89
+ envelope = trimObject({
90
+ tool: toolName,
91
+ status: toolResult?.status,
92
+ headers: trimObject({
93
+ contentType: toolResult?.headers?.['content-type'] || toolResult?.headers?.['Content-Type'],
94
+ contentLength: toolResult?.headers?.['content-length'] || toolResult?.headers?.['Content-Length']
95
+ }),
96
+ excerpt: lineExcerpt(toolResult?.body || toolResult, 18, Math.floor(softLimit * 0.65))
97
+ });
98
+ break;
99
+
100
+ case 'send_message':
101
+ case 'make_call':
102
+ case 'memory_save':
103
+ case 'memory_recall':
104
+ case 'memory_update_core':
105
+ case 'memory_read':
106
+ case 'memory_write':
107
+ case 'create_scheduled_task':
108
+ case 'schedule_run':
109
+ case 'list_scheduled_tasks':
110
+ case 'delete_scheduled_task':
111
+ case 'update_scheduled_task':
112
+ envelope = trimObject({
113
+ tool: toolName,
114
+ status: toolResult?.success === false || toolResult?.error ? 'error' : 'ok',
115
+ message: clampText(toolResult?.message || toolResult?.error || '', Math.floor(softLimit * 0.45)),
116
+ result: clampText(JSON.stringify(trimObject({
117
+ id: toolResult?.id,
118
+ key: toolResult?.key,
119
+ deleted: toolResult?.deleted,
120
+ sent: toolResult?.sent,
121
+ count: Array.isArray(toolResult?.results) ? toolResult.results.length : undefined
122
+ })), Math.floor(softLimit * 0.35))
123
+ });
124
+ break;
125
+
126
+ case 'spawn_subagent':
127
+ envelope = trimObject({
128
+ tool: toolName,
129
+ iterations: toolResult?.iterations,
130
+ tokens: toolResult?.tokens,
131
+ runId: toolResult?.runId,
132
+ summary: clampText(toolResult?.subagent_result || toolResult?.error || '', Math.floor(softLimit * 0.55))
133
+ });
134
+ break;
135
+
136
+ default:
137
+ envelope = trimObject({
138
+ tool: toolName,
139
+ summary: toJsonText(toolResult, Math.floor(softLimit * 0.75))
140
+ });
141
+ break;
142
+ }
143
+
144
+ return clampEnvelope(envelope, hardLimit);
145
+ }
146
+
147
+ module.exports = {
148
+ compactToolResult,
149
+ clampText,
150
+ lineExcerpt
151
+ };
@@ -87,7 +87,7 @@ class SkillRunner {
87
87
  }
88
88
 
89
89
  getSkillsForPrompt() {
90
- const skills = Array.from(this.skills.values());
90
+ const skills = Array.from(this.skills.values()).filter((skill) => skill.metadata.enabled !== false);
91
91
  if (skills.length === 0) return '';
92
92
 
93
93
  let prompt = '\n## Available Skills\n';
@@ -103,7 +103,7 @@ class SkillRunner {
103
103
  getToolDefinitions() {
104
104
  const tools = [];
105
105
  for (const skill of this.skills.values()) {
106
- if (skill.metadata.tool) {
106
+ if (skill.metadata.enabled !== false && skill.metadata.tool) {
107
107
  tools.push({
108
108
  name: skill.name,
109
109
  description: skill.description,
@@ -117,6 +117,9 @@ class SkillRunner {
117
117
  async executeTool(toolName, args) {
118
118
  const skill = this.skills.get(toolName);
119
119
  if (!skill) return null;
120
+ if (skill.metadata.enabled === false) {
121
+ return { error: `Skill '${toolName}' is disabled` };
122
+ }
120
123
 
121
124
  if (skill.metadata.command) {
122
125
  const { CLIExecutor } = require('../cli/executor');
@@ -140,8 +143,10 @@ class SkillRunner {
140
143
  const filePath = path.join(skillDir, 'SKILL.md');
141
144
  fs.writeFileSync(filePath, frontmatter + `\n\n${instructions}`);
142
145
 
143
- db.prepare('INSERT OR REPLACE INTO skills (name, description, file_path, metadata, auto_created, updated_at) VALUES (?, ?, ?, ?, 1, datetime(\'now\'))')
144
- .run(safeName, description, filePath, JSON.stringify(metadata));
146
+ db.prepare(`
147
+ INSERT OR REPLACE INTO skills (name, description, file_path, metadata, enabled, auto_created, updated_at)
148
+ VALUES (?, ?, ?, ?, ?, 1, datetime('now'))
149
+ `).run(safeName, description, filePath, JSON.stringify(metadata), metadata.enabled === false ? 0 : 1);
145
150
 
146
151
  this.loadSkillFile(filePath);
147
152
 
@@ -167,12 +172,24 @@ class SkillRunner {
167
172
 
168
173
  const frontmatter = this._buildFrontmatter(name, newDesc, metaToWrite);
169
174
  fs.writeFileSync(skill.filePath, frontmatter + `\n\n${newInstructions}`);
170
- db.prepare('UPDATE skills SET description = ?, updated_at = datetime(\'now\') WHERE name = ?').run(newDesc, name);
175
+ db.prepare('UPDATE skills SET description = ?, metadata = ?, enabled = ?, updated_at = datetime(\'now\') WHERE name = ?')
176
+ .run(newDesc, JSON.stringify(metaToWrite || {}), metaToWrite?.enabled === false ? 0 : 1, name);
171
177
  this.loadSkillFile(skill.filePath);
172
178
 
173
179
  return { success: true, name, path: skill.filePath };
174
180
  }
175
181
 
182
+ getSkill(name) {
183
+ return this.skills.get(name) || null;
184
+ }
185
+
186
+ setSkillEnabled(name, enabled) {
187
+ const skill = this.skills.get(name);
188
+ if (!skill) return { error: `Skill '${name}' not found` };
189
+ const metadata = { ...skill.metadata, enabled: !!enabled };
190
+ return this.updateSkill(name, { metadata });
191
+ }
192
+
176
193
  deleteSkill(name) {
177
194
  const skill = this.skills.get(name);
178
195
  if (!skill) return { error: `Skill '${name}' not found` };
@@ -211,7 +228,8 @@ class SkillRunner {
211
228
  name: s.name,
212
229
  description: s.description,
213
230
  metadata: s.metadata,
214
- filePath: s.filePath
231
+ filePath: s.filePath,
232
+ enabled: s.metadata.enabled !== false
215
233
  }));
216
234
  }
217
235
  }
@@ -0,0 +1,140 @@
1
+ const ALWAYS_ON_TOOLS = ['notify_user'];
2
+
3
+ const PACKS = {
4
+ code: ['execute_command', 'read_file', 'list_directory', 'search_files'],
5
+ web: ['web_search', 'http_request', 'browser_navigate', 'browser_extract', 'browser_click', 'browser_type', 'browser_screenshot'],
6
+ messaging: ['send_message', 'make_call'],
7
+ memory: ['memory_recall', 'session_search', 'memory_save', 'memory_update_core', 'memory_read', 'memory_write'],
8
+ scheduling: ['create_scheduled_task', 'schedule_run', 'list_scheduled_tasks', 'update_scheduled_task', 'delete_scheduled_task'],
9
+ protocols: ['manage_protocols'],
10
+ skills: ['create_skill', 'list_skills', 'update_skill', 'delete_skill'],
11
+ images: ['generate_image', 'analyze_image'],
12
+ tables: ['generate_table', 'generate_graph'],
13
+ subagents: ['spawn_subagent'],
14
+ mcpAdmin: ['mcp_add_server', 'mcp_list_servers', 'mcp_remove_server']
15
+ };
16
+
17
+ function containsAny(text, patterns) {
18
+ return patterns.some((pattern) => pattern.test(text));
19
+ }
20
+
21
+ function detectRequestedPacks(task = '', options = {}) {
22
+ const text = String(task || '').toLowerCase();
23
+ const packs = new Set();
24
+
25
+ if (containsAny(text, [
26
+ /\b(run|execute|command|shell|terminal|bash|zsh|npm|node|python|script|repo|code|bug|fix|patch|test|build|file|folder|directory|grep|search files?)\b/,
27
+ /\b(read|open|inspect)\s+(the\s+)?(file|repo|code)\b/
28
+ ])) {
29
+ packs.add('code');
30
+ }
31
+
32
+ if (containsAny(text, [
33
+ /\b(web|website|url|page|browser|click|navigate|scrape|search|google|lookup|http|fetch|api request|screenshot)\b/,
34
+ /\bopen\b.*\bsite\b/
35
+ ])) {
36
+ packs.add('web');
37
+ }
38
+
39
+ if (containsAny(text, [
40
+ /\b(message|reply|respond|text|whatsapp|telegram|discord|dm|email|call|phone|notify|send to)\b/,
41
+ /\[no response\]/,
42
+ /\bsend_message\b/,
43
+ /\bmake_call\b/
44
+ ])) {
45
+ packs.add('messaging');
46
+ }
47
+
48
+ if (containsAny(text, [
49
+ /\bmemory\b/,
50
+ /\bremember\b/,
51
+ /\brecall\b/,
52
+ /\bprevious chat\b/,
53
+ /\blast time\b/,
54
+ /\bpast conversation\b/,
55
+ /\bpreference\b/,
56
+ /\bprofile\b/,
57
+ /\bsoul\b/
58
+ ])) {
59
+ packs.add('memory');
60
+ }
61
+
62
+ if (containsAny(text, [
63
+ /\bschedule\b/,
64
+ /\bcron\b/,
65
+ /\bremind\b/,
66
+ /\brecurring\b/,
67
+ /\bweekly\b/,
68
+ /\bdaily\b/,
69
+ /\bone-time\b/,
70
+ /\btask\b.*\blater\b/
71
+ ])) {
72
+ packs.add('scheduling');
73
+ }
74
+
75
+ if (containsAny(text, [/\bprotocol\b/, /\bplaybook\b/])) {
76
+ packs.add('protocols');
77
+ }
78
+
79
+ if (containsAny(text, [/\bskill\b/, /\binstall skill\b/, /\bcreate skill\b/])) {
80
+ packs.add('skills');
81
+ }
82
+
83
+ if (containsAny(text, [/\bimage\b/, /\bpicture\b/, /\bphoto\b/, /\bgraph\b/, /\bchart\b/, /\btable\b/, /\bqr\b/, /\bocr\b/])) {
84
+ packs.add('images');
85
+ }
86
+
87
+ if (containsAny(text, [/\btable\b/, /\bspreadsheet\b/, /\bgraph\b/, /\bchart\b/])) {
88
+ packs.add('tables');
89
+ }
90
+
91
+ if (containsAny(text, [/\bsub-?agent\b/, /\bdelegate\b/, /\bparallel\b/, /\bbackground worker\b/])) {
92
+ packs.add('subagents');
93
+ }
94
+
95
+ if (containsAny(text, [/\bmcp\b/, /\bmodel context protocol\b/, /\bserver tool\b/])) {
96
+ packs.add('mcpAdmin');
97
+ }
98
+
99
+ if (options.mediaAttachments?.length) {
100
+ packs.add('images');
101
+ }
102
+
103
+ return packs;
104
+ }
105
+
106
+ function maybeSelectMcpTools(text, mcpTools = []) {
107
+ const normalized = String(text || '').toLowerCase();
108
+ if (!normalized || !mcpTools.length) return [];
109
+
110
+ const explicitMcp = /\bmcp\b|\bmodel context protocol\b/.test(normalized);
111
+ return mcpTools.filter((tool) => {
112
+ const name = String(tool.name || '').toLowerCase();
113
+ const original = String(tool.originalName || '').toLowerCase();
114
+ const server = String(tool.serverId || '').toLowerCase();
115
+ return explicitMcp || normalized.includes(name) || normalized.includes(original) || (server && normalized.includes(server));
116
+ });
117
+ }
118
+
119
+ function selectToolsForTask(task, builtInTools = [], mcpTools = [], options = {}) {
120
+ const packs = detectRequestedPacks(task, options);
121
+ const allowNames = new Set(ALWAYS_ON_TOOLS);
122
+
123
+ for (const pack of packs) {
124
+ for (const toolName of PACKS[pack] || []) {
125
+ allowNames.add(toolName);
126
+ }
127
+ }
128
+
129
+ const selectedBuiltIns = builtInTools.filter((tool) => allowNames.has(tool.name));
130
+ const selectedMcp = maybeSelectMcpTools(task, mcpTools);
131
+
132
+ return [...selectedBuiltIns, ...selectedMcp];
133
+ }
134
+
135
+ module.exports = {
136
+ ALWAYS_ON_TOOLS,
137
+ PACKS,
138
+ detectRequestedPacks,
139
+ selectToolsForTask
140
+ };
@@ -3,12 +3,54 @@ const path = require('path');
3
3
  const db = require('../../db/database');
4
4
  const { DATA_DIR } = require('../../../runtime/paths');
5
5
 
6
+ function compactText(text, maxChars = 120) {
7
+ const str = String(text || '').replace(/\s+/g, ' ').trim();
8
+ if (str.length <= maxChars) return str;
9
+ const trimmed = str.slice(0, maxChars);
10
+ const sentenceBreak = Math.max(trimmed.lastIndexOf('. '), trimmed.lastIndexOf('; '), trimmed.lastIndexOf(', '));
11
+ if (sentenceBreak > 40) return trimmed.slice(0, sentenceBreak + 1).trim();
12
+ return `${trimmed.trim()}...`;
13
+ }
14
+
15
+ function compactToolDefinition(tool, options = {}) {
16
+ const compact = {
17
+ name: tool.name,
18
+ parameters: {
19
+ ...(tool.parameters || { type: 'object', properties: {} }),
20
+ properties: {}
21
+ }
22
+ };
23
+
24
+ if (options.includeDescriptions) {
25
+ compact.description = compactText(tool.description, 120);
26
+ }
27
+
28
+ if (tool.parameters?.properties) {
29
+ const properties = {};
30
+ for (const [key, value] of Object.entries(tool.parameters.properties)) {
31
+ properties[key] = { ...value };
32
+ if (options.includeDescriptions && value.description) {
33
+ properties[key].description = compactText(value.description, 70);
34
+ } else {
35
+ delete properties[key].description;
36
+ }
37
+ }
38
+ compact.parameters = {
39
+ ...compact.parameters,
40
+ properties
41
+ };
42
+ }
43
+
44
+ return compact;
45
+ }
46
+
6
47
  /**
7
48
  * Returns the list of available tools for the agent.
8
49
  * @param {object} app - Express app instance.
50
+ * @param {object} options - Tool filtering options.
9
51
  * @returns {Array} List of tool definitions.
10
52
  */
11
- function getAvailableTools(app) {
53
+ function getAvailableTools(app, options = {}) {
12
54
  const tools = [
13
55
  {
14
56
  name: 'execute_command',
@@ -154,6 +196,18 @@ function getAvailableTools(app) {
154
196
  required: ['query']
155
197
  }
156
198
  },
199
+ {
200
+ name: 'session_search',
201
+ description: 'Search past runs and message threads for commands, decisions, file paths, or context from earlier conversations.',
202
+ parameters: {
203
+ type: 'object',
204
+ properties: {
205
+ query: { type: 'string', description: 'What to search for in prior sessions.' },
206
+ limit: { type: 'number', description: 'How many matching sessions to return (default 6).' }
207
+ },
208
+ required: ['query']
209
+ }
210
+ },
157
211
  {
158
212
  name: 'memory_update_core',
159
213
  description: 'Update core memory — always-injected facts that appear in every prompt. Use for critical always-relevant info: user\'s name, their main job, key standing preferences, how they want you to behave. Keep each entry concise.',
@@ -530,7 +584,12 @@ function getAvailableTools(app) {
530
584
  }
531
585
  ];
532
586
 
533
- return tools;
587
+ const compacted = tools.map((tool) => compactToolDefinition(tool, options));
588
+ if (options.names && Array.isArray(options.names)) {
589
+ const allow = new Set(options.names);
590
+ return compacted.filter((tool) => allow.has(tool.name));
591
+ }
592
+ return compacted;
534
593
  }
535
594
 
536
595
  /**
@@ -725,6 +784,16 @@ async function executeTool(toolName, args, context, engine) {
725
784
  return { results };
726
785
  }
727
786
 
787
+ case 'session_search': {
788
+ const { MemoryManager } = require('../memory/manager');
789
+ const mm = new MemoryManager();
790
+ const results = mm.searchConversations(userId, args.query, {
791
+ sessions: args.limit || 6
792
+ });
793
+ if (!results.length) return { results: [], message: 'No matching sessions found' };
794
+ return { results };
795
+ }
796
+
728
797
  case 'memory_update_core': {
729
798
  const { MemoryManager } = require('../memory/manager');
730
799
  const mm = new MemoryManager();
@@ -5,7 +5,9 @@ const { MemoryManager } = require('./memory/manager');
5
5
  const { MCPClient } = require('./mcp/client');
6
6
  const { BrowserController } = require('./browser/controller');
7
7
  const { AgentEngine } = require('./ai/engine');
8
+ const { LearningManager } = require('./ai/learning');
8
9
  const { MultiStepOrchestrator } = require('./ai/multiStep');
10
+ const { SkillRunner } = require('./ai/toolRunner');
9
11
  const { MessagingManager } = require('./messaging/manager');
10
12
  const { Scheduler } = require('./scheduler/cron');
11
13
  const { setupWebSocket } = require('./websocket');
@@ -29,7 +31,21 @@ async function startServices(app, io) {
29
31
  }
30
32
  app.locals.browserController = browserController;
31
33
 
32
- const agentEngine = new AgentEngine(io, { memoryManager, mcpClient, browserController, messagingManager: null });
34
+ const skillRunner = new SkillRunner();
35
+ await skillRunner.loadSkills();
36
+ app.locals.skillRunner = skillRunner;
37
+
38
+ const learningManager = new LearningManager(skillRunner, io);
39
+ app.locals.learningManager = learningManager;
40
+
41
+ const agentEngine = new AgentEngine(io, {
42
+ memoryManager,
43
+ mcpClient,
44
+ browserController,
45
+ messagingManager: null,
46
+ skillRunner,
47
+ learningManager
48
+ });
33
49
  app.locals.agentEngine = agentEngine;
34
50
 
35
51
  const multiStep = new MultiStepOrchestrator(agentEngine, io);
@@ -167,7 +183,14 @@ async function startServices(app, io) {
167
183
  agentEngine.scheduler = scheduler;
168
184
  scheduler.start();
169
185
 
170
- setupWebSocket(io, { agentEngine, messagingManager, mcpClient, scheduler, memoryManager, app });
186
+ setupWebSocket(io, {
187
+ agentEngine,
188
+ messagingManager,
189
+ mcpClient,
190
+ scheduler,
191
+ memoryManager,
192
+ app
193
+ });
171
194
  app.locals.io = io;
172
195
 
173
196
  console.log('All services initialized');
@@ -2,30 +2,80 @@
2
2
 
3
3
  /**
4
4
  * Embedding helpers for the semantic memory system.
5
- * Uses OpenAI text-embedding-3-small (1536 dims) when available.
6
- * Gracefully degrades to keyword search if OPENAI_API_KEY is missing.
5
+ *
6
+ * Provider selection (in priority order):
7
+ * 1. Google (text-embedding-004, 768 dims) — when provider hint is 'google' and GOOGLE_AI_KEY is set
8
+ * 2. OpenAI (text-embedding-3-small, 1536 dims) — when OPENAI_API_KEY is set
9
+ * 3. Keyword fallback — when no API key is available
7
10
  */
8
11
 
9
12
  const https = require('https');
10
13
 
11
- const EMBEDDING_MODEL = 'text-embedding-3-small';
12
- const EMBED_DIM = 1536;
14
+ const OPENAI_MODEL = 'text-embedding-3-small';
15
+ const OPENAI_DIM = 1536;
16
+ const GOOGLE_MODEL = 'text-embedding-004';
17
+ const GOOGLE_DIM = 768;
13
18
 
14
- /**
15
- * Get an embedding vector for a piece of text.
16
- * Returns a Float32Array of length EMBED_DIM, or null if unavailable.
17
- */
18
- async function getEmbedding(text) {
19
+ // Exported so callers can sanity-check stored vector dimensions if needed
20
+ const EMBED_DIM = OPENAI_DIM;
21
+ const EMBED_DIM_GOOGLE = GOOGLE_DIM;
22
+
23
+ async function getGeminiEmbedding(text) {
24
+ const apiKey = process.env.GOOGLE_AI_KEY;
25
+ if (!apiKey) return null;
26
+ if (!text || !text.trim()) return null;
27
+
28
+ const truncated = text.slice(0, 25000);
29
+
30
+ return new Promise((resolve) => {
31
+ const body = JSON.stringify({
32
+ model: `models/${GOOGLE_MODEL}`,
33
+ content: { parts: [{ text: truncated }] }
34
+ });
35
+
36
+ const path = `/v1beta/models/${GOOGLE_MODEL}:embedContent?key=${apiKey}`;
37
+ const options = {
38
+ hostname: 'generativelanguage.googleapis.com',
39
+ path,
40
+ method: 'POST',
41
+ headers: {
42
+ 'Content-Type': 'application/json',
43
+ 'Content-Length': Buffer.byteLength(body)
44
+ }
45
+ };
46
+
47
+ const req = https.request(options, (res) => {
48
+ let data = '';
49
+ res.on('data', chunk => { data += chunk; });
50
+ res.on('end', () => {
51
+ try {
52
+ const parsed = JSON.parse(data);
53
+ const vec = parsed.embedding?.values;
54
+ if (!vec) return resolve(null);
55
+ resolve(new Float32Array(vec));
56
+ } catch {
57
+ resolve(null);
58
+ }
59
+ });
60
+ });
61
+
62
+ req.on('error', () => resolve(null));
63
+ req.setTimeout(15000, () => { req.destroy(); resolve(null); });
64
+ req.write(body);
65
+ req.end();
66
+ });
67
+ }
68
+
69
+ async function getOpenAIEmbedding(text) {
19
70
  const apiKey = process.env.OPENAI_API_KEY;
20
71
  if (!apiKey) return null;
21
72
  if (!text || !text.trim()) return null;
22
73
 
23
- // Truncate very long text to stay within token limits (~8k tokens)
24
74
  const truncated = text.slice(0, 25000);
25
75
 
26
- return new Promise((resolve, reject) => {
76
+ return new Promise((resolve) => {
27
77
  const body = JSON.stringify({
28
- model: EMBEDDING_MODEL,
78
+ model: OPENAI_MODEL,
29
79
  input: truncated,
30
80
  encoding_format: 'float'
31
81
  });
@@ -64,6 +114,21 @@ async function getEmbedding(text) {
64
114
  });
65
115
  }
66
116
 
117
+ /**
118
+ * Get an embedding vector for a piece of text.
119
+ * @param {string} text
120
+ * @param {string} [provider] - 'google' to prefer Gemini embeddings
121
+ * @returns {Float32Array|null}
122
+ */
123
+ async function getEmbedding(text, provider) {
124
+ if (!text || !text.trim()) return null;
125
+ if (provider === 'google' && process.env.GOOGLE_AI_KEY) {
126
+ const vec = await getGeminiEmbedding(text);
127
+ if (vec) return vec;
128
+ }
129
+ return getOpenAIEmbedding(text);
130
+ }
131
+
67
132
  /**
68
133
  * Cosine similarity between two Float32Arrays.
69
134
  * Returns a value in [-1, 1]; higher = more similar.
@@ -72,7 +137,7 @@ function cosineSimilarity(a, b) {
72
137
  if (!a || !b || a.length !== b.length) return 0;
73
138
  let dot = 0, magA = 0, magB = 0;
74
139
  for (let i = 0; i < a.length; i++) {
75
- dot += a[i] * b[i];
140
+ dot += a[i] * b[i];
76
141
  magA += a[i] * a[i];
77
142
  magB += b[i] * b[i];
78
143
  }
@@ -122,5 +187,6 @@ module.exports = {
122
187
  serializeEmbedding,
123
188
  deserializeEmbedding,
124
189
  keywordSimilarity,
125
- EMBED_DIM
190
+ EMBED_DIM,
191
+ EMBED_DIM_GOOGLE
126
192
  };