@bibobonking/opensol 3.1.3 → 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 +13 -0
- 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 +48 -19
- package/src/core/plugins/tools/skill.ts +19 -76
- package/src/core/services/agent-loop.ts +16 -21
- 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 +45 -0
- package/src/lib/retry.ts +46 -0
- 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-DaJcH9ts.css +0 -1
- package/dist/assets/index-hs0KWMP6.js +0 -288
- package/src/components/SystemPromptModal.tsx +0 -168
- package/src/lib/skills.test.mjs +0 -22
- package/src/lib/skills.ts +0 -93
|
@@ -52,6 +52,8 @@ function approvalCopy(name: ToolName) {
|
|
|
52
52
|
return 'OpenSol wants to change the Git state of this repository.'
|
|
53
53
|
case 'subagent':
|
|
54
54
|
return 'OpenSol wants to start a read-only subagent to investigate.'
|
|
55
|
+
case 'skill':
|
|
56
|
+
return 'OpenSol wants to load an installed skill.'
|
|
55
57
|
case 'web_search':
|
|
56
58
|
return 'OpenSol wants to search the web.'
|
|
57
59
|
case 'read_url':
|
|
@@ -2,7 +2,6 @@ import {
|
|
|
2
2
|
FolderOpen,
|
|
3
3
|
ListChecks,
|
|
4
4
|
Menu,
|
|
5
|
-
MessageSquareCode,
|
|
6
5
|
Moon,
|
|
7
6
|
PanelLeftOpen,
|
|
8
7
|
Rocket,
|
|
@@ -20,8 +19,7 @@ import { cn } from '../lib/utils'
|
|
|
20
19
|
import { themeIsDark } from '../lib/themes'
|
|
21
20
|
import { LogoMark } from './Logo'
|
|
22
21
|
import { ContextBar } from './ContextBar'
|
|
23
|
-
import {
|
|
24
|
-
import { useState } from 'react'
|
|
22
|
+
import { SystemPromptButton } from './SystemPromptButton'
|
|
25
23
|
|
|
26
24
|
export type ChatViewMode = 'chat' | 'trajectory'
|
|
27
25
|
|
|
@@ -47,11 +45,7 @@ export function TopBar({
|
|
|
47
45
|
const setWorkspaceOpen = useUi((state) => state.setWorkspaceOpen)
|
|
48
46
|
const setDeployOpen = useUi((state) => state.setDeployOpen)
|
|
49
47
|
const workspace = useWorkspace((state) => state.info)
|
|
50
|
-
const workspaceRoot = useWorkspace((state) => state.root)
|
|
51
48
|
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()))
|
|
55
49
|
|
|
56
50
|
// Go through the theme registry so Night Owl, Claude Code, Solar and plugin themes count as dark.
|
|
57
51
|
const isDark = themeIsDark(
|
|
@@ -133,17 +127,6 @@ export function TopBar({
|
|
|
133
127
|
<ContextBar messages={session?.messages ?? []} model={activeModel} sessionId={session?.id ?? null} />
|
|
134
128
|
</div>
|
|
135
129
|
)}
|
|
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>
|
|
147
130
|
<button
|
|
148
131
|
type="button"
|
|
149
132
|
onClick={() => setWorkspaceOpen(!workspaceOpen)}
|
|
@@ -158,6 +141,7 @@ export function TopBar({
|
|
|
158
141
|
<FolderOpen size={13} className="shrink-0" />
|
|
159
142
|
<span className="max-w-[140px] truncate">{workspace.available ? workspace.name : 'No folder'}</span>
|
|
160
143
|
</button>
|
|
144
|
+
<SystemPromptButton session={session} />
|
|
161
145
|
<button
|
|
162
146
|
type="button"
|
|
163
147
|
className="icon-button hidden sm:inline-flex"
|
|
@@ -195,7 +179,6 @@ export function TopBar({
|
|
|
195
179
|
<Settings size={16} />
|
|
196
180
|
</button>
|
|
197
181
|
</div>
|
|
198
|
-
<SystemPromptModal session={session} open={promptOpen} onClose={() => setPromptOpen(false)} />
|
|
199
182
|
</header>
|
|
200
183
|
)
|
|
201
184
|
}
|
|
@@ -26,9 +26,9 @@ import { cn, formatBytes } from '../lib/utils'
|
|
|
26
26
|
import { useUi } from '../store/ui'
|
|
27
27
|
import { useWorkspace } from '../store/workspace'
|
|
28
28
|
import { PreviewPane } from './PreviewPane'
|
|
29
|
+
import { FileIcon, FolderIcon } from './FileIcon'
|
|
29
30
|
import { SessionsTab } from './SessionsTab'
|
|
30
31
|
import { useChats } from '../store/chats'
|
|
31
|
-
import { FileIcon } from './FileIcon'
|
|
32
32
|
import { Spinner } from './ui'
|
|
33
33
|
|
|
34
34
|
interface TreeNode {
|
|
@@ -269,6 +269,20 @@ export function WorkspacePanel() {
|
|
|
269
269
|
return () => window.removeEventListener('keydown', onKey)
|
|
270
270
|
}, [])
|
|
271
271
|
|
|
272
|
+
const gitStatus = useMemo(() => {
|
|
273
|
+
const files = new Map<string, string>()
|
|
274
|
+
const dirs = new Set<string>()
|
|
275
|
+
for (const change of git.entries) {
|
|
276
|
+
files.set(change.path, change.status.trim() || 'M')
|
|
277
|
+
let slash = change.path.lastIndexOf('/')
|
|
278
|
+
while (slash > 0) {
|
|
279
|
+
dirs.add(change.path.slice(0, slash))
|
|
280
|
+
slash = change.path.lastIndexOf('/', slash - 1)
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return { files, dirs }
|
|
284
|
+
}, [git.entries])
|
|
285
|
+
|
|
272
286
|
if (!open) return null
|
|
273
287
|
|
|
274
288
|
const submitEditor = async () => {
|
|
@@ -313,10 +327,15 @@ export function WorkspacePanel() {
|
|
|
313
327
|
}
|
|
314
328
|
}
|
|
315
329
|
|
|
330
|
+
const statusTone = (status: string) =>
|
|
331
|
+
status.includes('?') || status.includes('A') ? 'text-success' : status.includes('D') ? 'text-danger' : 'text-warning'
|
|
332
|
+
|
|
316
333
|
const renderNode = (node: TreeNode, depth: number): ReactNode => {
|
|
317
334
|
const path = node.entry.path
|
|
335
|
+
const indent = 10 + depth * 14
|
|
318
336
|
if (node.entry.type === 'dir') {
|
|
319
337
|
const closed = collapsed.has(path)
|
|
338
|
+
const touched = gitStatus.dirs.has(path)
|
|
320
339
|
return (
|
|
321
340
|
<div key={path}>
|
|
322
341
|
<button
|
|
@@ -329,13 +348,18 @@ export function WorkspacePanel() {
|
|
|
329
348
|
return next
|
|
330
349
|
})
|
|
331
350
|
}
|
|
332
|
-
className="group flex h-
|
|
333
|
-
style={{ paddingLeft:
|
|
351
|
+
className="group flex h-[26px] w-full items-center gap-1.5 rounded-md pr-1.5 text-left text-[12px] font-medium text-ink/85 transition-colors hover:bg-raised hover:text-ink"
|
|
352
|
+
style={{ paddingLeft: indent }}
|
|
334
353
|
title={path}
|
|
354
|
+
aria-expanded={!closed}
|
|
335
355
|
>
|
|
336
|
-
<ChevronRight size={
|
|
337
|
-
<
|
|
356
|
+
<ChevronRight size={11} strokeWidth={2.2} className={cn('shrink-0 text-faint transition-transform duration-150', !closed && 'rotate-90')} />
|
|
357
|
+
<FolderIcon name={node.entry.name} open={!closed} size={16} />
|
|
338
358
|
<span className="min-w-0 flex-1 truncate">{node.entry.name}</span>
|
|
359
|
+
{touched && closed && <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-warning" title="Contains changes" />}
|
|
360
|
+
{closed && node.children.length > 0 && (
|
|
361
|
+
<span className="shrink-0 rounded-full bg-raised px-1.5 text-[9px] tabular-nums text-faint group-hover:hidden">{node.children.length}</span>
|
|
362
|
+
)}
|
|
339
363
|
<span
|
|
340
364
|
role="button"
|
|
341
365
|
tabIndex={0}
|
|
@@ -349,32 +373,36 @@ export function WorkspacePanel() {
|
|
|
349
373
|
className="hidden h-5 w-5 items-center justify-center rounded text-faint hover:text-danger group-hover:flex"
|
|
350
374
|
aria-label={`Delete ${path}`}
|
|
351
375
|
>
|
|
352
|
-
<Trash2 size={
|
|
376
|
+
<Trash2 size={11} />
|
|
353
377
|
</span>
|
|
354
378
|
</button>
|
|
355
|
-
{!closed && node.children.
|
|
379
|
+
{!closed && node.children.length > 0 && (
|
|
380
|
+
<div className="relative">
|
|
381
|
+
<span aria-hidden className="pointer-events-none absolute bottom-0.5 top-0 w-px bg-line/70" style={{ left: indent + 5 }} />
|
|
382
|
+
{node.children.map((child) => renderNode(child, depth + 1))}
|
|
383
|
+
</div>
|
|
384
|
+
)}
|
|
356
385
|
</div>
|
|
357
386
|
)
|
|
358
387
|
}
|
|
388
|
+
const active = activeFile === path
|
|
389
|
+
const status = gitStatus.files.get(path)
|
|
359
390
|
return (
|
|
360
391
|
<button
|
|
361
392
|
key={path}
|
|
362
393
|
type="button"
|
|
363
394
|
onClick={() => setActiveFile(path)}
|
|
364
395
|
className={cn(
|
|
365
|
-
'group flex h-
|
|
366
|
-
|
|
396
|
+
'group relative flex h-[26px] w-full items-center gap-1.5 rounded-md pr-2 text-left text-[11.5px] transition-colors',
|
|
397
|
+
active ? 'bg-accent-soft font-medium text-ink' : 'text-muted hover:bg-raised hover:text-ink',
|
|
367
398
|
)}
|
|
368
|
-
style={{ paddingLeft:
|
|
399
|
+
style={{ paddingLeft: indent + 13 }}
|
|
369
400
|
title={path}
|
|
370
401
|
>
|
|
371
|
-
<
|
|
372
|
-
<
|
|
373
|
-
{
|
|
374
|
-
|
|
375
|
-
{formatBytes(node.entry.size)}
|
|
376
|
-
</span>
|
|
377
|
-
)}
|
|
402
|
+
{active && <span aria-hidden className="absolute inset-y-1 left-0 w-[3px] rounded-r-full bg-accent" />}
|
|
403
|
+
<FileIcon name={node.entry.name} size={15} />
|
|
404
|
+
<span className={cn('min-w-0 flex-1 truncate', status && statusTone(status))}>{node.entry.name}</span>
|
|
405
|
+
{status && <span className={cn('shrink-0 font-mono text-[9px] font-semibold', statusTone(status))}>{status.slice(0, 1)}</span>}
|
|
378
406
|
</button>
|
|
379
407
|
)
|
|
380
408
|
}
|
|
@@ -564,7 +592,7 @@ export function WorkspacePanel() {
|
|
|
564
592
|
: 'text-muted hover:bg-raised hover:text-ink',
|
|
565
593
|
)}
|
|
566
594
|
>
|
|
567
|
-
{entry.type === 'dir' ? <
|
|
595
|
+
{entry.type === 'dir' ? <FolderIcon name={entry.name} size={15} /> : <FileIcon name={entry.name} size={15} />}
|
|
568
596
|
<span className="truncate">{entry.path}</span>
|
|
569
597
|
</button>
|
|
570
598
|
))
|
|
@@ -688,7 +716,8 @@ export function WorkspacePanel() {
|
|
|
688
716
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left font-mono text-[10px] hover:bg-raised"
|
|
689
717
|
title={entry.path}
|
|
690
718
|
>
|
|
691
|
-
<span className="w-6 shrink-0 text-warning">{entry.status}</span>
|
|
719
|
+
<span className="w-6 shrink-0 font-mono font-semibold text-warning">{entry.status}</span>
|
|
720
|
+
<FileIcon name={entry.path.split('/').pop() ?? entry.path} size={14} />
|
|
692
721
|
<span className="truncate text-muted">{entry.path}</span>
|
|
693
722
|
</button>
|
|
694
723
|
))
|
|
@@ -1,101 +1,44 @@
|
|
|
1
1
|
import type { Context } from 'cordis'
|
|
2
|
-
import {
|
|
3
|
-
import type { Skill } from '../../../types'
|
|
2
|
+
import { readSkillApi } from '../../../lib/daemon'
|
|
4
3
|
import type { ToolResult } from '../../types'
|
|
5
4
|
|
|
6
|
-
|
|
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
|
-
}
|
|
5
|
+
const MAX_TOOL_OUTPUT = 60_000
|
|
14
6
|
|
|
15
7
|
export const name = 'tool-skill'
|
|
16
8
|
export const inject = ['tools']
|
|
17
9
|
|
|
18
10
|
export function apply(ctx: Context) {
|
|
19
|
-
|
|
11
|
+
return ctx.tools.register({
|
|
20
12
|
name: 'skill',
|
|
21
13
|
description:
|
|
22
|
-
'
|
|
14
|
+
'Load an installed skill by name. With only `name` it returns the skill\'s SKILL.md instructions and the list of bundled files. Pass `file` (relative path, e.g. "references/api.md") to read one bundled file. Skill scripts can be run with the shell tool from the returned directory.',
|
|
23
15
|
parameters: {
|
|
24
16
|
type: 'object',
|
|
25
17
|
properties: {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
command: { type: 'string', description: 'Shell command to run inside the skill folder. Required for run.' },
|
|
18
|
+
name: { type: 'string', description: 'Skill name from the Skills list in the system prompt.' },
|
|
19
|
+
file: { type: 'string', description: 'Optional bundled file to read, relative to the skill folder.' },
|
|
29
20
|
},
|
|
30
|
-
required: ['
|
|
21
|
+
required: ['name'],
|
|
31
22
|
additionalProperties: false,
|
|
32
23
|
},
|
|
33
24
|
allowedInMode: () => true,
|
|
34
|
-
async execute(args
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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,
|
|
25
|
+
async execute(args): Promise<ToolResult> {
|
|
26
|
+
const skillName = typeof args.name === 'string' ? args.name.trim() : ''
|
|
27
|
+
if (!skillName) return { output: 'Missing string argument "name".', isError: true }
|
|
28
|
+
try {
|
|
29
|
+
const result = await readSkillApi(skillName, typeof args.file === 'string' ? args.file : undefined)
|
|
30
|
+
const parts = [`Skill: ${result.name} · ${result.file}`, `Directory: ${result.directory}`, '────', result.content]
|
|
31
|
+
if (result.files && result.files.length > 0) {
|
|
32
|
+
parts.push('────', `Bundled files (read with this tool's "file" argument):\n${result.files.map((item) => `- ${item}`).join('\n')}`)
|
|
53
33
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if (action === 'read') {
|
|
57
|
-
const files = Object.keys(target.files ?? {})
|
|
34
|
+
const text = parts.join('\n')
|
|
58
35
|
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'),
|
|
36
|
+
output: text.length > MAX_TOOL_OUTPUT ? `${text.slice(0, MAX_TOOL_OUTPUT)}\n… truncated` : text,
|
|
69
37
|
isError: false,
|
|
70
38
|
}
|
|
39
|
+
} catch (error) {
|
|
40
|
+
return { output: error instanceof Error ? error.message : 'Could not load the skill.', isError: true }
|
|
71
41
|
}
|
|
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
42
|
},
|
|
98
43
|
})
|
|
99
|
-
|
|
100
|
-
return () => unregister()
|
|
101
44
|
}
|
|
@@ -4,18 +4,15 @@ 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'
|
|
8
7
|
import { getProviderName } from '../../data/providers'
|
|
9
8
|
import { describeRequestError } from '../../lib/sse'
|
|
9
|
+
import { messageCost } from '../../lib/credits'
|
|
10
|
+
import { MAX_RETRIES, backoffMs, classifyProviderError } from '../../lib/retry'
|
|
10
11
|
import type { AdapterResult } from '../../agent/adapters/shared'
|
|
11
12
|
import { createTurnQueue, lastUserMessageId } from '../../agent/queue'
|
|
12
13
|
import { resolveToolSetting } from '../mcp-tools'
|
|
13
14
|
import type { StepContext, StepResult, TurnResult } from '../types'
|
|
14
15
|
|
|
15
|
-
const MAX_RETRIES = 2
|
|
16
|
-
/** Provider hiccups worth another try: overload, rate limits, 5xx and dropped connections. */
|
|
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
|
|
18
|
-
|
|
19
16
|
function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
|
20
17
|
return new Promise((resolve) => {
|
|
21
18
|
const finish = () => {
|
|
@@ -112,7 +109,7 @@ export class AgentLoopService extends Service {
|
|
|
112
109
|
let turnError: string | undefined
|
|
113
110
|
|
|
114
111
|
try {
|
|
115
|
-
// No round cap: the turn ends when the model stops calling tools or the user stops it.
|
|
112
|
+
// No round cap: the turn ends when the model stops calling tools, errors, or the user stops it.
|
|
116
113
|
rounds: for (let round = 0; ; round += 1) {
|
|
117
114
|
totalRounds = round + 1
|
|
118
115
|
if (controller.signal.aborted) break
|
|
@@ -189,12 +186,7 @@ export class AgentLoopService extends Service {
|
|
|
189
186
|
settings,
|
|
190
187
|
model: findModel(settings.provider, activeModelId(settings)),
|
|
191
188
|
history,
|
|
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
|
-
}),
|
|
189
|
+
system: this.ctx.systemPrompt.assemble(settings, mode, sessionId),
|
|
198
190
|
mode,
|
|
199
191
|
signal: controller.signal,
|
|
200
192
|
handlers: {
|
|
@@ -216,24 +208,22 @@ export class AgentLoopService extends Service {
|
|
|
216
208
|
} catch (error) {
|
|
217
209
|
const stopped = isAbortError(error) || controller.signal.aborted
|
|
218
210
|
// Only retry when nothing reached the screen yet, so a retry never duplicates text.
|
|
211
|
+
const message = error instanceof Error ? error.message : ''
|
|
212
|
+
const decision = classifyProviderError(message)
|
|
219
213
|
const retryable =
|
|
220
|
-
!stopped &&
|
|
221
|
-
attempt < MAX_RETRIES &&
|
|
222
|
-
!streamedContent &&
|
|
223
|
-
!streamedReasoning &&
|
|
224
|
-
RETRYABLE.test(error instanceof Error ? error.message : '')
|
|
214
|
+
!stopped && decision.retry && attempt < MAX_RETRIES && !streamedContent && !streamedReasoning
|
|
225
215
|
if (retryable) {
|
|
226
216
|
attempt += 1
|
|
217
|
+
const wait = backoffMs(attempt, message)
|
|
227
218
|
window.dispatchEvent(
|
|
228
219
|
new CustomEvent('maxcode:notice', {
|
|
229
220
|
detail: {
|
|
230
|
-
message: `${providerName}
|
|
221
|
+
message: `${providerName} had a temporary error. Retrying in ${Math.round(wait / 1000)}s (${attempt}/${MAX_RETRIES})…`,
|
|
231
222
|
tone: 'info',
|
|
232
223
|
},
|
|
233
224
|
}),
|
|
234
225
|
)
|
|
235
|
-
await sleep(
|
|
236
|
-
if (controller.signal.aborted) continue
|
|
226
|
+
await sleep(wait, controller.signal)
|
|
237
227
|
continue
|
|
238
228
|
}
|
|
239
229
|
if (flushTimer !== undefined) window.clearTimeout(flushTimer)
|
|
@@ -256,7 +246,11 @@ export class AgentLoopService extends Service {
|
|
|
256
246
|
reasoning: result.reasoning,
|
|
257
247
|
thinkingBlocks: result.thinkingBlocks,
|
|
258
248
|
toolCalls: result.toolCalls,
|
|
259
|
-
usage: usage.input || usage.output
|
|
249
|
+
usage: usage.input || usage.output ? usage : result.usage,
|
|
250
|
+
cost: messageCost(
|
|
251
|
+
usage.input || usage.output ? usage : result.usage,
|
|
252
|
+
findModel(settings.provider, activeModelId(settings)),
|
|
253
|
+
),
|
|
260
254
|
durationMs: Date.now() - startedAt,
|
|
261
255
|
stopped: controller.signal.aborted || undefined,
|
|
262
256
|
})
|
|
@@ -321,6 +315,7 @@ export class AgentLoopService extends Service {
|
|
|
321
315
|
typeof call.args.task === 'string' ? call.args.task : '',
|
|
322
316
|
typeof call.args.context === 'string' ? call.args.context : '',
|
|
323
317
|
controller.signal,
|
|
318
|
+
sessionId,
|
|
324
319
|
),
|
|
325
320
|
),
|
|
326
321
|
isError: false,
|
|
@@ -9,7 +9,7 @@ export class SubagentService extends Service {
|
|
|
9
9
|
super(ctx, 'subagents')
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
async run(task: string, context: string, signal: AbortSignal): Promise<string> {
|
|
13
|
-
return await runSubagent(task, context, signal)
|
|
12
|
+
async run(task: string, context: string, signal: AbortSignal, sessionId?: string): Promise<string> {
|
|
13
|
+
return await runSubagent(task, context, signal, sessionId)
|
|
14
14
|
}
|
|
15
15
|
}
|
|
@@ -2,6 +2,8 @@ import { Service, type Context } from 'cordis'
|
|
|
2
2
|
import type { AgentMode, AppSettings } from '../../types'
|
|
3
3
|
import { activeModelId } from '../../store/settings'
|
|
4
4
|
import { useWorkspace } from '../../store/workspace'
|
|
5
|
+
import { skillCatalogPrompt } from '../../store/skills'
|
|
6
|
+
import { resolveBasePrompt } from '../../lib/system-prompt'
|
|
5
7
|
import type { PromptSection } from '../types'
|
|
6
8
|
|
|
7
9
|
function workspaceContext(): string {
|
|
@@ -43,31 +45,22 @@ export class SystemPromptService extends Service {
|
|
|
43
45
|
return () => this.customSections.delete(name)
|
|
44
46
|
}
|
|
45
47
|
|
|
46
|
-
assemble(
|
|
47
|
-
settings: AppSettings,
|
|
48
|
-
mode: AgentMode = settings.mode,
|
|
49
|
-
overrides: { sessionPrompt?: string; workspacePrompt?: string } = {},
|
|
50
|
-
): string {
|
|
48
|
+
assemble(settings: AppSettings, mode: AgentMode = settings.mode, sessionId?: string): string {
|
|
51
49
|
const sections: PromptSection[] = []
|
|
52
50
|
|
|
53
|
-
// 1. Base instruction
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
51
|
+
// 1. Base instruction
|
|
52
|
+
// Session prompt beats workspace prompt beats the app-wide one.
|
|
53
|
+
const workspaceState = useWorkspace.getState()
|
|
54
|
+
const session = sessionId ? this.ctx.sessions.current(sessionId) : undefined
|
|
55
|
+
const base = resolveBasePrompt(settings, session, workspaceState.root || workspaceState.info.root)
|
|
56
|
+
if (base.text.trim()) {
|
|
57
|
+
sections.push({ name: 'base', priority: 10, content: base.text.trim() })
|
|
57
58
|
}
|
|
58
59
|
|
|
59
|
-
// 2.
|
|
60
|
-
const
|
|
61
|
-
if (
|
|
62
|
-
sections.push({
|
|
63
|
-
name: 'skills',
|
|
64
|
-
priority: 20,
|
|
65
|
-
content: [
|
|
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'),
|
|
70
|
-
})
|
|
60
|
+
// 2. Skill catalog (names + descriptions; bodies load on demand through the skill tool)
|
|
61
|
+
const catalog = skillCatalogPrompt()
|
|
62
|
+
if (catalog && settings.tools.skill?.enabled !== false) {
|
|
63
|
+
sections.push({ name: 'skills', priority: 20, content: catalog })
|
|
71
64
|
}
|
|
72
65
|
|
|
73
66
|
// 3. Workspace
|
|
@@ -163,9 +156,9 @@ export class SystemPromptService extends Service {
|
|
|
163
156
|
})
|
|
164
157
|
}
|
|
165
158
|
|
|
166
|
-
buildSubagentPrompt(settings: AppSettings): string {
|
|
159
|
+
buildSubagentPrompt(settings: AppSettings, sessionId?: string): string {
|
|
167
160
|
return [
|
|
168
|
-
this.assemble(settings, 'plan'),
|
|
161
|
+
this.assemble(settings, 'plan', sessionId),
|
|
169
162
|
[
|
|
170
163
|
'## Subagent',
|
|
171
164
|
'You are a focused read-only investigator. Work only on the assigned task.',
|
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
|
+
}
|