@bibobonking/opensol 3.1.1 → 3.1.3

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 (42) hide show
  1. package/CHANGELOG.md +1 -1
  2. package/dist/assets/index-DaJcH9ts.css +1 -0
  3. package/dist/assets/index-hs0KWMP6.js +288 -0
  4. package/dist/favicon.svg +11 -5
  5. package/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/public/favicon.svg +11 -5
  8. package/server/daemon.mjs +44 -5
  9. package/server/lib/deploy.mjs +78 -7
  10. package/server/lib/skills.mjs +128 -0
  11. package/server/lib/tools.mjs +40 -0
  12. package/src/agent/adapters/openai-responses.ts +5 -1
  13. package/src/agent/adapters/openai.ts +15 -2
  14. package/src/agent/prompt.ts +4 -7
  15. package/src/agent/tools.ts +1 -0
  16. package/src/components/DeployModal.tsx +77 -15
  17. package/src/components/FileIcon.tsx +108 -0
  18. package/src/components/HomeDashboard.tsx +9 -2
  19. package/src/components/Logo.tsx +11 -5
  20. package/src/components/ModelDeck.tsx +12 -2
  21. package/src/components/SettingsModal.tsx +144 -199
  22. package/src/components/Sidebar.tsx +4 -5
  23. package/src/components/SystemPromptModal.tsx +168 -0
  24. package/src/components/TopBar.tsx +19 -0
  25. package/src/components/WorkspacePanel.tsx +10 -8
  26. package/src/core/kernel.ts +2 -0
  27. package/src/core/plugins/tools/skill.ts +101 -0
  28. package/src/core/services/agent-loop.ts +10 -22
  29. package/src/core/services/system-prompt.ts +15 -13
  30. package/src/core/services/tools.ts +1 -0
  31. package/src/lib/daemon.ts +19 -0
  32. package/src/lib/permissions.ts +1 -1
  33. package/src/lib/skills.test.mjs +22 -0
  34. package/src/lib/skills.ts +93 -0
  35. package/src/lib/usage.ts +12 -0
  36. package/src/store/chats.ts +1 -0
  37. package/src/store/settings.ts +21 -9
  38. package/src/types.ts +14 -1
  39. package/tsconfig.tsbuildinfo +1 -1
  40. package/dist/assets/index-CeN54A2N.css +0 -1
  41. package/dist/assets/index-fhIO9a7_.js +0 -318
  42. package/src/data/skills.ts +0 -68
package/dist/favicon.svg CHANGED
@@ -1,15 +1,21 @@
1
1
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
2
2
  <defs>
3
3
  <radialGradient id="g" cx="32" cy="30" r="22" gradientUnits="userSpaceOnUse">
4
- <stop offset="0" stop-color="#ffd25a"/>
4
+ <stop offset="0" stop-color="#ffe27a"/>
5
5
  <stop offset="1" stop-color="#f59e0b"/>
6
6
  </radialGradient>
7
7
  </defs>
8
8
  <rect width="64" height="64" rx="16" fill="#141413"/>
9
9
  <rect x=".75" y=".75" width="62.5" height="62.5" rx="15.25" stroke="#fff" stroke-opacity=".08" stroke-width="1.5"/>
10
- <circle cx="32" cy="32" r="11" fill="url(#g)"/>
11
- <g stroke="#f59e0b" stroke-width="4" stroke-linecap="round">
12
- <path d="M32 9v6M32 49v6M9 32h6M49 32h6"/>
13
- <path d="M15.7 15.7l4.2 4.2M44.1 44.1l4.2 4.2M15.7 48.3l4.2-4.2M44.1 19.9l4.2-4.2" opacity=".55"/>
10
+ <circle cx="32" cy="32" r="10.2" fill="url(#g)"/>
11
+ <circle cx="32" cy="32" r="16.8" stroke="#f59e0b" stroke-opacity=".22" stroke-width="1.8"/>
12
+ <g stroke="#f59e0b" stroke-width="4.2" stroke-linecap="round">
13
+ <path d="M32 8.8a23.2 23.2 0 0 1 19.4 11"/>
14
+ <path d="M55.2 32a23.2 23.2 0 0 1-11 19.4" opacity=".6"/>
15
+ <path d="M32 55.2a23.2 23.2 0 0 1-19.4-11" opacity=".32"/>
16
+ <path d="M8.8 32a23.2 23.2 0 0 1 11-19.4" opacity=".8"/>
17
+ </g>
18
+ <g stroke="#f59e0b" stroke-width="3.4" stroke-linecap="round">
19
+ <path d="M32 3v4.4M61 32h-4.4M32 61v-4.4M3 32h4.4" opacity=".9"/>
14
20
  </g>
