@bibobonking/opensol 3.1.4 → 3.1.5

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 (72) hide show
  1. package/CHANGELOG.md +10 -12
  2. package/desktop/src-tauri/icons/128x128.png +0 -0
  3. package/desktop/src-tauri/icons/128x128@2x.png +0 -0
  4. package/desktop/src-tauri/icons/32x32.png +0 -0
  5. package/desktop/src-tauri/icons/64x64.png +0 -0
  6. package/desktop/src-tauri/icons/Square107x107Logo.png +0 -0
  7. package/desktop/src-tauri/icons/Square142x142Logo.png +0 -0
  8. package/desktop/src-tauri/icons/Square150x150Logo.png +0 -0
  9. package/desktop/src-tauri/icons/Square284x284Logo.png +0 -0
  10. package/desktop/src-tauri/icons/Square30x30Logo.png +0 -0
  11. package/desktop/src-tauri/icons/Square310x310Logo.png +0 -0
  12. package/desktop/src-tauri/icons/Square44x44Logo.png +0 -0
  13. package/desktop/src-tauri/icons/Square71x71Logo.png +0 -0
  14. package/desktop/src-tauri/icons/Square89x89Logo.png +0 -0
  15. package/desktop/src-tauri/icons/StoreLogo.png +0 -0
  16. package/desktop/src-tauri/icons/icon.icns +0 -0
  17. package/desktop/src-tauri/icons/icon.ico +0 -0
  18. package/desktop/src-tauri/icons/icon.png +0 -0
  19. package/dist/assets/index-C0yvs6Vl.js +281 -0
  20. package/dist/assets/index-CxNPLBg7.css +1 -0
  21. package/dist/favicon.svg +18 -15
  22. package/dist/index.html +2 -2
  23. package/package.json +1 -1
  24. package/public/favicon.svg +18 -15
  25. package/server/daemon.mjs +66 -49
  26. package/server/lib/deploy.mjs +222 -121
  27. package/server/lib/providers.mjs +23 -1
  28. package/server/lib/skills.mjs +385 -100
  29. package/server/lib/tools.mjs +0 -40
  30. package/src/agent/adapters/openai-responses.ts +1 -5
  31. package/src/agent/adapters/openai.ts +4 -11
  32. package/src/agent/prompt.ts +7 -14
  33. package/src/agent/subagent.ts +3 -14
  34. package/src/agent/tools.ts +2 -0
  35. package/src/components/ChatView.tsx +2 -1
  36. package/src/components/Composer.tsx +2 -1
  37. package/src/components/ContextBar.tsx +10 -0
  38. package/src/components/DeployModal.tsx +266 -252
  39. package/src/components/FileIcon.tsx +391 -99
  40. package/src/components/HomeDashboard.tsx +5 -10
  41. package/src/components/Logo.tsx +9 -11
  42. package/src/components/ModelDeck.tsx +8 -11
  43. package/src/components/SettingsModal.tsx +160 -122
  44. package/src/components/Sidebar.tsx +4 -3
  45. package/src/components/SystemPromptButton.tsx +198 -0
  46. package/src/components/ToolCallCard.tsx +2 -0
  47. package/src/components/TopBar.tsx +2 -19
  48. package/src/components/WorkspacePanel.tsx +76 -93
  49. package/src/core/plugins/tools/skill.ts +19 -76
  50. package/src/core/services/agent-loop.ts +16 -31
  51. package/src/core/services/subagents.ts +2 -2
  52. package/src/core/services/system-prompt.ts +16 -23
  53. package/src/core/services/tools.ts +0 -1
  54. package/src/lib/boot.ts +2 -0
  55. package/src/lib/credits.ts +33 -0
  56. package/src/lib/daemon.ts +40 -19
  57. package/src/lib/permissions.ts +1 -1
  58. package/src/lib/retry.test.mjs +40 -12
  59. package/src/lib/retry.ts +41 -8
  60. package/src/lib/system-prompt.ts +23 -0
  61. package/src/lib/usage.ts +8 -13
  62. package/src/store/chats.ts +0 -1
  63. package/src/store/settings.ts +12 -44
  64. package/src/store/skills.ts +79 -0
  65. package/src/store/ui.ts +3 -1
  66. package/src/types.ts +30 -15
  67. package/tsconfig.tsbuildinfo +1 -1
  68. package/dist/assets/index-B1pQC9Fe.js +0 -288
  69. package/dist/assets/index-qLr4aZVC.css +0 -1
  70. package/src/components/SystemPromptModal.tsx +0 -168
  71. package/src/lib/skills.test.mjs +0 -22
  72. package/src/lib/skills.ts +0 -93
