neoagent 2.5.2-beta.9 → 3.0.1-beta.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/README.md +5 -1
- package/docs/integrations.md +11 -7
- package/flutter_app/lib/main_controller.dart +34 -0
- package/flutter_app/lib/main_security.dart +19 -0
- package/lib/schema_migrations.js +224 -0
- package/package.json +2 -2
- package/server/admin/admin.css +29 -0
- package/server/admin/admin.js +81 -1
- package/server/admin/index.html +97 -9
- package/server/db/database.js +57 -1
- package/server/public/.last_build_id +1 -1
- package/server/public/flutter_bootstrap.js +1 -1
- package/server/public/main.dart.js +32788 -32752
- package/server/routes/security.js +19 -0
- package/server/routes/settings.js +1 -0
- package/server/routes/skills.js +9 -5
- package/server/services/ai/deliverables/artifact_helpers.js +92 -9
- package/server/services/ai/deliverables/selector.js +17 -0
- package/server/services/ai/hooks.js +3 -2
- package/server/services/ai/learning.js +8 -3
- package/server/services/ai/loop/agent_engine_core.js +67 -0
- package/server/services/ai/loop/blank_recovery.js +37 -0
- package/server/services/ai/loop/conversation_loop.js +391 -252
- package/server/services/ai/loop/error_recovery.js +38 -0
- package/server/services/ai/loop/messaging_delivery.js +52 -6
- package/server/services/ai/loop/progress_classification.js +178 -0
- package/server/services/ai/loop/progress_monitor.js +6 -5
- package/server/services/ai/loop/tool_dispatch.js +1 -0
- package/server/services/ai/loopPolicy.js +15 -6
- package/server/services/ai/messagingFallback.js +23 -1
- package/server/services/ai/preModelCompaction.js +31 -0
- package/server/services/ai/repetitionGuard.js +47 -2
- package/server/services/ai/settings.js +8 -0
- package/server/services/ai/systemPrompt.js +24 -0
- package/server/services/ai/taskAnalysis.js +10 -0
- package/server/services/ai/toolEvidence.js +39 -3
- package/server/services/ai/toolResult.js +29 -0
- package/server/services/ai/toolRunner.js +262 -55
- package/server/services/ai/toolSelector.js +20 -3
- package/server/services/ai/tools.js +201 -31
- package/server/services/cli/shell_worker_pool.js +87 -5
- package/server/services/integrations/github/common.js +2 -2
- package/server/services/integrations/github/repos.js +123 -55
- package/server/services/manager.js +16 -0
- package/server/services/memory/manager.js +39 -24
- package/server/services/runtime/docker-vm-manager.js +21 -1
- package/server/services/runtime/manager.js +6 -2
- package/server/services/security/approval_gate_service.js +108 -5
- package/server/services/security/tool_categories.js +113 -4
- package/server/services/security/tool_security_hook.js +7 -2
- package/server/services/skills/runtime.js +2 -0
- package/server/services/workspace/manager.js +56 -0
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
const { compactToolResult } = require('./toolResult');
|
|
9
9
|
const { summarizeForLog } = require('./logFormat');
|
|
10
10
|
const { normalizeOutgoingMessage, clampRunContext } = require('./messagingFallback');
|
|
11
|
+
const {
|
|
12
|
+
isClearlyReadOnlyShellCommand,
|
|
13
|
+
isProgressToolCall,
|
|
14
|
+
} = require('./loop/progress_classification');
|
|
11
15
|
|
|
12
16
|
// Ordered classification rules mapping a tool name to its evidence "source"
|
|
13
17
|
// bucket. First matching rule wins, so order is significant. Declared as data
|
|
@@ -20,7 +24,7 @@ const EVIDENCE_SOURCE_RULES = [
|
|
|
20
24
|
{ source: 'memory', match: (name) => name.startsWith('memory_') || name === 'session_search' },
|
|
21
25
|
{ source: 'search', match: (name) => name === 'web_search' },
|
|
22
26
|
{ source: 'http', match: (name) => name === 'http_request' },
|
|
23
|
-
{ source: 'files', match: (name) => ['read_file', 'search_files', 'list_directory', 'write_file', 'edit_file', 'code_navigate', 'query_structured_data'].includes(name) },
|
|
27
|
+
{ source: 'files', match: (name) => ['read_file', 'read_files', 'search_files', 'list_directory', 'write_file', 'edit_file', 'replace_file_range', 'code_navigate', 'query_structured_data'].includes(name) },
|
|
24
28
|
{ source: 'command', match: (name) => name === 'execute_command' },
|
|
25
29
|
{ source: 'skills', match: (name) => name.includes('skill') },
|
|
26
30
|
{ source: 'tasks', match: (name) => name === 'create_task' || name === 'update_task' || name === 'delete_task' || name === 'list_tasks' || name.includes('widget') },
|
|
@@ -42,6 +46,7 @@ function classifyToolExecution(toolName, toolArgs = {}, result, errorMessage = '
|
|
|
42
46
|
'web_search',
|
|
43
47
|
'http_request',
|
|
44
48
|
'read_file',
|
|
49
|
+
'read_files',
|
|
45
50
|
'search_files',
|
|
46
51
|
'list_directory',
|
|
47
52
|
'code_navigate',
|
|
@@ -60,6 +65,7 @@ function classifyToolExecution(toolName, toolArgs = {}, result, errorMessage = '
|
|
|
60
65
|
'execute_command',
|
|
61
66
|
'write_file',
|
|
62
67
|
'edit_file',
|
|
68
|
+
'replace_file_range',
|
|
63
69
|
'send_interim_update',
|
|
64
70
|
'send_message',
|
|
65
71
|
'make_call',
|
|
@@ -83,7 +89,13 @@ function classifyToolExecution(toolName, toolArgs = {}, result, errorMessage = '
|
|
|
83
89
|
|
|
84
90
|
const evidenceRelevant = evidenceRelevantExact.has(name)
|
|
85
91
|
|| evidenceRelevantPrefixes.some((prefix) => name.startsWith(prefix));
|
|
86
|
-
const stateChanged =
|
|
92
|
+
const stateChanged = (
|
|
93
|
+
name === 'execute_command'
|
|
94
|
+
? !isClearlyReadOnlyShellCommand(toolArgs?.command || '')
|
|
95
|
+
: stateChangingExact.has(name)
|
|
96
|
+
)
|
|
97
|
+
|| (name.startsWith('github_') && isProgressToolCall(name, toolArgs))
|
|
98
|
+
|| (name === 'http_request' && isProgressToolCall(name, toolArgs))
|
|
87
99
|
|| name.startsWith('android_')
|
|
88
100
|
|| ['browser_click', 'browser_type', 'browser_evaluate'].includes(name);
|
|
89
101
|
|
|
@@ -125,7 +137,7 @@ function classifyToolExecution(toolName, toolArgs = {}, result, errorMessage = '
|
|
|
125
137
|
error: normalizedError,
|
|
126
138
|
evidenceSource,
|
|
127
139
|
evidenceRelevant,
|
|
128
|
-
stateChanged,
|
|
140
|
+
stateChanged: stateChanged && !normalizedError,
|
|
129
141
|
dependsOnOutput: true,
|
|
130
142
|
summary: compactToolResult(name, toolArgs, result || { error: errorMessage || 'Tool failed' }, {
|
|
131
143
|
softLimit: 500,
|
|
@@ -141,6 +153,27 @@ function summarizeToolExecutions(toolExecutions = [], maxItems = 10) {
|
|
|
141
153
|
}).join('\n');
|
|
142
154
|
}
|
|
143
155
|
|
|
156
|
+
function isSubstantiveProgressToolName(toolName = '') {
|
|
157
|
+
const name = String(toolName || '').trim();
|
|
158
|
+
if (!name) return false;
|
|
159
|
+
if (name === 'send_message' || name === 'send_interim_update' || name === 'make_call') return false;
|
|
160
|
+
if (name === 'think' || name === 'activate_tools' || name === 'task_complete') return false;
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function isSubstantiveProgressEvidence(item = {}) {
|
|
165
|
+
if (!isSubstantiveProgressToolName(item.toolName)) return false;
|
|
166
|
+
if (item.evidenceSource === 'messaging') return false;
|
|
167
|
+
return Boolean(item.evidenceRelevant || item.stateChanged || item.error);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function summarizeProgressToolExecutions(toolExecutions = [], maxItems = 10) {
|
|
171
|
+
return summarizeToolExecutions(
|
|
172
|
+
toolExecutions.filter(isSubstantiveProgressEvidence),
|
|
173
|
+
maxItems,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
144
177
|
function summarizeAvailableTools(tools = [], { exclude = [] } = {}) {
|
|
145
178
|
const excluded = new Set((Array.isArray(exclude) ? exclude : [exclude]).filter(Boolean));
|
|
146
179
|
return tools
|
|
@@ -202,6 +235,9 @@ function buildAutonomousRecoveryContext({ err, toolExecutions = [], tools = [],
|
|
|
202
235
|
module.exports = {
|
|
203
236
|
classifyToolExecution,
|
|
204
237
|
deriveEvidenceSource,
|
|
238
|
+
isSubstantiveProgressEvidence,
|
|
239
|
+
isSubstantiveProgressToolName,
|
|
240
|
+
summarizeProgressToolExecutions,
|
|
205
241
|
summarizeToolExecutions,
|
|
206
242
|
summarizeAvailableTools,
|
|
207
243
|
inferToolFailureMessage,
|
|
@@ -91,6 +91,35 @@ function compactToolResult(toolName, toolArgs = {}, toolResult, options = {}) {
|
|
|
91
91
|
});
|
|
92
92
|
break;
|
|
93
93
|
|
|
94
|
+
case 'read_files':
|
|
95
|
+
envelope = trimObject({
|
|
96
|
+
tool: toolName,
|
|
97
|
+
count: toolResult?.count || 0,
|
|
98
|
+
truncated: toolResult?.truncated || false,
|
|
99
|
+
results: (toolResult?.results || []).slice(0, 6).map((item) => trimObject({
|
|
100
|
+
path: item.path || item.requestedPath,
|
|
101
|
+
requestedPath: item.requestedPath,
|
|
102
|
+
rangeShown: item.rangeShown,
|
|
103
|
+
error: item.error,
|
|
104
|
+
content: lineExcerpt(item.content || '', 10, Math.floor(softLimit * 0.22))
|
|
105
|
+
}))
|
|
106
|
+
});
|
|
107
|
+
break;
|
|
108
|
+
|
|
109
|
+
case 'replace_file_range':
|
|
110
|
+
envelope = trimObject({
|
|
111
|
+
tool: toolName,
|
|
112
|
+
status: toolResult?.success === false || toolResult?.error ? 'error' : 'ok',
|
|
113
|
+
path: toolResult?.path || toolArgs.path,
|
|
114
|
+
startLine: toolResult?.startLine || toolArgs.start_line,
|
|
115
|
+
endLine: toolResult?.endLine || toolArgs.end_line,
|
|
116
|
+
replacedLines: toolResult?.replacedLines,
|
|
117
|
+
insertedLines: toolResult?.insertedLines,
|
|
118
|
+
totalLines: toolResult?.totalLines,
|
|
119
|
+
error: toolResult?.error
|
|
120
|
+
});
|
|
121
|
+
break;
|
|
122
|
+
|
|
94
123
|
case 'search_files':
|
|
95
124
|
envelope = trimObject({
|
|
96
125
|
tool: toolName,
|
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
1
3
|
const fs = require('fs');
|
|
2
4
|
const path = require('path');
|
|
3
5
|
const db = require('../../db/database');
|
|
4
6
|
const { AGENT_DATA_DIR } = require('../../../runtime/paths');
|
|
5
7
|
|
|
6
8
|
const SKILLS_DIR = path.join(AGENT_DATA_DIR, 'skills');
|
|
9
|
+
const USER_SKILLS_DIR = path.join(SKILLS_DIR, 'users');
|
|
10
|
+
const LEGACY_SKILL_USER_ID = 0;
|
|
7
11
|
|
|
8
12
|
function shellEscape(value) {
|
|
9
13
|
const text = String(value ?? '');
|
|
@@ -30,13 +34,56 @@ function clampText(value, maxChars) {
|
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
function isValidUserId(userId) {
|
|
37
|
+
return normalizeRuntimeUserId(userId) !== null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function normalizeRuntimeUserId(userId) {
|
|
33
41
|
if (typeof userId === 'number') {
|
|
34
|
-
return Number.isInteger(userId) && userId > 0;
|
|
42
|
+
return Number.isInteger(userId) && userId > 0 ? userId : null;
|
|
35
43
|
}
|
|
36
44
|
if (typeof userId === 'string') {
|
|
37
|
-
|
|
45
|
+
const trimmed = userId.trim();
|
|
46
|
+
if (!trimmed) return null;
|
|
47
|
+
const parsed = Number.parseInt(trimmed, 10);
|
|
48
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function normalizeStoredUserId(userId) {
|
|
54
|
+
if (typeof userId === 'number' && Number.isInteger(userId)) {
|
|
55
|
+
return userId;
|
|
56
|
+
}
|
|
57
|
+
if (typeof userId === 'string') {
|
|
58
|
+
const trimmed = userId.trim();
|
|
59
|
+
if (!trimmed) return LEGACY_SKILL_USER_ID;
|
|
60
|
+
const parsed = Number.parseInt(trimmed, 10);
|
|
61
|
+
return Number.isInteger(parsed) ? parsed : LEGACY_SKILL_USER_ID;
|
|
62
|
+
}
|
|
63
|
+
return LEGACY_SKILL_USER_ID;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function normalizeSkillName(name) {
|
|
67
|
+
return String(name || '')
|
|
68
|
+
.trim()
|
|
69
|
+
.replace(/[^a-z0-9-]/gi, '-')
|
|
70
|
+
.replace(/-+/g, '-')
|
|
71
|
+
.replace(/^-|-$/g, '')
|
|
72
|
+
.toLowerCase();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function buildSkillKey(ownerType, ownerId, name) {
|
|
76
|
+
return `${ownerType}:${ownerId ?? 'global'}:${name}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function parseMetadataJson(value) {
|
|
80
|
+
if (!value || typeof value !== 'string') return {};
|
|
81
|
+
try {
|
|
82
|
+
const parsed = JSON.parse(value);
|
|
83
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
84
|
+
} catch {
|
|
85
|
+
return {};
|
|
38
86
|
}
|
|
39
|
-
return false;
|
|
40
87
|
}
|
|
41
88
|
|
|
42
89
|
class SkillRunner {
|
|
@@ -47,47 +94,67 @@ class SkillRunner {
|
|
|
47
94
|
|
|
48
95
|
async loadSkills() {
|
|
49
96
|
this.skills.clear();
|
|
50
|
-
|
|
97
|
+
const dbSkills = db.prepare('SELECT * FROM skills').all();
|
|
98
|
+
const dbSkillPaths = new Set(
|
|
99
|
+
dbSkills
|
|
100
|
+
.map((skill) => path.resolve(String(skill.file_path || '')))
|
|
101
|
+
.filter(Boolean),
|
|
102
|
+
);
|
|
103
|
+
const loadedPaths = new Set();
|
|
51
104
|
|
|
52
105
|
const loadDir = (dir) => {
|
|
53
106
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
54
107
|
for (const entry of entries) {
|
|
55
108
|
const fullPath = path.join(dir, entry.name);
|
|
109
|
+
if (path.resolve(fullPath) === path.resolve(USER_SKILLS_DIR)) {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
56
112
|
if (entry.isDirectory()) {
|
|
57
113
|
const skillFile = path.join(fullPath, 'SKILL.md');
|
|
58
|
-
if (fs.existsSync(skillFile)) {
|
|
59
|
-
this.loadSkillFile(skillFile);
|
|
114
|
+
if (fs.existsSync(skillFile) && !dbSkillPaths.has(path.resolve(skillFile))) {
|
|
115
|
+
this.loadSkillFile(skillFile, { ownerType: 'global' });
|
|
116
|
+
loadedPaths.add(path.resolve(skillFile));
|
|
60
117
|
}
|
|
61
118
|
loadDir(fullPath);
|
|
62
|
-
} else if (
|
|
63
|
-
|
|
119
|
+
} else if (
|
|
120
|
+
entry.name.endsWith('.md')
|
|
121
|
+
&& !loadedPaths.has(path.resolve(fullPath))
|
|
122
|
+
&& !dbSkillPaths.has(path.resolve(fullPath))
|
|
123
|
+
) {
|
|
124
|
+
this.loadSkillFile(fullPath, { ownerType: 'global' });
|
|
125
|
+
loadedPaths.add(path.resolve(fullPath));
|
|
64
126
|
}
|
|
65
127
|
}
|
|
66
128
|
};
|
|
67
129
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
130
|
+
if (fs.existsSync(SKILLS_DIR)) {
|
|
131
|
+
loadDir(SKILLS_DIR);
|
|
132
|
+
}
|
|
71
133
|
for (const skill of dbSkills) {
|
|
72
134
|
if (fs.existsSync(skill.file_path)) {
|
|
73
|
-
this.loadSkillFile(skill.file_path
|
|
135
|
+
this.loadSkillFile(skill.file_path, {
|
|
136
|
+
ownerType: normalizeStoredUserId(skill.user_id) > 0 ? 'user' : 'legacy',
|
|
137
|
+
userId: normalizeStoredUserId(skill.user_id),
|
|
138
|
+
enabled: skill.enabled !== 0,
|
|
139
|
+
metadata: parseMetadataJson(skill.metadata),
|
|
140
|
+
});
|
|
74
141
|
}
|
|
75
142
|
}
|
|
76
143
|
}
|
|
77
144
|
|
|
78
|
-
loadSkillFile(filePath) {
|
|
145
|
+
loadSkillFile(filePath, options = {}) {
|
|
79
146
|
try {
|
|
80
147
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
81
|
-
const skill = this.parseSkillMd(content, filePath);
|
|
148
|
+
const skill = this.parseSkillMd(content, filePath, options);
|
|
82
149
|
if (skill) {
|
|
83
|
-
this.skills.set(skill.
|
|
150
|
+
this.skills.set(skill.key, skill);
|
|
84
151
|
}
|
|
85
152
|
} catch (err) {
|
|
86
153
|
console.error(`Failed to load skill from ${filePath}:`, err.message);
|
|
87
154
|
}
|
|
88
155
|
}
|
|
89
156
|
|
|
90
|
-
parseSkillMd(content, filePath) {
|
|
157
|
+
parseSkillMd(content, filePath, options = {}) {
|
|
91
158
|
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)/);
|
|
92
159
|
if (!frontmatterMatch) return null;
|
|
93
160
|
|
|
@@ -109,15 +176,32 @@ class SkillRunner {
|
|
|
109
176
|
}
|
|
110
177
|
}
|
|
111
178
|
|
|
112
|
-
|
|
179
|
+
const name = normalizeSkillName(metadata.name);
|
|
180
|
+
if (!name) return null;
|
|
181
|
+
|
|
182
|
+
const ownerType = options.ownerType || 'global';
|
|
183
|
+
const userId = ownerType === 'user' || ownerType === 'legacy'
|
|
184
|
+
? normalizeStoredUserId(options.userId)
|
|
185
|
+
: null;
|
|
186
|
+
const mergedMetadata = {
|
|
187
|
+
...metadata,
|
|
188
|
+
...(options.metadata && typeof options.metadata === 'object' ? options.metadata : {}),
|
|
189
|
+
};
|
|
190
|
+
if (typeof options.enabled === 'boolean') {
|
|
191
|
+
mergedMetadata.enabled = options.enabled;
|
|
192
|
+
}
|
|
113
193
|
|
|
114
194
|
return {
|
|
115
|
-
|
|
195
|
+
key: buildSkillKey(ownerType, userId, name),
|
|
196
|
+
name,
|
|
116
197
|
description: metadata.description || '',
|
|
117
|
-
metadata,
|
|
198
|
+
metadata: mergedMetadata,
|
|
118
199
|
instructions: body.trim(),
|
|
119
200
|
filePath,
|
|
120
|
-
dir: path.dirname(filePath)
|
|
201
|
+
dir: path.dirname(filePath),
|
|
202
|
+
ownerType,
|
|
203
|
+
userId,
|
|
204
|
+
readOnly: ownerType !== 'user',
|
|
121
205
|
};
|
|
122
206
|
}
|
|
123
207
|
|
|
@@ -125,7 +209,7 @@ class SkillRunner {
|
|
|
125
209
|
const maxTotalChars = options.maxTotalChars || 9000;
|
|
126
210
|
const maxDescriptionChars = options.maxDescriptionChars || 220;
|
|
127
211
|
const maxTriggerChars = options.maxTriggerChars || 120;
|
|
128
|
-
const skills =
|
|
212
|
+
const skills = this.getAll(options.userId)
|
|
129
213
|
.filter((skill) => skill.metadata.enabled !== false)
|
|
130
214
|
.sort((a, b) => {
|
|
131
215
|
const categoryCompare = String(a.metadata?.category || 'general')
|
|
@@ -166,9 +250,9 @@ class SkillRunner {
|
|
|
166
250
|
return `\n${lines.join('\n')}`;
|
|
167
251
|
}
|
|
168
252
|
|
|
169
|
-
getToolDefinitions() {
|
|
253
|
+
getToolDefinitions(options = {}) {
|
|
170
254
|
const tools = [];
|
|
171
|
-
for (const skill of this.
|
|
255
|
+
for (const skill of this.getAll(options.userId)) {
|
|
172
256
|
if (skill.metadata.enabled !== false && skill.metadata.tool) {
|
|
173
257
|
tools.push({
|
|
174
258
|
name: skill.name,
|
|
@@ -181,7 +265,7 @@ class SkillRunner {
|
|
|
181
265
|
}
|
|
182
266
|
|
|
183
267
|
async executeTool(toolName, args, context = {}) {
|
|
184
|
-
const skill = this.
|
|
268
|
+
const skill = this.getSkill(toolName, context.userId);
|
|
185
269
|
if (!skill) return null;
|
|
186
270
|
const metricContext = {
|
|
187
271
|
userId: context.userId,
|
|
@@ -272,28 +356,70 @@ class SkillRunner {
|
|
|
272
356
|
);
|
|
273
357
|
}
|
|
274
358
|
|
|
275
|
-
createSkill(name, description, instructions, metadata = {}) {
|
|
276
|
-
const
|
|
277
|
-
|
|
359
|
+
createSkill(userId, name, description, instructions, metadata = {}) {
|
|
360
|
+
const ownerId = normalizeRuntimeUserId(userId);
|
|
361
|
+
if (ownerId === null) {
|
|
362
|
+
return { error: 'Missing or invalid userId' };
|
|
363
|
+
}
|
|
364
|
+
const safeName = normalizeSkillName(name);
|
|
365
|
+
if (!safeName) {
|
|
366
|
+
return { error: 'Skill name is required' };
|
|
367
|
+
}
|
|
368
|
+
if (this._findOwnedSkill(safeName, ownerId)) {
|
|
369
|
+
return { error: `Skill '${safeName}' already exists` };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const metaToWrite = metadata && typeof metadata === 'object' ? metadata : {};
|
|
373
|
+
const skillDir = path.join(USER_SKILLS_DIR, String(ownerId), safeName);
|
|
278
374
|
if (!fs.existsSync(skillDir)) fs.mkdirSync(skillDir, { recursive: true });
|
|
279
375
|
|
|
280
|
-
const frontmatter = this._buildFrontmatter(safeName, description,
|
|
376
|
+
const frontmatter = this._buildFrontmatter(safeName, description, metaToWrite);
|
|
281
377
|
const filePath = path.join(skillDir, 'SKILL.md');
|
|
282
378
|
fs.writeFileSync(filePath, frontmatter + `\n\n${instructions}`);
|
|
283
379
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
380
|
+
try {
|
|
381
|
+
db.prepare(`
|
|
382
|
+
INSERT INTO skills (user_id, name, description, file_path, metadata, enabled, auto_created, updated_at)
|
|
383
|
+
VALUES (?, ?, ?, ?, ?, ?, 1, datetime('now'))
|
|
384
|
+
`).run(
|
|
385
|
+
ownerId,
|
|
386
|
+
safeName,
|
|
387
|
+
description,
|
|
388
|
+
filePath,
|
|
389
|
+
JSON.stringify(metaToWrite),
|
|
390
|
+
metaToWrite.enabled === false ? 0 : 1,
|
|
391
|
+
);
|
|
392
|
+
} catch (err) {
|
|
393
|
+
try {
|
|
394
|
+
fs.unlinkSync(filePath);
|
|
395
|
+
} catch {}
|
|
396
|
+
try {
|
|
397
|
+
const remaining = fs.readdirSync(skillDir);
|
|
398
|
+
if (remaining.length === 0) fs.rmdirSync(skillDir);
|
|
399
|
+
} catch {}
|
|
400
|
+
if (String(err?.message || '').includes('UNIQUE')) {
|
|
401
|
+
return { error: `Skill '${safeName}' already exists` };
|
|
402
|
+
}
|
|
403
|
+
throw err;
|
|
404
|
+
}
|
|
288
405
|
|
|
289
|
-
this.loadSkillFile(filePath
|
|
406
|
+
this.loadSkillFile(filePath, {
|
|
407
|
+
ownerType: 'user',
|
|
408
|
+
userId: ownerId,
|
|
409
|
+
enabled: metaToWrite.enabled !== false,
|
|
410
|
+
metadata: metaToWrite,
|
|
411
|
+
});
|
|
290
412
|
|
|
291
413
|
return { success: true, name: safeName, path: filePath };
|
|
292
414
|
}
|
|
293
415
|
|
|
294
|
-
updateSkill(name, { description, instructions, metadata } = {}) {
|
|
295
|
-
const
|
|
416
|
+
updateSkill(userId, name, { description, instructions, metadata } = {}) {
|
|
417
|
+
const ownerId = normalizeRuntimeUserId(userId);
|
|
418
|
+
const skill = this.getSkill(name, ownerId);
|
|
296
419
|
if (!skill) return { error: `Skill '${name}' not found` };
|
|
420
|
+
if (!this._canEditSkill(skill, ownerId)) {
|
|
421
|
+
return { error: `Skill '${skill.name}' is read-only`, code: 'forbidden' };
|
|
422
|
+
}
|
|
297
423
|
|
|
298
424
|
const newDesc = description !== undefined ? description : skill.description;
|
|
299
425
|
const newInstructions = instructions !== undefined ? instructions : skill.instructions;
|
|
@@ -308,29 +434,57 @@ class SkillRunner {
|
|
|
308
434
|
metaToWrite = existing;
|
|
309
435
|
}
|
|
310
436
|
|
|
311
|
-
const frontmatter = this._buildFrontmatter(name, newDesc, metaToWrite);
|
|
437
|
+
const frontmatter = this._buildFrontmatter(skill.name, newDesc, metaToWrite);
|
|
312
438
|
fs.writeFileSync(skill.filePath, frontmatter + `\n\n${newInstructions}`);
|
|
313
|
-
db.prepare(
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
439
|
+
db.prepare(
|
|
440
|
+
`UPDATE skills
|
|
441
|
+
SET description = ?, metadata = ?, enabled = ?, updated_at = datetime('now')
|
|
442
|
+
WHERE user_id = ? AND name = ?`
|
|
443
|
+
).run(
|
|
444
|
+
newDesc,
|
|
445
|
+
JSON.stringify(metaToWrite || {}),
|
|
446
|
+
metaToWrite?.enabled === false ? 0 : 1,
|
|
447
|
+
ownerId,
|
|
448
|
+
skill.name,
|
|
449
|
+
);
|
|
450
|
+
this.loadSkillFile(skill.filePath, {
|
|
451
|
+
ownerType: 'user',
|
|
452
|
+
userId: ownerId,
|
|
453
|
+
enabled: metaToWrite?.enabled !== false,
|
|
454
|
+
metadata: metaToWrite,
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
return { success: true, name: skill.name, path: skill.filePath };
|
|
318
458
|
}
|
|
319
459
|
|
|
320
|
-
getSkill(name) {
|
|
321
|
-
|
|
460
|
+
getSkill(name, userId = null) {
|
|
461
|
+
const skillName = normalizeSkillName(name);
|
|
462
|
+
if (!skillName) return null;
|
|
463
|
+
const ownerId = normalizeRuntimeUserId(userId);
|
|
464
|
+
if (ownerId !== null) {
|
|
465
|
+
const owned = this._findOwnedSkill(skillName, ownerId);
|
|
466
|
+
if (owned) return owned;
|
|
467
|
+
}
|
|
468
|
+
return this._findVisibleSkill(skillName, ownerId);
|
|
322
469
|
}
|
|
323
470
|
|
|
324
|
-
setSkillEnabled(name, enabled) {
|
|
325
|
-
const skill = this.
|
|
471
|
+
setSkillEnabled(userId, name, enabled) {
|
|
472
|
+
const skill = this.getSkill(name, userId);
|
|
326
473
|
if (!skill) return { error: `Skill '${name}' not found` };
|
|
474
|
+
if (!this._canEditSkill(skill, normalizeRuntimeUserId(userId))) {
|
|
475
|
+
return { error: `Skill '${skill.name}' is read-only`, code: 'forbidden' };
|
|
476
|
+
}
|
|
327
477
|
const metadata = { ...skill.metadata, enabled: !!enabled };
|
|
328
|
-
return this.updateSkill(name, { metadata });
|
|
478
|
+
return this.updateSkill(userId, skill.name, { metadata });
|
|
329
479
|
}
|
|
330
480
|
|
|
331
|
-
deleteSkill(name) {
|
|
332
|
-
const
|
|
481
|
+
deleteSkill(userId, name) {
|
|
482
|
+
const ownerId = normalizeRuntimeUserId(userId);
|
|
483
|
+
const skill = this.getSkill(name, ownerId);
|
|
333
484
|
if (!skill) return { error: `Skill '${name}' not found` };
|
|
485
|
+
if (!this._canEditSkill(skill, ownerId)) {
|
|
486
|
+
return { error: `Skill '${skill.name}' is read-only`, code: 'forbidden' };
|
|
487
|
+
}
|
|
334
488
|
|
|
335
489
|
try {
|
|
336
490
|
fs.unlinkSync(skill.filePath);
|
|
@@ -341,10 +495,10 @@ class SkillRunner {
|
|
|
341
495
|
}
|
|
342
496
|
} catch (e) { /* ignore */ }
|
|
343
497
|
|
|
344
|
-
db.prepare('DELETE FROM skills WHERE name = ?').run(name);
|
|
345
|
-
this.skills.delete(
|
|
498
|
+
db.prepare('DELETE FROM skills WHERE user_id = ? AND name = ?').run(ownerId, skill.name);
|
|
499
|
+
this.skills.delete(skill.key);
|
|
346
500
|
|
|
347
|
-
return { success: true, deleted: name };
|
|
501
|
+
return { success: true, deleted: skill.name };
|
|
348
502
|
}
|
|
349
503
|
|
|
350
504
|
_buildFrontmatter(name, description, metadata = {}) {
|
|
@@ -361,15 +515,68 @@ class SkillRunner {
|
|
|
361
515
|
return fm;
|
|
362
516
|
}
|
|
363
517
|
|
|
364
|
-
getAll() {
|
|
365
|
-
return
|
|
518
|
+
getAll(userId = null) {
|
|
519
|
+
return this._getVisibleSkills(userId).map((s) => ({
|
|
366
520
|
name: s.name,
|
|
367
521
|
description: s.description,
|
|
368
522
|
metadata: s.metadata,
|
|
369
523
|
filePath: s.filePath,
|
|
370
|
-
enabled: s.metadata.enabled !== false
|
|
524
|
+
enabled: s.metadata.enabled !== false,
|
|
525
|
+
userId: s.userId,
|
|
526
|
+
ownerType: s.ownerType,
|
|
527
|
+
readOnly: s.readOnly,
|
|
371
528
|
}));
|
|
372
529
|
}
|
|
530
|
+
|
|
531
|
+
findSkillByWorkflowSignature(userId, workflowSignature) {
|
|
532
|
+
const ownerId = normalizeRuntimeUserId(userId);
|
|
533
|
+
if (ownerId === null || !workflowSignature) return null;
|
|
534
|
+
return Array.from(this.skills.values()).find(
|
|
535
|
+
(skill) => skill.ownerType === 'user'
|
|
536
|
+
&& skill.userId === ownerId
|
|
537
|
+
&& skill.metadata?.workflow_signature === workflowSignature,
|
|
538
|
+
) || null;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
_findOwnedSkill(name, userId) {
|
|
542
|
+
const ownerId = normalizeRuntimeUserId(userId);
|
|
543
|
+
if (ownerId === null) return null;
|
|
544
|
+
return this.skills.get(buildSkillKey('user', ownerId, normalizeSkillName(name))) || null;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
_findVisibleSkill(name, userId) {
|
|
548
|
+
const skillName = normalizeSkillName(name);
|
|
549
|
+
return this._getVisibleSkills(userId).find((skill) => skill.name === skillName) || null;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
_getVisibleSkills(userId = null) {
|
|
553
|
+
const ownerId = normalizeRuntimeUserId(userId);
|
|
554
|
+
const visible = Array.from(this.skills.values()).filter((skill) => {
|
|
555
|
+
if (ownerId === null) return true;
|
|
556
|
+
if (skill.ownerType === 'user') return skill.userId === ownerId;
|
|
557
|
+
return true;
|
|
558
|
+
});
|
|
559
|
+
if (ownerId === null) return visible;
|
|
560
|
+
const ownedNames = new Set(
|
|
561
|
+
visible
|
|
562
|
+
.filter((skill) => skill.ownerType === 'user' && skill.userId === ownerId)
|
|
563
|
+
.map((skill) => skill.name),
|
|
564
|
+
);
|
|
565
|
+
return visible.filter((skill) => {
|
|
566
|
+
if (skill.ownerType === 'user') return skill.userId === ownerId;
|
|
567
|
+
return !ownedNames.has(skill.name);
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
_canEditSkill(skill, userId) {
|
|
572
|
+
const ownerId = normalizeRuntimeUserId(userId);
|
|
573
|
+
return skill?.ownerType === 'user' && skill.userId === ownerId;
|
|
574
|
+
}
|
|
373
575
|
}
|
|
374
576
|
|
|
375
|
-
module.exports = {
|
|
577
|
+
module.exports = {
|
|
578
|
+
SkillRunner,
|
|
579
|
+
LEGACY_SKILL_USER_ID,
|
|
580
|
+
USER_SKILLS_DIR,
|
|
581
|
+
normalizeSkillName,
|
|
582
|
+
};
|
|
@@ -15,6 +15,22 @@ const ALWAYS_INCLUDE_BUILT_INS = [
|
|
|
15
15
|
'send_message',
|
|
16
16
|
'send_interim_update',
|
|
17
17
|
];
|
|
18
|
+
const CORE_FILE_TOOLS = [
|
|
19
|
+
'read_file',
|
|
20
|
+
'read_files',
|
|
21
|
+
'list_directory',
|
|
22
|
+
'search_files',
|
|
23
|
+
'edit_file',
|
|
24
|
+
'replace_file_range',
|
|
25
|
+
'write_file',
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
function requiredToolNames(options = {}) {
|
|
29
|
+
const requiredNames = [...ALWAYS_INCLUDE_BUILT_INS];
|
|
30
|
+
if (options.widgetId) requiredNames.push('save_widget_snapshot');
|
|
31
|
+
if (options.includeCoreFileTools) requiredNames.push(...CORE_FILE_TOOLS);
|
|
32
|
+
return requiredNames;
|
|
33
|
+
}
|
|
18
34
|
|
|
19
35
|
function compactDescription(value, maxChars = 180) {
|
|
20
36
|
const text = String(value || '').replace(/\s+/g, ' ').trim();
|
|
@@ -37,8 +53,7 @@ function buildToolCatalog(tools = []) {
|
|
|
37
53
|
|
|
38
54
|
function ensureRequiredTools(selectedTools = [], builtInTools = [], options = {}) {
|
|
39
55
|
const limit = Number(options.maxTools) || MAX_TOOLS;
|
|
40
|
-
const requiredNames =
|
|
41
|
-
if (options.widgetId) requiredNames.push('save_widget_snapshot');
|
|
56
|
+
const requiredNames = requiredToolNames(options);
|
|
42
57
|
if (!requiredNames.length) return selectedTools;
|
|
43
58
|
|
|
44
59
|
const selected = Array.isArray(selectedTools) ? [...selectedTools] : [];
|
|
@@ -83,6 +98,7 @@ function selectInitialTools(allTools = [], suggestedNames = [], options = {}) {
|
|
|
83
98
|
|
|
84
99
|
function activateTools(currentTools = [], allTools = [], requestedNames = [], options = {}) {
|
|
85
100
|
const knownByName = new Map(allTools.map((tool) => [tool?.name, tool]));
|
|
101
|
+
const requiredNames = requiredToolNames(options);
|
|
86
102
|
let next = ensureRequiredTools(currentTools, allTools, options);
|
|
87
103
|
const activated = [];
|
|
88
104
|
const evicted = [];
|
|
@@ -102,7 +118,7 @@ function activateTools(currentTools = [], allTools = [], requestedNames = [], op
|
|
|
102
118
|
if (next.some((item) => item?.name === name)) continue;
|
|
103
119
|
if (next.length >= MAX_TOOLS) {
|
|
104
120
|
const replaceIndex = next.findIndex((item) => (
|
|
105
|
-
!
|
|
121
|
+
!requiredNames.includes(item?.name)
|
|
106
122
|
&& !requested.includes(item?.name)
|
|
107
123
|
));
|
|
108
124
|
if (replaceIndex === -1) {
|
|
@@ -136,6 +152,7 @@ function selectToolsForTask(task, builtInTools = [], mcpTools = [], _options = {
|
|
|
136
152
|
}
|
|
137
153
|
|
|
138
154
|
module.exports = {
|
|
155
|
+
CORE_FILE_TOOLS,
|
|
139
156
|
MAX_TOOLS,
|
|
140
157
|
activateTools,
|
|
141
158
|
buildToolCatalog,
|