opc-agent 1.4.0 → 2.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/CHANGELOG.md +25 -0
- package/README.md +91 -32
- package/dist/channels/telegram.d.ts +30 -9
- package/dist/channels/telegram.js +125 -33
- package/dist/cli.js +415 -8
- package/dist/core/agent.d.ts +23 -0
- package/dist/core/agent.js +120 -3
- package/dist/core/runtime.d.ts +1 -0
- package/dist/core/runtime.js +44 -0
- package/dist/core/scheduler.d.ts +52 -0
- package/dist/core/scheduler.js +168 -0
- package/dist/core/subagent.d.ts +28 -0
- package/dist/core/subagent.js +65 -0
- package/dist/daemon.d.ts +3 -0
- package/dist/daemon.js +134 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +17 -1
- package/dist/providers/index.d.ts +5 -1
- package/dist/providers/index.js +16 -9
- package/dist/schema/oad.d.ts +179 -4
- package/dist/schema/oad.js +12 -1
- package/dist/skills/auto-learn.d.ts +28 -0
- package/dist/skills/auto-learn.js +257 -0
- package/dist/tools/builtin/datetime.d.ts +3 -0
- package/dist/tools/builtin/datetime.js +44 -0
- package/dist/tools/builtin/file.d.ts +3 -0
- package/dist/tools/builtin/file.js +151 -0
- package/dist/tools/builtin/index.d.ts +15 -0
- package/dist/tools/builtin/index.js +30 -0
- package/dist/tools/builtin/shell.d.ts +3 -0
- package/dist/tools/builtin/shell.js +43 -0
- package/dist/tools/builtin/web.d.ts +3 -0
- package/dist/tools/builtin/web.js +37 -0
- package/dist/tools/mcp-client.d.ts +24 -0
- package/dist/tools/mcp-client.js +119 -0
- package/package.json +1 -1
- package/src/channels/telegram.ts +212 -90
- package/src/cli.ts +418 -8
- package/src/core/agent.ts +295 -152
- package/src/core/runtime.ts +47 -0
- package/src/core/scheduler.ts +187 -0
- package/src/core/subagent.ts +98 -0
- package/src/daemon.ts +96 -0
- package/src/index.ts +11 -0
- package/src/providers/index.ts +354 -339
- package/src/schema/oad.ts +167 -154
- package/src/skills/auto-learn.ts +262 -0
- package/src/tools/builtin/datetime.ts +41 -0
- package/src/tools/builtin/file.ts +107 -0
- package/src/tools/builtin/index.ts +28 -0
- package/src/tools/builtin/shell.ts +43 -0
- package/src/tools/builtin/web.ts +35 -0
- package/src/tools/mcp-client.ts +131 -0
- package/tests/auto-learn.test.ts +105 -0
- package/tests/builtin-tools.test.ts +83 -0
- package/tests/cli.test.ts +46 -0
- package/tests/subagent.test.ts +130 -0
- package/tests/telegram-discord.test.ts +60 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import type { MCPTool, MCPToolResult } from '../mcp';
|
|
4
|
+
import type { AgentContext } from '../../core/types';
|
|
5
|
+
|
|
6
|
+
function resolveSafe(basePath: string, targetPath: string): string | null {
|
|
7
|
+
const resolved = path.resolve(basePath, targetPath);
|
|
8
|
+
if (!resolved.startsWith(path.resolve(basePath))) return null;
|
|
9
|
+
return resolved;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function searchFiles(dir: string, query: string, results: string[] = [], maxResults = 20): string[] {
|
|
13
|
+
if (results.length >= maxResults) return results;
|
|
14
|
+
try {
|
|
15
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
16
|
+
for (const entry of entries) {
|
|
17
|
+
if (results.length >= maxResults) break;
|
|
18
|
+
const full = path.join(dir, entry.name);
|
|
19
|
+
if (entry.isDirectory()) {
|
|
20
|
+
if (entry.name === 'node_modules' || entry.name === '.git') continue;
|
|
21
|
+
searchFiles(full, query, results, maxResults);
|
|
22
|
+
} else if (entry.isFile()) {
|
|
23
|
+
try {
|
|
24
|
+
const content = fs.readFileSync(full, 'utf-8');
|
|
25
|
+
const lines = content.split('\n');
|
|
26
|
+
for (let i = 0; i < lines.length; i++) {
|
|
27
|
+
if (lines[i].includes(query)) {
|
|
28
|
+
results.push(`${full}:${i + 1}: ${lines[i].trim()}`);
|
|
29
|
+
if (results.length >= maxResults) break;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
} catch { /* skip binary/unreadable */ }
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
} catch { /* skip inaccessible dirs */ }
|
|
36
|
+
return results;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const fileTool: MCPTool = {
|
|
40
|
+
name: 'file_operations',
|
|
41
|
+
description: 'Read, write, list, and search files in the workspace',
|
|
42
|
+
inputSchema: {
|
|
43
|
+
type: 'object',
|
|
44
|
+
properties: {
|
|
45
|
+
action: { type: 'string', enum: ['read', 'write', 'list', 'search', 'exists'] },
|
|
46
|
+
path: { type: 'string' },
|
|
47
|
+
content: { type: 'string' },
|
|
48
|
+
query: { type: 'string' },
|
|
49
|
+
},
|
|
50
|
+
required: ['action'],
|
|
51
|
+
},
|
|
52
|
+
async execute(input: Record<string, unknown>, context?: AgentContext): Promise<MCPToolResult> {
|
|
53
|
+
const action = input.action as string;
|
|
54
|
+
const workspace = process.cwd();
|
|
55
|
+
const targetPath = input.path as string | undefined;
|
|
56
|
+
|
|
57
|
+
if (action === 'search') {
|
|
58
|
+
const query = input.query as string;
|
|
59
|
+
if (!query) return { content: 'query is required for search', isError: true };
|
|
60
|
+
const results = searchFiles(workspace, query);
|
|
61
|
+
return { content: results.length ? results.join('\n') : 'No matches found', isError: false };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (action === 'list') {
|
|
65
|
+
const dir = targetPath ? resolveSafe(workspace, targetPath) : workspace;
|
|
66
|
+
if (!dir) return { content: 'Path outside workspace', isError: true };
|
|
67
|
+
try {
|
|
68
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
69
|
+
const listing = entries.map(e => `${e.isDirectory() ? '[DIR] ' : ''}${e.name}`).join('\n');
|
|
70
|
+
return { content: listing || '(empty directory)', isError: false };
|
|
71
|
+
} catch (err) {
|
|
72
|
+
return { content: `Error listing directory: ${err instanceof Error ? err.message : String(err)}`, isError: true };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!targetPath) return { content: 'path is required', isError: true };
|
|
77
|
+
const resolved = resolveSafe(workspace, targetPath);
|
|
78
|
+
if (!resolved) return { content: 'Path outside workspace', isError: true };
|
|
79
|
+
|
|
80
|
+
switch (action) {
|
|
81
|
+
case 'read': {
|
|
82
|
+
try {
|
|
83
|
+
const content = fs.readFileSync(resolved, 'utf-8');
|
|
84
|
+
return { content: content.slice(0, 50000), isError: false };
|
|
85
|
+
} catch (err) {
|
|
86
|
+
return { content: `Error reading file: ${err instanceof Error ? err.message : String(err)}`, isError: true };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
case 'write': {
|
|
90
|
+
const content = input.content as string;
|
|
91
|
+
if (content === undefined) return { content: 'content is required for write', isError: true };
|
|
92
|
+
try {
|
|
93
|
+
fs.mkdirSync(path.dirname(resolved), { recursive: true });
|
|
94
|
+
fs.writeFileSync(resolved, content, 'utf-8');
|
|
95
|
+
return { content: `Written ${content.length} bytes to ${targetPath}`, isError: false };
|
|
96
|
+
} catch (err) {
|
|
97
|
+
return { content: `Error writing file: ${err instanceof Error ? err.message : String(err)}`, isError: true };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
case 'exists': {
|
|
101
|
+
return { content: String(fs.existsSync(resolved)), isError: false };
|
|
102
|
+
}
|
|
103
|
+
default:
|
|
104
|
+
return { content: `Unknown action: ${action}`, isError: true };
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { MCPTool } from '../mcp';
|
|
2
|
+
import { fileTool } from './file';
|
|
3
|
+
import { webTool } from './web';
|
|
4
|
+
import { shellTool } from './shell';
|
|
5
|
+
import { datetimeTool } from './datetime';
|
|
6
|
+
|
|
7
|
+
export { fileTool, webTool, shellTool, datetimeTool };
|
|
8
|
+
|
|
9
|
+
const ALL_BUILTIN_TOOLS: MCPTool[] = [fileTool, webTool, shellTool, datetimeTool];
|
|
10
|
+
|
|
11
|
+
const BUILTIN_MAP = new Map<string, MCPTool>(
|
|
12
|
+
ALL_BUILTIN_TOOLS.map(t => [t.name, t])
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Get all built-in tools.
|
|
17
|
+
*/
|
|
18
|
+
export function getBuiltinTools(): MCPTool[] {
|
|
19
|
+
return [...ALL_BUILTIN_TOOLS];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Get specific built-in tools by name. If no names given, returns all.
|
|
24
|
+
*/
|
|
25
|
+
export function getBuiltinToolsByName(names?: string[]): MCPTool[] {
|
|
26
|
+
if (!names || names.length === 0) return getBuiltinTools();
|
|
27
|
+
return names.map(n => BUILTIN_MAP.get(n)).filter((t): t is MCPTool => !!t);
|
|
28
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import type { MCPTool, MCPToolResult } from '../mcp';
|
|
4
|
+
|
|
5
|
+
export const shellTool: MCPTool = {
|
|
6
|
+
name: 'shell_exec',
|
|
7
|
+
description: 'Execute a shell command (sandboxed to workspace)',
|
|
8
|
+
inputSchema: {
|
|
9
|
+
type: 'object',
|
|
10
|
+
properties: {
|
|
11
|
+
command: { type: 'string' },
|
|
12
|
+
timeout: { type: 'number', default: 30000 },
|
|
13
|
+
},
|
|
14
|
+
required: ['command'],
|
|
15
|
+
},
|
|
16
|
+
async execute(input: Record<string, unknown>): Promise<MCPToolResult> {
|
|
17
|
+
const command = input.command as string;
|
|
18
|
+
const timeout = (input.timeout as number) || 30000;
|
|
19
|
+
const workspace = process.cwd();
|
|
20
|
+
|
|
21
|
+
// Block path traversal attempts
|
|
22
|
+
if (command.includes('..')) {
|
|
23
|
+
return { content: 'Commands with ".." are not allowed for security', isError: true };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const output = execSync(command, {
|
|
28
|
+
cwd: workspace,
|
|
29
|
+
timeout,
|
|
30
|
+
encoding: 'utf-8',
|
|
31
|
+
maxBuffer: 1024 * 1024,
|
|
32
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
33
|
+
});
|
|
34
|
+
const result = (output || '').slice(0, 5000);
|
|
35
|
+
return { content: result || '(no output)', isError: false };
|
|
36
|
+
} catch (err: any) {
|
|
37
|
+
const stderr = err.stderr ? String(err.stderr).slice(0, 2500) : '';
|
|
38
|
+
const stdout = err.stdout ? String(err.stdout).slice(0, 2500) : '';
|
|
39
|
+
const output = [stdout, stderr].filter(Boolean).join('\n') || err.message;
|
|
40
|
+
return { content: `Command failed: ${output.slice(0, 5000)}`, isError: true };
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { MCPTool, MCPToolResult } from '../mcp';
|
|
2
|
+
|
|
3
|
+
export const webTool: MCPTool = {
|
|
4
|
+
name: 'web_fetch',
|
|
5
|
+
description: 'Fetch content from a URL',
|
|
6
|
+
inputSchema: {
|
|
7
|
+
type: 'object',
|
|
8
|
+
properties: {
|
|
9
|
+
url: { type: 'string' },
|
|
10
|
+
method: { type: 'string', enum: ['GET', 'POST'], default: 'GET' },
|
|
11
|
+
maxLength: { type: 'number', default: 5000 },
|
|
12
|
+
},
|
|
13
|
+
required: ['url'],
|
|
14
|
+
},
|
|
15
|
+
async execute(input: Record<string, unknown>): Promise<MCPToolResult> {
|
|
16
|
+
const url = input.url as string;
|
|
17
|
+
const method = (input.method as string) || 'GET';
|
|
18
|
+
const maxLength = (input.maxLength as number) || 5000;
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
const response = await fetch(url, { method, signal: AbortSignal.timeout(15000) });
|
|
22
|
+
const text = await response.text();
|
|
23
|
+
const truncated = text.length > maxLength ? text.slice(0, maxLength) + '\n...[truncated]' : text;
|
|
24
|
+
return {
|
|
25
|
+
content: `Status: ${response.status}\n\n${truncated}`,
|
|
26
|
+
isError: false,
|
|
27
|
+
};
|
|
28
|
+
} catch (err) {
|
|
29
|
+
return {
|
|
30
|
+
content: `Fetch error: ${err instanceof Error ? err.message : String(err)}`,
|
|
31
|
+
isError: true,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
};
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from 'child_process';
|
|
2
|
+
import type { MCPToolDefinition, MCPToolResult } from './mcp';
|
|
3
|
+
|
|
4
|
+
export interface MCPServerConfig {
|
|
5
|
+
name: string;
|
|
6
|
+
command: string;
|
|
7
|
+
args?: string[];
|
|
8
|
+
env?: Record<string, string>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class MCPClient {
|
|
12
|
+
private process: ChildProcess | null = null;
|
|
13
|
+
private config: MCPServerConfig | null = null;
|
|
14
|
+
private nextId = 1;
|
|
15
|
+
private pending = new Map<number, { resolve: (v: any) => void; reject: (e: Error) => void }>();
|
|
16
|
+
private buffer = '';
|
|
17
|
+
private connected = false;
|
|
18
|
+
|
|
19
|
+
async connect(config: MCPServerConfig): Promise<void> {
|
|
20
|
+
this.config = config;
|
|
21
|
+
this.process = spawn(config.command, config.args ?? [], {
|
|
22
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
23
|
+
env: { ...process.env, ...config.env },
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
this.process.stdout!.on('data', (data: Buffer) => {
|
|
27
|
+
this.buffer += data.toString();
|
|
28
|
+
this.processBuffer();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
this.process.on('error', (err) => {
|
|
32
|
+
for (const [, p] of this.pending) p.reject(err);
|
|
33
|
+
this.pending.clear();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
this.process.on('exit', () => {
|
|
37
|
+
this.connected = false;
|
|
38
|
+
for (const [, p] of this.pending) p.reject(new Error('MCP server exited'));
|
|
39
|
+
this.pending.clear();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Send initialize
|
|
43
|
+
await this.sendRequest('initialize', {
|
|
44
|
+
protocolVersion: '2024-11-05',
|
|
45
|
+
capabilities: {},
|
|
46
|
+
clientInfo: { name: 'opc-agent', version: '0.7.0' },
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Send initialized notification
|
|
50
|
+
this.sendNotification('notifications/initialized', {});
|
|
51
|
+
this.connected = true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private processBuffer(): void {
|
|
55
|
+
const lines = this.buffer.split('\n');
|
|
56
|
+
this.buffer = lines.pop() ?? '';
|
|
57
|
+
for (const line of lines) {
|
|
58
|
+
const trimmed = line.trim();
|
|
59
|
+
if (!trimmed) continue;
|
|
60
|
+
try {
|
|
61
|
+
const msg = JSON.parse(trimmed);
|
|
62
|
+
if (msg.id !== undefined && this.pending.has(msg.id)) {
|
|
63
|
+
const p = this.pending.get(msg.id)!;
|
|
64
|
+
this.pending.delete(msg.id);
|
|
65
|
+
if (msg.error) {
|
|
66
|
+
p.reject(new Error(msg.error.message || JSON.stringify(msg.error)));
|
|
67
|
+
} else {
|
|
68
|
+
p.resolve(msg.result);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
} catch { /* skip non-JSON lines */ }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private sendRequest(method: string, params?: Record<string, unknown>): Promise<any> {
|
|
76
|
+
return new Promise((resolve, reject) => {
|
|
77
|
+
if (!this.process?.stdin?.writable) {
|
|
78
|
+
reject(new Error('MCP server not connected'));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const id = this.nextId++;
|
|
82
|
+
this.pending.set(id, { resolve, reject });
|
|
83
|
+
const msg = JSON.stringify({ jsonrpc: '2.0', method, params: params ?? {}, id });
|
|
84
|
+
this.process.stdin.write(msg + '\n');
|
|
85
|
+
|
|
86
|
+
// Timeout after 30s
|
|
87
|
+
setTimeout(() => {
|
|
88
|
+
if (this.pending.has(id)) {
|
|
89
|
+
this.pending.delete(id);
|
|
90
|
+
reject(new Error(`MCP request timed out: ${method}`));
|
|
91
|
+
}
|
|
92
|
+
}, 30000);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private sendNotification(method: string, params: Record<string, unknown>): void {
|
|
97
|
+
if (!this.process?.stdin?.writable) return;
|
|
98
|
+
const msg = JSON.stringify({ jsonrpc: '2.0', method, params });
|
|
99
|
+
this.process.stdin.write(msg + '\n');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async listTools(): Promise<MCPToolDefinition[]> {
|
|
103
|
+
const result = await this.sendRequest('tools/list');
|
|
104
|
+
return (result.tools ?? []).map((t: any) => ({
|
|
105
|
+
name: t.name,
|
|
106
|
+
description: t.description ?? '',
|
|
107
|
+
inputSchema: t.inputSchema ?? {},
|
|
108
|
+
}));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async callTool(name: string, input: Record<string, unknown>): Promise<MCPToolResult> {
|
|
112
|
+
const result = await this.sendRequest('tools/call', { name, arguments: input });
|
|
113
|
+
const content = (result.content ?? [])
|
|
114
|
+
.map((c: any) => c.text ?? JSON.stringify(c))
|
|
115
|
+
.join('\n');
|
|
116
|
+
return { content, isError: result.isError ?? false };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async disconnect(): Promise<void> {
|
|
120
|
+
if (this.process) {
|
|
121
|
+
this.process.kill();
|
|
122
|
+
this.process = null;
|
|
123
|
+
}
|
|
124
|
+
this.connected = false;
|
|
125
|
+
this.pending.clear();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
isConnected(): boolean {
|
|
129
|
+
return this.connected;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import * as os from 'os';
|
|
5
|
+
import { SkillLearner, skillToMarkdown, parseSkillMarkdown, type LearnedSkill } from '../src/skills/auto-learn';
|
|
6
|
+
|
|
7
|
+
function makeSkill(overrides: Partial<LearnedSkill> = {}): LearnedSkill {
|
|
8
|
+
return {
|
|
9
|
+
name: 'test-skill',
|
|
10
|
+
description: 'A test skill',
|
|
11
|
+
trigger: 'deploy|deployment',
|
|
12
|
+
instructions: '1. Check env\n2. Run deploy\n3. Verify',
|
|
13
|
+
examples: ['deploy to production', 'run deployment'],
|
|
14
|
+
createdAt: new Date('2026-01-01T00:00:00Z'),
|
|
15
|
+
usageCount: 0,
|
|
16
|
+
version: 1,
|
|
17
|
+
...overrides,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('SkillLearner', () => {
|
|
22
|
+
let tmpDir: string;
|
|
23
|
+
let learner: SkillLearner;
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opc-skills-'));
|
|
27
|
+
learner = new SkillLearner(tmpDir);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('saveSkill / loadLearnedSkills', () => {
|
|
35
|
+
it('should save and load a skill', async () => {
|
|
36
|
+
const skill = makeSkill();
|
|
37
|
+
await learner.saveSkill(skill);
|
|
38
|
+
|
|
39
|
+
const loaded = await learner.loadLearnedSkills();
|
|
40
|
+
expect(loaded).toHaveLength(1);
|
|
41
|
+
expect(loaded[0].name).toBe('test-skill');
|
|
42
|
+
expect(loaded[0].description).toBe('A test skill');
|
|
43
|
+
expect(loaded[0].trigger).toBe('deploy|deployment');
|
|
44
|
+
expect(loaded[0].examples).toEqual(['deploy to production', 'run deployment']);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should create directory if not exists', async () => {
|
|
48
|
+
const nested = path.join(tmpDir, 'deep', 'nested');
|
|
49
|
+
const l = new SkillLearner(nested);
|
|
50
|
+
await l.saveSkill(makeSkill());
|
|
51
|
+
expect(fs.existsSync(path.join(nested, 'test-skill.md'))).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should return empty array for nonexistent dir', async () => {
|
|
55
|
+
const l = new SkillLearner(path.join(tmpDir, 'nope'));
|
|
56
|
+
const skills = await l.loadLearnedSkills();
|
|
57
|
+
expect(skills).toEqual([]);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('matchSkill', () => {
|
|
62
|
+
it('should match by regex pattern', async () => {
|
|
63
|
+
await learner.saveSkill(makeSkill({ trigger: 'deploy|deployment' }));
|
|
64
|
+
await learner.loadLearnedSkills();
|
|
65
|
+
|
|
66
|
+
expect(learner.matchSkill('please deploy to production')).not.toBeNull();
|
|
67
|
+
expect(learner.matchSkill('run deployment now')).not.toBeNull();
|
|
68
|
+
expect(learner.matchSkill('hello world')).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('should match by keyword fallback for invalid regex', async () => {
|
|
72
|
+
// Use an actually invalid regex so the catch branch is triggered
|
|
73
|
+
await learner.saveSkill(makeSkill({ trigger: '(deploy[broken, kubernetes' }));
|
|
74
|
+
await learner.loadLearnedSkills();
|
|
75
|
+
|
|
76
|
+
expect(learner.matchSkill('kubernetes cluster')).not.toBeNull();
|
|
77
|
+
expect(learner.matchSkill('random text')).toBeNull();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('should return null if not loaded', () => {
|
|
81
|
+
expect(learner.matchSkill('deploy')).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('skillToMarkdown / parseSkillMarkdown', () => {
|
|
86
|
+
it('should round-trip a skill through markdown', () => {
|
|
87
|
+
const skill = makeSkill();
|
|
88
|
+
const md = skillToMarkdown(skill);
|
|
89
|
+
const parsed = parseSkillMarkdown(md);
|
|
90
|
+
|
|
91
|
+
expect(parsed).not.toBeNull();
|
|
92
|
+
expect(parsed!.name).toBe(skill.name);
|
|
93
|
+
expect(parsed!.description).toBe(skill.description);
|
|
94
|
+
expect(parsed!.trigger).toBe(skill.trigger);
|
|
95
|
+
expect(parsed!.instructions).toBe(skill.instructions);
|
|
96
|
+
expect(parsed!.examples).toEqual(skill.examples);
|
|
97
|
+
expect(parsed!.version).toBe(1);
|
|
98
|
+
expect(parsed!.usageCount).toBe(0);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('should return null for invalid markdown', () => {
|
|
102
|
+
expect(parseSkillMarkdown('just some text')).toBeNull();
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
2
|
+
import { mkdtempSync, rmSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
5
|
+
import { getBuiltinTools, getBuiltinToolsByName } from '../src/tools/builtin';
|
|
6
|
+
import { fileTool, shellTool, datetimeTool } from '../src/tools/builtin';
|
|
7
|
+
|
|
8
|
+
describe('getBuiltinTools', () => {
|
|
9
|
+
it('returns 4 tools', () => {
|
|
10
|
+
const tools = getBuiltinTools();
|
|
11
|
+
expect(tools).toHaveLength(4);
|
|
12
|
+
const names = tools.map(t => t.name);
|
|
13
|
+
expect(names).toContain('file_operations');
|
|
14
|
+
expect(names).toContain('web_fetch');
|
|
15
|
+
expect(names).toContain('shell_exec');
|
|
16
|
+
expect(names).toContain('datetime');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('getBuiltinToolsByName filters correctly', () => {
|
|
20
|
+
const tools = getBuiltinToolsByName(['datetime', 'file_operations']);
|
|
21
|
+
expect(tools).toHaveLength(2);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('getBuiltinToolsByName with no args returns all', () => {
|
|
25
|
+
expect(getBuiltinToolsByName()).toHaveLength(4);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('file_operations tool', () => {
|
|
30
|
+
// file tool resolves paths relative to cwd, so use relative paths from a temp dir
|
|
31
|
+
// Actually, it uses process.cwd() as workspace. Let's just test with paths relative to cwd.
|
|
32
|
+
const testFile = `tmp-test-${Date.now()}.txt`;
|
|
33
|
+
|
|
34
|
+
afterAll(() => {
|
|
35
|
+
try { require('fs').unlinkSync(testFile); } catch {}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('write and read a file', async () => {
|
|
39
|
+
const writeRes = await fileTool.execute({ action: 'write', path: testFile, content: 'hello' });
|
|
40
|
+
expect(writeRes.isError).toBe(false);
|
|
41
|
+
|
|
42
|
+
const readRes = await fileTool.execute({ action: 'read', path: testFile });
|
|
43
|
+
expect(readRes.isError).toBe(false);
|
|
44
|
+
expect(readRes.content).toBe('hello');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('list files', async () => {
|
|
48
|
+
const res = await fileTool.execute({ action: 'list', path: '.' });
|
|
49
|
+
expect(res.isError).toBe(false);
|
|
50
|
+
expect(res.content).toContain('package.json');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('exists check', async () => {
|
|
54
|
+
const res = await fileTool.execute({ action: 'exists', path: testFile });
|
|
55
|
+
expect(res.content).toBe('true');
|
|
56
|
+
|
|
57
|
+
const res2 = await fileTool.execute({ action: 'exists', path: 'nope-does-not-exist.txt' });
|
|
58
|
+
expect(res2.content).toBe('false');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('rejects path outside workspace', async () => {
|
|
62
|
+
const res = await fileTool.execute({ action: 'read', path: '../../etc/passwd' });
|
|
63
|
+
expect(res.isError).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('datetime tool', () => {
|
|
68
|
+
it('returns valid JSON with iso field', async () => {
|
|
69
|
+
const res = await datetimeTool.execute({});
|
|
70
|
+
expect(res.isError).toBe(false);
|
|
71
|
+
const parsed = JSON.parse(res.content);
|
|
72
|
+
expect(parsed.iso).toBeDefined();
|
|
73
|
+
expect(new Date(parsed.iso).toISOString()).toBe(parsed.iso);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('shell_exec tool', () => {
|
|
78
|
+
it('runs a command', async () => {
|
|
79
|
+
const res = await shellTool.execute({ command: 'echo hello' });
|
|
80
|
+
expect(res.isError).toBe(false);
|
|
81
|
+
expect(res.content).toContain('hello');
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
|
|
5
|
+
describe('CLI: opc chat slash commands', () => {
|
|
6
|
+
// Test that slash commands are recognized patterns
|
|
7
|
+
const slashCommands = ['/help', '/quit', '/exit', '/clear', '/skills', '/memory', '/info'];
|
|
8
|
+
|
|
9
|
+
it('should define all expected slash commands', () => {
|
|
10
|
+
const cliSource = fs.readFileSync(path.join(__dirname, '..', 'src', 'cli.ts'), 'utf-8');
|
|
11
|
+
for (const cmd of slashCommands) {
|
|
12
|
+
expect(cliSource).toContain(`'${cmd}'`);
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('chat command should be registered', () => {
|
|
17
|
+
const cliSource = fs.readFileSync(path.join(__dirname, '..', 'src', 'cli.ts'), 'utf-8');
|
|
18
|
+
expect(cliSource).toContain(".command('chat')");
|
|
19
|
+
expect(cliSource).toContain('Interactive CLI chat with the agent');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('init command should generate SOUL.md and CONTEXT.md', () => {
|
|
23
|
+
const cliSource = fs.readFileSync(path.join(__dirname, '..', 'src', 'cli.ts'), 'utf-8');
|
|
24
|
+
expect(cliSource).toContain("'SOUL.md'");
|
|
25
|
+
expect(cliSource).toContain("'CONTEXT.md'");
|
|
26
|
+
expect(cliSource).toContain('# Project Context');
|
|
27
|
+
expect(cliSource).toContain('Personality');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('chat banner should include expected elements', () => {
|
|
31
|
+
const cliSource = fs.readFileSync(path.join(__dirname, '..', 'src', 'cli.ts'), 'utf-8');
|
|
32
|
+
expect(cliSource).toContain('OPC Agent — Interactive Chat');
|
|
33
|
+
expect(cliSource).toContain('/help for commands');
|
|
34
|
+
expect(cliSource).toContain('╔');
|
|
35
|
+
expect(cliSource).toContain('╚');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('chat should load SOUL.md and CONTEXT.md', () => {
|
|
39
|
+
const cliSource = fs.readFileSync(path.join(__dirname, '..', 'src', 'cli.ts'), 'utf-8');
|
|
40
|
+
expect(cliSource).toContain("'SOUL.md'");
|
|
41
|
+
expect(cliSource).toContain("'CONTEXT.md'");
|
|
42
|
+
// Should prepend to system prompt
|
|
43
|
+
expect(cliSource).toContain('soulContent');
|
|
44
|
+
expect(cliSource).toContain('contextContent');
|
|
45
|
+
});
|
|
46
|
+
});
|