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.
Files changed (52) hide show
  1. package/README.md +5 -1
  2. package/docs/integrations.md +11 -7
  3. package/flutter_app/lib/main_controller.dart +34 -0
  4. package/flutter_app/lib/main_security.dart +19 -0
  5. package/lib/schema_migrations.js +224 -0
  6. package/package.json +2 -2
  7. package/server/admin/admin.css +29 -0
  8. package/server/admin/admin.js +81 -1
  9. package/server/admin/index.html +97 -9
  10. package/server/db/database.js +57 -1
  11. package/server/public/.last_build_id +1 -1
  12. package/server/public/flutter_bootstrap.js +1 -1
  13. package/server/public/main.dart.js +32788 -32752
  14. package/server/routes/security.js +19 -0
  15. package/server/routes/settings.js +1 -0
  16. package/server/routes/skills.js +9 -5
  17. package/server/services/ai/deliverables/artifact_helpers.js +92 -9
  18. package/server/services/ai/deliverables/selector.js +17 -0
  19. package/server/services/ai/hooks.js +3 -2
  20. package/server/services/ai/learning.js +8 -3
  21. package/server/services/ai/loop/agent_engine_core.js +67 -0
  22. package/server/services/ai/loop/blank_recovery.js +37 -0
  23. package/server/services/ai/loop/conversation_loop.js +391 -252
  24. package/server/services/ai/loop/error_recovery.js +38 -0
  25. package/server/services/ai/loop/messaging_delivery.js +52 -6
  26. package/server/services/ai/loop/progress_classification.js +178 -0
  27. package/server/services/ai/loop/progress_monitor.js +6 -5
  28. package/server/services/ai/loop/tool_dispatch.js +1 -0
  29. package/server/services/ai/loopPolicy.js +15 -6
  30. package/server/services/ai/messagingFallback.js +23 -1
  31. package/server/services/ai/preModelCompaction.js +31 -0
  32. package/server/services/ai/repetitionGuard.js +47 -2
  33. package/server/services/ai/settings.js +8 -0
  34. package/server/services/ai/systemPrompt.js +24 -0
  35. package/server/services/ai/taskAnalysis.js +10 -0
  36. package/server/services/ai/toolEvidence.js +39 -3
  37. package/server/services/ai/toolResult.js +29 -0
  38. package/server/services/ai/toolRunner.js +262 -55
  39. package/server/services/ai/toolSelector.js +20 -3
  40. package/server/services/ai/tools.js +201 -31
  41. package/server/services/cli/shell_worker_pool.js +87 -5
  42. package/server/services/integrations/github/common.js +2 -2
  43. package/server/services/integrations/github/repos.js +123 -55
  44. package/server/services/manager.js +16 -0
  45. package/server/services/memory/manager.js +39 -24
  46. package/server/services/runtime/docker-vm-manager.js +21 -1
  47. package/server/services/runtime/manager.js +6 -2
  48. package/server/services/security/approval_gate_service.js +108 -5
  49. package/server/services/security/tool_categories.js +113 -4
  50. package/server/services/security/tool_security_hook.js +7 -2
  51. package/server/services/skills/runtime.js +2 -0
  52. 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 = stateChangingExact.has(name)
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
- return userId.trim() !== '';
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
- if (!fs.existsSync(SKILLS_DIR)) return;
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 (entry.name.endsWith('.md')) {
63
- this.loadSkillFile(fullPath);
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
- loadDir(SKILLS_DIR);
69
-
70
- const dbSkills = db.prepare('SELECT * FROM skills WHERE enabled = 1').all();
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.name, 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
- if (!metadata.name) return null;
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
- name: metadata.name,
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 = Array.from(this.skills.values())
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.skills.values()) {
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.skills.get(toolName);
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 safeName = name.replace(/[^a-z0-9-]/gi, '-').toLowerCase();
277
- const skillDir = path.join(SKILLS_DIR, safeName);
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, metadata);
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
- db.prepare(`
285
- INSERT OR REPLACE INTO skills (name, description, file_path, metadata, enabled, auto_created, updated_at)
286
- VALUES (?, ?, ?, ?, ?, 1, datetime('now'))
287
- `).run(safeName, description, filePath, JSON.stringify(metadata), metadata.enabled === false ? 0 : 1);
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 skill = this.skills.get(name);
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('UPDATE skills SET description = ?, metadata = ?, enabled = ?, updated_at = datetime(\'now\') WHERE name = ?')
314
- .run(newDesc, JSON.stringify(metaToWrite || {}), metaToWrite?.enabled === false ? 0 : 1, name);
315
- this.loadSkillFile(skill.filePath);
316
-
317
- return { success: true, name, path: skill.filePath };
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
- return this.skills.get(name) || null;
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.skills.get(name);
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 skill = this.skills.get(name);
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(name);
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 Array.from(this.skills.values()).map(s => ({
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 = { SkillRunner };
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 = [...ALWAYS_INCLUDE_BUILT_INS];
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
- !ALWAYS_INCLUDE_BUILT_INS.includes(item?.name)
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,