15
21
  </svg>
package/dist/index.html CHANGED
@@ -31,8 +31,8 @@
31
31
  if (DARK.includes(theme)) root.classList.add('dark')
32
32
  } catch {}
33
33
  </script>
34
- <script type="module" crossorigin src="./assets/index-fhIO9a7_.js"></script>
35
- <link rel="stylesheet" crossorigin href="./assets/index-CeN54A2N.css">
34
+ <script type="module" crossorigin src="./assets/index-hs0KWMP6.js"></script>
35
+ <link rel="stylesheet" crossorigin href="./assets/index-DaJcH9ts.css">
36
36
  </head>
37
37
  <body>
38
38
  <div id="root"><div class="splash" id="boot-splash" aria-hidden="true">
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@bibobonking/opensol",
3
3
  "private": false,
4
- "version": "3.1.1",
4
+ "version": "3.1.3",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "opensol": "./server/opensol.mjs"
@@ -1,15 +1,21 @@
1
1
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
2
2
  <defs>
3
3
  <radialGradient id="g" cx="32" cy="30" r="22" gradientUnits="userSpaceOnUse">
4
- <stop offset="0" stop-color="#ffd25a"/>
4
+ <stop offset="0" stop-color="#ffe27a"/>
5
5
  <stop offset="1" stop-color="#f59e0b"/>
6
6
  </radialGradient>
7
7
  </defs>
8
8
  <rect width="64" height="64" rx="16" fill="#141413"/>
9
9
  <rect x=".75" y=".75" width="62.5" height="62.5" rx="15.25" stroke="#fff" stroke-opacity=".08" stroke-width="1.5"/>
10
- <circle cx="32" cy="32" r="11" fill="url(#g)"/>
11
- <g stroke="#f59e0b" stroke-width="4" stroke-linecap="round">
12
- <path d="M32 9v6M32 49v6M9 32h6M49 32h6"/>
13
- <path d="M15.7 15.7l4.2 4.2M44.1 44.1l4.2 4.2M15.7 48.3l4.2-4.2M44.1 19.9l4.2-4.2" opacity=".55"/>
10
+ <circle cx="32" cy="32" r="10.2" fill="url(#g)"/>
11
+ <circle cx="32" cy="32" r="16.8" stroke="#f59e0b" stroke-opacity=".22" stroke-width="1.8"/>
12
+ <g stroke="#f59e0b" stroke-width="4.2" stroke-linecap="round">
13
+ <path d="M32 8.8a23.2 23.2 0 0 1 19.4 11"/>
14
+ <path d="M55.2 32a23.2 23.2 0 0 1-11 19.4" opacity=".6"/>
15
+ <path d="M32 55.2a23.2 23.2 0 0 1-19.4-11" opacity=".32"/>
16
+ <path d="M8.8 32a23.2 23.2 0 0 1 11-19.4" opacity=".8"/>
17
+ </g>
18
+ <g stroke="#f59e0b" stroke-width="3.4" stroke-linecap="round">
19
+ <path d="M32 3v4.4M61 32h-4.4M32 61v-4.4M3 32h4.4" opacity=".9"/>
14
20
  </g>
15
21
  </svg>
package/server/daemon.mjs CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  dataPaths,
25
25
  forgetWorkspace,
26
26
  getMcpServers,
27
+ loadSecrets,
27
28
  loadSecretsSafe,
28
29
  loadState,
29
30
  migrateLegacy,
@@ -68,7 +69,8 @@ import {
68
69
  workspaceRoot,
69
70
  writeWorkspaceFile,
70
71
  } from './lib/tools.mjs'
