@bibobonking/opensol 3.1.4 → 3.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -12
- package/desktop/src-tauri/icons/128x128.png +0 -0
- package/desktop/src-tauri/icons/128x128@2x.png +0 -0
- package/desktop/src-tauri/icons/32x32.png +0 -0
- package/desktop/src-tauri/icons/64x64.png +0 -0
- package/desktop/src-tauri/icons/Square107x107Logo.png +0 -0
- package/desktop/src-tauri/icons/Square142x142Logo.png +0 -0
- package/desktop/src-tauri/icons/Square150x150Logo.png +0 -0
- package/desktop/src-tauri/icons/Square284x284Logo.png +0 -0
- package/desktop/src-tauri/icons/Square30x30Logo.png +0 -0
- package/desktop/src-tauri/icons/Square310x310Logo.png +0 -0
- package/desktop/src-tauri/icons/Square44x44Logo.png +0 -0
- package/desktop/src-tauri/icons/Square71x71Logo.png +0 -0
- package/desktop/src-tauri/icons/Square89x89Logo.png +0 -0
- package/desktop/src-tauri/icons/StoreLogo.png +0 -0
- package/desktop/src-tauri/icons/icon.icns +0 -0
- package/desktop/src-tauri/icons/icon.ico +0 -0
- package/desktop/src-tauri/icons/icon.png +0 -0
- package/dist/assets/index-C0yvs6Vl.js +281 -0
- package/dist/assets/index-CxNPLBg7.css +1 -0
- package/dist/favicon.svg +18 -15
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/public/favicon.svg +18 -15
- package/server/daemon.mjs +66 -49
- package/server/lib/deploy.mjs +222 -121
- package/server/lib/providers.mjs +23 -1
- package/server/lib/skills.mjs +385 -100
- package/server/lib/tools.mjs +0 -40
- package/src/agent/adapters/openai-responses.ts +1 -5
- package/src/agent/adapters/openai.ts +4 -11
- package/src/agent/prompt.ts +7 -14
- package/src/agent/subagent.ts +3 -14
- package/src/agent/tools.ts +2 -0
- package/src/components/ChatView.tsx +2 -1
- package/src/components/Composer.tsx +2 -1
- package/src/components/ContextBar.tsx +10 -0
- package/src/components/DeployModal.tsx +266 -252
- package/src/components/FileIcon.tsx +391 -99
- package/src/components/HomeDashboard.tsx +5 -10
- package/src/components/Logo.tsx +9 -11
- package/src/components/ModelDeck.tsx +8 -11
- package/src/components/SettingsModal.tsx +160 -122
- package/src/components/Sidebar.tsx +4 -3
- package/src/components/SystemPromptButton.tsx +198 -0
- package/src/components/ToolCallCard.tsx +2 -0
- package/src/components/TopBar.tsx +2 -19
- package/src/components/WorkspacePanel.tsx +76 -93
- package/src/core/plugins/tools/skill.ts +19 -76
- package/src/core/services/agent-loop.ts +16 -31
- package/src/core/services/subagents.ts +2 -2
- package/src/core/services/system-prompt.ts +16 -23
- package/src/core/services/tools.ts +0 -1
- package/src/lib/boot.ts +2 -0
- package/src/lib/credits.ts +33 -0
- package/src/lib/daemon.ts +40 -19
- package/src/lib/permissions.ts +1 -1
- package/src/lib/retry.test.mjs +40 -12
- package/src/lib/retry.ts +41 -8
- package/src/lib/system-prompt.ts +23 -0
- package/src/lib/usage.ts +8 -13
- package/src/store/chats.ts +0 -1
- package/src/store/settings.ts +12 -44
- package/src/store/skills.ts +79 -0
- package/src/store/ui.ts +3 -1
- package/src/types.ts +30 -15
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/assets/index-B1pQC9Fe.js +0 -288
- package/dist/assets/index-qLr4aZVC.css +0 -1
- package/src/components/SystemPromptModal.tsx +0 -168
- package/src/lib/skills.test.mjs +0 -22
- package/src/lib/skills.ts +0 -93
package/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
|
+
}
|
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']
|
|
25
25
|
|
|
26
26
|
export function permissionMode(tools: AppSettings['tools']): PermissionMode {
|
|
27
27
|
const writesAuto = WRITE_TOOLS.every((tool) => tools[tool]?.approval === 'auto')
|
package/src/lib/retry.test.mjs
CHANGED
|
@@ -1,17 +1,45 @@
|
|
|
1
|
-
import test from 'node:test'
|
|
2
1
|
import assert from 'node:assert/strict'
|
|
3
|
-
import
|
|
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('
|
|
6
|
-
assert.equal(
|
|
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('
|
|
14
|
-
assert.equal(
|
|
15
|
-
assert.equal(
|
|
16
|
-
assert.equal(
|
|
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
|
-
/**
|
|
2
|
-
|
|
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
|
|
5
|
-
|
|
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
|
-
|
|
8
|
-
|
|
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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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('/')
|
package/src/store/chats.ts
CHANGED
|
@@ -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
|
package/src/store/settings.ts
CHANGED
|
@@ -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: '
|
|
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
|
-
|
|
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 }),
|