@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.
Files changed (42) hide show
  1. package/CHANGELOG.md +1 -1
  2. package/dist/assets/index-DaJcH9ts.css +1 -0
  3. package/dist/assets/index-hs0KWMP6.js +288 -0
  4. package/dist/favicon.svg +11 -5
  5. package/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/public/favicon.svg +11 -5
  8. package/server/daemon.mjs +42 -4
  9. package/server/lib/deploy.mjs +78 -7
  10. package/server/lib/skills.mjs +128 -0
  11. package/server/lib/tools.mjs +40 -0
  12. package/src/agent/adapters/openai-responses.ts +5 -1
  13. package/src/agent/adapters/openai.ts +15 -2
  14. package/src/agent/prompt.ts +4 -7
  15. package/src/agent/tools.ts +1 -0
  16. package/src/components/DeployModal.tsx +77 -15
  17. package/src/components/FileIcon.tsx +108 -0
  18. package/src/components/HomeDashboard.tsx +9 -2
  19. package/src/components/Logo.tsx +11 -5
  20. package/src/components/ModelDeck.tsx +12 -2
  21. package/src/components/SettingsModal.tsx +144 -199
  22. package/src/components/Sidebar.tsx +4 -5
  23. package/src/components/SystemPromptModal.tsx +168 -0
  24. package/src/components/TopBar.tsx +19 -0
  25. package/src/components/WorkspacePanel.tsx +10 -8
  26. package/src/core/kernel.ts +2 -0
  27. package/src/core/plugins/tools/skill.ts +101 -0
  28. package/src/core/services/agent-loop.ts +10 -22
  29. package/src/core/services/system-prompt.ts +15 -13
  30. package/src/core/services/tools.ts +1 -0
  31. package/src/lib/daemon.ts +19 -0
  32. package/src/lib/permissions.ts +1 -1
  33. package/src/lib/skills.test.mjs +22 -0
  34. package/src/lib/skills.ts +93 -0
  35. package/src/lib/usage.ts +12 -0
  36. package/src/store/chats.ts +1 -0
  37. package/src/store/settings.ts +21 -9
  38. package/src/types.ts +14 -1
  39. package/tsconfig.tsbuildinfo +1 -1
  40. package/dist/assets/index-CeN54A2N.css +0 -1
  41. package/dist/assets/index-fhIO9a7_.js +0 -318
  42. package/src/data/skills.ts +0 -68
@@ -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
- <FileText size={11} className={cn('shrink-0', activeFile === path ? 'text-accent' : 'text-faint')} />
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' ? <Folder size={11} /> : <FileText size={11} />}
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
  ))
@@ -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
- rounds: for (let round = 0; round < MAX_ROUNDS; round += 1) {
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(settings: AppSettings, mode: AgentMode = settings.mode): string {
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
- if (settings.systemPrompt?.trim()) {
51
- sections.push({ name: 'base', priority: 10, content: settings.systemPrompt.trim() })
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. Loaded skills
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
- '## Loaded skills',
62
- 'Apply these skills when the task matches their purpose. They refine the working rules above; they never override safety, tool permissions or plan mode.',
63
- ...skills.map(
64
- (skill) =>
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
 
@@ -15,6 +15,7 @@ const READ_ONLY_TOOLS = new Set<ToolName>([
15
15
  'grep',
16
16
  'web_search',
17
17
  'read_url',
18
+ 'skill',
18
19
  ])
19
20
 
20
21
  export function toolAllowedInMode(name: ToolName, mode: AgentMode): boolean {
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,
@@ -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('/')
@@ -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
@@ -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: BUILTIN_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
- const byId = new Map<string, Skill>()
126
- for (const skill of BUILTIN_SKILLS) byId.set(skill.id, skill)
127
- for (const skill of saved ?? []) {
128
- if (!skill || typeof skill.id !== 'string') continue
129
- byId.set(skill.id, { ...(byId.get(skill.id) ?? {}), ...skill, builtIn: skill.builtIn ?? byId.has(skill.id) })
130
- }
131
- return [...byId.values()]
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',