@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.
Files changed (72) hide show
  1. package/CHANGELOG.md +10 -12
  2. package/desktop/src-tauri/icons/128x128.png +0 -0
  3. package/desktop/src-tauri/icons/128x128@2x.png +0 -0
  4. package/desktop/src-tauri/icons/32x32.png +0 -0
  5. package/desktop/src-tauri/icons/64x64.png +0 -0
  6. package/desktop/src-tauri/icons/Square107x107Logo.png +0 -0
  7. package/desktop/src-tauri/icons/Square142x142Logo.png +0 -0
  8. package/desktop/src-tauri/icons/Square150x150Logo.png +0 -0
  9. package/desktop/src-tauri/icons/Square284x284Logo.png +0 -0
  10. package/desktop/src-tauri/icons/Square30x30Logo.png +0 -0
  11. package/desktop/src-tauri/icons/Square310x310Logo.png +0 -0
  12. package/desktop/src-tauri/icons/Square44x44Logo.png +0 -0
  13. package/desktop/src-tauri/icons/Square71x71Logo.png +0 -0
  14. package/desktop/src-tauri/icons/Square89x89Logo.png +0 -0
  15. package/desktop/src-tauri/icons/StoreLogo.png +0 -0
  16. package/desktop/src-tauri/icons/icon.icns +0 -0
  17. package/desktop/src-tauri/icons/icon.ico +0 -0
  18. package/desktop/src-tauri/icons/icon.png +0 -0
  19. package/dist/assets/index-C0yvs6Vl.js +281 -0
  20. package/dist/assets/index-CxNPLBg7.css +1 -0
  21. package/dist/favicon.svg +18 -15
  22. package/dist/index.html +2 -2
  23. package/package.json +1 -1
  24. package/public/favicon.svg +18 -15
  25. package/server/daemon.mjs +66 -49
  26. package/server/lib/deploy.mjs +222 -121
  27. package/server/lib/providers.mjs +23 -1
  28. package/server/lib/skills.mjs +385 -100
  29. package/server/lib/tools.mjs +0 -40
  30. package/src/agent/adapters/openai-responses.ts +1 -5
  31. package/src/agent/adapters/openai.ts +4 -11
  32. package/src/agent/prompt.ts +7 -14
  33. package/src/agent/subagent.ts +3 -14
  34. package/src/agent/tools.ts +2 -0
  35. package/src/components/ChatView.tsx +2 -1
  36. package/src/components/Composer.tsx +2 -1
  37. package/src/components/ContextBar.tsx +10 -0
  38. package/src/components/DeployModal.tsx +266 -252
  39. package/src/components/FileIcon.tsx +391 -99
  40. package/src/components/HomeDashboard.tsx +5 -10
  41. package/src/components/Logo.tsx +9 -11
  42. package/src/components/ModelDeck.tsx +8 -11
  43. package/src/components/SettingsModal.tsx +160 -122
  44. package/src/components/Sidebar.tsx +4 -3
  45. package/src/components/SystemPromptButton.tsx +198 -0
  46. package/src/components/ToolCallCard.tsx +2 -0
  47. package/src/components/TopBar.tsx +2 -19
  48. package/src/components/WorkspacePanel.tsx +76 -93
  49. package/src/core/plugins/tools/skill.ts +19 -76
  50. package/src/core/services/agent-loop.ts +16 -31
  51. package/src/core/services/subagents.ts +2 -2
  52. package/src/core/services/system-prompt.ts +16 -23
  53. package/src/core/services/tools.ts +0 -1
  54. package/src/lib/boot.ts +2 -0
  55. package/src/lib/credits.ts +33 -0
  56. package/src/lib/daemon.ts +40 -19
  57. package/src/lib/permissions.ts +1 -1
  58. package/src/lib/retry.test.mjs +40 -12
  59. package/src/lib/retry.ts +41 -8
  60. package/src/lib/system-prompt.ts +23 -0
  61. package/src/lib/usage.ts +8 -13
  62. package/src/store/chats.ts +0 -1
  63. package/src/store/settings.ts +12 -44
  64. package/src/store/skills.ts +79 -0
  65. package/src/store/ui.ts +3 -1
  66. package/src/types.ts +30 -15
  67. package/tsconfig.tsbuildinfo +1 -1
  68. package/dist/assets/index-B1pQC9Fe.js +0 -288
  69. package/dist/assets/index-qLr4aZVC.css +0 -1
  70. package/src/components/SystemPromptModal.tsx +0 -168
  71. package/src/lib/skills.test.mjs +0 -22
  72. package/src/lib/skills.ts +0 -93
