@bibobonking/opensol 3.1.2 → 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.
- package/CHANGELOG.md +1 -1
- package/dist/assets/index-DaJcH9ts.css +1 -0
- package/dist/assets/index-hs0KWMP6.js +288 -0
- package/dist/favicon.svg +11 -5
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/public/favicon.svg +11 -5
- package/server/daemon.mjs +42 -4
- package/server/lib/deploy.mjs +78 -7
- package/server/lib/skills.mjs +128 -0
- package/server/lib/tools.mjs +40 -0
- package/src/agent/adapters/openai-responses.ts +5 -1
- package/src/agent/adapters/openai.ts +15 -2
- package/src/agent/prompt.ts +4 -7
- package/src/agent/tools.ts +1 -0
- package/src/components/DeployModal.tsx +77 -15
- package/src/components/FileIcon.tsx +108 -0
- package/src/components/HomeDashboard.tsx +9 -2
- package/src/components/Logo.tsx +11 -5
- package/src/components/ModelDeck.tsx +12 -2
- package/src/components/SettingsModal.tsx +144 -199
- package/src/components/Sidebar.tsx +4 -5
- package/src/components/SystemPromptModal.tsx +168 -0
- package/src/components/TopBar.tsx +19 -0
- package/src/components/WorkspacePanel.tsx +10 -8
- package/src/core/kernel.ts +2 -0
- package/src/core/plugins/tools/skill.ts +101 -0
- package/src/core/services/agent-loop.ts +10 -22
- package/src/core/services/system-prompt.ts +15 -13
- package/src/core/services/tools.ts +1 -0
- package/src/lib/daemon.ts +19 -0
- package/src/lib/permissions.ts +1 -1
- package/src/lib/skills.test.mjs +22 -0
- package/src/lib/skills.ts +93 -0
- package/src/lib/usage.ts +12 -0
- package/src/store/chats.ts +1 -0
- package/src/store/settings.ts +21 -9
- package/src/types.ts +14 -1
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/assets/index-CeN54A2N.css +0 -1
- package/dist/assets/index-fhIO9a7_.js +0 -318
- 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="#
|
|
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
|
-
<
|
|
12
|
-
|
|
13
|
-
<path d="
|
|
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-
|
|
35
|
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
|
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
package/public/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="#
|
|
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
|
-
<
|
|
12
|
-
|
|
13
|
-
<path d="
|
|
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
|
@@ -69,7 +69,8 @@ import {
|
|
|
69
69
|
workspaceRoot,
|
|
70
70
|
writeWorkspaceFile,
|
|
71
71
|
} from './lib/tools.mjs'
|
|
72
|
-
import { deployToolStatus, runDeploy } from './lib/deploy.mjs'
|
|
72
|
+
import { deployToolStatus, inspectDeployProject, runDeploy } from './lib/deploy.mjs'
|
|
73
|
+
import { installSkillFromNpm } from './lib/skills.mjs'
|
|
73
74
|
import {
|
|
74
75
|
getPluginArchive,
|
|
75
76
|
installPlugin,
|
|
@@ -201,6 +202,8 @@ const TOOL_NAMES = new Set([
|
|
|
201
202
|
'read_url',
|
|
202
203
|
'git',
|
|
203
204
|
'subagent',
|
|
205
|
+
'skill',
|
|
206
|
+
'skill_run',
|
|
204
207
|
])
|
|
205
208
|
const SEARCH_PROVIDERS = new Set(['duckduckgo', 'tavily', 'brave', 'serper'])
|
|
206
209
|
|
|
@@ -220,6 +223,16 @@ function sanitizeSettings(patch, current = {}) {
|
|
|
220
223
|
next.maxTokens = Math.max(256, Math.min(200_000, Math.round(source.maxTokens)))
|
|
221
224
|
}
|
|
222
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
|
+
}
|
|
223
236
|
if (typeof source.searchProvider === 'string' && SEARCH_PROVIDERS.has(source.searchProvider)) {
|
|
224
237
|
next.searchProvider = source.searchProvider
|
|
225
238
|
}
|
|
@@ -260,13 +273,24 @@ function sanitizeSettings(patch, current = {}) {
|
|
|
260
273
|
if (Array.isArray(source.skills)) {
|
|
261
274
|
next.skills = source.skills.slice(0, 60).map((skill) => {
|
|
262
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
|
+
}
|
|
263
284
|
return {
|
|
264
285
|
id: typeof item.id === 'string' ? item.id.slice(0, 120) : `skill-${randomBytes(4).toString('hex')}`,
|
|
265
286
|
name: sanitizeString(item.name, 120) ?? 'Skill',
|
|
266
287
|
description: sanitizeString(item.description, 600) ?? '',
|
|
267
|
-
instructions: sanitizeString(item.instructions,
|
|
268
|
-
enabled: item.enabled
|
|
269
|
-
|
|
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,
|
|
270
294
|
}
|
|
271
295
|
})
|
|
272
296
|
}
|
|
@@ -284,6 +308,7 @@ function sanitizeChats(payload) {
|
|
|
284
308
|
...raw,
|
|
285
309
|
id: raw.id.slice(0, 120),
|
|
286
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,
|
|
287
312
|
// Keep the newest messages: slicing from the start dropped the latest replies.
|
|
288
313
|
messages: raw.messages.slice(-2000),
|
|
289
314
|
updatedAt: typeof raw.updatedAt === 'number' ? raw.updatedAt : Date.now(),
|
|
@@ -777,11 +802,24 @@ async function route(req, res) {
|
|
|
777
802
|
return
|
|
778
803
|
}
|
|
779
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
|
+
|
|
780
811
|
if (pathname === '/api/deploy' && method === 'POST') {
|
|
781
812
|
await handleDeploy(req, res)
|
|
782
813
|
return
|
|
783
814
|
}
|
|
784
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
|
+
|
|
785
823
|
if (pathname === '/api/llm' && method === 'POST') {
|
|
786
824
|
await handleLlm(req, res)
|
|
787
825
|
return
|
package/server/lib/deploy.mjs
CHANGED
|
@@ -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 = '
|
|
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(
|
|
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)
|
|
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())
|
|
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
|
+
}
|
package/server/lib/tools.mjs
CHANGED
|
@@ -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 = {
|
|
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 {
|
|
138
|
-
|
|
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
|
|
package/src/agent/prompt.ts
CHANGED
|
@@ -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
|
-
'##
|
|
40
|
-
'
|
|
41
|
-
...skills.map(
|
|
42
|
-
|
|
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
|
|