@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.
- 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 +44 -5
- 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
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
FolderOpen,
|
|
3
3
|
ListChecks,
|
|
4
4
|
Menu,
|
|
5
|
+
MessageSquareCode,
|
|
5
6
|
Moon,
|
|
6
7
|
PanelLeftOpen,
|
|
7
8
|
Rocket,
|
|
@@ -19,6 +20,8 @@ import { cn } from '../lib/utils'
|
|
|
19
20
|
import { themeIsDark } from '../lib/themes'
|
|
20
21
|
import { LogoMark } from './Logo'
|
|
21
22
|
import { ContextBar } from './ContextBar'
|
|
23
|
+
import { SystemPromptModal } from './SystemPromptModal'
|
|
24
|
+
import { useState } from 'react'
|
|
22
25
|
|
|
23
26
|
export type ChatViewMode = 'chat' | 'trajectory'
|
|
24
27
|
|
|
@@ -44,7 +47,11 @@ export function TopBar({
|
|
|
44
47
|
const setWorkspaceOpen = useUi((state) => state.setWorkspaceOpen)
|
|
45
48
|
const setDeployOpen = useUi((state) => state.setDeployOpen)
|
|
46
49
|
const workspace = useWorkspace((state) => state.info)
|
|
50
|
+
const workspaceRoot = useWorkspace((state) => state.root)
|
|
47
51
|
const activeModel = useActiveModel()
|
|
52
|
+
const [promptOpen, setPromptOpen] = useState(false)
|
|
53
|
+
const root = session?.workspace || workspaceRoot || ''
|
|
54
|
+
const promptIsCustom = Boolean(session?.systemPrompt?.trim() || (root && settings.workspacePrompts[root]?.trim()))
|
|
48
55
|
|
|
49
56
|
// Go through the theme registry so Night Owl, Claude Code, Solar and plugin themes count as dark.
|
|
50
57
|
const isDark = themeIsDark(
|
|
@@ -126,6 +133,17 @@ export function TopBar({
|
|
|
126
133
|
<ContextBar messages={session?.messages ?? []} model={activeModel} sessionId={session?.id ?? null} />
|
|
127
134
|
</div>
|
|
128
135
|
)}
|
|
136
|
+
<button
|
|
137
|
+
type="button"
|
|
138
|
+
className={cn('icon-button relative hidden sm:inline-flex', promptIsCustom && 'text-accent')}
|
|
139
|
+
onClick={() => setPromptOpen(true)}
|
|
140
|
+
disabled={!session}
|
|
141
|
+
title={promptIsCustom ? 'Custom system prompt for this session or workspace' : 'Edit the system prompt for this session'}
|
|
142
|
+
aria-label="Edit system prompt"
|
|
143
|
+
>
|
|
144
|
+
<MessageSquareCode size={16} />
|
|
145
|
+
{promptIsCustom && <span className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-accent" />}
|
|
146
|
+
</button>
|
|
129
147
|
<button
|
|
130
148
|
type="button"
|
|
131
149
|
onClick={() => setWorkspaceOpen(!workspaceOpen)}
|
|
@@ -177,6 +195,7 @@ export function TopBar({
|
|
|
177
195
|
<Settings size={16} />
|
|
178
196
|
</button>
|
|
179
197
|
</div>
|
|
198
|
+
<SystemPromptModal session={session} open={promptOpen} onClose={() => setPromptOpen(false)} />
|
|
180
199
|
</header>
|
|
181
200
|
)
|
|
182
201
|
}
|
|
@@ -28,6 +28,7 @@ import { useWorkspace } from '../store/workspace'
|
|
|
28
28
|
import { PreviewPane } from './PreviewPane'
|
|
29
29
|
import { SessionsTab } from './SessionsTab'
|
|
30
30
|
import { useChats } from '../store/chats'
|
|
31
|
+
import { FileIcon } from './FileIcon'
|
|
31
32
|
import { Spinner } from './ui'
|
|
32
33
|
|
|
33
34
|
interface TreeNode {
|
|
@@ -333,11 +334,7 @@ export function WorkspacePanel() {
|
|
|
333
334
|
title={path}
|
|
334
335
|
>
|
|
335
336
|
<ChevronRight size={10} className={cn('shrink-0 text-faint transition-transform', !closed && 'rotate-90')} />
|
|
336
|
-
{closed
|
|
337
|
-
<Folder size={12} className="shrink-0 text-faint" />
|
|
338
|
-
) : (
|
|
339
|
-
<FolderOpen size={12} className="shrink-0 text-faint" />
|
|
340
|
-
)}
|
|
337
|
+
<FileIcon path={path} type="dir" open={!closed} size={13} />
|
|
341
338
|
<span className="min-w-0 flex-1 truncate">{node.entry.name}</span>
|
|
342
339
|
<span
|
|
343
340
|
role="button"
|
|
@@ -365,14 +362,19 @@ export function WorkspacePanel() {
|
|
|
365
362
|
type="button"
|
|
366
363
|
onClick={() => setActiveFile(path)}
|
|
367
364
|
className={cn(
|
|
368
|
-
'flex h-7 w-full items-center gap-1.5 rounded-md pr-1.5 text-left font-mono text-[10.5px] transition-colors',
|
|
365
|
+
'group flex h-7 w-full items-center gap-1.5 rounded-md pr-1.5 text-left font-mono text-[10.5px] transition-colors',
|
|
369
366
|
activeFile === path ? 'bg-accent-soft font-medium text-accent' : 'text-muted hover:bg-raised hover:text-ink',
|
|
370
367
|
)}
|
|
371
368
|
style={{ paddingLeft: 8 + depth * 13 + 10 }}
|
|
372
369
|
title={path}
|
|
373
370
|
>
|
|
374
|
-
<
|
|
371
|
+
<FileIcon path={path} size={12} className={cn(activeFile === path && 'text-accent')} />
|
|
375
372
|
<span className="min-w-0 flex-1 truncate">{node.entry.name}</span>
|
|
373
|
+
{node.entry.size > 0 && (
|
|
374
|
+
<span className="shrink-0 text-[8.5px] tabular-nums text-faint/70 opacity-0 transition-opacity group-hover:opacity-100">
|
|
375
|
+
{formatBytes(node.entry.size)}
|
|
376
|
+
</span>
|
|
377
|
+
)}
|
|
376
378
|
</button>
|
|
377
379
|
)
|
|
378
380
|
}
|
|
@@ -562,7 +564,7 @@ export function WorkspacePanel() {
|
|
|
562
564
|
: 'text-muted hover:bg-raised hover:text-ink',
|
|
563
565
|
)}
|
|
564
566
|
>
|
|
565
|
-
{entry.type === 'dir' ? <
|
|
567
|
+
{entry.type === 'dir' ? <FileIcon path={entry.path} type="dir" size={12} /> : <FileIcon path={entry.path} size={12} />}
|
|
566
568
|
<span className="truncate">{entry.path}</span>
|
|
567
569
|
</button>
|
|
568
570
|
))
|
package/src/core/kernel.ts
CHANGED
|
@@ -17,6 +17,7 @@ import * as toolGrep from './plugins/tools/grep'
|
|
|
17
17
|
import * as toolGit from './plugins/tools/git'
|
|
18
18
|
import * as toolWeb from './plugins/tools/web'
|
|
19
19
|
import * as toolSubagent from './plugins/tools/subagent'
|
|
20
|
+
import * as toolSkill from './plugins/tools/skill'
|
|
20
21
|
|
|
21
22
|
export function createAppKernel(): Context {
|
|
22
23
|
const ctx = new Context()
|
|
@@ -40,6 +41,7 @@ export function createAppKernel(): Context {
|
|
|
40
41
|
ctx.plugin(toolGit)
|
|
41
42
|
ctx.plugin(toolWeb)
|
|
42
43
|
ctx.plugin(toolSubagent)
|
|
44
|
+
ctx.plugin(toolSkill)
|
|
43
45
|
|
|
44
46
|
return ctx
|
|
45
47
|
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { Context } from 'cordis'
|
|
2
|
+
import { executeDaemonTool } from '../../../lib/daemon'
|
|
3
|
+
import type { Skill } from '../../../types'
|
|
4
|
+
import type { ToolResult } from '../../types'
|
|
5
|
+
|
|
6
|
+
function findSkill(skills: Skill[], name: string): Skill | undefined {
|
|
7
|
+
const clean = name.trim().toLowerCase()
|
|
8
|
+
return skills.find((skill) => skill.id.toLowerCase() === clean || skill.name.toLowerCase() === clean)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function text(args: Record<string, unknown>, key: string): string {
|
|
12
|
+
return typeof args[key] === 'string' ? String(args[key]).trim() : ''
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const name = 'tool-skill'
|
|
16
|
+
export const inject = ['tools']
|
|
17
|
+
|
|
18
|
+
export function apply(ctx: Context) {
|
|
19
|
+
const unregister = ctx.tools.register({
|
|
20
|
+
name: 'skill',
|
|
21
|
+
description:
|
|
22
|
+
'Work with installed skills. Use action "list" to see them, "read" to load a skill’s SKILL.md, and "run" to execute a command inside the skill folder.',
|
|
23
|
+
parameters: {
|
|
24
|
+
type: 'object',
|
|
25
|
+
properties: {
|
|
26
|
+
action: { type: 'string', enum: ['list', 'read', 'run'], description: 'What to do.' },
|
|
27
|
+
name: { type: 'string', description: 'Skill id or name. Required for read and run.' },
|
|
28
|
+
command: { type: 'string', description: 'Shell command to run inside the skill folder. Required for run.' },
|
|
29
|
+
},
|
|
30
|
+
required: ['action'],
|
|
31
|
+
additionalProperties: false,
|
|
32
|
+
},
|
|
33
|
+
allowedInMode: () => true,
|
|
34
|
+
async execute(args, exec): Promise<ToolResult> {
|
|
35
|
+
const skills = exec.settings.skills.filter((skill) => skill.enabled)
|
|
36
|
+
const action = text(args, 'action') || 'list'
|
|
37
|
+
|
|
38
|
+
if (action === 'list') {
|
|
39
|
+
if (skills.length === 0) return { output: 'No skills are installed.', isError: false }
|
|
40
|
+
return {
|
|
41
|
+
output: skills
|
|
42
|
+
.map((skill) => `${skill.name} (${skill.id})${skill.package ? ` · ${skill.package}` : ''}\n ${skill.description}`)
|
|
43
|
+
.join('\n'),
|
|
44
|
+
isError: false,
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const target = findSkill(skills, text(args, 'name'))
|
|
49
|
+
if (!target) {
|
|
50
|
+
return {
|
|
51
|
+
output: `No enabled skill matches "${text(args, 'name')}". Call action "list" to see the available skills.`,
|
|
52
|
+
isError: true,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (action === 'read') {
|
|
57
|
+
const files = Object.keys(target.files ?? {})
|
|
58
|
+
return {
|
|
59
|
+
output: [
|
|
60
|
+
`# ${target.name}`,
|
|
61
|
+
target.description,
|
|
62
|
+
target.package ? `Package: ${target.package}` : '',
|
|
63
|
+
files.length > 0 ? `Files:\n${files.map((file) => `- ${file}`).join('\n')}` : '',
|
|
64
|
+
'---',
|
|
65
|
+
target.instructions.trim() || 'This skill has no instructions.',
|
|
66
|
+
]
|
|
67
|
+
.filter(Boolean)
|
|
68
|
+
.join('\n\n'),
|
|
69
|
+
isError: false,
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (action === 'run') {
|
|
74
|
+
if (exec.mode === 'plan') {
|
|
75
|
+
return { output: 'Plan mode is read-only: skill scripts may change the machine, so they cannot run here.', isError: true }
|
|
76
|
+
}
|
|
77
|
+
const command = text(args, 'command')
|
|
78
|
+
if (!command) return { output: 'Pass the command to run inside the skill folder.', isError: true }
|
|
79
|
+
if (!target.files || Object.keys(target.files).length === 0) {
|
|
80
|
+
return { output: `"${target.name}" has no files to run. Install it from a folder or npm.`, isError: true }
|
|
81
|
+
}
|
|
82
|
+
const result = (await executeDaemonTool(
|
|
83
|
+
'skill_run',
|
|
84
|
+
{ name: target.id, files: target.files, command },
|
|
85
|
+
exec.mode,
|
|
86
|
+
exec.signal,
|
|
87
|
+
)).result as { stdout?: string; stderr?: string; code?: number }
|
|
88
|
+
const code = typeof result.code === 'number' ? result.code : 1
|
|
89
|
+
const body = `${result.stdout ?? ''}${result.stderr ?? ''}`.trim()
|
|
90
|
+
return {
|
|
91
|
+
output: body || `The skill command exited with code ${code} and printed nothing.`,
|
|
92
|
+
isError: code !== 0,
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { output: `Unknown skill action "${action}". Use list, read or run.`, isError: true }
|
|
97
|
+
},
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
return () => unregister()
|
|
101
|
+
}
|
|
@@ -4,6 +4,7 @@ import { activeModelId, useSettings } from '../../store/settings'
|
|
|
4
4
|
import { createId } from '../../store/chats'
|
|
5
5
|
import { findModel } from '../../store/models'
|
|
6
6
|
import { useRuntime } from '../../store/ui'
|
|
7
|
+
import { useWorkspace } from '../../store/workspace'
|
|
7
8
|
import { getProviderName } from '../../data/providers'
|
|
8
9
|
import { describeRequestError } from '../../lib/sse'
|
|
9
10
|
import type { AdapterResult } from '../../agent/adapters/shared'
|
|
@@ -11,7 +12,6 @@ import { createTurnQueue, lastUserMessageId } from '../../agent/queue'
|
|
|
11
12
|
import { resolveToolSetting } from '../mcp-tools'
|
|
12
13
|
import type { StepContext, StepResult, TurnResult } from '../types'
|
|
13
14
|
|
|
14
|
-
const MAX_ROUNDS = 16
|
|
15
15
|
const MAX_RETRIES = 2
|
|
16
16
|
/** Provider hiccups worth another try: overload, rate limits, 5xx and dropped connections. */
|
|
17
17
|
const RETRYABLE = /\b(429|500|502|503|504|529)\b|rate.?limit|overloaded|temporar|timed out|could not be reached|lost the connection|failed to fetch|networkerror/i
|
|
@@ -112,7 +112,8 @@ export class AgentLoopService extends Service {
|
|
|
112
112
|
let turnError: string | undefined
|
|
113
113
|
|
|
114
114
|
try {
|
|
115
|
-
|
|
115
|
+
// No round cap: the turn ends when the model stops calling tools or the user stops it.
|
|
116
|
+
rounds: for (let round = 0; ; round += 1) {
|
|
116
117
|
totalRounds = round + 1
|
|
117
118
|
if (controller.signal.aborted) break
|
|
118
119
|
let current = this.ctx.sessions.current(sessionId)
|
|
@@ -188,7 +189,12 @@ export class AgentLoopService extends Service {
|
|
|
188
189
|
settings,
|
|
189
190
|
model: findModel(settings.provider, activeModelId(settings)),
|
|
190
191
|
history,
|
|
191
|
-
system: this.ctx.systemPrompt.assemble(settings, mode
|
|
192
|
+
system: this.ctx.systemPrompt.assemble(settings, mode, {
|
|
193
|
+
sessionPrompt: current.systemPrompt,
|
|
194
|
+
workspacePrompt: current.workspace
|
|
195
|
+
? settings.workspacePrompts[current.workspace]
|
|
196
|
+
: settings.workspacePrompts[useWorkspace.getState().root ?? ''],
|
|
197
|
+
}),
|
|
192
198
|
mode,
|
|
193
199
|
signal: controller.signal,
|
|
194
200
|
handlers: {
|
|
@@ -250,7 +256,7 @@ export class AgentLoopService extends Service {
|
|
|
250
256
|
reasoning: result.reasoning,
|
|
251
257
|
thinkingBlocks: result.thinkingBlocks,
|
|
252
258
|
toolCalls: result.toolCalls,
|
|
253
|
-
usage: usage.input || usage.output ? usage : result.usage,
|
|
259
|
+
usage: usage.input || usage.output || usage.cost ? usage : result.usage,
|
|
254
260
|
durationMs: Date.now() - startedAt,
|
|
255
261
|
stopped: controller.signal.aborted || undefined,
|
|
256
262
|
})
|
|
@@ -384,24 +390,6 @@ export class AgentLoopService extends Service {
|
|
|
384
390
|
break
|
|
385
391
|
}
|
|
386
392
|
}
|
|
387
|
-
|
|
388
|
-
const finalSession = this.ctx.sessions.current(sessionId)
|
|
389
|
-
if (!controller.signal.aborted && finalSession) {
|
|
390
|
-
const last = finalSession.messages[finalSession.messages.length - 1]
|
|
391
|
-
if (last?.role === 'tool') {
|
|
392
|
-
const message: AssistantMessage = {
|
|
393
|
-
id: createId('msg'),
|
|
394
|
-
role: 'assistant',
|
|
395
|
-
content: '',
|
|
396
|
-
reasoning: '',
|
|
397
|
-
toolCalls: [],
|
|
398
|
-
round: MAX_ROUNDS,
|
|
399
|
-
createdAt: Date.now(),
|
|
400
|
-
error: `Stopped after ${MAX_ROUNDS} tool rounds. Send another message to continue.`,
|
|
401
|
-
}
|
|
402
|
-
this.ctx.sessions.addMessage(sessionId, message)
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
393
|
} finally {
|
|
406
394
|
this.controllers.delete(sessionId)
|
|
407
395
|
useRuntime.getState().setStreaming(sessionId, false)
|
|
@@ -43,28 +43,30 @@ export class SystemPromptService extends Service {
|
|
|
43
43
|
return () => this.customSections.delete(name)
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
assemble(
|
|
46
|
+
assemble(
|
|
47
|
+
settings: AppSettings,
|
|
48
|
+
mode: AgentMode = settings.mode,
|
|
49
|
+
overrides: { sessionPrompt?: string; workspacePrompt?: string } = {},
|
|
50
|
+
): string {
|
|
47
51
|
const sections: PromptSection[] = []
|
|
48
52
|
|
|
49
|
-
// 1. Base instruction
|
|
50
|
-
|
|
51
|
-
|
|
53
|
+
// 1. Base instruction: session override, then workspace override, then the shared prompt.
|
|
54
|
+
const base = overrides.sessionPrompt?.trim() || overrides.workspacePrompt?.trim() || settings.systemPrompt?.trim()
|
|
55
|
+
if (base) {
|
|
56
|
+
sections.push({ name: 'base', priority: 10, content: base })
|
|
52
57
|
}
|
|
53
58
|
|
|
54
|
-
// 2.
|
|
55
|
-
const skills = settings.skills.filter((skill) => skill.enabled)
|
|
59
|
+
// 2. Skills are discovered, not pasted: the model loads one through the skill tool.
|
|
60
|
+
const skills = settings.tools.skill?.enabled === false ? [] : settings.skills.filter((skill) => skill.enabled)
|
|
56
61
|
if (skills.length > 0) {
|
|
57
62
|
sections.push({
|
|
58
63
|
name: 'skills',
|
|
59
64
|
priority: 20,
|
|
60
65
|
content: [
|
|
61
|
-
'##
|
|
62
|
-
'
|
|
63
|
-
...skills.map(
|
|
64
|
-
|
|
65
|
-
`<skill name="${skill.name}">\nPurpose: ${skill.description}\n${skill.instructions.trim()}\n</skill>`,
|
|
66
|
-
),
|
|
67
|
-
].join('\n\n'),
|
|
66
|
+
'## Available skills',
|
|
67
|
+
'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.',
|
|
68
|
+
...skills.map((skill) => `- ${skill.name} (${skill.id}): ${skill.description}`),
|
|
69
|
+
].join('\n'),
|
|
68
70
|
})
|
|
69
71
|
}
|
|
70
72
|
|
package/src/lib/daemon.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
ProviderId,
|
|
13
13
|
ProviderStatus,
|
|
14
14
|
SecretsSummary,
|
|
15
|
+
Skill,
|
|
15
16
|
WorkspaceFile,
|
|
16
17
|
WorkspaceInfo,
|
|
17
18
|
} from '../types'
|
|
@@ -263,6 +264,10 @@ export function setDeployCredentials(provider: DeployProviderId, fields: Record<
|
|
|
263
264
|
})
|
|
264
265
|
}
|
|
265
266
|
|
|
267
|
+
export function installSkillFromNpmRequest(packageName: string) {
|
|
268
|
+
return api<{ skill: Skill }>('/api/skills/install', { method: 'POST', body: JSON.stringify({ package: packageName }) })
|
|
269
|
+
}
|
|
270
|
+
|
|
266
271
|
export function resetScope(scope: 'history' | 'secrets' | 'settings' | 'all'): Promise<DaemonSnapshot> {
|
|
267
272
|
return api('/api/reset', jsonBody({ scope }))
|
|
268
273
|
}
|
|
@@ -452,6 +457,20 @@ export async function requestLLM(
|
|
|
452
457
|
return response
|
|
453
458
|
}
|
|
454
459
|
|
|
460
|
+
export interface DeployInspection {
|
|
461
|
+
path: string
|
|
462
|
+
name: string
|
|
463
|
+
hasPackageJson: boolean
|
|
464
|
+
framework: string
|
|
465
|
+
buildCommand: string
|
|
466
|
+
outputDir: string
|
|
467
|
+
existingOutputs: string[]
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
export function inspectDeploy(path: string): Promise<DeployInspection> {
|
|
471
|
+
return api('/api/deploy/inspect', { method: 'POST', body: JSON.stringify({ path }) })
|
|
472
|
+
}
|
|
473
|
+
|
|
455
474
|
export async function* streamDeploy(
|
|
456
475
|
request: DeployRequest,
|
|
457
476
|
signal?: AbortSignal,
|
package/src/lib/permissions.ts
CHANGED
|
@@ -21,7 +21,7 @@ export const PERMISSION_MODES: Array<{ value: PermissionMode; label: string; des
|
|
|
21
21
|
]
|
|
22
22
|
|
|
23
23
|
const WRITE_TOOLS: BuiltinToolName[] = ['write_file', 'edit_file', 'rewrite_file']
|
|
24
|
-
const GUARDED_TOOLS: BuiltinToolName[] = ['shell', 'git', 'subagent']
|
|
24
|
+
const GUARDED_TOOLS: BuiltinToolName[] = ['shell', 'git', 'subagent', 'skill']
|
|
25
25
|
|
|
26
26
|
export function permissionMode(tools: AppSettings['tools']): PermissionMode {
|
|
27
27
|
const writesAuto = WRITE_TOOLS.every((tool) => tools[tool]?.approval === 'auto')
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { parseSkillMarkdown } from './skills.ts'
|
|
4
|
+
|
|
5
|
+
test('frontmatter supplies the name and description', () => {
|
|
6
|
+
const skill = parseSkillMarkdown('---\nname: API reviewer\ndescription: "Checks a diff"\n---\n# Ignored\n\nBody text.', 'fallback')
|
|
7
|
+
assert.equal(skill.name, 'API reviewer')
|
|
8
|
+
assert.equal(skill.description, 'Checks a diff')
|
|
9
|
+
assert.equal(skill.instructions, '# Ignored\n\nBody text.')
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
test('a plain SKILL.md falls back to its heading and first paragraph', () => {
|
|
13
|
+
const skill = parseSkillMarkdown('# Deploy helper\n\nRuns the release checklist.\n\n## Steps\n1. build', 'fallback')
|
|
14
|
+
assert.equal(skill.name, 'Deploy helper')
|
|
15
|
+
assert.equal(skill.description, 'Runs the release checklist.')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('an empty fallback markdown still returns a usable skill', () => {
|
|
19
|
+
const skill = parseSkillMarkdown('', 'my-skill')
|
|
20
|
+
assert.equal(skill.name, 'my-skill')
|
|
21
|
+
assert.equal(skill.description, 'Installed from a folder')
|
|
22
|
+
})
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { Skill } from '../types'
|
|
2
|
+
|
|
3
|
+
const TEXT_FILE = /\.(md|markdown|txt|json|ya?ml|toml|js|mjs|cjs|ts|tsx|jsx|py|sh|bash|zsh|css|html|xml|csv)$/i
|
|
4
|
+
const MAX_FILE = 512 * 1024
|
|
5
|
+
const MAX_TOTAL = 4 * 1024 * 1024
|
|
6
|
+
const MAX_FILES = 200
|
|
7
|
+
|
|
8
|
+
export interface ParsedSkill {
|
|
9
|
+
name: string
|
|
10
|
+
description: string
|
|
11
|
+
instructions: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Read the optional YAML frontmatter, then the first heading and paragraph. */
|
|
15
|
+
export function parseSkillMarkdown(markdown: string, fallbackName: string): ParsedSkill {
|
|
16
|
+
const text = String(markdown ?? '').replace(/\r\n/g, '\n')
|
|
17
|
+
const front = /^---\n([\s\S]*?)\n---\n?/.exec(text)
|
|
18
|
+
const meta: Record<string, string> = {}
|
|
19
|
+
let body = text
|
|
20
|
+
if (front) {
|
|
21
|
+
for (const line of front[1].split('\n')) {
|
|
22
|
+
const match = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/.exec(line.trim())
|
|
23
|
+
if (match) meta[match[1].toLowerCase()] = match[2].replace(/^["']|["']$/g, '').trim()
|
|
24
|
+
}
|
|
25
|
+
body = text.slice(front[0].length)
|
|
26
|
+
}
|
|
27
|
+
const heading = /^#\s+(.+)$/m.exec(body)
|
|
28
|
+
const paragraph = body
|
|
29
|
+
.split(/\n\s*\n/)
|
|
30
|
+
.map((part) => part.trim())
|
|
31
|
+
.find((part) => part && !part.startsWith('#') && !part.startsWith('---'))
|
|
32
|
+
return {
|
|
33
|
+
name: meta.name || heading?.[1]?.trim() || fallbackName,
|
|
34
|
+
description: (meta.description || paragraph || 'Installed from a folder')
|
|
35
|
+
.replace(/[#>*`]/g, '')
|
|
36
|
+
.replace(/\s+/g, ' ')
|
|
37
|
+
.slice(0, 200),
|
|
38
|
+
instructions: body.trim(),
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function slug(value: string) {
|
|
43
|
+
return (
|
|
44
|
+
value
|
|
45
|
+
.toLowerCase()
|
|
46
|
+
.replace(/[^a-z0-9-]+/g, '-')
|
|
47
|
+
.replace(/^-+|-+$/g, '')
|
|
48
|
+
.slice(0, 48) || 'skill'
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type FolderFile = { path: string; text: string }
|
|
53
|
+
|
|
54
|
+
function pickSkillPath(files: FolderFile[]) {
|
|
55
|
+
const candidates = files.filter((file) => /(^|\/)SKILL\.md$/i.test(file.path))
|
|
56
|
+
candidates.sort((a, b) => a.path.split('/').length - b.path.split('/').length || a.path.localeCompare(b.path))
|
|
57
|
+
return candidates[0]?.path ?? ''
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Turn a folder upload into a skill. Files stay in settings so the skill tool can run them. */
|
|
61
|
+
export async function skillFromFolder(files: File[]): Promise<Skill> {
|
|
62
|
+
const queue = files.slice(0, MAX_FILES)
|
|
63
|
+
const read: FolderFile[] = []
|
|
64
|
+
let total = 0
|
|
65
|
+
for (const file of queue) {
|
|
66
|
+
const path = file.webkitRelativePath || file.name
|
|
67
|
+
if (file.size > MAX_FILE || !TEXT_FILE.test(path)) continue
|
|
68
|
+
if (total + file.size > MAX_TOTAL) break
|
|
69
|
+
const text = await file.text()
|
|
70
|
+
total += text.length
|
|
71
|
+
read.push({ path, text })
|
|
72
|
+
}
|
|
73
|
+
if (read.length === 0) throw new Error('That folder has no readable text files.')
|
|
74
|
+
const skillPath = pickSkillPath(read)
|
|
75
|
+
if (!skillPath) throw new Error('That folder has no SKILL.md.')
|
|
76
|
+
// Strip the folder the user picked so paths are relative to the skill root.
|
|
77
|
+
const rootMatch = /^(.*?)\//.exec(skillPath)
|
|
78
|
+
const root = rootMatch?.[1] ?? ''
|
|
79
|
+
const scoped = read
|
|
80
|
+
.filter((file) => !root || file.path === root || file.path.startsWith(`${root}/`))
|
|
81
|
+
.map((file) => ({ path: root ? file.path.slice(root.length + 1) : file.path, text: file.text }))
|
|
82
|
+
const manifest = scoped.find((file) => /^SKILL\.md$/i.test(file.path)) ?? scoped[0]
|
|
83
|
+
const parsed = parseSkillMarkdown(manifest.text, root.split('/').pop() || 'skill')
|
|
84
|
+
return {
|
|
85
|
+
id: `skill_${slug(parsed.name)}`,
|
|
86
|
+
name: parsed.name,
|
|
87
|
+
description: parsed.description,
|
|
88
|
+
instructions: parsed.instructions,
|
|
89
|
+
enabled: true,
|
|
90
|
+
source: 'folder',
|
|
91
|
+
files: Object.fromEntries(scoped.map((file) => [file.path, file.text])),
|
|
92
|
+
}
|
|
93
|
+
}
|
package/src/lib/usage.ts
CHANGED
|
@@ -18,6 +18,7 @@ export interface UsageStats {
|
|
|
18
18
|
sessions: number
|
|
19
19
|
messages: number
|
|
20
20
|
tokens: number
|
|
21
|
+
credits: number
|
|
21
22
|
activeDays: number
|
|
22
23
|
peakHour: string
|
|
23
24
|
favoriteModel: string
|
|
@@ -81,6 +82,7 @@ export function computeUsage(sessions: ChatSession[], range: UsageRange, now = D
|
|
|
81
82
|
const days = new Map<string, HeatmapDay>()
|
|
82
83
|
const models = new Map<string, ModelUsage>()
|
|
83
84
|
let tokens = 0
|
|
85
|
+
let credits = 0
|
|
84
86
|
|
|
85
87
|
for (const { message, session } of messages) {
|
|
86
88
|
const at = messageTime(message, session.updatedAt)
|
|
@@ -93,6 +95,9 @@ export function computeUsage(sessions: ChatSession[], range: UsageRange, now = D
|
|
|
93
95
|
if (message.role !== 'assistant') continue
|
|
94
96
|
const messageTokens = estimateTokens(message)
|
|
95
97
|
tokens += messageTokens
|
|
98
|
+
if (typeof message.usage?.cost === 'number' && Number.isFinite(message.usage.cost)) {
|
|
99
|
+
credits += Math.max(0, message.usage.cost)
|
|
100
|
+
}
|
|
96
101
|
const model = typeof message.model === 'string' ? message.model.trim() : ''
|
|
97
102
|
if (!model) continue
|
|
98
103
|
const entry = models.get(model) ?? { model, messages: 0, tokens: 0 }
|
|
@@ -119,6 +124,7 @@ export function computeUsage(sessions: ChatSession[], range: UsageRange, now = D
|
|
|
119
124
|
sessions: filteredSessions.length,
|
|
120
125
|
messages: messages.length,
|
|
121
126
|
tokens,
|
|
127
|
+
credits,
|
|
122
128
|
activeDays: days.size,
|
|
123
129
|
peakHour: messages.length ? hourLabel(peak) : '—',
|
|
124
130
|
favoriteModel: favorite?.model ?? '—',
|
|
@@ -148,6 +154,12 @@ export function formatTokens(value: number): string {
|
|
|
148
154
|
return String(value)
|
|
149
155
|
}
|
|
150
156
|
|
|
157
|
+
export function formatCredits(value: number): string {
|
|
158
|
+
if (!value) return '—'
|
|
159
|
+
if (value < 0.01) return `$${value.toFixed(4)}`
|
|
160
|
+
return `$${value.toFixed(2)}`
|
|
161
|
+
}
|
|
162
|
+
|
|
151
163
|
export function shortModelName(model: string): string {
|
|
152
164
|
if (!model || model === '—') return model
|
|
153
165
|
const parts = model.split('/')
|
package/src/store/chats.ts
CHANGED
|
@@ -48,6 +48,7 @@ function normalizeSession(session: ChatSession): ChatSession {
|
|
|
48
48
|
updatedAt: typeof session.updatedAt === 'number' ? session.updatedAt : Date.now(),
|
|
49
49
|
mode: session.mode === 'plan' ? 'plan' : session.mode === 'build' ? 'build' : undefined,
|
|
50
50
|
workspace: typeof session.workspace === 'string' ? session.workspace : undefined,
|
|
51
|
+
systemPrompt: typeof session.systemPrompt === 'string' ? session.systemPrompt : undefined,
|
|
51
52
|
titleSource:
|
|
52
53
|
session.titleSource === 'ai' || session.titleSource === 'user' || session.titleSource === 'auto'
|
|
53
54
|
? session.titleSource
|
package/src/store/settings.ts
CHANGED
|
@@ -13,7 +13,6 @@ import type {
|
|
|
13
13
|
BuiltinToolName,
|
|
14
14
|
ToolSetting,
|
|
15
15
|
} from '../types'
|
|
16
|
-
import { BUILTIN_SKILLS } from '../data/skills'
|
|
17
16
|
import { PROVIDER_MAP } from '../data/providers'
|
|
18
17
|
import {
|
|
19
18
|
patchSettings,
|
|
@@ -59,6 +58,7 @@ const DEFAULT_TOOLS: Record<BuiltinToolName, ToolSetting> = {
|
|
|
59
58
|
read_url: { enabled: true, approval: 'auto' },
|
|
60
59
|
git: { enabled: true, approval: 'ask' },
|
|
61
60
|
subagent: { enabled: true, approval: 'ask' },
|
|
61
|
+
skill: { enabled: true, approval: 'ask' },
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
const DEFAULTS: AppSettings = {
|
|
@@ -69,10 +69,11 @@ const DEFAULTS: AppSettings = {
|
|
|
69
69
|
temperature: 0.4,
|
|
70
70
|
maxTokens: 8192,
|
|
71
71
|
systemPrompt: DEFAULT_SYSTEM_PROMPT,
|
|
72
|
+
workspacePrompts: {},
|
|
72
73
|
providers: {},
|
|
73
74
|
searchProvider: 'duckduckgo',
|
|
74
75
|
tools: DEFAULT_TOOLS,
|
|
75
|
-
skills:
|
|
76
|
+
skills: [],
|
|
76
77
|
theme: 'system',
|
|
77
78
|
showTokenEstimate: true,
|
|
78
79
|
mode: 'build',
|
|
@@ -122,13 +123,15 @@ function mergeTools(saved?: Partial<Record<BuiltinToolName, Partial<ToolSetting>
|
|
|
122
123
|
}
|
|
123
124
|
|
|
124
125
|
function mergeSkills(saved?: Skill[]) {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
126
|
+
// Built-in skills were removed; drop any leftovers persisted by older versions.
|
|
127
|
+
return (saved ?? [])
|
|
128
|
+
.filter((skill): skill is Skill => Boolean(skill) && typeof skill.id === 'string' && typeof skill.name === 'string' && !skill.builtIn)
|
|
129
|
+
.map((skill) => ({
|
|
130
|
+
...skill,
|
|
131
|
+
description: typeof skill.description === 'string' ? skill.description : '',
|
|
132
|
+
instructions: typeof skill.instructions === 'string' ? skill.instructions : '',
|
|
133
|
+
enabled: skill.enabled !== false,
|
|
134
|
+
}))
|
|
132
135
|
}
|
|
133
136
|
|
|
134
137
|
function upgradePrompt(value: unknown) {
|
|
@@ -178,6 +181,14 @@ function buildSettings(persisted?: Partial<AppSettings>): AppSettings {
|
|
|
178
181
|
? Math.max(256, Math.min(200_000, Math.round(saved.maxTokens)))
|
|
179
182
|
: DEFAULTS.maxTokens,
|
|
180
183
|
systemPrompt: upgradePrompt(saved.systemPrompt),
|
|
184
|
+
workspacePrompts:
|
|
185
|
+
saved.workspacePrompts && typeof saved.workspacePrompts === 'object'
|
|
186
|
+
? Object.fromEntries(
|
|
187
|
+
Object.entries(saved.workspacePrompts).filter(
|
|
188
|
+
([key, value]) => key.startsWith('/') && typeof value === 'string',
|
|
189
|
+
),
|
|
190
|
+
)
|
|
191
|
+
: {},
|
|
181
192
|
providers: sanitizeBaseUrls(saved.providers),
|
|
182
193
|
searchProvider:
|
|
183
194
|
saved.searchProvider && ['duckduckgo', 'tavily', 'brave', 'serper'].includes(saved.searchProvider)
|
|
@@ -211,6 +222,7 @@ function persistedPatch(patch: Partial<AppSettings>): Partial<AppSettings> {
|
|
|
211
222
|
'temperature',
|
|
212
223
|
'maxTokens',
|
|
213
224
|
'systemPrompt',
|
|
225
|
+
'workspacePrompts',
|
|
214
226
|
'providers',
|
|
215
227
|
'searchProvider',
|
|
216
228
|
'tools',
|