@@ -1,128 +1,413 @@
1
- import { gunzipSync } from 'node:zlib'
1
+ // Agent Skills store. A skill is a folder with a SKILL.md (YAML frontmatter: name, description)
2
+ // plus any bundled files (scripts/, references/, assets/). Folders live in <data>/skills/<name>/.
3
+ // The model only sees name + description up front and loads the rest on demand with the `skill` tool.
4
+ import { execFile } from 'node:child_process'
5
+ import { randomBytes } from 'node:crypto'
6
+ import { mkdir, mkdtemp, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
7
+ import { tmpdir } from 'node:os'
8
+ import path from 'node:path'
2
9
  import { HttpError } from './http.mjs'
10
+ import { DATA_DIR, loadState } from './store.mjs'
3
11
 
4
- const MAX_TARBALL = 12 * 1024 * 1024
5
- const MAX_FILES = 200
6
- const MAX_FILE = 512 * 1024
7
-
8
- function registryName(pkg) {
9
- return pkg.startsWith('@') ? pkg.replace('/', '%2f') : encodeURIComponent(pkg)
10
- }
11
-
12
- /** Minimal ustar reader: enough for npm packages, no extra dependency. */
13
- function untar(buffer) {
14
- const files = new Map()
15
- let offset = 0
16
- while (offset + 512 <= buffer.length) {
17
- const header = buffer.subarray(offset, offset + 512)
18
- const name = header.subarray(0, 100).toString('utf8').replace(/\0.*$/, '')
19
- if (!name) break
20
- const size = parseInt(header.subarray(124, 136).toString('utf8').replace(/\0.*$/, '').trim(), 8) || 0
21
- const type = String.fromCharCode(header[156] || 48)
22
- const prefix = header.subarray(345, 500).toString('utf8').replace(/\0.*$/, '')
23
- const full = prefix ? `${prefix}/${name}` : name
24
- offset += 512
25
- if (type === '0' || type === '\0' || type === '48') {
26
- if (files.size < MAX_FILES && size > 0 && size <= MAX_FILE) {
27
- files.set(full, buffer.subarray(offset, offset + size).toString('utf8'))
28
- }
29
- }
30
- offset += Math.ceil(size / 512) * 512
31
- }
32
- return files
12
+ export const SKILLS_DIR = path.join(DATA_DIR, 'skills')
13
+ const STATE_FILE = path.join(SKILLS_DIR, '.state.json')
14
+ const META_FILE = '.skill-meta.json'
15
+
16
+ const MAX_FILES = 500
17
+ const MAX_FILE_BYTES = 1024 * 1024
18
+ const MAX_TOTAL_BYTES = 6 * 1024 * 1024
19
+ const MAX_READ_BYTES = 200 * 1024
20
+ const NPM_TIMEOUT_MS = 120_000
21
+ const NPM_SPEC = /^(?:@[a-z0-9~][a-z0-9-._~]*\/)?[a-z0-9~][a-z0-9-._~]*(?:@[A-Za-z0-9][A-Za-z0-9._+-]*)?$/
22
+ const SKIP_DIRS = new Set(['node_modules', '.git', '.svn', '.hg', '__pycache__'])
23
+ const TEXT_EXT = /\.(md|markdown|txt|json|ya?ml|toml|csv|tsv|xml|html?|css|js|mjs|cjs|ts|tsx|jsx|py|sh|bash|zsh|ps1|rb|go|rs|java|c|h|cpp|sql|ini|cfg|env|svg)$/i
24
+
25
+ let writes = Promise.resolve()
26
+ function serial(task) {
27
+ const next = writes.then(task, task)
28
+ writes = next.catch(() => undefined)
29
+ return next
33
30
  }
34
31
 
35
- function skillFile(files) {
36
- const candidates = [...files.keys()].filter((path) => /(^|\/)SKILL\.md$/i.test(path))
37
- candidates.sort((a, b) => a.split('/').length - b.split('/').length || a.localeCompare(b))
38
- return candidates[0] ?? ''
32
+ export function slugifySkillName(value) {
33
+ const slug = String(value ?? '')
34
+ .toLowerCase()
35
+ .replace(/[^a-z0-9._-]+/g, '-')
36
+ .replace(/^[-._]+|[-._]+$/g, '')
37
+ .slice(0, 64)
38
+ return slug
39
39
  }
40
40
 
41
- function parseSkill(markdown, fallbackName) {
42
- const text = markdown.replace(/\r\n/g, '\n')
43
- const front = /^---\n([\s\S]*?)\n---\n?/.exec(text)
41
+ function assertName(name) {
42
+ if (typeof name !== 'string' || !/^[a-z0-9][a-z0-9._-]{0,63}$/.test(name)) {
43
+ throw new HttpError(400, 'Invalid skill name.', 'invalid_skill_name')
44
+ }
45
+ return name
46
+ }
47
+
48
+ /** Minimal YAML frontmatter reader: scalars, quoted strings and `>` / `|` blocks. */
49
+ export function parseSkillMarkdown(text) {
50
+ const clean = String(text ?? '').replace(/^/, '')
51
+ const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(clean)
44
52
  const meta = {}
45
- let body = text
46
- if (front) {
47
- for (const line of front[1].split('\n')) {
48
- const match = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/.exec(line.trim())
49
- if (match) meta[match[1].toLowerCase()] = match[2].replace(/^["']|["']$/g, '').trim()
53
+ let body = clean
54
+ if (match) {
55
+ body = match[2]
56
+ const lines = match[1].split(/\r?\n/)
57
+ for (let index = 0; index < lines.length; index += 1) {
58
+ const entry = /^([A-Za-z_][\w-]*)\s*:\s*(.*)$/.exec(lines[index])
59
+ if (!entry) continue
60
+ const key = entry[1]
61
+ let value = entry[2].trim()
62
+ if (/^[>|][+-]?$/.test(value)) {
63
+ const block = []
64
+ while (index + 1 < lines.length && (/^\s+/.test(lines[index + 1]) || lines[index + 1] === '')) {
65
+ block.push(lines[index + 1].trim())
66
+ index += 1
67
+ }
68
+ value = block.join(value.startsWith('>') ? ' ' : '\n').trim()
69
+ } else if (/^(["']).*\1$/.test(value)) {
70
+ value = value.slice(1, -1)
71
+ }
72
+ meta[key] = value
50
73
  }
51
- body = text.slice(front[0].length)
52
74
  }
75
+ return { meta, body: body.trim() }
76
+ }
77
+
78
+ function describeSkill(raw, folder) {
79
+ const { meta, body } = parseSkillMarkdown(raw)
53
80
  const heading = /^#\s+(.+)$/m.exec(body)
54
- const paragraph = body
81
+ const firstParagraph = body
55
82
  .split(/\n\s*\n/)
56
83
  .map((part) => part.trim())
57
- .find((part) => part && !part.startsWith('#') && !part.startsWith('---'))
84
+ .find((part) => part && !part.startsWith('#'))
58
85
  return {
59
- name: meta.name || heading?.[1]?.trim() || fallbackName,
60
- description: (meta.description || paragraph || 'Installed from npm').replace(/[#>*`]/g, '').replace(/\s+/g, ' ').slice(0, 200),
61
- instructions: body.trim(),
86
+ name: slugifySkillName(meta.name || folder) || slugifySkillName(heading?.[1]) || folder,
87
+ description: String(meta.description || firstParagraph || heading?.[1] || '')
88
+ .replace(/\s+/g, ' ')
89
+ .trim()
90
+ .slice(0, 600),
62
91
  }
63
92
  }
64
93
 
65
- function slug(value) {
66
- return String(value || 'skill')
67
- .toLowerCase()
68
- .replace(/^@[^/]+\//, '')
69
- .replace(/[^a-z0-9-]+/g, '-')
70
- .replace(/^-+|-+$/g, '')
71
- .slice(0, 48) || 'skill'
94
+ async function readState() {
95
+ try {
96
+ const parsed = JSON.parse(await readFile(STATE_FILE, 'utf8'))
97
+ return {
98
+ disabled: Array.isArray(parsed.disabled) ? parsed.disabled.filter((item) => typeof item === 'string') : [],
99
+ legacyMigrated: parsed.legacyMigrated === true,
100
+ }
101
+ } catch {
102
+ return { disabled: [], legacyMigrated: false }
103
+ }
104
+ }
105
+
106
+ async function writeState(state) {
107
+ await mkdir(SKILLS_DIR, { recursive: true, mode: 0o700 })
108
+ await writeFile(STATE_FILE, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 })
109
+ }
110
+
111
+ async function walk(root, base = root, depth = 0, out = []) {
112
+ if (depth > 8) return out
113
+ let entries
114
+ try {
115
+ entries = await readdir(base, { withFileTypes: true })
116
+ } catch {
117
+ return out
118
+ }
119
+ for (const entry of entries) {
120
+ if (entry.name === META_FILE) continue
121
+ const full = path.join(base, entry.name)
122
+ if (entry.isDirectory()) {
123
+ if (SKIP_DIRS.has(entry.name)) continue
124
+ await walk(root, full, depth + 1, out)
125
+ } else if (entry.isFile()) {
126
+ const info = await stat(full)
127
+ out.push({ path: path.relative(root, full).split(path.sep).join('/'), size: info.size })
128
+ }
129
+ }
130
+ return out
72
131
  }
73
132
 
74
- export async function installSkillFromNpm(packageName) {
75
- const pkg = String(packageName ?? '').trim()
76
- if (!/^(@[a-z0-9-_.]+\/)?[a-z0-9-_.]+$/i.test(pkg)) {
77
- throw new HttpError(400, 'Enter a valid npm package name, for example @scope/skill-name.', 'bad_skill_package')
133
+ async function loadSkillRecord(folder, disabled) {
134
+ const dir = path.join(SKILLS_DIR, folder)
135
+ let raw
136
+ try {
137
+ raw = await readFile(path.join(dir, 'SKILL.md'), 'utf8')
138
+ } catch {
139
+ return null
78
140
  }
79
- const metaResponse = await fetch(`https://registry.npmjs.org/${registryName(pkg)}`, {
80
- signal: AbortSignal.timeout(20_000),
141
+ let meta = {}
142
+ try {
143
+ meta = JSON.parse(await readFile(path.join(dir, META_FILE), 'utf8'))
144
+ } catch {
145
+ // Folder was placed by hand; that is fine.
146
+ }
147
+ const info = describeSkill(raw, folder)
148
+ const files = await walk(dir)
149
+ return {
150
+ name: folder,
151
+ title: info.name,
152
+ description: info.description,
153
+ enabled: !disabled.includes(folder),
154
+ source: typeof meta.source === 'string' ? meta.source : 'folder',
155
+ installedAt: typeof meta.installedAt === 'string' ? meta.installedAt : '',
156
+ fileCount: files.length,
157
+ size: files.reduce((total, file) => total + file.size, 0),
158
+ path: dir,
159
+ }
160
+ }
161
+
162
+ async function migrateLegacy() {
163
+ const state = await readState()
164
+ if (state.legacyMigrated) return
165
+ try {
166
+ const legacy = (await loadState()).settings?.skills
167
+ if (Array.isArray(legacy)) {
168
+ for (const item of legacy) {
169
+ if (!item || typeof item !== 'object' || item.builtIn === true) continue
170
+ const name = slugifySkillName(item.name)
171
+ const instructions = typeof item.instructions === 'string' ? item.instructions.trim() : ''
172
+ if (!name || !instructions) continue
173
+ const dir = path.join(SKILLS_DIR, name)
174
+ await mkdir(dir, { recursive: true, mode: 0o700 })
175
+ const description = String(item.description ?? '').replace(/\s+/g, ' ').trim()
176
+ const header = `---\nname: ${name}\ndescription: ${JSON.stringify(description || String(item.name))}\n---\n\n`
177
+ await writeFile(path.join(dir, 'SKILL.md'), `${header}${instructions}\n`, { mode: 0o600 })
178
+ await writeFile(path.join(dir, META_FILE), JSON.stringify({ source: 'legacy', installedAt: new Date().toISOString() }))
179
+ if (item.enabled !== true) state.disabled.push(name)
180
+ }
181
+ }
182
+ } catch {
183
+ // Migration is best-effort.
184
+ }
185
+ state.legacyMigrated = true
186
+ await writeState(state)
187
+ }
188
+
189
+ export async function listSkills() {
190
+ await mkdir(SKILLS_DIR, { recursive: true, mode: 0o700 })
191
+ await serial(migrateLegacy)
192
+ const { disabled } = await readState()
193
+ const folders = (await readdir(SKILLS_DIR, { withFileTypes: true }))
194
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
195
+ .map((entry) => entry.name)
196
+ .sort()
197
+ const records = []
198
+ for (const folder of folders) {
199
+ const record = await loadSkillRecord(folder, disabled)
200
+ if (record) records.push(record)
201
+ }
202
+ return records
203
+ }
204
+
205
+ function safeRelative(input) {
206
+ const parts = String(input ?? '')
207
+ .replace(/\\/g, '/')
208
+ .split('/')
209
+ .filter((part) => part && part !== '.')
210
+ if (parts.length === 0 || parts.some((part) => part === '..' || SKIP_DIRS.has(part))) return null
211
+ return parts.join('/')
212
+ }
213
+
214
+ /** files: [{ path, bytes: Buffer }] with forward-slash relative paths. Finds every SKILL.md root and installs it. */
215
+ async function installFiles(files, source) {
216
+ if (files.length === 0) throw new HttpError(400, 'No files were provided.', 'empty_skill')
217
+ if (files.length > MAX_FILES) throw new HttpError(400, `A skill can hold at most ${MAX_FILES} files.`, 'skill_too_large')
218
+ let total = 0
219
+ for (const file of files) {
220
+ if (file.bytes.length > MAX_FILE_BYTES) {
221
+ throw new HttpError(400, `${file.path} is larger than 1 MB.`, 'skill_too_large')
222
+ }
223
+ total += file.bytes.length
224
+ }
225
+ if (total > MAX_TOTAL_BYTES) throw new HttpError(400, 'Skill folder is larger than 6 MB.', 'skill_too_large')
226
+
227
+ const roots = files
228
+ .filter((file) => path.posix.basename(file.path).toLowerCase() === 'skill.md')
229
+ .map((file) => path.posix.dirname(file.path))
230
+ .map((dir) => (dir === '.' ? '' : dir))
231
+ if (roots.length === 0) {
232
+ throw new HttpError(400, 'No SKILL.md found. A skill folder must contain a SKILL.md file.', 'missing_skill_md')
233
+ }
234
+ // Keep outermost roots only so scripts/ or references/ never turn into skills of their own.
235
+ const unique = [...new Set(roots)].sort((a, b) => a.length - b.length)
236
+ const outer = unique.filter((root) => !unique.some((other) => other !== root && (other === '' || root.startsWith(`${other}/`))))
237
+ const effective = outer.length > 0 ? outer : unique
238
+
239
+ const installed = []
240
+ await serial(async () => {
241
+ await mkdir(SKILLS_DIR, { recursive: true, mode: 0o700 })
242
+ for (const root of effective) {
243
+ const prefix = root ? `${root}/` : ''
244
+ const members = files.filter((file) => (root ? file.path.startsWith(prefix) : true))
245
+ const skillFile = members.find((file) => file.path.slice(prefix.length).toLowerCase() === 'skill.md')
246
+ if (!skillFile) continue
247
+ const folder = root ? path.posix.basename(root) : ''
248
+ const info = describeSkill(skillFile.bytes.toString('utf8'), slugifySkillName(folder) || 'skill')
249
+ const name = slugifySkillName(info.name) || slugifySkillName(folder)
250
+ if (!name) throw new HttpError(400, 'The skill needs a name in its SKILL.md frontmatter.', 'missing_skill_name')
251
+
252
+ const stage = path.join(SKILLS_DIR, `.stage-${randomBytes(4).toString('hex')}`)
253
+ await mkdir(stage, { recursive: true, mode: 0o700 })
254
+ for (const member of members) {
255
+ const relative = member.path.slice(prefix.length)
256
+ const target = path.join(stage, ...relative.split('/'))
257
+ await mkdir(path.dirname(target), { recursive: true, mode: 0o700 })
258
+ await writeFile(target, member.bytes, { mode: 0o600 })
259
+ }
260
+ // Normalise the entry file name so lookups are case-stable.
261
+ if (skillFile.path.slice(prefix.length) !== 'SKILL.md') {
262
+ await rename(path.join(stage, skillFile.path.slice(prefix.length)), path.join(stage, 'SKILL.md'))
263
+ }
264
+ await writeFile(path.join(stage, META_FILE), JSON.stringify({ source, installedAt: new Date().toISOString() }))
265
+ const final = path.join(SKILLS_DIR, name)
266
+ await rm(final, { recursive: true, force: true })
267
+ await rename(stage, final)
268
+ installed.push(name)
269
+ }
81
270
  })
82
- if (!metaResponse.ok) {
83
- throw new HttpError(404, `npm could not find "${pkg}".`, 'skill_not_found')
271
+ if (installed.length === 0) throw new HttpError(400, 'No valid skill was found.', 'missing_skill_md')
272
+ const state = await readState()
273
+ if (installed.some((name) => state.disabled.includes(name))) {
274
+ await writeState({ ...state, disabled: state.disabled.filter((name) => !installed.includes(name)) })
84
275
  }
85
- const meta = await metaResponse.json()
86
- const version = meta?.['dist-tags']?.latest
87
- const tarball = version ? meta?.versions?.[version]?.dist?.tarball : ''
88
- if (!version || typeof tarball !== 'string' || !tarball.startsWith('https://registry.npmjs.org/')) {
89
- throw new HttpError(502, 'npm did not return a download for that package.', 'skill_not_found')
276
+ return installed
277
+ }
278
+
279
+ /** Browser folder upload: entries carry webkitRelativePath-style paths and base64 data. */
280
+ export async function installSkillUpload(entries) {
281
+ if (!Array.isArray(entries)) throw new HttpError(400, 'Expected a list of files.', 'invalid_skill_upload')
282
+ const files = []
283
+ for (const entry of entries) {
284
+ if (!entry || typeof entry !== 'object' || typeof entry.data !== 'string') continue
285
+ const relative = safeRelative(entry.path)
286
+ if (!relative) continue
287
+ files.push({ path: relative, bytes: Buffer.from(entry.data, 'base64') })
90
288
  }
91
- const archiveResponse = await fetch(tarball, { signal: AbortSignal.timeout(30_000) })
92
- if (!archiveResponse.ok) throw new HttpError(502, 'Could not download the skill package.', 'skill_download_failed')
93
- const bytes = new Uint8Array(await archiveResponse.arrayBuffer())
94
- if (bytes.length > MAX_TARBALL) throw new HttpError(413, 'That package is too large for a skill.', 'skill_too_large')
289
+ return installFiles(files, 'upload')
290
+ }
95
291
 
96
- let files
292
+ function run(file, args, options) {
293
+ return new Promise((resolve, reject) => {
294
+ execFile(file, args, { ...options, windowsHide: true, maxBuffer: 4 * 1024 * 1024 }, (error, stdout, stderr) => {
295
+ if (error) reject(Object.assign(error, { stdout, stderr }))
296
+ else resolve({ stdout, stderr })
297
+ })
298
+ })
299
+ }
300
+
301
+ export async function installSkillFromNpm(spec) {
302
+ const value = typeof spec === 'string' ? spec.trim() : ''
303
+ if (!NPM_SPEC.test(value)) {
304
+ throw new HttpError(400, 'Enter an npm package name such as @scope/my-skills or my-skills@1.2.0.', 'invalid_npm_spec')
305
+ }
306
+ const at = value.lastIndexOf('@')
307
+ const packageName = at > 0 ? value.slice(0, at) : value
308
+ const work = await mkdtemp(path.join(tmpdir(), 'opensol-skill-'))
97
309
  try {
98
- files = untar(gunzipSync(bytes))
310
+ await writeFile(path.join(work, 'package.json'), '{"name":"opensol-skill-install","private":true}\n')
311
+ const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'
312
+ try {
313
+ await run(
314
+ npm,
315
+ ['install', '--ignore-scripts', '--no-audit', '--no-fund', '--no-package-lock', '--omit=dev', '--loglevel=error', value],
316
+ { cwd: work, timeout: NPM_TIMEOUT_MS, shell: process.platform === 'win32' },
317
+ )
318
+ } catch (error) {
319
+ const detail = String(error?.stderr || error?.message || '').split('\n').find((line) => line.trim()) ?? ''
320
+ throw new HttpError(
321
+ 400,
322
+ `npm could not install "${value}".${detail ? ` ${detail.trim().slice(0, 200)}` : ''}`,
323
+ 'npm_install_failed',
324
+ )
325
+ }
326
+ const packageDir = path.join(work, 'node_modules', ...packageName.split('/'))
327
+ const found = await walk(packageDir)
328
+ const files = []
329
+ for (const file of found) {
330
+ const relative = safeRelative(file.path)
331
+ if (!relative) continue
332
+ files.push({ path: relative, bytes: await readFile(path.join(packageDir, ...relative.split('/'))) })
333
+ }
334
+ // A package whose only skill is its root SKILL.md takes the package name.
335
+ const rootSkill = files.find((file) => file.path.toLowerCase() === 'skill.md')
336
+ if (rootSkill && !parseSkillMarkdown(rootSkill.bytes.toString('utf8')).meta.name) {
337
+ const body = rootSkill.bytes.toString('utf8')
338
+ const slug = slugifySkillName(packageName.replace(/^@/, '').replace('/', '-'))
339
+ rootSkill.bytes = Buffer.from(`---\nname: ${slug}\n---\n\n${body}`)
340
+ }
341
+ return await installFiles(files, `npm:${value}`)
342
+ } finally {
343
+ await rm(work, { recursive: true, force: true }).catch(() => undefined)
344
+ }
345
+ }
346
+
347
+ export async function removeSkill(name) {
348
+ assertName(name)
349
+ await serial(async () => {
350
+ await rm(path.join(SKILLS_DIR, name), { recursive: true, force: true })
351
+ const state = await readState()
352
+ await writeState({ ...state, disabled: state.disabled.filter((item) => item !== name) })
353
+ })
354
+ }
355
+
356
+ export async function setSkillEnabled(name, enabled) {
357
+ assertName(name)
358
+ await serial(async () => {
359
+ const state = await readState()
360
+ const disabled = new Set(state.disabled)
361
+ if (enabled) disabled.delete(name)
362
+ else disabled.add(name)
363
+ await writeState({ ...state, disabled: [...disabled] })
364
+ })
365
+ }
366
+
367
+ /** Load a skill for the model: SKILL.md (with the frontmatter removed) or one bundled text file. */
368
+ export async function readSkill(name, file) {
369
+ assertName(name)
370
+ const dir = path.join(SKILLS_DIR, name)
371
+ let raw
372
+ try {
373
+ raw = await readFile(path.join(dir, 'SKILL.md'), 'utf8')
99
374
  } catch {
100
- throw new HttpError(502, 'That package is not a readable npm tarball.', 'skill_download_failed')
101
- }
102
- // Strip the leading "package/" folder npm adds.
103
- const normalised = new Map()
104
- for (const [path, content] of files) {
105
- const clean = path.replace(/^package\//, '').replace(/\\/g, '/')
106
- if (clean && !clean.includes('..')) normalised.set(clean, content)
107
- }
108
- const skillPath = skillFile(normalised)
109
- if (!skillPath) throw new HttpError(400, 'That package has no SKILL.md, so it is not a skill.', 'skill_missing_manifest')
110
- const root = skillPath.includes('/') ? skillPath.slice(0, skillPath.lastIndexOf('/') + 1) : ''
111
- const scoped = new Map()
112
- for (const [path, content] of normalised) {
113
- if (root && !path.startsWith(root)) continue
114
- scoped.set(root ? path.slice(root.length) : path, content)
115
- }
116
- const parsed = parseSkill(normalised.get(skillPath), pkg)
375
+ throw new HttpError(404, `Skill "${name}" is not installed.`, 'skill_not_found')
376
+ }
377
+ const { disabled } = await readState()
378
+ if (disabled.includes(name)) throw new HttpError(403, `Skill "${name}" is turned off in Settings → Skills.`, 'skill_disabled')
379
+
380
+ if (typeof file === 'string' && file.trim() && file.trim().toLowerCase() !== 'skill.md') {
381
+ const relative = safeRelative(file)
382
+ if (!relative) throw new HttpError(400, 'Invalid file path.', 'invalid_skill_file')
383
+ const target = path.join(dir, ...relative.split('/'))
384
+ if (!target.startsWith(`${dir}${path.sep}`)) throw new HttpError(400, 'Invalid file path.', 'invalid_skill_file')
385
+ let info
386
+ try {
387
+ info = await stat(target)
388
+ } catch {
389
+ throw new HttpError(404, `File "${relative}" is not in skill "${name}".`, 'skill_file_not_found')
390
+ }
391
+ if (!info.isFile()) throw new HttpError(400, `"${relative}" is not a file.`, 'invalid_skill_file')
392
+ if (!TEXT_EXT.test(relative)) {
393
+ return { name, file: relative, directory: dir, content: `(binary or unknown file type, ${info.size} bytes — use it from a shell command at ${target})` }
394
+ }
395
+ const content = await readFile(target, 'utf8')
396
+ return {
397
+ name,
398
+ file: relative,
399
+ directory: dir,
400
+ content: content.length > MAX_READ_BYTES ? `${content.slice(0, MAX_READ_BYTES)}\n… truncated` : content,
401
+ }
402
+ }
403
+
404
+ const { body } = parseSkillMarkdown(raw)
405
+ const files = (await walk(dir)).filter((item) => item.path !== 'SKILL.md')
117
406
  return {
118
- id: `skill_${slug(pkg)}`,
119
- name: parsed.name,
120
- description: parsed.description,
121
- instructions: parsed.instructions,
122
- enabled: true,
123
- source: 'npm',
124
- package: pkg,
125
- version,
126
- files: Object.fromEntries(scoped),
407
+ name,
408
+ file: 'SKILL.md',
409
+ directory: dir,
410
+ content: body.length > MAX_READ_BYTES ? `${body.slice(0, MAX_READ_BYTES)}\n… truncated` : body,
411
+ files: files.map((item) => item.path),
127
412
  }
128
413
  }
@@ -5,7 +5,6 @@ import {
5
5
  chmod,
6
6
  lstat,
7
7
  mkdir,
8
- mkdtemp,
9
8
  readdir,
10
9
  readFile,
11
10
  realpath,
@@ -15,7 +14,6 @@ import {
15
14
  writeFile,
16
15
  } from 'node:fs/promises'
17
16
  import { isIP } from 'node:net'
18
- import os from 'node:os'
19
17
  import path from 'node:path'
20
18
  import { HttpError } from './http.mjs'
21
19
  import { loadState, loadSecretsSafe } from './store.mjs'
@@ -973,39 +971,6 @@ export async function checkpointSummary(id) {
973
971
  return checkpoint ? describeCheckpoint(checkpoint) : null
974
972
  }
975
973
 
976
- /**
977
- * Skills are uploaded as text files. Materialise them in a throwaway folder,
978
- * run one command there, and delete the folder so nothing leaks between calls.
979
- */
980
- async function runSkillScript({ files, command }) {
981
- if (!files || typeof files !== 'object') throw new HttpError(400, 'The skill has no files.', 'bad_skill')
982
- const entries = Object.entries(files).filter(
983
- (entry) => typeof entry[1] === 'string' && typeof entry[0] === 'string' && entry[0].trim(),
984
- )
985
- if (entries.length === 0) throw new HttpError(400, 'The skill has no files.', 'bad_skill')
986
- if (entries.length > 200) throw new HttpError(400, 'A skill may ship at most 200 files.', 'skill_too_large')
987
- const root = await mkdtemp(path.join(os.tmpdir(), 'opensol-skill-'))
988
- try {
989
- for (const [relative, content] of entries) {
990
- const clean = relative.replace(/\\/g, '/').replace(/^\/+/, '')
991
- const absolute = path.resolve(root, clean)
992
- if (!absolute.startsWith(`${root}${path.sep}`)) {
993
- throw new HttpError(400, `Unsafe path in skill files: ${relative}`, 'bad_skill')
994
- }
995
- await mkdir(path.dirname(absolute), { recursive: true })
996
- await writeFile(absolute, content.slice(0, 512 * 1024))
997
- }
998
- const result = await runProcess('/bin/bash', ['-lc', String(command ?? '')], {
999
- cwd: root,
1000
- timeoutMs: 120_000,
1001
- env: { OPENSOL_SKILL_ROOT: root },
1002
- })
1003
- return result
1004
- } finally {
1005
- await rm(root, { recursive: true, force: true }).catch(() => undefined)
1006
- }
1007
- }
1008
-
1009
974
  export async function executeTool(name, args = {}, { mode = 'build', checkpoint = '' } = {}) {
1010
975
  const input = args && typeof args === 'object' ? args : {}
1011
976
  // The prompt promises the daemon enforces plan mode, so enforce it here and not only in the UI.
@@ -1016,9 +981,6 @@ export async function executeTool(name, args = {}, { mode = 'build', checkpoint
1016
981
  'plan_readonly',
1017
982
  )
1018
983
  }
1019
- if (mode === 'plan' && name === 'skill_run') {
1020
- throw new HttpError(403, 'Plan mode is read-only. Skill scripts may change the machine.', 'plan_readonly')
1021
- }
1022
984
  if (checkpoint && FILE_MUTATIONS.has(name)) {
1023
985
  try {
1024
986
  const { root, absolute, relative } = await resolveWorkspacePath(String(input.path ?? ''), { allowRoot: false })
@@ -1055,8 +1017,6 @@ export async function executeTool(name, args = {}, { mode = 'build', checkpoint
1055
1017
  return readUrl(String(input.url ?? ''))
1056
1018
  case 'git':
1057
1019
  return runGit(String(input.action ?? 'status'), input, { mode })
1058
- case 'skill_run':
1059
- return runSkillScript({ files: input.files, command: input.command })
1060
1020
  default:
1061
1021
  throw new HttpError(400, `Unknown tool: ${name}`, 'unknown_tool')
1062
1022
  }
@@ -190,11 +190,7 @@ export async function streamOpenAIResponses(request: AdapterRequest): Promise<Ad
190
190
  const done = itemRecord(event.response)
191
191
  const value = itemRecord(done.usage)
192
192
  if (typeof value.input_tokens === 'number' || typeof value.output_tokens === 'number') {
193
- usage = {
194
- input: Number(value.input_tokens) || undefined,
195
- output: Number(value.output_tokens) || undefined,
196
- cost: typeof value.cost === 'number' ? value.cost : undefined,
197
- }
193
+ usage = { input: Number(value.input_tokens) || undefined, output: Number(value.output_tokens) || undefined }
198
194
  handlers.onUsage(usage)
199
195
  }
200
196
  }
@@ -95,10 +95,8 @@ export async function streamOpenAI(request: AdapterRequest): Promise<AdapterResu
95
95
  if (settings.provider === 'openai' || settings.provider === 'openrouter') {
96
96
  body.stream_options = { include_usage: true }
97
97
  }
98
- if (settings.provider === 'openrouter') {
99
- // Ask OpenRouter to include the credit cost in the final usage chunk.
100
- body.usage = { include: true }
101
- }
98
+ // OpenRouter bills in credits and returns the exact cost when asked.
99
+ if (settings.provider === 'openrouter') body.usage = { include: true }
102
100
  if (usesCompletionTokens(settings.provider, modelId)) {
103
101
  body.max_completion_tokens = settings.maxTokens
104
102
  } else {
@@ -138,16 +136,11 @@ export async function streamOpenAI(request: AdapterRequest): Promise<AdapterResu
138
136
  }
139
137
 
140
138
  if (payload.usage && typeof payload.usage === 'object') {
141
- const value = payload.usage as {
142
- prompt_tokens?: number
143
- completion_tokens?: number
144
- cost?: number
145
- total_cost?: number
146
- }
139
+ const value = payload.usage as { prompt_tokens?: number; completion_tokens?: number; cost?: number }
147
140
  usage = {
148
141
  input: value.prompt_tokens,
149
142
  output: value.completion_tokens,
150
- cost: typeof value.cost === 'number' ? value.cost : typeof value.total_cost === 'number' ? value.total_cost : undefined,
143
+ cost: typeof value.cost === 'number' && Number.isFinite(value.cost) ? value.cost : undefined,
151
144
  }
152
145
  handlers.onUsage(usage)
153
146
  }