71
- import { deployToolStatus, runDeploy } from './lib/deploy.mjs'
72
+ import { deployToolStatus, inspectDeployProject, runDeploy } from './lib/deploy.mjs'
73
+ import { installSkillFromNpm } from './lib/skills.mjs'
72
74
  import {
73
75
  getPluginArchive,
74
76
  installPlugin,
@@ -83,7 +85,7 @@ import { mcp, sanitizeServerConfig } from './lib/mcp.mjs'
83
85
 
84
86
  const HERE = path.dirname(fileURLToPath(import.meta.url))
85
87
  const DIST = path.resolve(HERE, '..', 'dist')
86
- const VERSION = '3.1.0'
88
+ const VERSION = '3.1.1'
87
89
  const SESSION_COOKIE = 'maxcode_session'
88
90
  const CSRF_HEADER = 'x-maxcode-csrf'
89
91
  const SESSION_TTL = 14 * 24 * 60 * 60 * 1000
@@ -200,6 +202,8 @@ const TOOL_NAMES = new Set([
200
202
  'read_url',
201
203
  'git',
202
204
  'subagent',
205
+ 'skill',
206
+ 'skill_run',
203
207
  ])
204
208
  const SEARCH_PROVIDERS = new Set(['duckduckgo', 'tavily', 'brave', 'serper'])
205
209
 
@@ -219,6 +223,16 @@ function sanitizeSettings(patch, current = {}) {
219
223
  next.maxTokens = Math.max(256, Math.min(200_000, Math.round(source.maxTokens)))
220
224
  }
221
225
  if (typeof source.systemPrompt === 'string') next.systemPrompt = source.systemPrompt.slice(0, 60_000)
226
+ if (source.workspacePrompts && typeof source.workspacePrompts === 'object') {
227
+ const prompts = { ...(current.workspacePrompts ?? {}) }
228
+ for (const [root, value] of Object.entries(source.workspacePrompts)) {
229
+ if (!root.startsWith('/') || root.length > 4096) continue
230
+ if (typeof value !== 'string') continue
231
+ if (value.trim()) prompts[root] = value.slice(0, 60_000)
232
+ else delete prompts[root]
233
+ }
234
+ next.workspacePrompts = prompts
235
+ }
222
236
  if (typeof source.searchProvider === 'string' && SEARCH_PROVIDERS.has(source.searchProvider)) {
223
237
  next.searchProvider = source.searchProvider
224
238
  }
@@ -259,13 +273,24 @@ function sanitizeSettings(patch, current = {}) {
259
273
  if (Array.isArray(source.skills)) {
260
274
  next.skills = source.skills.slice(0, 60).map((skill) => {
261
275
  const item = skill && typeof skill === 'object' ? skill : {}
276
+ const files = {}
277
+ if (item.files && typeof item.files === 'object') {
278
+ for (const [path, content] of Object.entries(item.files).slice(0, 200)) {
279
+ if (typeof path === 'string' && typeof content === 'string' && path.length <= 300) {
280
+ files[path] = content.slice(0, 512 * 1024)
281
+ }
282
+ }
283
+ }
262
284
  return {
263
285
  id: typeof item.id === 'string' ? item.id.slice(0, 120) : `skill-${randomBytes(4).toString('hex')}`,
264
286
  name: sanitizeString(item.name, 120) ?? 'Skill',
265
287
  description: sanitizeString(item.description, 600) ?? '',
266
- instructions: sanitizeString(item.instructions, 20_000) ?? '',
267
- enabled: item.enabled === true,
268
- builtIn: item.builtIn === true,
288
+ instructions: sanitizeString(item.instructions, 60_000) ?? '',
289
+ enabled: item.enabled !== false,
290
+ source: item.source === 'npm' ? 'npm' : item.source === 'folder' ? 'folder' : undefined,
291
+ package: sanitizeString(item.package, 200),
292
+ version: sanitizeString(item.version, 60),
293
+ files: Object.keys(files).length > 0 ? files : undefined,
269
294
  }
270
295
  })
271
296
  }
@@ -283,6 +308,7 @@ function sanitizeChats(payload) {
283
308
  ...raw,
284
309
  id: raw.id.slice(0, 120),
285
310
  title: typeof raw.title === 'string' ? raw.title.slice(0, 300) : 'Untitled',
311
+ systemPrompt: typeof raw.systemPrompt === 'string' ? raw.systemPrompt.slice(0, 60_000) : undefined,
286
312
  // Keep the newest messages: slicing from the start dropped the latest replies.
287
313
  messages: raw.messages.slice(-2000),
288
314
  updatedAt: typeof raw.updatedAt === 'number' ? raw.updatedAt : Date.now(),
@@ -776,11 +802,24 @@ async function route(req, res) {
776
802
  return
777
803
  }
778
804
 
805
+ if (pathname === '/api/deploy/inspect' && method === 'POST') {
806
+ const body = await readJson(req)
807
+ json(res, 200, await inspectDeployProject(String(body.path ?? '')))
808
+ return
809
+ }
810
+
779
811
  if (pathname === '/api/deploy' && method === 'POST') {
780
812
  await handleDeploy(req, res)
781
813
  return
782
814
  }
783
815
 
816
+ if (pathname === '/api/skills/install' && method === 'POST') {
817
+ const body = await readJson(req)
818
+ const skill = await installSkillFromNpm(String(body.package ?? ''))
819
+ json(res, 200, { skill })
820
+ return
821
+ }
822
+
784
823
  if (pathname === '/api/llm' && method === 'POST') {
785
824
  await handleLlm(req, res)
786
825
  return
@@ -1,5 +1,5 @@
1
1
  import { spawn } from 'node:child_process'
2
- import { realpath, stat } from 'node:fs/promises'
2
+ import { readFile, realpath, stat } from 'node:fs/promises'
3
3
  import os from 'node:os'
4
4
  import path from 'node:path'
5
5
  import { HttpError } from './http.mjs'
@@ -13,7 +13,7 @@ const CLIS = {
13
13
  cloudflare: { bin: 'wrangler', pkg: 'wrangler@latest', label: 'Wrangler' },
14
14
  }
15
15
 
16
- function slugify(value, fallback = 'maxcode-site') {
16
+ function slugify(value, fallback = 'opensol-site') {
17
17
  const slug = String(value ?? '')
18
18
  .toLowerCase()
19
19
  .replace(/[^a-z0-9-]+/g, '-')
@@ -171,10 +171,68 @@ async function fetchJson(url, options = {}) {
171
171
 
172
172
  function firstUrl(text) {
173
173
  const matches = text.match(/https:\/\/[^\s"'<>)\]]+/g) ?? []
174
- const filtered = matches.filter((url) => !/vercel\.com\/(docs|cli|pricing)/i.test(url))
174
+ const filtered = matches.filter(
175
+ (url) =>
176
+ !/vercel\.com\/(docs|cli|pricing)/i.test(url) &&
177
+ !/netlify\.com\/(docs|pricing|app)/i.test(url) &&
178
+ !/developers\.cloudflare\.com/i.test(url) &&
179
+ !/wrangler/i.test(url) &&
180
+ !/github\.com/i.test(url),
181
+ )
175
182
  return filtered[filtered.length - 1] ?? ''
176
183
  }
177
184
 
185
+ const OUTPUT_CANDIDATES = ['dist', 'build', 'out', '.output/public', 'public']
186
+ const FRAMEWORKS = [
187
+ { dep: 'next', name: 'Next.js', output: 'out' },
188
+ { dep: 'nuxt', name: 'Nuxt', output: '.output/public' },
189
+ { dep: 'astro', name: 'Astro', output: 'dist' },
190
+ { dep: '@sveltejs/kit', name: 'SvelteKit', output: 'build' },
191
+ { dep: 'vite', name: 'Vite', output: 'dist' },
192
+ { dep: 'react-scripts', name: 'Create React App', output: 'build' },
193
+ ]
194
+
195
+ async function packageManager(root) {
196
+ for (const [file, command] of [
197
+ ['pnpm-lock.yaml', 'pnpm'],
198
+ ['yarn.lock', 'yarn'],
199
+ ['bun.lockb', 'bun'],
200
+ ['bun.lock', 'bun'],
201
+ ['package-lock.json', 'npm'],
202
+ ]) {
203
+ if (await stat(path.join(root, file)).catch(() => null)) return command
204
+ }
205
+ return 'npm'
206
+ }
207
+
208
+ /** Read package.json and existing build output so the panel can fill the form correctly. */
209
+ export async function inspectDeployProject(rawPath) {
210
+ const absolute = await resolveDeployPath(rawPath)
211
+ await ensureDir(absolute)
212
+ const pkg = await readFile(path.join(absolute, 'package.json'), 'utf8')
213
+ .then((text) => JSON.parse(text))
214
+ .catch(() => null)
215
+ const deps = { ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) }
216
+ const framework = FRAMEWORKS.find((entry) => deps[entry.dep])
217
+ const found = []
218
+ for (const candidate of OUTPUT_CANDIDATES) {
219
+ const info = await stat(path.join(absolute, candidate)).catch(() => null)
220
+ if (info?.isDirectory()) found.push(candidate)
221
+ }
222
+ const manager = await packageManager(absolute)
223
+ const script = typeof pkg?.scripts?.build === 'string' ? pkg.scripts.build : ''
224
+ const buildCommand = script ? (manager === 'npm' ? 'npm run build' : `${manager} build`) : ''
225
+ return {
226
+ path: absolute,
227
+ name: slugify(path.basename(absolute), ''),
228
+ hasPackageJson: Boolean(pkg),
229
+ framework: framework?.name ?? (pkg ? 'Node project' : 'Static folder'),
230
+ buildCommand,
231
+ outputDir: found[0] ?? framework?.output ?? '',
232
+ existingOutputs: found,
233
+ }
234
+ }
235
+
178
236
  async function resolveCli(provider) {
179
237
  const def = CLIS[provider]
180
238
  if (!def) throw new HttpError(400, `Unknown deploy provider: ${provider}`, 'bad_deploy_provider')
@@ -208,9 +266,16 @@ async function buildIfNeeded(cwd, buildCommand, onLog, signal) {
208
266
  }
209
267
  }
210
268
 
211
- function outputDirFor(cwd, outputDir) {
269
+ async function outputDirFor(cwd, outputDir) {
212
270
  const clean = typeof outputDir === 'string' ? outputDir.trim() : ''
213
- if (!clean) return cwd
271
+ if (!clean || clean === 'auto') {
272
+ // With no explicit choice, use a real build folder if one exists instead of dumping the whole project.
273
+ for (const candidate of OUTPUT_CANDIDATES) {
274
+ const found = await stat(path.join(cwd, candidate)).catch(() => null)
275
+ if (found?.isDirectory()) return path.join(cwd, candidate)
276
+ }
277
+ return cwd
278
+ }
214
279
  const resolved = path.resolve(cwd, clean)
215
280
  if (!resolved.startsWith(cwd.endsWith(path.sep) ? cwd : `${cwd}${path.sep}`) && resolved !== cwd) {
216
281
  throw new HttpError(400, 'The output folder must stay inside the deploy folder.', 'bad_output')
@@ -220,7 +285,13 @@ function outputDirFor(cwd, outputDir) {
220
285
 
221
286
  async function ensureDir(target) {
222
287
  const info = await stat(target).catch(() => null)
223
- if (!info?.isDirectory()) throw new HttpError(400, 'The folder to deploy does not exist.', 'bad_deploy_path')
288
+ if (!info?.isDirectory()) {
289
+ throw new HttpError(
290
+ 400,
291
+ `The folder "${path.basename(target)}" does not exist. Run a build command or choose the folder that actually contains the site.`,
292
+ 'bad_deploy_path',
293
+ )
294
+ }
224
295
  }
225
296
 
226
297
  /**
@@ -418,7 +489,7 @@ export async function runDeploy(input, { onLog = () => undefined, signal } = {})
418
489
  }
419
490
  const absolute = await resolveDeployPath(input?.path)
420
491
  await ensureDir(absolute)
421
- const targetDir = outputDirFor(absolute, input?.outputDir)
492
+ const targetDir = await outputDirFor(absolute, input?.outputDir)
422
493
  await buildIfNeeded(absolute, input?.buildCommand, onLog, signal)
423
494
  await ensureDir(targetDir)
424
495
 
@@ -0,0 +1,128 @@
1
+ import { gunzipSync } from 'node:zlib'
2
+ import { HttpError } from './http.mjs'
3
+
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
33
+ }
34
+
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] ?? ''
39
+ }
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)
44
+ 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()
50
+ }
51
+ body = text.slice(front[0].length)
52
+ }
53
+ const heading = /^#\s+(.+)$/m.exec(body)
54
+ const paragraph = body
55
+ .split(/\n\s*\n/)
56
+ .map((part) => part.trim())
57
+ .find((part) => part && !part.startsWith('#') && !part.startsWith('---'))
58
+ 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(),
62
+ }
63
+ }
64
+
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'
72
+ }
73
+
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')
78
+ }
79
+ const metaResponse = await fetch(`https://registry.npmjs.org/${registryName(pkg)}`, {
80
+ signal: AbortSignal.timeout(20_000),
81
+ })
82
+ if (!metaResponse.ok) {
83
+ throw new HttpError(404, `npm could not find "${pkg}".`, 'skill_not_found')
84
+ }
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')
90
+ }
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')
95
+
96
+ let files
97
+ try {
98
+ files = untar(gunzipSync(bytes))
99
+ } 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)
117
+ 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),
127
+ }
128
+ }
@@ -5,6 +5,7 @@ import {
5
5
  chmod,
6
6
  lstat,
7
7
  mkdir,
8
+ mkdtemp,
8
9
  readdir,
9
10
  readFile,
10
11
  realpath,
@@ -14,6 +15,7 @@ import {
14
15
  writeFile,
15
16
  } from 'node:fs/promises'
16
17
  import { isIP } from 'node:net'
18
+ import os from 'node:os'
17
19
  import path from 'node:path'
18
20
  import { HttpError } from './http.mjs'
19
21
  import { loadState, loadSecretsSafe } from './store.mjs'
@@ -971,6 +973,39 @@ export async function checkpointSummary(id) {
971
973
  return checkpoint ? describeCheckpoint(checkpoint) : null
972
974
  }
973
975
 
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
+
974
1009
  export async function executeTool(name, args = {}, { mode = 'build', checkpoint = '' } = {}) {
975
1010
  const input = args && typeof args === 'object' ? args : {}
976
1011
  // The prompt promises the daemon enforces plan mode, so enforce it here and not only in the UI.
@@ -981,6 +1016,9 @@ export async function executeTool(name, args = {}, { mode = 'build', checkpoint
981
1016
  'plan_readonly',
982
1017
  )
983
1018
  }
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
+ }
984
1022
  if (checkpoint && FILE_MUTATIONS.has(name)) {
985
1023
  try {
986
1024
  const { root, absolute, relative } = await resolveWorkspacePath(String(input.path ?? ''), { allowRoot: false })
@@ -1017,6 +1055,8 @@ export async function executeTool(name, args = {}, { mode = 'build', checkpoint
1017
1055
  return readUrl(String(input.url ?? ''))
1018
1056
  case 'git':
1019
1057
  return runGit(String(input.action ?? 'status'), input, { mode })
1058
+ case 'skill_run':
1059
+ return runSkillScript({ files: input.files, command: input.command })
1020
1060
  default:
1021
1061
  throw new HttpError(400, `Unknown tool: ${name}`, 'unknown_tool')
1022
1062
  }
@@ -190,7 +190,11 @@ 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 = { input: Number(value.input_tokens) || undefined, output: Number(value.output_tokens) || undefined }
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
+ }
194
198
  handlers.onUsage(usage)
195
199
  }
196
200
  }
@@ -95,6 +95,10 @@ 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
102
  if (usesCompletionTokens(settings.provider, modelId)) {
99
103
  body.max_completion_tokens = settings.maxTokens
100
104
  } else {
@@ -134,8 +138,17 @@ export async function streamOpenAI(request: AdapterRequest): Promise<AdapterResu
134
138
  }
135
139
 
136
140
  if (payload.usage && typeof payload.usage === 'object') {
137
- const value = payload.usage as { prompt_tokens?: number; completion_tokens?: number }
138
- usage = { input: value.prompt_tokens, output: value.completion_tokens }
141
+ const value = payload.usage as {
142
+ prompt_tokens?: number
143
+ completion_tokens?: number
144
+ cost?: number
145
+ total_cost?: number
146
+ }
147
+ usage = {
148
+ input: value.prompt_tokens,
149
+ output: value.completion_tokens,
150
+ cost: typeof value.cost === 'number' ? value.cost : typeof value.total_cost === 'number' ? value.total_cost : undefined,
151
+ }
139
152
  handlers.onUsage(usage)
140
153
  }
141
154
 
@@ -36,13 +36,10 @@ export function buildSystemPrompt(settings: AppSettings, mode: AgentMode = setti
36
36
  if (skills.length > 0) {
37
37
  chunks.push(
38
38
  [
39
- '## Loaded skills',
40
- 'Apply these skills when the task matches their purpose. They refine the working rules above; they never override safety, tool permissions or plan mode.',
41
- ...skills.map(
42
- (skill) =>
43
- `<skill name="${skill.name}">\nPurpose: ${skill.description}\n${skill.instructions.trim()}\n</skill>`,
44
- ),
45
- ].join('\n\n'),
39
+ '## Available skills',
40
+ 'Call the `skill` tool to load the full instructions (and run scripts from the skill folder) when a task matches one of these. Do not guess a skill’s contents.',
41
+ ...skills.map((skill) => `- ${skill.name} (${skill.id}): ${skill.description}`),
42
+ ].join('\n'),
46
43
  )
47
44
  }
48
45
 
@@ -519,6 +519,7 @@ export function toolLabel(name: ToolName): string {
519
519
  read_url: 'Read URL',
520
520
  git: 'Git',
521
521
  subagent: 'Subagent',
522
+ skill: 'Skill',
522
523
  }
523
524
  return labels[name as BuiltinToolName] ?? 'Tool'
524
525
  }