osai-agent 4.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.
- package/LICENSE +7 -0
- package/package.json +72 -0
- package/src/agent/context.js +141 -0
- package/src/agent/loop/context-summary.js +196 -0
- package/src/agent/loop/directory-utils.js +102 -0
- package/src/agent/loop/local.js +196 -0
- package/src/agent/loop/loop-detection.js +288 -0
- package/src/agent/loop/stream-parser.js +515 -0
- package/src/agent/loop/tool-executor.js +470 -0
- package/src/agent/loop/verification.js +263 -0
- package/src/agent/loop/websocket.js +80 -0
- package/src/agent/prompt.js +259 -0
- package/src/agent/react-loop.js +697 -0
- package/src/agent/subagent.js +263 -0
- package/src/commands/config.js +53 -0
- package/src/commands/connect.js +190 -0
- package/src/commands/devices.js +121 -0
- package/src/commands/login.js +77 -0
- package/src/commands/logout.js +31 -0
- package/src/commands/mcp.js +258 -0
- package/src/commands/provider.js +633 -0
- package/src/commands/register.js +74 -0
- package/src/commands/run.js +150 -0
- package/src/commands/search.js +64 -0
- package/src/commands/session.js +57 -0
- package/src/commands/skills.js +54 -0
- package/src/commands/stop-subagent.js +58 -0
- package/src/index.js +208 -0
- package/src/llm/direct.js +317 -0
- package/src/memory/store.js +215 -0
- package/src/mock-readline.js +27 -0
- package/src/parser/dependencies.js +71 -0
- package/src/parser/markdown.js +505 -0
- package/src/parser/stream.js +96 -0
- package/src/prompts/modes/CODING.js +160 -0
- package/src/prompts/modes/GENERAL.js +105 -0
- package/src/prompts/modes/NETWORK.js +69 -0
- package/src/prompts/modes/SSH.js +53 -0
- package/src/prompts/systemPrompt.js +85 -0
- package/src/safety/check.js +210 -0
- package/src/services/crypto.js +78 -0
- package/src/services/executor.js +68 -0
- package/src/services/history.js +58 -0
- package/src/services/server-url.js +11 -0
- package/src/services/session.js +194 -0
- package/src/services/ssh.js +176 -0
- package/src/services/websocket.js +112 -0
- package/src/skills/loader.js +231 -0
- package/src/tools/browser.js +434 -0
- package/src/tools/local.js +1254 -0
- package/src/tools/mcp-client.js +209 -0
- package/src/tools/registry.js +132 -0
- package/src/tools/search-providers.js +237 -0
- package/src/tools/ssh.js +74 -0
- package/src/ui/App.js +2031 -0
- package/src/ui/animation.js +47 -0
- package/src/ui/components/AskUserDialog.js +33 -0
- package/src/ui/components/ConfirmationDialog.js +45 -0
- package/src/ui/components/DiffView.js +201 -0
- package/src/ui/components/Header.js +157 -0
- package/src/ui/components/HistoryPicker.js +130 -0
- package/src/ui/components/InputShell.js +22 -0
- package/src/ui/components/MessageHistory.js +1200 -0
- package/src/ui/components/ModalPanel.js +40 -0
- package/src/ui/components/ModePicker.js +161 -0
- package/src/ui/components/PlanDialog.js +48 -0
- package/src/ui/components/ProviderMenu.js +1095 -0
- package/src/ui/components/SavePicker.js +106 -0
- package/src/ui/components/SelectMenu.js +194 -0
- package/src/ui/components/SlashMenu.js +168 -0
- package/src/ui/components/SubagentPanel.js +138 -0
- package/src/ui/components/TextInputSafe.js +117 -0
- package/src/ui/components/TodoPanel.js +54 -0
- package/src/ui/components/ToolExecution.js +261 -0
- package/src/ui/components/TranscriptViewport.js +99 -0
- package/src/ui/diff.js +249 -0
- package/src/ui/h.js +7 -0
- package/src/ui/mouse-scroll.js +63 -0
- package/src/ui/slash-picker.js +58 -0
- package/src/ui/terminal.js +41 -0
- package/src/ui/theme.js +5 -0
- package/src/ui/welcome.js +12 -0
- package/src/utils/constants.js +231 -0
- package/src/utils/helpers.js +154 -0
- package/src/utils/logger.js +81 -0
- package/src/utils/sound.js +33 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { Client, StdioClientTransport, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
|
2
|
+
import { logger } from '../utils/logger.js';
|
|
3
|
+
|
|
4
|
+
class McpConnection {
|
|
5
|
+
constructor(name, config) {
|
|
6
|
+
this.name = name;
|
|
7
|
+
this.config = config;
|
|
8
|
+
this.client = null;
|
|
9
|
+
this.transport = null;
|
|
10
|
+
this.tools = [];
|
|
11
|
+
this.connected = false;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async connect() {
|
|
15
|
+
const { transport: type, command, args, url, env } = this.config;
|
|
16
|
+
|
|
17
|
+
if (type === 'http' || type === 'streamable-http') {
|
|
18
|
+
const transportOpts = this.config.headers
|
|
19
|
+
? { requestInit: { headers: this.config.headers } }
|
|
20
|
+
: undefined;
|
|
21
|
+
this.transport = new StreamableHTTPClientTransport(new URL(url), transportOpts);
|
|
22
|
+
} else {
|
|
23
|
+
this.transport = new StdioClientTransport({
|
|
24
|
+
command,
|
|
25
|
+
args: args || [],
|
|
26
|
+
env: { ...(env || {}) },
|
|
27
|
+
stderr: 'pipe',
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
this.client = new Client({
|
|
32
|
+
name: 'osai-agent',
|
|
33
|
+
version: '4.0.0',
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
await this.client.connect(this.transport);
|
|
37
|
+
this.connected = true;
|
|
38
|
+
|
|
39
|
+
const result = await this.client.listTools();
|
|
40
|
+
this.tools = result.tools || [];
|
|
41
|
+
logger.debug(`MCP server "${this.name}": ${this.tools.length} tools available`);
|
|
42
|
+
|
|
43
|
+
return this.tools;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async disconnect() {
|
|
47
|
+
this.connected = false;
|
|
48
|
+
if (this.transport) {
|
|
49
|
+
try { await this.transport.close(); } catch {}
|
|
50
|
+
}
|
|
51
|
+
this.client = null;
|
|
52
|
+
this.transport = null;
|
|
53
|
+
this.tools = [];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async callTool(toolName, args = {}) {
|
|
57
|
+
if (!this.connected || !this.client) {
|
|
58
|
+
throw new Error(`MCP server "${this.name}" is not connected`);
|
|
59
|
+
}
|
|
60
|
+
const result = await this.client.callTool({
|
|
61
|
+
name: toolName,
|
|
62
|
+
arguments: args,
|
|
63
|
+
});
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
getTool(toolName) {
|
|
68
|
+
return this.tools.find(t => t.name === toolName) || null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export class McpClientManager {
|
|
73
|
+
constructor() {
|
|
74
|
+
this.connections = new Map();
|
|
75
|
+
this.toolIndex = new Map();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async addServer(name, config) {
|
|
79
|
+
if (this.connections.has(name)) {
|
|
80
|
+
await this.removeServer(name);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const conn = new McpConnection(name, config);
|
|
84
|
+
try {
|
|
85
|
+
const tools = await conn.connect();
|
|
86
|
+
this.connections.set(name, conn);
|
|
87
|
+
|
|
88
|
+
for (const tool of tools) {
|
|
89
|
+
const key = `${name}:${tool.name}`;
|
|
90
|
+
this.toolIndex.set(key, { serverName: name, toolDef: tool });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
logger.info(`MCP server "${name}" connected with ${tools.length} tools`);
|
|
94
|
+
return tools;
|
|
95
|
+
} catch (err) {
|
|
96
|
+
logger.error(`Failed to connect MCP server "${name}": ${err.message}`);
|
|
97
|
+
try { await conn.disconnect(); } catch {}
|
|
98
|
+
throw err;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async removeServer(name) {
|
|
103
|
+
const conn = this.connections.get(name);
|
|
104
|
+
if (!conn) return;
|
|
105
|
+
|
|
106
|
+
for (const tool of conn.tools) {
|
|
107
|
+
const key = `${name}:${tool.name}`;
|
|
108
|
+
this.toolIndex.delete(key);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
await conn.disconnect();
|
|
112
|
+
this.connections.delete(name);
|
|
113
|
+
logger.info(`MCP server "${name}" disconnected`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async executeTool(serverName, toolName, params = {}) {
|
|
117
|
+
const conn = this.connections.get(serverName);
|
|
118
|
+
if (!conn) {
|
|
119
|
+
return { success: false, output: '', error: `MCP server "${serverName}" not found` };
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
const result = await conn.callTool(toolName, params);
|
|
123
|
+
const output = (result.content || [])
|
|
124
|
+
.map(c => c.text || JSON.stringify(c))
|
|
125
|
+
.join('\n');
|
|
126
|
+
return { success: true, output, isError: !!result.isError };
|
|
127
|
+
} catch (err) {
|
|
128
|
+
return { success: false, output: '', error: err.message };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
getToolDescriptions() {
|
|
133
|
+
const descriptions = [];
|
|
134
|
+
for (const [name, conn] of this.connections) {
|
|
135
|
+
for (const tool of conn.tools) {
|
|
136
|
+
descriptions.push({
|
|
137
|
+
server: name,
|
|
138
|
+
mcpTool: tool.name,
|
|
139
|
+
description: tool.description || '',
|
|
140
|
+
inputSchema: tool.inputSchema || {},
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return descriptions;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
listServers() {
|
|
148
|
+
const result = [];
|
|
149
|
+
for (const [name, conn] of this.connections) {
|
|
150
|
+
result.push({
|
|
151
|
+
name,
|
|
152
|
+
connected: conn.connected,
|
|
153
|
+
tools: conn.tools.map(t => ({
|
|
154
|
+
name: t.name,
|
|
155
|
+
description: t.description || '',
|
|
156
|
+
})),
|
|
157
|
+
config: { ...conn.config },
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
return result;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
getServer(name) {
|
|
164
|
+
return this.connections.get(name) || null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async loadFromConfig(config) {
|
|
168
|
+
const servers = config || {};
|
|
169
|
+
const names = Object.keys(servers);
|
|
170
|
+
if (names.length === 0) return;
|
|
171
|
+
|
|
172
|
+
logger.info(`Loading ${names.length} MCP server(s) from config`);
|
|
173
|
+
const results = { connected: [], failed: [] };
|
|
174
|
+
|
|
175
|
+
for (const name of names) {
|
|
176
|
+
try {
|
|
177
|
+
const tools = await this.addServer(name, servers[name]);
|
|
178
|
+
results.connected.push({ name, toolCount: tools.length });
|
|
179
|
+
} catch (err) {
|
|
180
|
+
results.failed.push({ name, error: err.message });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (results.failed.length > 0) {
|
|
185
|
+
logger.warn('MCP server connection failures:', results.failed);
|
|
186
|
+
}
|
|
187
|
+
return results;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async reload(config) {
|
|
191
|
+
const currentNames = Array.from(this.connections.keys());
|
|
192
|
+
for (const name of currentNames) {
|
|
193
|
+
await this.removeServer(name);
|
|
194
|
+
}
|
|
195
|
+
return this.loadFromConfig(config);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
disconnectAll() {
|
|
199
|
+
for (const name of Array.from(this.connections.keys())) {
|
|
200
|
+
this.removeServer(name).catch(() => {});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
getToolByKey(key) {
|
|
205
|
+
return this.toolIndex.get(key) || null;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export const mcpClientManager = new McpClientManager();
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// OS AI Agent — Tool Registry
|
|
3
|
+
// =============================================================================
|
|
4
|
+
// Central registry for all tools the agent can use. Each tool is an independent
|
|
5
|
+
// module with a standard interface: name, description, parameters, execute(),
|
|
6
|
+
// and safety tier.
|
|
7
|
+
// =============================================================================
|
|
8
|
+
|
|
9
|
+
import { logger } from '../utils/logger.js';
|
|
10
|
+
import { TOOLS, SAFETY_TIERS } from '../utils/constants.js';
|
|
11
|
+
import { browse, searchAndBrowse, extractStructuredData } from './browser.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Represents a single tool that the agent can invoke
|
|
15
|
+
*/
|
|
16
|
+
class Tool {
|
|
17
|
+
/**
|
|
18
|
+
* @param {Object} opts
|
|
19
|
+
* @param {string} opts.name - Tool identifier (e.g. TOOLS.LOCAL_CMD)
|
|
20
|
+
* @param {string} opts.description - Human-readable description
|
|
21
|
+
* @param {Array} opts.parameters - Parameter schema
|
|
22
|
+
* @param {Function} opts.execute - Async execution function
|
|
23
|
+
* @param {string} opts.safety - Default safety tier
|
|
24
|
+
*/
|
|
25
|
+
constructor({ name, description, parameters, execute, safety }) {
|
|
26
|
+
this.name = name;
|
|
27
|
+
this.description = description || '';
|
|
28
|
+
this.parameters = parameters || [];
|
|
29
|
+
this.execute = execute;
|
|
30
|
+
this.safety = safety || SAFETY_TIERS.WRITE;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Central tool registry — register, discover, and execute tools
|
|
36
|
+
*/
|
|
37
|
+
export class ToolRegistry {
|
|
38
|
+
constructor() {
|
|
39
|
+
this.tools = new Map();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Register a new tool */
|
|
43
|
+
register(toolDefinition) {
|
|
44
|
+
const tool = toolDefinition instanceof Tool
|
|
45
|
+
? toolDefinition
|
|
46
|
+
: new Tool(toolDefinition);
|
|
47
|
+
this.tools.set(tool.name, tool);
|
|
48
|
+
logger.debug(`Tool registered: ${tool.name}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Get a tool by name */
|
|
52
|
+
get(name) {
|
|
53
|
+
return this.tools.get(name) || null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** List all registered tools */
|
|
57
|
+
list() {
|
|
58
|
+
return Array.from(this.tools.values());
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** List all tool names */
|
|
62
|
+
listNames() {
|
|
63
|
+
return Array.from(this.tools.keys());
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Check if a tool is registered */
|
|
67
|
+
has(name) {
|
|
68
|
+
return this.tools.has(name);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Execute a tool by name with given parameters and context */
|
|
72
|
+
async execute(name, params, context = {}) {
|
|
73
|
+
const tool = this.tools.get(name);
|
|
74
|
+
if (!tool) {
|
|
75
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
76
|
+
}
|
|
77
|
+
logger.debug(`Executing tool: ${name}`, { params });
|
|
78
|
+
try {
|
|
79
|
+
const result = await tool.execute(params, context);
|
|
80
|
+
return result;
|
|
81
|
+
} catch (error) {
|
|
82
|
+
logger.error(`Tool execution failed: ${name}`, { error: error.message });
|
|
83
|
+
return { success: false, output: '', error: error.message };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Get the safety tier of a tool */
|
|
88
|
+
getSafetyTier(name) {
|
|
89
|
+
const tool = this.tools.get(name);
|
|
90
|
+
return tool ? tool.safety : SAFETY_TIERS.WRITE;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Get tool descriptions for prompt generation */
|
|
94
|
+
getToolDescriptions() {
|
|
95
|
+
return this.list().map(t => ({
|
|
96
|
+
name: t.name,
|
|
97
|
+
description: t.description,
|
|
98
|
+
safety: t.safety,
|
|
99
|
+
}));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Global singleton registry */
|
|
104
|
+
export const registry = new ToolRegistry();
|
|
105
|
+
|
|
106
|
+
// Register browser tools
|
|
107
|
+
registry.register({
|
|
108
|
+
name: TOOLS.BROWSE,
|
|
109
|
+
description: 'Open a URL in a headless browser and extract readable text content. Falls back to HTTP fetch if Playwright is unavailable.',
|
|
110
|
+
parameters: [{ name: 'url', type: 'string', required: true, description: 'The http/https URL to browse' }],
|
|
111
|
+
execute: async (params) => await browse(params.url),
|
|
112
|
+
safety: SAFETY_TIERS.READ,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
registry.register({
|
|
116
|
+
name: TOOLS.BROWSE_SEARCH,
|
|
117
|
+
description: 'Search the web using DuckDuckGo, then open the first result and extract its content.',
|
|
118
|
+
parameters: [{ name: 'query', type: 'string', required: true, description: 'The search query' }],
|
|
119
|
+
execute: async (params) => await searchAndBrowse(params.query),
|
|
120
|
+
safety: SAFETY_TIERS.READ,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
registry.register({
|
|
124
|
+
name: TOOLS.BROWSE_EXTRACT,
|
|
125
|
+
description: 'Navigate to a URL and extract specific DOM elements using CSS selectors.',
|
|
126
|
+
parameters: [
|
|
127
|
+
{ name: 'url', type: 'string', required: true, description: 'The http/https URL' },
|
|
128
|
+
{ name: 'selectors', type: 'object', required: true, description: 'Map of key → CSS selector' },
|
|
129
|
+
],
|
|
130
|
+
execute: async (params) => await extractStructuredData(params.selectors, params.url),
|
|
131
|
+
safety: SAFETY_TIERS.READ,
|
|
132
|
+
});
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import ddg from 'duck-duck-scrape';
|
|
2
|
+
import { getJson } from 'serpapi';
|
|
3
|
+
import { tavily } from '@tavily/core';
|
|
4
|
+
import Conf from 'conf';
|
|
5
|
+
import { logger } from '../utils/logger.js';
|
|
6
|
+
|
|
7
|
+
const SEARCH_TIMEOUT = 15000;
|
|
8
|
+
|
|
9
|
+
const parseKeys = (envVar) => (envVar || '')
|
|
10
|
+
.split(',')
|
|
11
|
+
.map(k => k.trim())
|
|
12
|
+
.filter(Boolean);
|
|
13
|
+
|
|
14
|
+
const createKeyRotator = (keys) => {
|
|
15
|
+
let index = 0;
|
|
16
|
+
return {
|
|
17
|
+
keys,
|
|
18
|
+
hasKeys: keys.length > 0,
|
|
19
|
+
getNext() {
|
|
20
|
+
if (keys.length === 0) return null;
|
|
21
|
+
const key = keys[index % keys.length];
|
|
22
|
+
index++;
|
|
23
|
+
return key;
|
|
24
|
+
},
|
|
25
|
+
count: keys.length,
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function getConfKeys(provider) {
|
|
30
|
+
try {
|
|
31
|
+
const config = new Conf({ projectName: 'osai-agent' });
|
|
32
|
+
const searchKeys = config.get('searchApiKeys', {});
|
|
33
|
+
return searchKeys[provider] || null;
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getSearchKeys(provider) {
|
|
40
|
+
const envVarName = provider === 'serpapi' ? 'SERPAPI_API_KEY' : 'TAVILY_API_KEYS';
|
|
41
|
+
const envKeys = parseKeys(process.env[envVarName]);
|
|
42
|
+
if (envKeys.length > 0) return envKeys;
|
|
43
|
+
const confKey = getConfKeys(provider);
|
|
44
|
+
if (confKey) return [confKey];
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const serpapiRotator = createKeyRotator(getSearchKeys('serpapi'));
|
|
49
|
+
const tavilyRotator = createKeyRotator(getSearchKeys('tavily'));
|
|
50
|
+
|
|
51
|
+
export async function searchDDG(query, maxResults = 5) {
|
|
52
|
+
const searchResults = await ddg.search(query, { maxResults });
|
|
53
|
+
if (!searchResults || !searchResults.results || searchResults.results.length === 0) {
|
|
54
|
+
throw new Error('DDG: no results');
|
|
55
|
+
}
|
|
56
|
+
return searchResults.results.slice(0, maxResults).map(r => ({
|
|
57
|
+
title: r.title || '',
|
|
58
|
+
url: r.href || r.url || '',
|
|
59
|
+
snippet: r.description || r.snippet || '',
|
|
60
|
+
}));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function searchSerpAPI(query, maxResults = 5) {
|
|
64
|
+
if (!serpapiRotator.hasKeys) throw new Error('SerpAPI: no API keys configured');
|
|
65
|
+
|
|
66
|
+
let lastError = null;
|
|
67
|
+
const maxAttempts = Math.min(serpapiRotator.count, 3);
|
|
68
|
+
|
|
69
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
70
|
+
const apiKey = serpapiRotator.getNext();
|
|
71
|
+
try {
|
|
72
|
+
const response = await getJson({
|
|
73
|
+
engine: 'google',
|
|
74
|
+
api_key: apiKey,
|
|
75
|
+
q: query,
|
|
76
|
+
num: maxResults,
|
|
77
|
+
});
|
|
78
|
+
const results = (response.organic_results || []).slice(0, maxResults).map(r => ({
|
|
79
|
+
title: r.title || '',
|
|
80
|
+
url: r.link || r.url || '',
|
|
81
|
+
snippet: r.snippet || '',
|
|
82
|
+
}));
|
|
83
|
+
if (results.length > 0) return results;
|
|
84
|
+
lastError = new Error('SerpAPI: empty results');
|
|
85
|
+
} catch (err) {
|
|
86
|
+
lastError = err;
|
|
87
|
+
logger.debug(`SerpAPI key failed (attempt ${attempt + 1}): ${err.message}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
throw lastError || new Error('SerpAPI: all keys failed');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function searchTavily(query, maxResults = 5) {
|
|
94
|
+
if (!tavilyRotator.hasKeys) throw new Error('Tavily: no API keys configured');
|
|
95
|
+
|
|
96
|
+
let lastError = null;
|
|
97
|
+
const maxAttempts = Math.min(tavilyRotator.count, 3);
|
|
98
|
+
|
|
99
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
100
|
+
const apiKey = tavilyRotator.getNext();
|
|
101
|
+
try {
|
|
102
|
+
const tvly = tavily({ apiKey });
|
|
103
|
+
const response = await tvly.search(query, {
|
|
104
|
+
maxResults,
|
|
105
|
+
searchDepth: 'basic',
|
|
106
|
+
});
|
|
107
|
+
const results = (response.results || []).slice(0, maxResults).map(r => ({
|
|
108
|
+
title: r.title || '',
|
|
109
|
+
url: r.url || '',
|
|
110
|
+
snippet: r.content || r.description || '',
|
|
111
|
+
}));
|
|
112
|
+
if (results.length > 0) return results;
|
|
113
|
+
lastError = new Error('Tavily: empty results');
|
|
114
|
+
} catch (err) {
|
|
115
|
+
lastError = err;
|
|
116
|
+
logger.debug(`Tavily key failed (attempt ${attempt + 1}): ${err.message}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
throw lastError || new Error('Tavily: all keys failed');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function searchDDGHttp(query, maxResults = 5) {
|
|
123
|
+
const encoded = encodeURIComponent(query);
|
|
124
|
+
const BROWSER_HEADERS = {
|
|
125
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
|
|
126
|
+
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
127
|
+
'Accept-Language': 'en-US,en;q=0.9',
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const stripHtml = (html) => html
|
|
131
|
+
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
|
132
|
+
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
|
133
|
+
.replace(/<[^>]+>/g, ' ')
|
|
134
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
135
|
+
.replace(/"/g, '"').replace(/'/g, "'").replace(/'/g, "'")
|
|
136
|
+
.replace(///g, '/').replace(/&#(\d+);/g, (_, c) => String.fromCharCode(parseInt(c)))
|
|
137
|
+
.replace(/ /g, ' ').replace(/©/g, '(c)').replace(/®/g, '(r)')
|
|
138
|
+
.replace(/\s+([.,;:!?)])/g, '$1').replace(/\s{2,}/g, ' ').trim();
|
|
139
|
+
|
|
140
|
+
const controller = new AbortController();
|
|
141
|
+
const timer = setTimeout(() => controller.abort(), SEARCH_TIMEOUT);
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
const res = await fetch(`https://html.duckduckgo.com/html/?q=${encoded}`, {
|
|
145
|
+
headers: BROWSER_HEADERS,
|
|
146
|
+
signal: controller.signal,
|
|
147
|
+
redirect: 'follow',
|
|
148
|
+
});
|
|
149
|
+
clearTimeout(timer);
|
|
150
|
+
|
|
151
|
+
if (!res.ok) throw new Error(`DDG HTTP: ${res.status}`);
|
|
152
|
+
|
|
153
|
+
const html = await res.text();
|
|
154
|
+
const results = [];
|
|
155
|
+
|
|
156
|
+
const resultBlocks = html.split(/<div\s+class="result[\s"]/i);
|
|
157
|
+
for (const block of resultBlocks.slice(1, maxResults + 1)) {
|
|
158
|
+
const titleMatch = block.match(/<a[^>]+class="result__a"[^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i);
|
|
159
|
+
const snippetMatch = block.match(/<a[^>]+class="result__snippet"[^>]*>([\s\S]*?)<\/a>/i)
|
|
160
|
+
|| block.match(/<td[^>]*class="result__snippet"[^>]*>([\s\S]*?)<\/td>/i);
|
|
161
|
+
|
|
162
|
+
if (titleMatch) {
|
|
163
|
+
let url = titleMatch[1].replace(/&/g, '&');
|
|
164
|
+
const uddgMatch = url.match(/uddg=([^&]+)/);
|
|
165
|
+
if (uddgMatch) url = decodeURIComponent(uddgMatch[1]);
|
|
166
|
+
if (url && !url.includes('duckduckgo.com') && (url.startsWith('http') || url.startsWith('//'))) {
|
|
167
|
+
if (url.startsWith('//')) url = 'https:' + url;
|
|
168
|
+
results.push({
|
|
169
|
+
title: stripHtml(titleMatch[2]),
|
|
170
|
+
url,
|
|
171
|
+
snippet: snippetMatch ? stripHtml(snippetMatch[1]) : '',
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (results.length > 0) return results;
|
|
178
|
+
if (results.length === 0) {
|
|
179
|
+
const rows = html.match(/<tr[^>]*>[\s\S]*?<\/tr>/gi) || [];
|
|
180
|
+
let currentLink = null;
|
|
181
|
+
for (const row of rows) {
|
|
182
|
+
const linkMatch = row.match(/<a[^>]+class="result-link"[^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i)
|
|
183
|
+
|| row.match(/<a[^>]+href="([^"]+)"[^>]*class="result-link"[^>]*>([\s\S]*?)<\/a>/i)
|
|
184
|
+
|| row.match(/<a[^>]+class="result__a"[^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i);
|
|
185
|
+
const snippetMatch = row.match(/<td[^>]*class="result-snippet"[^>]*>([\s\S]*?)<\/td>/i)
|
|
186
|
+
|| row.match(/<a[^>]+class="result__snippet"[^>]*>([\s\S]*?)<\/a>/i);
|
|
187
|
+
|
|
188
|
+
if (linkMatch) {
|
|
189
|
+
let url = linkMatch[1].replace(/&/g, '&');
|
|
190
|
+
const uddgMatch = url.match(/uddg=([^&]+)/);
|
|
191
|
+
if (uddgMatch) url = decodeURIComponent(uddgMatch[1]);
|
|
192
|
+
if (url && !url.includes('duckduckgo.com') && (url.startsWith('http') || url.startsWith('//'))) {
|
|
193
|
+
if (url.startsWith('//')) url = 'https:' + url;
|
|
194
|
+
currentLink = { url, title: stripHtml(linkMatch[2]), snippet: '' };
|
|
195
|
+
if (snippetMatch) {
|
|
196
|
+
currentLink.snippet = stripHtml(snippetMatch[1]);
|
|
197
|
+
results.push(currentLink);
|
|
198
|
+
currentLink = null;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
} else if (currentLink && snippetMatch) {
|
|
202
|
+
currentLink.snippet = stripHtml(snippetMatch[1]);
|
|
203
|
+
results.push(currentLink);
|
|
204
|
+
currentLink = null;
|
|
205
|
+
}
|
|
206
|
+
if (results.length >= maxResults) break;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (results.length === 0) throw new Error('DDG HTTP: no results parsed');
|
|
211
|
+
return results;
|
|
212
|
+
} catch (err) {
|
|
213
|
+
clearTimeout(timer);
|
|
214
|
+
throw err;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function formatSearchOutput(query, results) {
|
|
219
|
+
const topResults = results.slice(0, 5);
|
|
220
|
+
const formatted = topResults.map((r, i) =>
|
|
221
|
+
`[${i + 1}] ${r.title}\n URL: ${r.url}\n ${r.snippet || 'No description'}`
|
|
222
|
+
).join('\n\n');
|
|
223
|
+
return {
|
|
224
|
+
success: true,
|
|
225
|
+
output: `Web Search: "${query.trim()}"\nFound ${topResults.length} results:\n\n${formatted}\n\nTip: Use FETCH_URL to read the full content of any URL.`,
|
|
226
|
+
error: null,
|
|
227
|
+
results: topResults,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function getProviderStatus() {
|
|
232
|
+
return {
|
|
233
|
+
ddg: { available: true, type: 'scrape' },
|
|
234
|
+
serpapi: { available: serpapiRotator.hasKeys, keyCount: serpapiRotator.count, type: 'api' },
|
|
235
|
+
tavily: { available: tavilyRotator.hasKeys, keyCount: tavilyRotator.count, type: 'api' },
|
|
236
|
+
};
|
|
237
|
+
}
|
package/src/tools/ssh.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// OS AI Agent — SSH Tool (Connection Pool + Dispatch)
|
|
3
|
+
// =============================================================================
|
|
4
|
+
import { connectSSH, execSSH, closeSSH } from '../services/ssh.js';
|
|
5
|
+
import { logger } from '../utils/logger.js';
|
|
6
|
+
|
|
7
|
+
/** Connection pool keyed by "host:port" */
|
|
8
|
+
const connections = new Map();
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Execute a command on a remote device via SSH.
|
|
12
|
+
* Reuses connections from the pool when available.
|
|
13
|
+
*/
|
|
14
|
+
export const executeSSH = async (device, command) => {
|
|
15
|
+
const deviceKey = `${device.ip}:${device.port || 22}`;
|
|
16
|
+
const auth = device.auth_decrypted || {};
|
|
17
|
+
|
|
18
|
+
if (!auth.username) {
|
|
19
|
+
return { success: false, output: '', error: 'No SSH username configured for this device' };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
23
|
+
const connParams = {
|
|
24
|
+
host: device.ip,
|
|
25
|
+
port: device.port || 22,
|
|
26
|
+
username: auth.username,
|
|
27
|
+
password: auth.password,
|
|
28
|
+
privateKey: auth.privateKey || null,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
let conn = connections.get(deviceKey);
|
|
33
|
+
if (!conn) {
|
|
34
|
+
conn = await connectSSH(connParams, device.type);
|
|
35
|
+
connections.set(deviceKey, conn);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const result = await execSSH(conn, command);
|
|
39
|
+
return {
|
|
40
|
+
success: result.status === 'success',
|
|
41
|
+
output: result.output,
|
|
42
|
+
error: result.status === 'error' ? result.output : null,
|
|
43
|
+
};
|
|
44
|
+
} catch (error) {
|
|
45
|
+
connections.delete(deviceKey);
|
|
46
|
+
if (attempt >= 1) {
|
|
47
|
+
logger.error('SSH execution failed', { device: deviceKey, error: error.message });
|
|
48
|
+
return { success: false, output: '', error: error.message };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Close all pooled SSH connections
|
|
56
|
+
*/
|
|
57
|
+
export const closeAllConnections = () => {
|
|
58
|
+
for (const [key] of connections.entries()) {
|
|
59
|
+
try {
|
|
60
|
+
closeSSH(key.split(':')[0]);
|
|
61
|
+
} catch {
|
|
62
|
+
// Connection may already be closed
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
connections.clear();
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Get connection pool status
|
|
70
|
+
*/
|
|
71
|
+
export const getConnectionStatus = () => ({
|
|
72
|
+
active: connections.size,
|
|
73
|
+
devices: Array.from(connections.keys()),
|
|
74
|
+
});
|