@bibobonking/opensol 0.0.0-stage → 3.0.2
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/.github/workflows/build.yml +25 -0
- package/README.md +74 -2
- package/dist/assets/index-BmN7pBji.css +1 -0
- package/dist/assets/index-DgNDjkOz.js +301 -0
- package/dist/favicon.svg +15 -0
- package/dist/index.html +55 -0
- package/dist/preview-host.html +33 -0
- package/index.html +54 -0
- package/package.json +43 -4
- package/postcss.config.js +6 -0
- package/public/favicon.svg +15 -0
- package/public/preview-host.html +33 -0
- package/server/README.md +75 -0
- package/server/daemon.mjs +1082 -0
- package/server/lib/checkpoints.mjs +124 -0
- package/server/lib/checkpoints.test.mjs +110 -0
- package/server/lib/deploy.mjs +436 -0
- package/server/lib/http.mjs +237 -0
- package/server/lib/plugins.mjs +321 -0
- package/server/lib/plugins.test.mjs +140 -0
- package/server/lib/providers.mjs +594 -0
- package/server/lib/store.mjs +491 -0
- package/server/lib/tools.mjs +990 -0
- package/server/opensol.mjs +202 -0
- package/server/package.json +42 -0
- package/shared/plugin-builder.test.mjs +43 -0
- package/shared/plugin-package.d.mts +34 -0
- package/shared/plugin-package.mjs +197 -0
- package/shared/plugin-package.test.mjs +94 -0
- package/src/App.tsx +152 -0
- package/src/agent/adapters/anthropic.ts +233 -0
- package/src/agent/adapters/gemini.ts +225 -0
- package/src/agent/adapters/openai-responses.ts +215 -0
- package/src/agent/adapters/openai.ts +200 -0
- package/src/agent/adapters/shared.ts +96 -0
- package/src/agent/approval.ts +20 -0
- package/src/agent/compact.ts +234 -0
- package/src/agent/index.ts +36 -0
- package/src/agent/model.ts +20 -0
- package/src/agent/prompt.ts +107 -0
- package/src/agent/queue.test.mjs +111 -0
- package/src/agent/queue.ts +84 -0
- package/src/agent/subagent.ts +109 -0
- package/src/agent/tools.ts +541 -0
- package/src/components/ChatView.tsx +403 -0
- package/src/components/CommandPalette.tsx +416 -0
- package/src/components/Composer.tsx +678 -0
- package/src/components/ContextBar.tsx +58 -0
- package/src/components/DeployModal.tsx +512 -0
- package/src/components/ErrorBoundary.tsx +75 -0
- package/src/components/HomeDashboard.tsx +189 -0
- package/src/components/Logo.tsx +41 -0
- package/src/components/Markdown.tsx +232 -0
- package/src/components/MessageItem.tsx +655 -0
- package/src/components/ModelPicker.tsx +487 -0
- package/src/components/PixleSol.tsx +243 -0
- package/src/components/PluginBuilder.tsx +285 -0
- package/src/components/PluginManager.tsx +341 -0
- package/src/components/PreviewPane.tsx +166 -0
- package/src/components/SettingsModal.tsx +2052 -0
- package/src/components/Sidebar.tsx +504 -0
- package/src/components/Splash.tsx +104 -0
- package/src/components/ToolCallCard.tsx +177 -0
- package/src/components/TopBar.tsx +182 -0
- package/src/components/TrajectoryView.tsx +178 -0
- package/src/components/WorkspacePanel.tsx +710 -0
- package/src/components/ui.tsx +311 -0
- package/src/core/kernel.ts +45 -0
- package/src/core/plugins/tools/fs.ts +219 -0
- package/src/core/plugins/tools/git.ts +83 -0
- package/src/core/plugins/tools/grep.ts +98 -0
- package/src/core/plugins/tools/shell.ts +88 -0
- package/src/core/plugins/tools/subagent.ts +36 -0
- package/src/core/plugins/tools/web.ts +97 -0
- package/src/core/services/agent-loop.ts +417 -0
- package/src/core/services/approval.ts +66 -0
- package/src/core/services/compaction.ts +45 -0
- package/src/core/services/llm.ts +32 -0
- package/src/core/services/sessions.ts +47 -0
- package/src/core/services/subagents.ts +15 -0
- package/src/core/services/system-prompt.ts +177 -0
- package/src/core/services/tools.ts +135 -0
- package/src/core/services/ui.ts +109 -0
- package/src/core/types.ts +139 -0
- package/src/data/providers.ts +125 -0
- package/src/data/skills.ts +68 -0
- package/src/index.css +776 -0
- package/src/lib/boot.ts +94 -0
- package/src/lib/context.ts +75 -0
- package/src/lib/conversation.test.mjs +64 -0
- package/src/lib/conversation.ts +59 -0
- package/src/lib/daemon.ts +475 -0
- package/src/lib/hooks.ts +33 -0
- package/src/lib/permissions.ts +60 -0
- package/src/lib/pixlesol.test.mjs +51 -0
- package/src/lib/pixlesol.ts +55 -0
- package/src/lib/plugin-folder.test.mjs +145 -0
- package/src/lib/plugin-folder.ts +96 -0
- package/src/lib/reasoning.ts +87 -0
- package/src/lib/sse.ts +75 -0
- package/src/lib/themes.test.mjs +72 -0
- package/src/lib/themes.ts +52 -0
- package/src/lib/usage.test.mjs +72 -0
- package/src/lib/usage.ts +155 -0
- package/src/lib/utils.ts +73 -0
- package/src/main.tsx +16 -0
- package/src/plugins/api.test.mjs +77 -0
- package/src/plugins/api.ts +113 -0
- package/src/plugins/bridge.d.mts +1 -0
- package/src/plugins/bridge.mjs +26 -0
- package/src/plugins/bridge.test.mjs +17 -0
- package/src/plugins/index.ts +52 -0
- package/src/plugins/preview.test.mjs +18 -0
- package/src/plugins/preview.ts +34 -0
- package/src/plugins/runtime.ts +194 -0
- package/src/plugins/types.ts +40 -0
- package/src/store/chats.ts +278 -0
- package/src/store/models.ts +120 -0
- package/src/store/settings.ts +445 -0
- package/src/store/ui.ts +132 -0
- package/src/store/workspace.ts +299 -0
- package/src/types.ts +337 -0
- package/src/vite-env.d.ts +1 -0
- package/tailwind.config.js +83 -0
- package/tsconfig.json +22 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vite.config.ts +19 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { useEffect, useRef, type ReactNode } from 'react'
|
|
2
|
+
import { createPortal } from 'react-dom'
|
|
3
|
+
import {
|
|
4
|
+
AlertTriangle,
|
|
5
|
+
Check,
|
|
6
|
+
CircleCheck,
|
|
7
|
+
CircleX,
|
|
8
|
+
Info,
|
|
9
|
+
LoaderCircle,
|
|
10
|
+
X,
|
|
11
|
+
} from 'lucide-react'
|
|
12
|
+
import { cn } from '../lib/utils'
|
|
13
|
+
import { useUi } from '../store/ui'
|
|
14
|
+
|
|
15
|
+
export function Modal({
|
|
16
|
+
open,
|
|
17
|
+
onClose,
|
|
18
|
+
children,
|
|
19
|
+
className,
|
|
20
|
+
labelledBy,
|
|
21
|
+
wide = false,
|
|
22
|
+
}: {
|
|
23
|
+
open: boolean
|
|
24
|
+
onClose: () => void
|
|
25
|
+
children: ReactNode
|
|
26
|
+
className?: string
|
|
27
|
+
labelledBy?: string
|
|
28
|
+
wide?: boolean
|
|
29
|
+
}) {
|
|
30
|
+
const panelRef = useRef<HTMLDivElement>(null)
|
|
31
|
+
const restoreRef = useRef<HTMLElement | null>(null)
|
|
32
|
+
const closeRef = useRef(onClose)
|
|
33
|
+
closeRef.current = onClose
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (!open) return
|
|
37
|
+
restoreRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
|
38
|
+
const panel = panelRef.current
|
|
39
|
+
const focusables = () =>
|
|
40
|
+
Array.from(
|
|
41
|
+
panel?.querySelectorAll<HTMLElement>(
|
|
42
|
+
'button:not([disabled]), a[href], input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
|
43
|
+
) ?? [],
|
|
44
|
+
).filter((element) => element.offsetParent !== null)
|
|
45
|
+
|
|
46
|
+
const timer = window.setTimeout(() => {
|
|
47
|
+
const target = panel?.querySelector<HTMLElement>('[data-autofocus]') ?? focusables()[0]
|
|
48
|
+
;(target ?? panel)?.focus()
|
|
49
|
+
}, 30)
|
|
50
|
+
|
|
51
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
52
|
+
if (event.key === 'Escape') {
|
|
53
|
+
event.preventDefault()
|
|
54
|
+
closeRef.current()
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
if (event.key !== 'Tab') return
|
|
58
|
+
const items = focusables()
|
|
59
|
+
if (items.length === 0) return
|
|
60
|
+
const first = items[0]
|
|
61
|
+
const last = items[items.length - 1]
|
|
62
|
+
if (event.shiftKey && document.activeElement === first) {
|
|
63
|
+
event.preventDefault()
|
|
64
|
+
last.focus()
|
|
65
|
+
} else if (!event.shiftKey && document.activeElement === last) {
|
|
66
|
+
event.preventDefault()
|
|
67
|
+
first.focus()
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
document.addEventListener('keydown', onKeyDown)
|
|
72
|
+
const previousOverflow = document.body.style.overflow
|
|
73
|
+
document.body.style.overflow = 'hidden'
|
|
74
|
+
return () => {
|
|
75
|
+
window.clearTimeout(timer)
|
|
76
|
+
document.removeEventListener('keydown', onKeyDown)
|
|
77
|
+
document.body.style.overflow = previousOverflow
|
|
78
|
+
restoreRef.current?.focus()
|
|
79
|
+
}
|
|
80
|
+
}, [open])
|
|
81
|
+
|
|
82
|
+
if (!open) return null
|
|
83
|
+
|
|
84
|
+
return createPortal(
|
|
85
|
+
<div className="fixed inset-0 z-[80] flex items-end justify-center p-0 sm:items-center sm:p-6">
|
|
86
|
+
<button
|
|
87
|
+
type="button"
|
|
88
|
+
aria-label="Close dialog"
|
|
89
|
+
tabIndex={-1}
|
|
90
|
+
onClick={onClose}
|
|
91
|
+
className="modal-scrim absolute inset-0 cursor-default bg-black/35 backdrop-blur-[3px] dark:bg-black/60"
|
|
92
|
+
/>
|
|
93
|
+
<div
|
|
94
|
+
ref={panelRef}
|
|
95
|
+
role="dialog"
|
|
96
|
+
aria-modal="true"
|
|
97
|
+
aria-labelledby={labelledBy}
|
|
98
|
+
tabIndex={-1}
|
|
99
|
+
className={cn(
|
|
100
|
+
'modal-panel relative flex max-h-[92dvh] w-full flex-col overflow-hidden rounded-t-2xl border border-line surface-lift shadow-float sm:max-h-[86dvh] sm:rounded-xl',
|
|
101
|
+
wide ? 'sm:w-[min(920px,94vw)]' : 'sm:w-[min(760px,94vw)]',
|
|
102
|
+
className,
|
|
103
|
+
)}
|
|
104
|
+
>
|
|
105
|
+
{children}
|
|
106
|
+
</div>
|
|
107
|
+
</div>,
|
|
108
|
+
document.body,
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function ModalHeader({
|
|
113
|
+
title,
|
|
114
|
+
description,
|
|
115
|
+
onClose,
|
|
116
|
+
id,
|
|
117
|
+
actions,
|
|
118
|
+
}: {
|
|
119
|
+
title: string
|
|
120
|
+
description?: string
|
|
121
|
+
onClose: () => void
|
|
122
|
+
id?: string
|
|
123
|
+
actions?: ReactNode
|
|
124
|
+
}) {
|
|
125
|
+
return (
|
|
126
|
+
<div className="flex items-start justify-between gap-4 border-b border-line px-5 py-4 sm:px-6">
|
|
127
|
+
<div className="min-w-0">
|
|
128
|
+
<h2 id={id} className="text-[15px] font-semibold tracking-[-0.02em] text-ink">
|
|
129
|
+
{title}
|
|
130
|
+
</h2>
|
|
131
|
+
{description && <p className="mt-1 text-[12.5px] leading-relaxed text-muted">{description}</p>}
|
|
132
|
+
</div>
|
|
133
|
+
<div className="flex shrink-0 items-center gap-1">
|
|
134
|
+
{actions}
|
|
135
|
+
<button type="button" className="icon-button" onClick={onClose} aria-label="Close">
|
|
136
|
+
<X size={16} />
|
|
137
|
+
</button>
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function Spinner({ size = 14, className }: { size?: number; className?: string }) {
|
|
144
|
+
return <LoaderCircle size={size} className={cn('animate-spin text-current', className)} aria-hidden="true" />
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function Switch({
|
|
148
|
+
checked,
|
|
149
|
+
onChange,
|
|
150
|
+
label,
|
|
151
|
+
disabled = false,
|
|
152
|
+
}: {
|
|
153
|
+
checked: boolean
|
|
154
|
+
onChange: (checked: boolean) => void
|
|
155
|
+
label: string
|
|
156
|
+
disabled?: boolean
|
|
157
|
+
}) {
|
|
158
|
+
return (
|
|
159
|
+
<button
|
|
160
|
+
type="button"
|
|
161
|
+
role="switch"
|
|
162
|
+
aria-label={label}
|
|
163
|
+
aria-checked={checked}
|
|
164
|
+
disabled={disabled}
|
|
165
|
+
onClick={() => onChange(!checked)}
|
|
166
|
+
className={cn(
|
|
167
|
+
'relative inline-flex h-[22px] w-[38px] shrink-0 items-center rounded-full border transition-colors duration-200 disabled:opacity-40',
|
|
168
|
+
checked ? 'border-accent bg-accent' : 'border-line bg-raised',
|
|
169
|
+
)}
|
|
170
|
+
>
|
|
171
|
+
<span
|
|
172
|
+
className={cn(
|
|
173
|
+
'pointer-events-none block h-[16px] w-[16px] rounded-full bg-white shadow-sm transition-transform duration-300 ease-spring',
|
|
174
|
+
checked ? 'translate-x-[19px]' : 'translate-x-[3px]',
|
|
175
|
+
)}
|
|
176
|
+
/>
|
|
177
|
+
</button>
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function Segmented<T extends string>({
|
|
182
|
+
options,
|
|
183
|
+
value,
|
|
184
|
+
onChange,
|
|
185
|
+
disabled,
|
|
186
|
+
className,
|
|
187
|
+
size = 'sm',
|
|
188
|
+
}: {
|
|
189
|
+
options: Array<{ value: T; label: string; title?: string }>
|
|
190
|
+
value: T
|
|
191
|
+
onChange: (value: T) => void
|
|
192
|
+
disabled?: boolean
|
|
193
|
+
className?: string
|
|
194
|
+
size?: 'sm' | 'md'
|
|
195
|
+
}) {
|
|
196
|
+
return (
|
|
197
|
+
<div
|
|
198
|
+
className={cn(
|
|
199
|
+
'inline-flex items-center rounded-lg bg-raised p-0.5',
|
|
200
|
+
size === 'md' ? 'text-[12.5px]' : 'text-[11.5px]',
|
|
201
|
+
disabled && 'opacity-50',
|
|
202
|
+
className,
|
|
203
|
+
)}
|
|
204
|
+
>
|
|
205
|
+
{options.map((option) => (
|
|
206
|
+
<button
|
|
207
|
+
key={option.value}
|
|
208
|
+
type="button"
|
|
209
|
+
title={option.title}
|
|
210
|
+
disabled={disabled}
|
|
211
|
+
onClick={() => onChange(option.value)}
|
|
212
|
+
className={cn(
|
|
213
|
+
'rounded-md font-medium transition-all duration-200 disabled:cursor-not-allowed',
|
|
214
|
+
size === 'md' ? 'px-3 py-1.5' : 'px-2.5 py-1',
|
|
215
|
+
value === option.value
|
|
216
|
+
? 'bg-panel text-ink shadow-[0_1px_2px_rgba(20,22,30,0.08)]'
|
|
217
|
+
: 'text-muted hover:text-ink',
|
|
218
|
+
)}
|
|
219
|
+
>
|
|
220
|
+
{option.label}
|
|
221
|
+
</button>
|
|
222
|
+
))}
|
|
223
|
+
</div>
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function Kbd({ children }: { children: ReactNode }) {
|
|
228
|
+
return (
|
|
229
|
+
<kbd className="rounded border border-line bg-raised px-1.5 py-0.5 font-sans text-[10px] font-medium text-faint">
|
|
230
|
+
{children}
|
|
231
|
+
</kbd>
|
|
232
|
+
)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const toneStyles = {
|
|
236
|
+
info: { icon: Info, className: 'text-accent' },
|
|
237
|
+
success: { icon: CircleCheck, className: 'text-success' },
|
|
238
|
+
error: { icon: CircleX, className: 'text-danger' },
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function ToastHost() {
|
|
242
|
+
const toast = useUi((state) => state.toast)
|
|
243
|
+
const dismiss = useUi((state) => state.dismissToast)
|
|
244
|
+
|
|
245
|
+
useEffect(() => {
|
|
246
|
+
if (!toast) return
|
|
247
|
+
const timer = window.setTimeout(() => dismiss(toast.id), 4500)
|
|
248
|
+
return () => window.clearTimeout(timer)
|
|
249
|
+
}, [toast, dismiss])
|
|
250
|
+
|
|
251
|
+
if (!toast) return null
|
|
252
|
+
const tone = toneStyles[toast.tone]
|
|
253
|
+
const Icon = tone.icon
|
|
254
|
+
return createPortal(
|
|
255
|
+
<div
|
|
256
|
+
className="pointer-events-none fixed bottom-56 left-1/2 z-[100] flex w-[min(92vw,440px)] -translate-x-1/2 animate-scalein items-start gap-2.5 rounded-xl border border-line surface-lift px-3.5 py-3 text-[12.5px] text-ink shadow-float sm:bottom-48"
|
|
257
|
+
role="status"
|
|
258
|
+
>
|
|
259
|
+
<Icon size={16} className={cn('mt-0.5 shrink-0', tone.className)} />
|
|
260
|
+
<p className="flex-1 leading-relaxed">{toast.message}</p>
|
|
261
|
+
<button type="button" className="pointer-events-auto icon-button -mr-1 -mt-1 h-6 w-6" onClick={() => dismiss(toast.id)} aria-label="Dismiss">
|
|
262
|
+
<X size={13} />
|
|
263
|
+
</button>
|
|
264
|
+
</div>,
|
|
265
|
+
document.body,
|
|
266
|
+
)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function ConfirmDialog() {
|
|
270
|
+
const confirm = useUi((state) => state.confirm)
|
|
271
|
+
const resolve = useUi((state) => state.resolveConfirm)
|
|
272
|
+
const danger = confirm?.tone === 'danger'
|
|
273
|
+
|
|
274
|
+
return (
|
|
275
|
+
<Modal open={Boolean(confirm)} onClose={() => resolve(false)} className="sm:w-[420px]">
|
|
276
|
+
<div className="p-5 sm:p-6">
|
|
277
|
+
<div className="flex items-start gap-3">
|
|
278
|
+
<span
|
|
279
|
+
className={cn(
|
|
280
|
+
'mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-full',
|
|
281
|
+
danger ? 'bg-danger/10 text-danger' : 'bg-accent-soft text-accent',
|
|
282
|
+
)}
|
|
283
|
+
>
|
|
284
|
+
<AlertTriangle size={17} />
|
|
285
|
+
</span>
|
|
286
|
+
<div className="min-w-0">
|
|
287
|
+
<h2 className="text-[15px] font-semibold tracking-[-0.01em]">{confirm?.title}</h2>
|
|
288
|
+
<p className="mt-1.5 text-[12.5px] leading-relaxed text-muted">{confirm?.body}</p>
|
|
289
|
+
</div>
|
|
290
|
+
</div>
|
|
291
|
+
<div className="mt-6 flex justify-end gap-2">
|
|
292
|
+
<button type="button" className="chip" onClick={() => resolve(false)}>
|
|
293
|
+
Cancel
|
|
294
|
+
</button>
|
|
295
|
+
<button
|
|
296
|
+
type="button"
|
|
297
|
+
data-autofocus
|
|
298
|
+
onClick={() => resolve(true)}
|
|
299
|
+
className={cn(
|
|
300
|
+
'inline-flex h-8 items-center gap-1.5 rounded-lg px-3 text-[12.5px] font-medium text-on-accent transition-all active:scale-95',
|
|
301
|
+
danger ? 'bg-danger hover:bg-danger/90' : 'bg-accent hover:bg-accent/90',
|
|
302
|
+
)}
|
|
303
|
+
>
|
|
304
|
+
<Check size={14} />
|
|
305
|
+
{confirm?.confirmLabel ?? 'Confirm'}
|
|
306
|
+
</button>
|
|
307
|
+
</div>
|
|
308
|
+
</div>
|
|
309
|
+
</Modal>
|
|
310
|
+
)
|
|
311
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Context } from 'cordis'
|
|
2
|
+
import './types'
|
|
3
|
+
import { SessionService } from './services/sessions'
|
|
4
|
+
import { SystemPromptService } from './services/system-prompt'
|
|
5
|
+
import { ToolService } from './services/tools'
|
|
6
|
+
import { ApprovalService } from './services/approval'
|
|
7
|
+
import { LlmService } from './services/llm'
|
|
8
|
+
import { CompactionService } from './services/compaction'
|
|
9
|
+
import { SubagentService } from './services/subagents'
|
|
10
|
+
import { UiService } from './services/ui'
|
|
11
|
+
import { AgentLoopService } from './services/agent-loop'
|
|
12
|
+
|
|
13
|
+
import * as toolFs from './plugins/tools/fs'
|
|
14
|
+
import * as toolShell from './plugins/tools/shell'
|
|
15
|
+
import * as toolGrep from './plugins/tools/grep'
|
|
16
|
+
import * as toolGit from './plugins/tools/git'
|
|
17
|
+
import * as toolWeb from './plugins/tools/web'
|
|
18
|
+
import * as toolSubagent from './plugins/tools/subagent'
|
|
19
|
+
|
|
20
|
+
export function createAppKernel(): Context {
|
|
21
|
+
const ctx = new Context()
|
|
22
|
+
|
|
23
|
+
// 1. Mount Core Services
|
|
24
|
+
ctx.plugin(SessionService)
|
|
25
|
+
ctx.plugin(SystemPromptService)
|
|
26
|
+
ctx.plugin(ToolService)
|
|
27
|
+
ctx.plugin(ApprovalService)
|
|
28
|
+
ctx.plugin(LlmService)
|
|
29
|
+
ctx.plugin(CompactionService)
|
|
30
|
+
ctx.plugin(SubagentService)
|
|
31
|
+
ctx.plugin(UiService)
|
|
32
|
+
ctx.plugin(AgentLoopService)
|
|
33
|
+
|
|
34
|
+
// 2. Mount First-Party Tool Plugins
|
|
35
|
+
ctx.plugin(toolFs)
|
|
36
|
+
ctx.plugin(toolShell)
|
|
37
|
+
ctx.plugin(toolGrep)
|
|
38
|
+
ctx.plugin(toolGit)
|
|
39
|
+
ctx.plugin(toolWeb)
|
|
40
|
+
ctx.plugin(toolSubagent)
|
|
41
|
+
|
|
42
|
+
return ctx
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const kernel = createAppKernel()
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import type { Context } from 'cordis'
|
|
2
|
+
import { executeDaemonTool } from '../../../lib/daemon'
|
|
3
|
+
import { useWorkspace } from '../../../store/workspace'
|
|
4
|
+
import type { ToolResult } from '../../types'
|
|
5
|
+
|
|
6
|
+
const MAX_TOOL_OUTPUT = 28_000
|
|
7
|
+
|
|
8
|
+
function truncate(value: string, limit = MAX_TOOL_OUTPUT) {
|
|
9
|
+
if (value.length <= limit) return value
|
|
10
|
+
return `${value.slice(0, limit)}\n… truncated (${value.length - limit} more characters)`
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function record(value: unknown): Record<string, unknown> {
|
|
14
|
+
return value && typeof value === 'object' ? (value as Record<string, unknown>) : {}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function textValue(value: unknown): string {
|
|
18
|
+
return typeof value === 'string' ? value : ''
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function numberValue(value: unknown): number | undefined {
|
|
22
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function requireString(args: Record<string, unknown>, key: string): string {
|
|
26
|
+
const value = args[key]
|
|
27
|
+
if (typeof value !== 'string' || !value.trim()) throw new Error(`Missing string argument "${key}".`)
|
|
28
|
+
return value
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function optionalString(args: Record<string, unknown>, key: string): string | undefined {
|
|
32
|
+
const value = args[key]
|
|
33
|
+
return typeof value === 'string' ? value : undefined
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function numberArg(args: Record<string, unknown>, key: string, fallback: number): number {
|
|
37
|
+
const value = args[key]
|
|
38
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value
|
|
39
|
+
if (typeof value === 'string' && Number.isFinite(Number(value))) return Number(value)
|
|
40
|
+
return fallback
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function formatList(result: Record<string, unknown>): string {
|
|
44
|
+
const entries = Array.isArray(result.entries) ? result.entries : []
|
|
45
|
+
if (entries.length === 0) return 'This folder is empty.'
|
|
46
|
+
const lines = entries.map((raw) => {
|
|
47
|
+
const entry = record(raw)
|
|
48
|
+
const path = textValue(entry.path)
|
|
49
|
+
if (entry.type === 'dir') return `${path}/`
|
|
50
|
+
if (entry.type === 'link') return `${path} -> link`
|
|
51
|
+
return `${path}${numberValue(entry.size) !== undefined ? ` (${numberValue(entry.size)} B)` : ''}`
|
|
52
|
+
})
|
|
53
|
+
const suffix = result.truncated === true ? '\n… listing truncated. Narrow the path or reduce depth.' : ''
|
|
54
|
+
return `${textValue(result.root) ? `Root: ${textValue(result.root)}\n` : ''}${lines.join('\n')}${suffix}`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function refreshWorkspace() {
|
|
58
|
+
await useWorkspace.getState().refresh().catch(() => undefined)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const name = 'tool-fs'
|
|
62
|
+
export const inject = ['tools']
|
|
63
|
+
|
|
64
|
+
export function apply(ctx: Context) {
|
|
65
|
+
const unregisters = [
|
|
66
|
+
ctx.tools.register({
|
|
67
|
+
name: 'read_file',
|
|
68
|
+
description: 'Read a text file from the workspace. Use offset and limit for large files.',
|
|
69
|
+
parameters: {
|
|
70
|
+
type: 'object',
|
|
71
|
+
properties: {
|
|
72
|
+
path: { type: 'string', description: 'Relative path from the workspace root.' },
|
|
73
|
+
offset: { type: 'number', description: '1-based first line. Defaults to 1.' },
|
|
74
|
+
limit: { type: 'number', description: 'Maximum lines. Defaults to 400.' },
|
|
75
|
+
},
|
|
76
|
+
required: ['path'],
|
|
77
|
+
additionalProperties: false,
|
|
78
|
+
},
|
|
79
|
+
allowedInMode: () => true,
|
|
80
|
+
async execute(args, exec): Promise<ToolResult> {
|
|
81
|
+
const path = requireString(args, 'path')
|
|
82
|
+
const result = record((await executeDaemonTool('read_file', { path }, exec.mode, exec.signal)).result)
|
|
83
|
+
if (result.binary === true) {
|
|
84
|
+
return { output: `${path} is binary (${numberValue(result.size) ?? 0} bytes).`, isError: false }
|
|
85
|
+
}
|
|
86
|
+
const offset = Math.max(1, Math.round(numberArg(args, 'offset', 1)))
|
|
87
|
+
const limit = Math.min(Math.max(1, Math.round(numberArg(args, 'limit', 400))), 2000)
|
|
88
|
+
const lines = textValue(result.content).split('\n')
|
|
89
|
+
const slice = lines.slice(offset - 1, offset - 1 + limit)
|
|
90
|
+
const remaining = lines.length - (offset - 1 + limit)
|
|
91
|
+
const header = `${textValue(result.path) || path} · ${lines.length} lines${result.truncated === true ? ' · file clipped by daemon' : ''}`
|
|
92
|
+
const footer = remaining > 0 ? `\n… ${remaining} more lines. Read again with offset ${offset + limit}.` : ''
|
|
93
|
+
return { output: truncate(`${header}\n────\n${slice.join('\n')}${footer}`), isError: false }
|
|
94
|
+
},
|
|
95
|
+
}),
|
|
96
|
+
|
|
97
|
+
ctx.tools.register({
|
|
98
|
+
name: 'list_files',
|
|
99
|
+
description: 'List files and folders below a workspace path.',
|
|
100
|
+
parameters: {
|
|
101
|
+
type: 'object',
|
|
102
|
+
properties: {
|
|
103
|
+
path: { type: 'string', description: 'Folder path. Defaults to the workspace root.' },
|
|
104
|
+
depth: { type: 'number', description: 'Recursion depth, 1–8. Defaults to 3.' },
|
|
105
|
+
},
|
|
106
|
+
additionalProperties: false,
|
|
107
|
+
},
|
|
108
|
+
allowedInMode: () => true,
|
|
109
|
+
async execute(args, exec): Promise<ToolResult> {
|
|
110
|
+
const result = record(
|
|
111
|
+
(
|
|
112
|
+
await executeDaemonTool(
|
|
113
|
+
'list_files',
|
|
114
|
+
{
|
|
115
|
+
path: optionalString(args, 'path') ?? '',
|
|
116
|
+
depth: Math.min(Math.max(1, numberArg(args, 'depth', 3)), 8),
|
|
117
|
+
},
|
|
118
|
+
exec.mode,
|
|
119
|
+
exec.signal,
|
|
120
|
+
)
|
|
121
|
+
).result,
|
|
122
|
+
)
|
|
123
|
+
return { output: formatList(result), isError: false }
|
|
124
|
+
},
|
|
125
|
+
}),
|
|
126
|
+
|
|
127
|
+
ctx.tools.register({
|
|
128
|
+
name: 'write_file',
|
|
129
|
+
description: 'Create a file or replace its full contents. Prefer edit_file for small changes.',
|
|
130
|
+
parameters: {
|
|
131
|
+
type: 'object',
|
|
132
|
+
properties: {
|
|
133
|
+
path: { type: 'string', description: 'Relative file path.' },
|
|
134
|
+
content: { type: 'string', description: 'Complete file content.' },
|
|
135
|
+
},
|
|
136
|
+
required: ['path', 'content'],
|
|
137
|
+
additionalProperties: false,
|
|
138
|
+
},
|
|
139
|
+
allowedInMode: (mode) => mode === 'build',
|
|
140
|
+
async execute(args, exec): Promise<ToolResult> {
|
|
141
|
+
const path = requireString(args, 'path')
|
|
142
|
+
const content = requireString(args, 'content')
|
|
143
|
+
const result = record((await executeDaemonTool('write_file', { path, content }, exec.mode, exec.signal, exec.turnId)).result)
|
|
144
|
+
await refreshWorkspace()
|
|
145
|
+
const verb = result.created === true ? 'Created' : 'Updated'
|
|
146
|
+
return {
|
|
147
|
+
output: `${verb} ${textValue(result.path) || path} · ${content.split('\n').length} lines, ${numberValue(result.size) ?? content.length} bytes.`,
|
|
148
|
+
isError: false,
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
}),
|
|
152
|
+
|
|
153
|
+
ctx.tools.register({
|
|
154
|
+
name: 'rewrite_file',
|
|
155
|
+
description: 'Replace the complete contents of an existing file. Use when most of the file changes.',
|
|
156
|
+
parameters: {
|
|
157
|
+
type: 'object',
|
|
158
|
+
properties: {
|
|
159
|
+
path: { type: 'string', description: 'Relative file path.' },
|
|
160
|
+
content: { type: 'string', description: 'The new complete file content.' },
|
|
161
|
+
},
|
|
162
|
+
required: ['path', 'content'],
|
|
163
|
+
additionalProperties: false,
|
|
164
|
+
},
|
|
165
|
+
allowedInMode: (mode) => mode === 'build',
|
|
166
|
+
async execute(args, exec): Promise<ToolResult> {
|
|
167
|
+
const path = requireString(args, 'path')
|
|
168
|
+
const content = requireString(args, 'content')
|
|
169
|
+
const result = record((await executeDaemonTool('rewrite_file', { path, content }, exec.mode, exec.signal, exec.turnId)).result)
|
|
170
|
+
await refreshWorkspace()
|
|
171
|
+
return {
|
|
172
|
+
output: `Rewrote ${textValue(result.path) || path} · ${content.split('\n').length} lines, ${numberValue(result.size) ?? content.length} bytes.`,
|
|
173
|
+
isError: false,
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
}),
|
|
177
|
+
|
|
178
|
+
ctx.tools.register({
|
|
179
|
+
name: 'edit_file',
|
|
180
|
+
description:
|
|
181
|
+
'Replace an exact text occurrence in a file. oldText must match whitespace exactly and be unique unless replaceAll is true.',
|
|
182
|
+
parameters: {
|
|
183
|
+
type: 'object',
|
|
184
|
+
properties: {
|
|
185
|
+
path: { type: 'string', description: 'Relative file path.' },
|
|
186
|
+
oldText: { type: 'string', description: 'Exact text to replace.' },
|
|
187
|
+
newText: { type: 'string', description: 'Replacement text.' },
|
|
188
|
+
replaceAll: { type: 'boolean', description: 'Replace every occurrence. Defaults to false.' },
|
|
189
|
+
},
|
|
190
|
+
required: ['path', 'oldText', 'newText'],
|
|
191
|
+
additionalProperties: false,
|
|
192
|
+
},
|
|
193
|
+
allowedInMode: (mode) => mode === 'build',
|
|
194
|
+
async execute(args, exec): Promise<ToolResult> {
|
|
195
|
+
const path = requireString(args, 'path')
|
|
196
|
+
const oldText = requireString(args, 'oldText')
|
|
197
|
+
const newText = typeof args.newText === 'string' ? args.newText : ''
|
|
198
|
+
const result = record(
|
|
199
|
+
(
|
|
200
|
+
await executeDaemonTool(
|
|
201
|
+
'edit_file',
|
|
202
|
+
{ path, oldText, newText, replaceAll: args.replaceAll === true },
|
|
203
|
+
exec.mode,
|
|
204
|
+
exec.signal,
|
|
205
|
+
exec.turnId,
|
|
206
|
+
)
|
|
207
|
+
).result,
|
|
208
|
+
)
|
|
209
|
+
await refreshWorkspace()
|
|
210
|
+
return {
|
|
211
|
+
output: `Edited ${textValue(result.path) || path} · ${numberValue(result.replacements) ?? 1} replacement${numberValue(result.replacements) === 1 ? '' : 's'}.`,
|
|
212
|
+
isError: false,
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
}),
|
|
216
|
+
]
|
|
217
|
+
|
|
218
|
+
return () => unregisters.forEach((fn) => fn())
|
|
219
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { Context } from 'cordis'
|
|
2
|
+
import { executeDaemonTool } from '../../../lib/daemon'
|
|
3
|
+
import { useWorkspace } from '../../../store/workspace'
|
|
4
|
+
import type { ToolResult } from '../../types'
|
|
5
|
+
|
|
6
|
+
const MAX_TOOL_OUTPUT = 28_000
|
|
7
|
+
|
|
8
|
+
function truncate(value: string, limit = MAX_TOOL_OUTPUT) {
|
|
9
|
+
if (value.length <= limit) return value
|
|
10
|
+
return `${value.slice(0, limit)}\n… truncated (${value.length - limit} more characters)`
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function record(value: unknown): Record<string, unknown> {
|
|
14
|
+
return value && typeof value === 'object' ? (value as Record<string, unknown>) : {}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function textValue(value: unknown): string {
|
|
18
|
+
return typeof value === 'string' ? value : ''
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function numberValue(value: unknown): number | undefined {
|
|
22
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function requireString(args: Record<string, unknown>, key: string): string {
|
|
26
|
+
const value = args[key]
|
|
27
|
+
if (typeof value !== 'string' || !value.trim()) throw new Error(`Missing string argument "${key}".`)
|
|
28
|
+
return value
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function formatGit(result: Record<string, unknown>): string {
|
|
32
|
+
const parts: string[] = []
|
|
33
|
+
if (textValue(result.current)) parts.push(`Current branch: ${textValue(result.current)}`)
|
|
34
|
+
if (textValue(result.branches)) parts.push(textValue(result.branches))
|
|
35
|
+
if (textValue(result.stdout)) parts.push(textValue(result.stdout))
|
|
36
|
+
if (textValue(result.stderr)) parts.push(`stderr:\n${textValue(result.stderr)}`)
|
|
37
|
+
const code = numberValue(result.code)
|
|
38
|
+
if (code !== undefined && code !== 0) parts.push(`exit code: ${code}`)
|
|
39
|
+
return parts.filter(Boolean).join('\n') || 'Git completed with no output.'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const name = 'tool-git'
|
|
43
|
+
export const inject = ['tools']
|
|
44
|
+
|
|
45
|
+
export function apply(ctx: Context) {
|
|
46
|
+
return ctx.tools.register({
|
|
47
|
+
name: 'git',
|
|
48
|
+
description: 'Inspect or change git state. In Plan mode only status, diff, log, show and branch are accepted.',
|
|
49
|
+
parameters: {
|
|
50
|
+
type: 'object',
|
|
51
|
+
properties: {
|
|
52
|
+
action: {
|
|
53
|
+
type: 'string',
|
|
54
|
+
enum: ['status', 'diff', 'log', 'show', 'branch', 'add', 'commit', 'init', 'checkout'],
|
|
55
|
+
},
|
|
56
|
+
path: { type: 'string', description: 'Limit diff to this file.' },
|
|
57
|
+
staged: { type: 'boolean', description: 'Diff the staged changes.' },
|
|
58
|
+
ref: { type: 'string', description: 'Commit or ref for show.' },
|
|
59
|
+
limit: { type: 'number', description: 'Log limit.' },
|
|
60
|
+
paths: { type: 'array', items: { type: 'string' }, description: 'Files to stage.' },
|
|
61
|
+
message: { type: 'string', description: 'Commit message.' },
|
|
62
|
+
branch: { type: 'string', description: 'Branch to create or check out.' },
|
|
63
|
+
create: { type: 'boolean', description: 'Create a new branch. Defaults to true.' },
|
|
64
|
+
},
|
|
65
|
+
required: ['action'],
|
|
66
|
+
additionalProperties: false,
|
|
67
|
+
},
|
|
68
|
+
allowedInMode: () => true,
|
|
69
|
+
async execute(args, exec): Promise<ToolResult> {
|
|
70
|
+
const action = requireString(args, 'action')
|
|
71
|
+
const gitArgs: Record<string, unknown> = { ...args }
|
|
72
|
+
delete gitArgs.action
|
|
73
|
+
const result = record((await executeDaemonTool('git', { action, ...gitArgs }, exec.mode, exec.signal)).result)
|
|
74
|
+
if (action === 'add' || action === 'commit' || action === 'init' || action === 'checkout') {
|
|
75
|
+
await useWorkspace.getState().loadGit().catch(() => undefined)
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
output: truncate(formatGit(result)),
|
|
79
|
+
isError: (numberValue(result.code) ?? 0) !== 0,
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
})
|
|
83
|
+
}
|