@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.
- package/CHANGELOG.md +10 -12
- package/desktop/src-tauri/icons/128x128.png +0 -0
- package/desktop/src-tauri/icons/128x128@2x.png +0 -0
- package/desktop/src-tauri/icons/32x32.png +0 -0
- package/desktop/src-tauri/icons/64x64.png +0 -0
- package/desktop/src-tauri/icons/Square107x107Logo.png +0 -0
- package/desktop/src-tauri/icons/Square142x142Logo.png +0 -0
- package/desktop/src-tauri/icons/Square150x150Logo.png +0 -0
- package/desktop/src-tauri/icons/Square284x284Logo.png +0 -0
- package/desktop/src-tauri/icons/Square30x30Logo.png +0 -0
- package/desktop/src-tauri/icons/Square310x310Logo.png +0 -0
- package/desktop/src-tauri/icons/Square44x44Logo.png +0 -0
- package/desktop/src-tauri/icons/Square71x71Logo.png +0 -0
- package/desktop/src-tauri/icons/Square89x89Logo.png +0 -0
- package/desktop/src-tauri/icons/StoreLogo.png +0 -0
- package/desktop/src-tauri/icons/icon.icns +0 -0
- package/desktop/src-tauri/icons/icon.ico +0 -0
- package/desktop/src-tauri/icons/icon.png +0 -0
- package/dist/assets/index-C0yvs6Vl.js +281 -0
- package/dist/assets/index-CxNPLBg7.css +1 -0
- package/dist/favicon.svg +18 -15
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/public/favicon.svg +18 -15
- package/server/daemon.mjs +66 -49
- package/server/lib/deploy.mjs +222 -121
- package/server/lib/providers.mjs +23 -1
- package/server/lib/skills.mjs +385 -100
- package/server/lib/tools.mjs +0 -40
- package/src/agent/adapters/openai-responses.ts +1 -5
- package/src/agent/adapters/openai.ts +4 -11
- package/src/agent/prompt.ts +7 -14
- package/src/agent/subagent.ts +3 -14
- package/src/agent/tools.ts +2 -0
- package/src/components/ChatView.tsx +2 -1
- package/src/components/Composer.tsx +2 -1
- package/src/components/ContextBar.tsx +10 -0
- package/src/components/DeployModal.tsx +266 -252
- package/src/components/FileIcon.tsx +391 -99
- package/src/components/HomeDashboard.tsx +5 -10
- package/src/components/Logo.tsx +9 -11
- package/src/components/ModelDeck.tsx +8 -11
- package/src/components/SettingsModal.tsx +160 -122
- package/src/components/Sidebar.tsx +4 -3
- package/src/components/SystemPromptButton.tsx +198 -0
- package/src/components/ToolCallCard.tsx +2 -0
- package/src/components/TopBar.tsx +2 -19
- package/src/components/WorkspacePanel.tsx +76 -93
- package/src/core/plugins/tools/skill.ts +19 -76
- package/src/core/services/agent-loop.ts +16 -31
- package/src/core/services/subagents.ts +2 -2
- package/src/core/services/system-prompt.ts +16 -23
- package/src/core/services/tools.ts +0 -1
- package/src/lib/boot.ts +2 -0
- package/src/lib/credits.ts +33 -0
- package/src/lib/daemon.ts +40 -19
- package/src/lib/permissions.ts +1 -1
- package/src/lib/retry.test.mjs +40 -12
- package/src/lib/retry.ts +41 -8
- package/src/lib/system-prompt.ts +23 -0
- package/src/lib/usage.ts +8 -13
- package/src/store/chats.ts +0 -1
- package/src/store/settings.ts +12 -44
- package/src/store/skills.ts +79 -0
- package/src/store/ui.ts +3 -1
- package/src/types.ts +30 -15
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/assets/index-B1pQC9Fe.js +0 -288
- package/dist/assets/index-qLr4aZVC.css +0 -1
- package/src/components/SystemPromptModal.tsx +0 -168
- package/src/lib/skills.test.mjs +0 -22
- package/src/lib/skills.ts +0 -93
package/server/lib/skills.mjs
CHANGED
|
@@ -1,128 +1,413 @@
|
|
|
1
|
-
|
|
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
|
|
5
|
-
const
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
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 =
|
|
46
|
-
if (
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
81
|
+
const firstParagraph = body
|
|
55
82
|
.split(/\n\s*\n/)
|
|
56
83
|
.map((part) => part.trim())
|
|
57
|
-
.find((part) => part && !part.startsWith('#')
|
|
84
|
+
.find((part) => part && !part.startsWith('#'))
|
|
58
85
|
return {
|
|
59
|
-
name: meta.name || heading?.[1]
|
|
60
|
-
description: (meta.description ||
|
|
61
|
-
|
|
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
|
|
66
|
-
|
|
67
|
-
.
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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 (
|
|
83
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
}
|
package/server/lib/tools.mjs
CHANGED
|
@@ -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
|
-
|
|
99
|
-
|
|
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'
|
|
143
|
+
cost: typeof value.cost === 'number' && Number.isFinite(value.cost) ? value.cost : undefined,
|
|
151
144
|
}
|
|
152
145
|
handlers.onUsage(usage)
|
|
153
146
|
}
|