@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
|
@@ -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 {
|
|
@@ -249,9 +249,6 @@ export function WorkspacePanel() {
|
|
|
249
249
|
return entries.filter((entry) => entry.path.toLowerCase().includes(clean)).slice(0, 300)
|
|
250
250
|
}, [entries, query])
|
|
251
251
|
const lineCount = content ? content.split('\n').length : 0
|
|
252
|
-
const dirCount = entries.filter((entry) => entry.type === 'dir').length
|
|
253
|
-
const fileCount = entries.length - dirCount
|
|
254
|
-
const dirPaths = useMemo(() => entries.filter((entry) => entry.type === 'dir').map((entry) => entry.path), [entries])
|
|
255
252
|
|
|
256
253
|
useEffect(() => {
|
|
257
254
|
if (editor) editorRef.current?.focus()
|
|
@@ -272,6 +269,20 @@ export function WorkspacePanel() {
|
|
|
272
269
|
return () => window.removeEventListener('keydown', onKey)
|
|
273
270
|
}, [])
|
|
274
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
|
+
|
|
275
286
|
if (!open) return null
|
|
276
287
|
|
|
277
288
|
const submitEditor = async () => {
|
|
@@ -316,87 +327,82 @@ export function WorkspacePanel() {
|
|
|
316
327
|
}
|
|
317
328
|
}
|
|
318
329
|
|
|
330
|
+
const statusTone = (status: string) =>
|
|
331
|
+
status.includes('?') || status.includes('A') ? 'text-success' : status.includes('D') ? 'text-danger' : 'text-warning'
|
|
332
|
+
|
|
319
333
|
const renderNode = (node: TreeNode, depth: number): ReactNode => {
|
|
320
334
|
const path = node.entry.path
|
|
335
|
+
const indent = 10 + depth * 14
|
|
321
336
|
if (node.entry.type === 'dir') {
|
|
322
337
|
const closed = collapsed.has(path)
|
|
338
|
+
const touched = gitStatus.dirs.has(path)
|
|
323
339
|
return (
|
|
324
340
|
<div key={path}>
|
|
325
|
-
<
|
|
326
|
-
|
|
327
|
-
|
|
341
|
+
<button
|
|
342
|
+
type="button"
|
|
343
|
+
onClick={() =>
|
|
344
|
+
setCollapsed((current) => {
|
|
345
|
+
const next = new Set(current)
|
|
346
|
+
if (next.has(path)) next.delete(path)
|
|
347
|
+
else next.add(path)
|
|
348
|
+
return next
|
|
349
|
+
})
|
|
350
|
+
}
|
|
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 }}
|
|
353
|
+
title={path}
|
|
354
|
+
aria-expanded={!closed}
|
|
328
355
|
>
|
|
329
|
-
<
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
<FileIcon path={path} type="dir" open={!closed} size={13} />
|
|
348
|
-
<span className="min-w-0 flex-1 truncate">{node.entry.name}</span>
|
|
349
|
-
<span className="shrink-0 pr-1 font-mono text-[8.5px] tabular-nums text-faint opacity-0 transition-opacity group-hover/row:opacity-100">
|
|
350
|
-
{node.children.length}
|
|
351
|
-
</span>
|
|
352
|
-
</button>
|
|
353
|
-
<button
|
|
354
|
-
type="button"
|
|
355
|
-
onClick={() => void removeEntry(path)}
|
|
356
|
-
className="mr-1 flex h-5 w-5 shrink-0 items-center justify-center rounded text-faint opacity-0 transition-all hover:bg-danger/10 hover:text-danger focus-visible:opacity-100 group-hover/row:opacity-100"
|
|
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} />
|
|
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
|
+
)}
|
|
363
|
+
<span
|
|
364
|
+
role="button"
|
|
365
|
+
tabIndex={0}
|
|
366
|
+
onClick={(event) => {
|
|
367
|
+
event.stopPropagation()
|
|
368
|
+
void removeEntry(path)
|
|
369
|
+
}}
|
|
370
|
+
onKeyDown={(event) => {
|
|
371
|
+
if (event.key === 'Enter') void removeEntry(path)
|
|
372
|
+
}}
|
|
373
|
+
className="hidden h-5 w-5 items-center justify-center rounded text-faint hover:text-danger group-hover:flex"
|
|
357
374
|
aria-label={`Delete ${path}`}
|
|
358
|
-
title={`Delete ${path}`}
|
|
359
375
|
>
|
|
360
|
-
<Trash2 size={
|
|
361
|
-
</
|
|
362
|
-
</
|
|
363
|
-
{!closed && (
|
|
376
|
+
<Trash2 size={11} />
|
|
377
|
+
</span>
|
|
378
|
+
</button>
|
|
379
|
+
{!closed && node.children.length > 0 && (
|
|
364
380
|
<div className="relative">
|
|
365
|
-
<span
|
|
366
|
-
aria-hidden
|
|
367
|
-
className="pointer-events-none absolute bottom-0.5 top-0 w-px bg-line/60"
|
|
368
|
-
style={{ left: 11 + depth * 14 }}
|
|
369
|
-
/>
|
|
381
|
+
<span aria-hidden className="pointer-events-none absolute bottom-0.5 top-0 w-px bg-line/70" style={{ left: indent + 5 }} />
|
|
370
382
|
{node.children.map((child) => renderNode(child, depth + 1))}
|
|
371
383
|
</div>
|
|
372
384
|
)}
|
|
373
385
|
</div>
|
|
374
386
|
)
|
|
375
387
|
}
|
|
388
|
+
const active = activeFile === path
|
|
389
|
+
const status = gitStatus.files.get(path)
|
|
376
390
|
return (
|
|
377
391
|
<button
|
|
378
392
|
key={path}
|
|
379
393
|
type="button"
|
|
380
394
|
onClick={() => setActiveFile(path)}
|
|
381
395
|
className={cn(
|
|
382
|
-
'group
|
|
383
|
-
|
|
384
|
-
? 'bg-accent-soft/80 font-medium text-accent'
|
|
385
|
-
: 'text-muted hover:bg-raised/80 hover:text-ink',
|
|
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',
|
|
386
398
|
)}
|
|
387
|
-
style={{ paddingLeft:
|
|
399
|
+
style={{ paddingLeft: indent + 13 }}
|
|
388
400
|
title={path}
|
|
389
401
|
>
|
|
390
|
-
{
|
|
391
|
-
|
|
392
|
-
)}
|
|
393
|
-
|
|
394
|
-
<span className="min-w-0 flex-1 truncate">{node.entry.name}</span>
|
|
395
|
-
{node.entry.size > 0 && (
|
|
396
|
-
<span className="shrink-0 font-mono text-[8.5px] tabular-nums text-faint opacity-0 transition-opacity group-hover/row:opacity-100">
|
|
397
|
-
{formatBytes(node.entry.size)}
|
|
398
|
-
</span>
|
|
399
|
-
)}
|
|
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>}
|
|
400
406
|
</button>
|
|
401
407
|
)
|
|
402
408
|
}
|
|
@@ -539,20 +545,6 @@ export function WorkspacePanel() {
|
|
|
539
545
|
aria-label="Find a file"
|
|
540
546
|
/>
|
|
541
547
|
</div>
|
|
542
|
-
<div className="flex items-center justify-between gap-2 px-1 pt-1.5 text-[8.5px] text-faint">
|
|
543
|
-
<span className="truncate">
|
|
544
|
-
{filtered ? `${filtered.length} matches` : `${fileCount} files · ${dirCount} folders`}
|
|
545
|
-
</span>
|
|
546
|
-
{!filtered && dirCount > 0 && (
|
|
547
|
-
<button
|
|
548
|
-
type="button"
|
|
549
|
-
onClick={() => setCollapsed(collapsed.size > 0 ? new Set() : new Set(dirPaths))}
|
|
550
|
-
className="shrink-0 font-medium hover:text-ink"
|
|
551
|
-
>
|
|
552
|
-
{collapsed.size > 0 ? 'Expand all' : 'Collapse all'}
|
|
553
|
-
</button>
|
|
554
|
-
)}
|
|
555
|
-
</div>
|
|
556
548
|
{editor && (
|
|
557
549
|
<div className="mt-1.5 flex items-center gap-1">
|
|
558
550
|
<input
|
|
@@ -587,34 +579,24 @@ export function WorkspacePanel() {
|
|
|
587
579
|
<Spinner size={11} /> Reading folder…
|
|
588
580
|
</p>
|
|
589
581
|
)}
|
|
590
|
-
{filtered
|
|
591
|
-
|
|
592
|
-
{filtered.map((entry) => (
|
|
582
|
+
{filtered
|
|
583
|
+
? filtered.map((entry) => (
|
|
593
584
|
<button
|
|
594
585
|
key={entry.path}
|
|
595
586
|
type="button"
|
|
596
587
|
onClick={() => entry.type === 'file' && setActiveFile(entry.path)}
|
|
597
588
|
className={cn(
|
|
598
|
-
'
|
|
589
|
+
'flex h-7 w-full items-center gap-1.5 rounded-md px-2 text-left font-mono text-[10.5px]',
|
|
599
590
|
activeFile === entry.path
|
|
600
|
-
? 'bg-accent-soft
|
|
601
|
-
: 'text-muted hover:bg-raised
|
|
591
|
+
? 'bg-accent-soft font-medium text-accent'
|
|
592
|
+
: 'text-muted hover:bg-raised hover:text-ink',
|
|
602
593
|
)}
|
|
603
594
|
>
|
|
604
|
-
{
|
|
605
|
-
<span aria-hidden className="absolute left-0 top-1 bottom-1 w-[2px] rounded-full bg-accent" />
|
|
606
|
-
)}
|
|
607
|
-
<FileIcon path={entry.path} type={entry.type === 'dir' ? 'dir' : 'file'} size={12} />
|
|
595
|
+
{entry.type === 'dir' ? <FolderIcon name={entry.name} size={15} /> : <FileIcon name={entry.name} size={15} />}
|
|
608
596
|
<span className="truncate">{entry.path}</span>
|
|
609
597
|
</button>
|
|
610
|
-
))
|
|
611
|
-
|
|
612
|
-
<p className="px-2 py-4 text-center text-[10.5px] text-faint">No matches in this folder.</p>
|
|
613
|
-
)}
|
|
614
|
-
</>
|
|
615
|
-
) : (
|
|
616
|
-
tree.map((node) => renderNode(node, 0))
|
|
617
|
-
)}
|
|
598
|
+
))
|
|
599
|
+
: tree.map((node) => renderNode(node, 0))}
|
|
618
600
|
{!loadingTree && !treeError && entries.length === 0 && (
|
|
619
601
|
<p className="px-2 py-4 text-center text-[10.5px] text-faint">This folder is empty.</p>
|
|
620
602
|
)}
|
|
@@ -734,7 +716,8 @@ export function WorkspacePanel() {
|
|
|
734
716
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left font-mono text-[10px] hover:bg-raised"
|
|
735
717
|
title={entry.path}
|
|
736
718
|
>
|
|
737
|
-
<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} />
|
|
738
721
|
<span className="truncate text-muted">{entry.path}</span>
|
|
739
722
|
</button>
|
|
740
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,17 +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'
|
|
10
|
-
import {
|
|
9
|
+
import { messageCost } from '../../lib/credits'
|
|
10
|
+
import { MAX_RETRIES, backoffMs, classifyProviderError } from '../../lib/retry'
|
|
11
11
|
import type { AdapterResult } from '../../agent/adapters/shared'
|
|
12
12
|
import { createTurnQueue, lastUserMessageId } from '../../agent/queue'
|
|
13
13
|
import { resolveToolSetting } from '../mcp-tools'
|
|
14
14
|
import type { StepContext, StepResult, TurnResult } from '../types'
|
|
15
15
|
|
|
16
|
-
const MAX_RETRIES = MAX_PROVIDER_RETRIES
|
|
17
|
-
|
|
18
16
|
function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
|
19
17
|
return new Promise((resolve) => {
|
|
20
18
|
const finish = () => {
|
|
@@ -111,7 +109,7 @@ export class AgentLoopService extends Service {
|
|
|
111
109
|
let turnError: string | undefined
|
|
112
110
|
|
|
113
111
|
try {
|
|
114
|
-
// 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.
|
|
115
113
|
rounds: for (let round = 0; ; round += 1) {
|
|
116
114
|
totalRounds = round + 1
|
|
117
115
|
if (controller.signal.aborted) break
|
|
@@ -188,12 +186,7 @@ export class AgentLoopService extends Service {
|
|
|
188
186
|
settings,
|
|
189
187
|
model: findModel(settings.provider, activeModelId(settings)),
|
|
190
188
|
history,
|
|
191
|
-
system: this.ctx.systemPrompt.assemble(settings, mode,
|
|
192
|
-
sessionPrompt: current.systemPrompt,
|
|
193
|
-
workspacePrompt: current.workspace
|
|
194
|
-
? settings.workspacePrompts[current.workspace]
|
|
195
|
-
: settings.workspacePrompts[useWorkspace.getState().root ?? ''],
|
|
196
|
-
}),
|
|
189
|
+
system: this.ctx.systemPrompt.assemble(settings, mode, sessionId),
|
|
197
190
|
mode,
|
|
198
191
|
signal: controller.signal,
|
|
199
192
|
handlers: {
|
|
@@ -215,39 +208,26 @@ export class AgentLoopService extends Service {
|
|
|
215
208
|
} catch (error) {
|
|
216
209
|
const stopped = isAbortError(error) || controller.signal.aborted
|
|
217
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)
|
|
218
213
|
const retryable =
|
|
219
|
-
!stopped &&
|
|
220
|
-
attempt < MAX_RETRIES &&
|
|
221
|
-
!streamedContent &&
|
|
222
|
-
!streamedReasoning &&
|
|
223
|
-
isRetryableProviderError(error instanceof Error ? error.message : '')
|
|
214
|
+
!stopped && decision.retry && attempt < MAX_RETRIES && !streamedContent && !streamedReasoning
|
|
224
215
|
if (retryable) {
|
|
225
216
|
attempt += 1
|
|
226
|
-
const
|
|
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)
|
|
240
230
|
turnError = stopped ? undefined : describeRequestError(error, providerName)
|
|
241
|
-
if (turnError) {
|
|
242
|
-
window.dispatchEvent(
|
|
243
|
-
new CustomEvent('maxcode:notice', {
|
|
244
|
-
detail: {
|
|
245
|
-
message: `${providerName} failed after ${attempt + 1} attempt${attempt === 0 ? '' : 's'}: ${turnError}`,
|
|
246
|
-
tone: 'error',
|
|
247
|
-
},
|
|
248
|
-
}),
|
|
249
|
-
)
|
|
250
|
-
}
|
|
251
231
|
this.ctx.sessions.updateMessage(sessionId, assistantId, {
|
|
252
232
|
content: streamedContent,
|
|
253
233
|
reasoning: streamedReasoning,
|
|
@@ -266,7 +246,11 @@ export class AgentLoopService extends Service {
|
|
|
266
246
|
reasoning: result.reasoning,
|
|
267
247
|
thinkingBlocks: result.thinkingBlocks,
|
|
268
248
|
toolCalls: result.toolCalls,
|
|
269
|
-
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
|
+
),
|
|
270
254
|
durationMs: Date.now() - startedAt,
|
|
271
255
|
stopped: controller.signal.aborted || undefined,
|
|
272
256
|
})
|
|
@@ -331,6 +315,7 @@ export class AgentLoopService extends Service {
|
|
|
331
315
|
typeof call.args.task === 'string' ? call.args.task : '',
|
|
332
316
|
typeof call.args.context === 'string' ? call.args.context : '',
|
|
333
317
|
controller.signal,
|
|
318
|
+
sessionId,
|
|
334
319
|
),
|
|
335
320
|
),
|
|
336
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.',
|