package/src/lib/boot.ts CHANGED
@@ -4,6 +4,7 @@ import { ensureSession, getState, migrateLegacy } from './daemon'
4
4
  import { enableChatPersistence, useChats } from '../store/chats'
5
5
  import { enableSettingsPersistence, useSettings } from '../store/settings'
6
6
  import { useWorkspace } from '../store/workspace'
7
+ import { useSkills } from '../store/skills'
7
8
 
8
9
  const LEGACY_KEYS = {
9
10
  settings: 'maxcode-settings-v1',
@@ -59,6 +60,7 @@ export async function applySnapshot(snapshot: DaemonSnapshot): Promise<void> {
59
60
  useSettings.getState().hydrate(snapshot)
60
61
  useChats.getState().hydrate(snapshot.chats)
61
62
  await useWorkspace.getState().hydrate(snapshot.workspace)
63
+ void useSkills.getState().load()
62
64
  }
63
65
 
64
66
  function parseLegacy(value: string | null): unknown {
@@ -0,0 +1,33 @@
1
+ import type { Message, ModelInfo, Usage } from '../types'
2
+
3
+ /**
4
+ * Credits for one reply. Providers that report their own cost win; otherwise the cost is
5
+ * tokens × the model's published price (USD per 1M tokens). Unknown or free models give
6
+ * undefined, so nothing is shown for them.
7
+ */
8
+ export function messageCost(
9
+ usage: Usage | undefined,
10
+ model: Pick<ModelInfo, 'pricing'> | null | undefined,
11
+ ): number | undefined {
12
+ if (!usage) return undefined
13
+ if (typeof usage.cost === 'number' && Number.isFinite(usage.cost) && usage.cost > 0) return usage.cost
14
+ const pricing = model?.pricing
15
+ if (!pricing) return undefined
16
+ const cost = ((usage.input ?? 0) * pricing.input + (usage.output ?? 0) * pricing.output) / 1_000_000
17
+ return cost > 0 ? cost : undefined
18
+ }
19
+
20
+ export function sumCost(messages: Message[]): number {
21
+ let total = 0
22
+ for (const message of messages) {
23
+ if (message.role === 'assistant' && typeof message.cost === 'number') total += message.cost
24
+ }
25
+ return total
26
+ }
27
+
28
+ export function formatCredits(value: number): string {
29
+ if (value <= 0) return '$0.00'
30
+ if (value < 0.01) return `$${value.toFixed(4)}`
31
+ if (value < 1) return `$${value.toFixed(3)}`
32
+ return `$${value.toFixed(2)}`
33
+ }
package/src/lib/daemon.ts CHANGED
@@ -3,6 +3,7 @@ import type {
3
3
  AppSettings,
4
4
  ChatSession,
5
5
  DaemonSnapshot,
6
+ DeployDetection,
6
7
  DeployEvent,
7
8
  DeployProviderId,
8
9
  DeployRequest,
@@ -12,10 +13,10 @@ import type {
12
13
  ProviderId,
13
14
  ProviderStatus,
14
15
  SecretsSummary,
15
- Skill,
16
16
  WorkspaceFile,
17
17
  WorkspaceInfo,
18
18
  } from '../types'
19
+ import type { InstalledSkill } from '../types'
19
20
  import type { PluginManifest, PluginCapability } from '../../shared/plugin-package.mjs'
20
21
 
21
22
  export interface InstalledPlugin {
@@ -264,10 +265,6 @@ export function setDeployCredentials(provider: DeployProviderId, fields: Record<
264
265
  })
265
266
  }
266
267
 
267
- export function installSkillFromNpmRequest(packageName: string) {
268
- return api<{ skill: Skill }>('/api/skills/install', { method: 'POST', body: JSON.stringify({ package: packageName }) })
269
- }
270
-
271
268
  export function resetScope(scope: 'history' | 'secrets' | 'settings' | 'all'): Promise<DaemonSnapshot> {
272
269
  return api('/api/reset', jsonBody({ scope }))
273
270
  }
@@ -457,20 +454,6 @@ export async function requestLLM(
457
454
  return response
458
455
  }
459
456
 
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
-
474
457
  export async function* streamDeploy(
475
458
  request: DeployRequest,
476
459
  signal?: AbortSignal,
@@ -530,3 +513,41 @@ export async function* streamDeploy(
530
513
  }
531
514
 
532
515
  export type { DeployResult, SecretsSummary }
516
+
517
+ export interface SkillUploadFile {
518
+ path: string
519
+ /** base64 */
520
+ data: string
521
+ }
522
+
523
+ export function listSkillsApi(): Promise<{ skills: InstalledSkill[] }> {
524
+ return api('/api/skills')
525
+ }
526
+
527
+ export function uploadSkillFiles(files: SkillUploadFile[]): Promise<{ installed: string[]; skills: InstalledSkill[] }> {
528
+ return api('/api/skills/upload', jsonBody({ files }))
529
+ }
530
+
531
+ export function installSkillNpm(spec: string): Promise<{ installed: string[]; skills: InstalledSkill[] }> {
532
+ return api('/api/skills/npm', jsonBody({ spec }))
533
+ }
534
+
535
+ export function setSkillEnabledApi(name: string, enabled: boolean): Promise<{ skills: InstalledSkill[] }> {
536
+ return api(`/api/skills/${encodeURIComponent(name)}/enabled`, { method: 'PUT', body: JSON.stringify({ enabled }) })
537
+ }
538
+
539
+ export function removeSkillApi(name: string): Promise<{ skills: InstalledSkill[] }> {
540
+ return api(`/api/skills/${encodeURIComponent(name)}`, { method: 'DELETE' })
541
+ }
542
+
543
+ export function readSkillApi(
544
+ name: string,
545
+ file?: string,
546
+ ): Promise<{ name: string; file: string; directory: string; content: string; files?: string[] }> {
547
+ return api('/api/skills/read', jsonBody({ name, file }))
548
+ }
549
+
550
+ export async function detectDeployProject(path: string): Promise<DeployDetection> {
551
+ const result = await api<{ project: DeployDetection }>('/api/deploy/detect', jsonBody({ path }))
552
+ return result.project
553
+ }
@@ -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', 'skill']
24
+ const GUARDED_TOOLS: BuiltinToolName[] = ['shell', 'git', 'subagent']
25
25
 
26
26
  export function permissionMode(tools: AppSettings['tools']): PermissionMode {
27
27
  const writesAuto = WRITE_TOOLS.every((tool) => tools[tool]?.approval === 'auto')
@@ -1,17 +1,45 @@
1
- import test from 'node:test'
2
1
  import assert from 'node:assert/strict'
3
- import { MAX_PROVIDER_RETRIES, isRetryableProviderError, retryDelayMs } from './retry.ts'
2
+ import test from 'node:test'
3
+ import { backoffMs, classifyProviderError, retryAfterMs } from './retry.ts'
4
+
5
+ test('retries transient errors', () => {
6
+ for (const message of [
7
+ 'OpenAI returned 429: Rate limit reached for requests per minute. Please retry in 6s.',
8
+ 'Anthropic is overloaded (529)',
9
+ '503 Service Unavailable',
10
+ 'The request timed out',
11
+ 'Lost the connection to the local daemon',
12
+ '500 Internal Server Error',
13
+ ]) {
14
+ assert.equal(classifyProviderError(message).retry, true, message)
15
+ }
16
+ })
17
+
18
+ test('never retries limits that were reached or other permanent errors', () => {
19
+ for (const message of [
20
+ 'You exceeded your current quota, please check your plan and billing details. (429)',
21
+ 'Usage limit reached for this month',
22
+ 'insufficient_quota',
23
+ 'Insufficient credits. Add credits to continue (402)',
24
+ '401 Invalid API key',
25
+ '403 Forbidden',
26
+ '400 invalid_request_error: context length exceeded',
27
+ '404 model not found',
28
+ 'Daily limit reached',
29
+ ]) {
30
+ assert.equal(classifyProviderError(message).retry, false, message)
31
+ }
32
+ })
4
33
 
5
- test('provider hiccups are retried, auth and validation errors are not', () => {
6
- assert.equal(isRetryableProviderError('429 Too Many Requests'), true)
7
- assert.equal(isRetryableProviderError('503 Service Unavailable'), true)
8
- assert.equal(isRetryableProviderError('socket hang up: ECONNRESET'), true)
9
- assert.equal(isRetryableProviderError('401 Unauthorized: invalid api key'), false)
10
- assert.equal(isRetryableProviderError('400 invalid request: unknown field'), false)
34
+ test('unknown errors are not retried', () => {
35
+ assert.equal(classifyProviderError('Something odd happened').retry, false)
11
36
  })
12
37
 
13
- test('five retries with a capped backoff', () => {
14
- assert.equal(MAX_PROVIDER_RETRIES, 5)
15
- assert.equal(retryDelayMs(1), 1200)
16
- assert.equal(retryDelayMs(5), 8000)
38
+ test('reads provider retry hints and backs off with a floor', () => {
39
+ assert.equal(retryAfterMs('Please retry in 6s'), 6000)
40
+ assert.equal(retryAfterMs('retry-after: 2'), 2000)
41
+ assert.equal(retryAfterMs('nothing here'), undefined)
42
+ assert.equal(backoffMs(1, '', () => 0.5), 1500)
43
+ assert.equal(backoffMs(3, '', () => 0.5), 6000)
44
+ assert.equal(backoffMs(1, 'retry in 10s', () => 0.5), 10000)
17
45
  })
package/src/lib/retry.ts CHANGED
@@ -1,13 +1,46 @@
1
- /** Automatic retries for provider hiccups: overload, rate limits, 5xx and dropped connections. */
2
- export const MAX_PROVIDER_RETRIES = 5
1
+ /**
2
+ * Decides whether a provider error is worth retrying.
3
+ *
4
+ * Transient problems (rate-limit bursts, overload, 5xx, dropped connections) are retried.
5
+ * Permanent ones (quota or credits exhausted, bad key, bad request, context too long) never are,
6
+ * because waiting cannot fix them. A 429 that says the quota or limit was *reached* is permanent too.
7
+ */
8
+ export const MAX_RETRIES = 5
3
9
 
4
- const RETRYABLE =
5
- /\b(408|409|425|429|500|502|503|504|522|524|529)\b|rate.?limit|overloaded|temporar|timed out|could not be reached|lost the connection|failed to fetch|networkerror|econnreset|etimedout|enotfound|eai_again/i
10
+ const PERMANENT =
11
+ /quota|insufficient[_ ]?(funds|credit|balance)|out of credits?|no credits?|billing|payment required|\b402\b|credit balance|limit (has been )?(reached|exceeded)|reached (the |your )?(\w+ )?limit|exceeded (your |the )?(current )?(quota|limit|plan)|usage limit|monthly limit|daily limit|spend(ing)? limit|invalid[_ ]?(api[_ ]?)?key|incorrect api key|unauthori[sz]ed|\b401\b|\b403\b|forbidden|permission|not found|\b404\b|invalid[_ ]request|\b400\b|context (length|window)|too many tokens|maximum context|content[_ ]policy|safety|model .*(not|does not)|credentials|deprecated/i
6
12
 
7
- export function isRetryableProviderError(message: string): boolean {
8
- return RETRYABLE.test(message)
13
+ const TRANSIENT =
14
+ /\b(429|500|502|503|504|520|521|522|523|524|529)\b|rate.?limit|too many requests|overloaded|capacity|temporar|try again|unavailable|timed? ?out|timeout|could not be reached|lost the connection|failed to fetch|networkerror|network error|econnreset|etimedout|socket hang up|bad gateway|gateway|stream (ended|closed)|internal server error|server error/i
15
+
16
+ export type RetryDecision = { retry: boolean; reason: 'transient' | 'permanent' | 'unknown' }
17
+
18
+ export function classifyProviderError(message: string): RetryDecision {
19
+ const text = message || ''
20
+ // Short-window throttling ("rate limit … per minute", "retry in 6s") clears by itself, even when it says
21
+ // "reached". Anything about quota, credits, billing or a monthly/daily cap does not.
22
+ const burst = /rate.?limit|too many requests|slow down|per[- ]minute|per[- ]second|requests? per|retry (in|after)|try again (in|later)/i.test(text)
23
+ const hardCap = /quota|credit|billing|balance|usage limit|monthly|daily|spend(ing)? limit|exceeded your (current )?(plan|limit)|payment|\b402\b/i.test(text)
24
+ if (burst && !hardCap && !/invalid|unauthori|forbidden|\b40[0134]\b/i.test(text)) return { retry: true, reason: 'transient' }
25
+ if (PERMANENT.test(text)) return { retry: false, reason: 'permanent' }
26
+ if (TRANSIENT.test(text)) return { retry: true, reason: 'transient' }
27
+ return { retry: false, reason: 'unknown' }
28
+ }
29
+
30
+ /** Seconds the provider asked us to wait ("retry in 12s", "retry-after: 30"), if it said. */
31
+ export function retryAfterMs(message: string): number | undefined {
32
+ const match = /(?:retry[- ]after|retry in|try again in|wait)\D{0,6}(\d+(?:\.\d+)?)\s*(ms|milliseconds?|s|sec|seconds?|m|min)?/i.exec(message || '')
33
+ if (!match) return undefined
34
+ const value = Number(match[1])
35
+ if (!Number.isFinite(value)) return undefined
36
+ const unit = (match[2] ?? 's').toLowerCase()
37
+ const ms = unit.startsWith('ms') || unit.startsWith('milli') ? value : unit.startsWith('m') && unit !== 'ms' && !unit.startsWith('milli') ? value * 60_000 : value * 1000
38
+ return Math.min(Math.max(ms, 500), 60_000)
9
39
  }
10
40
 
11
- export function retryDelayMs(attempt: number): number {
12
- return Math.min(1200 * attempt * attempt, 8000)
41
+ /** Exponential backoff (1.5s, 3s, 6s, 12s, 24s) with jitter, never shorter than the provider's hint. */
42
+ export function backoffMs(attempt: number, message = '', random = Math.random): number {
43
+ const base = Math.min(1500 * 2 ** (attempt - 1), 24_000)
44
+ const jittered = base * (0.8 + random() * 0.4)
45
+ return Math.round(Math.max(jittered, retryAfterMs(message) ?? 0))
13
46
  }
@@ -0,0 +1,23 @@
1
+ import type { AppSettings, ChatSession } from '../types'
2
+
3
+ export type PromptSource = 'session' | 'workspace' | 'app'
4
+
5
+ /** The prompt a scope would fall back to when it is set to "shared". */
6
+ export function inheritedPrompt(settings: Pick<AppSettings, 'systemPrompt' | 'workspacePrompts'>, root: string): {
7
+ text: string
8
+ source: Exclude<PromptSource, 'session'>
9
+ } {
10
+ const custom = root ? settings.workspacePrompts?.[root] : undefined
11
+ if (typeof custom === 'string') return { text: custom, source: 'workspace' }
12
+ return { text: settings.systemPrompt, source: 'app' }
13
+ }
14
+
15
+ /** Session custom → workspace custom → app-wide prompt. */
16
+ export function resolveBasePrompt(
17
+ settings: Pick<AppSettings, 'systemPrompt' | 'workspacePrompts'>,
18
+ session: Pick<ChatSession, 'systemPrompt'> | null | undefined,
19
+ root: string,
20
+ ): { text: string; source: PromptSource } {
21
+ if (typeof session?.systemPrompt === 'string') return { text: session.systemPrompt, source: 'session' }
22
+ return inheritedPrompt(settings, root)
23
+ }
package/src/lib/usage.ts CHANGED
@@ -6,6 +6,7 @@ export interface ModelUsage {
6
6
  model: string
7
7
  messages: number
8
8
  tokens: number
9
+ cost: number
9
10
  }
10
11
 
11
12
  export interface HeatmapDay {
@@ -18,7 +19,7 @@ export interface UsageStats {
18
19
  sessions: number
19
20
  messages: number
20
21
  tokens: number
21
- credits: number
22
+ cost: number
22
23
  activeDays: number
23
24
  peakHour: string
24
25
  favoriteModel: string
@@ -82,7 +83,7 @@ export function computeUsage(sessions: ChatSession[], range: UsageRange, now = D
82
83
  const days = new Map<string, HeatmapDay>()
83
84
  const models = new Map<string, ModelUsage>()
84
85
  let tokens = 0
85
- let credits = 0
86
+ let cost = 0
86
87
 
87
88
  for (const { message, session } of messages) {
88
89
  const at = messageTime(message, session.updatedAt)
@@ -95,14 +96,14 @@ export function computeUsage(sessions: ChatSession[], range: UsageRange, now = D
95
96
  if (message.role !== 'assistant') continue
96
97
  const messageTokens = estimateTokens(message)
97
98
  tokens += messageTokens
98
- if (typeof message.usage?.cost === 'number' && Number.isFinite(message.usage.cost)) {
99
- credits += Math.max(0, message.usage.cost)
100
- }
99
+ const messageCredits = typeof message.cost === 'number' ? message.cost : 0
100
+ cost += messageCredits
101
101
  const model = typeof message.model === 'string' ? message.model.trim() : ''
102
102
  if (!model) continue
103
- const entry = models.get(model) ?? { model, messages: 0, tokens: 0 }
103
+ const entry = models.get(model) ?? { model, messages: 0, tokens: 0, cost: 0 }
104
104
  entry.messages += 1
105
105
  entry.tokens += messageTokens
106
+ entry.cost += messageCredits
106
107
  models.set(model, entry)
107
108
  }
108
109
 
@@ -124,7 +125,7 @@ export function computeUsage(sessions: ChatSession[], range: UsageRange, now = D
124
125
  sessions: filteredSessions.length,
125
126
  messages: messages.length,
126
127
  tokens,
127
- credits,
128
+ cost,
128
129
  activeDays: days.size,
129
130
  peakHour: messages.length ? hourLabel(peak) : '—',
130
131
  favoriteModel: favorite?.model ?? '—',
@@ -154,12 +155,6 @@ export function formatTokens(value: number): string {
154
155
  return String(value)
155
156
  }
156
157
 
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
-
163
158
  export function shortModelName(model: string): string {
164
159
  if (!model || model === '—') return model
165
160
  const parts = model.split('/')
@@ -48,7 +48,6 @@ 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,
52
51
  titleSource:
53
52
  session.titleSource === 'ai' || session.titleSource === 'user' || session.titleSource === 'auto'
54
53
  ? session.titleSource
@@ -9,7 +9,6 @@ import type {
9
9
  ProviderId,
10
10
  SecretEntry,
11
11
  SecretsSummary,
12
- Skill,
13
12
  BuiltinToolName,
14
13
  ToolSetting,
15
14
  } from '../types'
@@ -58,7 +57,7 @@ const DEFAULT_TOOLS: Record<BuiltinToolName, ToolSetting> = {
58
57
  read_url: { enabled: true, approval: 'auto' },
59
58
  git: { enabled: true, approval: 'ask' },
60
59
  subagent: { enabled: true, approval: 'ask' },
61
- skill: { enabled: true, approval: 'ask' },
60
+ skill: { enabled: true, approval: 'auto' },
62
61
  }
63
62
 
64
63
  const DEFAULTS: AppSettings = {
@@ -73,7 +72,6 @@ const DEFAULTS: AppSettings = {
73
72
  providers: {},
74
73
  searchProvider: 'duckduckgo',
75
74
  tools: DEFAULT_TOOLS,
76
- skills: [],
77
75
  theme: 'system',
78
76
  showTokenEstimate: true,
79
77
  mode: 'build',
@@ -102,9 +100,6 @@ interface SettingsStore extends AppSettings {
102
100
  saveDeployCredentials: (provider: DeployProviderId, fields: Record<string, string>) => Promise<void>
103
101
  refreshProviders: () => Promise<void>
104
102
  updateTool: (tool: BuiltinToolName, patch: Partial<ToolSetting>) => void
105
- addSkill: (skill: Skill) => void
106
- updateSkill: (id: string, patch: Partial<Skill>) => void
107
- removeSkill: (id: string) => void
108
103
  setMode: (mode: AppSettings['mode']) => void
109
104
  flush: () => Promise<void>
110
105
  }
@@ -122,18 +117,6 @@ function mergeTools(saved?: Partial<Record<BuiltinToolName, Partial<ToolSetting>
122
117
  return merged
123
118
  }
124
119
 
125
- function mergeSkills(saved?: Skill[]) {
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
- }))
135
- }
136
-
137
120
  function upgradePrompt(value: unknown) {
138
121
  if (typeof value !== 'string' || !value.trim()) return DEFAULT_SYSTEM_PROMPT
139
122
  const legacy =
@@ -143,6 +126,15 @@ function upgradePrompt(value: unknown) {
143
126
  return legacy ? DEFAULT_SYSTEM_PROMPT : value
144
127
  }
145
128
 
129
+ function sanitizeWorkspacePrompts(value: unknown): Record<string, string> {
130
+ const out: Record<string, string> = {}
131
+ if (!value || typeof value !== 'object') return out
132
+ for (const [root, prompt] of Object.entries(value as Record<string, unknown>)) {
133
+ if (root && typeof prompt === 'string') out[root] = prompt
134
+ }
135
+ return out
136
+ }
137
+
146
138
  function sanitizeBaseUrls(saved: AppSettings['providers'] | undefined) {
147
139
  const providers: AppSettings['providers'] = {}
148
140
  for (const [id, value] of Object.entries(saved ?? {})) {
@@ -181,21 +173,12 @@ function buildSettings(persisted?: Partial<AppSettings>): AppSettings {
181
173
  ? Math.max(256, Math.min(200_000, Math.round(saved.maxTokens)))
182
174
  : DEFAULTS.maxTokens,
183
175
  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
- : {},
192
176
  providers: sanitizeBaseUrls(saved.providers),
193
177
  searchProvider:
194
178
  saved.searchProvider && ['duckduckgo', 'tavily', 'brave', 'serper'].includes(saved.searchProvider)
195
179
  ? saved.searchProvider
196
180
  : DEFAULTS.searchProvider,
197
181
  tools: mergeTools(saved.tools),
198
- skills: mergeSkills(saved.skills),
199
182
  theme: normalizeTheme(saved.theme),
200
183
  chatFontSize:
201
184
  typeof saved.chatFontSize === 'number' && Number.isFinite(saved.chatFontSize)
@@ -204,6 +187,7 @@ function buildSettings(persisted?: Partial<AppSettings>): AppSettings {
204
187
  workDetail: saved.workDetail === 'concise' ? 'concise' : 'detailed',
205
188
  autoCollapseWork: saved.autoCollapseWork !== false,
206
189
  aiSessionTitles: saved.aiSessionTitles !== false,
190
+ workspacePrompts: sanitizeWorkspacePrompts(saved.workspacePrompts),
207
191
  }
208
192
  }
209
193
 
@@ -222,11 +206,9 @@ function persistedPatch(patch: Partial<AppSettings>): Partial<AppSettings> {
222
206
  'temperature',
223
207
  'maxTokens',
224
208
  'systemPrompt',
225
- 'workspacePrompts',
226
209
  'providers',
227
210
  'searchProvider',
228
211
  'tools',
229
- 'skills',
230
212
  'theme',
231
213
  'showTokenEstimate',
232
214
  'mode',
@@ -234,6 +216,7 @@ function persistedPatch(patch: Partial<AppSettings>): Partial<AppSettings> {
234
216
  'workDetail',
235
217
  'autoCollapseWork',
236
218
  'aiSessionTitles',
219
+ 'workspacePrompts',
237
220
  ]
238
221
  for (const key of keys) {
239
222
  if (key in patch) out[key] = patch[key]
@@ -382,21 +365,6 @@ export const useSettings = create<SettingsStore>()((set, get) => ({
382
365
  set(next)
383
366
  queueSave(next)
384
367
  },
385
- addSkill: (skill) => {
386
- const next = { skills: [...get().skills, skill] }
387
- set(next)
388
- queueSave(next)
389
- },
390
- updateSkill: (id, patch) => {
391
- const next = { skills: get().skills.map((skill) => (skill.id === id ? { ...skill, ...patch } : skill)) }
392
- set(next)
393
- queueSave(next)
394
- },
395
- removeSkill: (id) => {
396
- const next = { skills: get().skills.filter((skill) => skill.id !== id) }
397
- set(next)
398
- queueSave(next)
399
- },
400
368
  setMode: (mode) => {
401
369
  const patch = { mode }
402
370
  set(patch)
@@ -0,0 +1,79 @@
1
+ import { create } from 'zustand'
2
+ import type { InstalledSkill } from '../types'
3
+ import {
4
+ installSkillNpm,
5
+ listSkillsApi,
6
+ removeSkillApi,
7
+ setSkillEnabledApi,
8
+ uploadSkillFiles,
9
+ type SkillUploadFile,
10
+ } from '../lib/daemon'
11
+
12
+ interface SkillsState {
13
+ skills: InstalledSkill[]
14
+ loaded: boolean
15
+ loading: boolean
16
+ error: string
17
+ load: () => Promise<void>
18
+ upload: (files: SkillUploadFile[]) => Promise<string[]>
19
+ installNpm: (spec: string) => Promise<string[]>
20
+ setEnabled: (name: string, enabled: boolean) => Promise<void>
21
+ remove: (name: string) => Promise<void>
22
+ }
23
+
24
+ export const useSkills = create<SkillsState>()((set) => ({
25
+ skills: [],
26
+ loaded: false,
27
+ loading: false,
28
+ error: '',
29
+ load: async () => {
30
+ set({ loading: true })
31
+ try {
32
+ const { skills } = await listSkillsApi()
33
+ set({ skills, loaded: true, loading: false, error: '' })
34
+ } catch (error) {
35
+ set({ loading: false, error: error instanceof Error ? error.message : 'Could not load skills.' })
36
+ }
37
+ },
38
+ upload: async (files) => {
39
+ const result = await uploadSkillFiles(files)
40
+ set({ skills: result.skills, loaded: true })
41
+ return result.installed
42
+ },
43
+ installNpm: async (spec) => {
44
+ const result = await installSkillNpm(spec)
45
+ set({ skills: result.skills, loaded: true })
46
+ return result.installed
47
+ },
48
+ setEnabled: async (name, enabled) => {
49
+ set((state) => ({ skills: state.skills.map((skill) => (skill.name === name ? { ...skill, enabled } : skill)) }))
50
+ try {
51
+ const result = await setSkillEnabledApi(name, enabled)
52
+ set({ skills: result.skills })
53
+ } catch (error) {
54
+ set((state) => ({
55
+ skills: state.skills.map((skill) => (skill.name === name ? { ...skill, enabled: !enabled } : skill)),
56
+ error: error instanceof Error ? error.message : 'Could not update the skill.',
57
+ }))
58
+ }
59
+ },
60
+ remove: async (name) => {
61
+ const result = await removeSkillApi(name)
62
+ set({ skills: result.skills })
63
+ },
64
+ }))
65
+
66
+ export function enabledSkills(): InstalledSkill[] {
67
+ return useSkills.getState().skills.filter((skill) => skill.enabled)
68
+ }
69
+
70
+ /** Catalog section for the system prompt: names and descriptions only; bodies load through the skill tool. */
71
+ export function skillCatalogPrompt(): string {
72
+ const skills = enabledSkills()
73
+ if (skills.length === 0) return ''
74
+ return [
75
+ '## Skills',
76
+ 'Skills are folders of expert instructions. When a task matches a skill below, call the `skill` tool with its name BEFORE starting, then follow what it returns. Load bundled files (scripts, references) with the same tool via `file`. Do not guess a skill\'s contents.',
77
+ ...skills.map((skill) => `- ${skill.name}: ${skill.description || 'No description.'}`),
78
+ ].join('\n')
79
+ }
package/src/store/ui.ts CHANGED
@@ -117,7 +117,9 @@ export const useUi = create<UiState>()((set, get) => ({
117
117
  setMcpOpen: (open) => set({ mcpOpen: open, ...(open ? { mobileSidebarOpen: false, settingsOpen: false } : {}) }),
118
118
  settingsOpen: false,
119
119
  settingsTab: 'general',
120
- openSettings: (tab = 'general') => set({ settingsOpen: true, settingsTab: tab, mobileSidebarOpen: false }),
120
+ // Plugins are paused for maintenance: any request for that tab lands on General instead.
121
+ openSettings: (tab = 'general') =>
122
+ set({ settingsOpen: true, settingsTab: tab === 'plugins' ? 'general' : tab, mobileSidebarOpen: false }),
121
123
  closeSettings: () => set({ settingsOpen: false }),
122
124
  deployOpen: false,
123
125
  setDeployOpen: (open) => set({ deployOpen: open }),