mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-13 23:53:25 +00:00
Compare commits
11 Commits
fix/patch-
...
chore/unsh
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c23821bc0 | ||
|
|
723bd4821d | ||
|
|
6f8da5b7fb | ||
|
|
50cbe48558 | ||
|
|
d81b99c8e3 | ||
|
|
86cb03a1fc | ||
|
|
7765d99c73 | ||
|
|
db5e55a174 | ||
|
|
fbae45eb97 | ||
|
|
554fcd7c06 | ||
|
|
eed158eca0 |
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
@@ -38,10 +38,6 @@ jobs:
|
||||
command: (cd apps/server && bun run test:api)
|
||||
junit_path: test-results/server-api.xml
|
||||
needs_browser: false
|
||||
- suite: server-skills
|
||||
command: (cd apps/server && bun run test:skills)
|
||||
junit_path: test-results/server-skills.xml
|
||||
needs_browser: false
|
||||
- suite: server-tools
|
||||
command: (cd apps/server && bun run test:tools)
|
||||
junit_path: test-results/server-tools.xml
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
Settings,
|
||||
Shield,
|
||||
Sparkles,
|
||||
Wand2,
|
||||
} from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { NavLink, useLocation } from 'react-router'
|
||||
@@ -47,12 +46,6 @@ const primaryNavItems: NavItem[] = [
|
||||
icon: Cpu,
|
||||
feature: Feature.ALPHA_FEATURES_SUPPORT,
|
||||
},
|
||||
{
|
||||
name: 'Skills',
|
||||
to: '/home/skills',
|
||||
icon: Wand2,
|
||||
feature: Feature.SKILLS_SUPPORT,
|
||||
},
|
||||
{
|
||||
name: 'Memory',
|
||||
to: '/home/memory',
|
||||
|
||||
@@ -32,7 +32,6 @@ import { MemoryPage } from './memory/MemoryPage'
|
||||
import { ProfilePage } from './profile/ProfilePage'
|
||||
import { ScheduledTasksPage } from './scheduled-tasks/ScheduledTasksPage'
|
||||
import { SearchProviderPage } from './search-provider/SearchProviderPage'
|
||||
import { SkillsPage } from './skills/SkillsPage'
|
||||
import { SoulPage } from './soul/SoulPage'
|
||||
import { ToolApprovalsPage } from './tool-approvals/ToolApprovalsPage'
|
||||
import { UsagePage } from './usage/UsagePage'
|
||||
@@ -57,7 +56,6 @@ const OptionsRedirect: FC = () => {
|
||||
customization: '/settings/customization',
|
||||
search: '/settings/search',
|
||||
soul: '/home/soul',
|
||||
skills: '/home/skills',
|
||||
'jtbd-agent': '/settings/survey',
|
||||
scheduled: '/scheduled',
|
||||
}
|
||||
@@ -105,7 +103,6 @@ export const App: FC = () => {
|
||||
<Route index element={<NewTab />} />
|
||||
)}
|
||||
<Route path="soul" element={<SoulPage />} />
|
||||
<Route path="skills" element={<SkillsPage />} />
|
||||
<Route path="memory" element={<MemoryPage />} />
|
||||
</Route>
|
||||
|
||||
@@ -182,10 +179,6 @@ export const App: FC = () => {
|
||||
path="/settings/soul"
|
||||
element={<Navigate to="/home/soul" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="/settings/skills"
|
||||
element={<Navigate to="/home/skills" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="/audit"
|
||||
element={<Navigate to={alphaEnabled ? '/admin' : '/home'} replace />}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { type FC, useEffect, useMemo, useRef } from 'react'
|
||||
import { ArrowLeft, PanelRight } from 'lucide-react'
|
||||
import { type FC, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Navigate, useNavigate, useParams, useSearchParams } from 'react-router'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type {
|
||||
@@ -16,8 +16,14 @@ import {
|
||||
useUpdateHarnessAgent,
|
||||
} from '@/entrypoints/app/agents/useAgents'
|
||||
import type { AgentEntry } from '@/entrypoints/app/agents/useOpenClaw'
|
||||
import { type ProducedFilesRailGroup, useAgentOutputs } from '@/lib/agent-files'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { AgentRail } from './AgentRail'
|
||||
import { useAgentCommandData } from './agent-command-layout'
|
||||
import {
|
||||
OutputsRail,
|
||||
useOutputsRailOpen,
|
||||
} from './agent-conversation.outputs-rail'
|
||||
import { ClawChat } from './ClawChat'
|
||||
import { ConversationHeader } from './ConversationHeader'
|
||||
import { ConversationInput } from './ConversationInput'
|
||||
@@ -25,6 +31,8 @@ import {
|
||||
buildChatHistoryFromClawMessages,
|
||||
filterTurnsPersistedInHistory,
|
||||
flattenHistoryPages,
|
||||
mapHistoryToProducedFilesGroups,
|
||||
selectStripOnlyTurns,
|
||||
} from './claw-chat-types'
|
||||
import { consumePendingInitialMessage } from './pending-initial-message'
|
||||
import { QueuePanel } from './QueuePanel'
|
||||
@@ -38,6 +46,7 @@ function AgentConversationController({
|
||||
agents,
|
||||
agentPathPrefix,
|
||||
createAgentPath,
|
||||
onOpenOutputsRail,
|
||||
}: {
|
||||
agentId: string
|
||||
initialMessage: string | null
|
||||
@@ -45,6 +54,7 @@ function AgentConversationController({
|
||||
agents: AgentEntry[]
|
||||
agentPathPrefix: string
|
||||
createAgentPath: string
|
||||
onOpenOutputsRail?: ((turnId?: string | null) => void) | null
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
const initialMessageSentRef = useRef<string | null>(null)
|
||||
@@ -76,6 +86,15 @@ function AgentConversationController({
|
||||
const harnessAgent = harnessAgents.find((entry) => entry.id === agentId)
|
||||
const queue = harnessAgent?.queue ?? []
|
||||
const activeTurnId = harnessAgent?.activeTurnId ?? null
|
||||
const isOpenClawAgent = harnessAgent?.adapter === 'openclaw'
|
||||
|
||||
// Used to surface produced-files strips on a fresh page load
|
||||
// when there's no optimistic turn to carry the data. Disabled
|
||||
// for non-openclaw adapters since they don't attribute files.
|
||||
const { groups: agentOutputGroups } = useAgentOutputs(
|
||||
agentId,
|
||||
isOpenClawAgent,
|
||||
)
|
||||
|
||||
const { turns, streaming, send } = useAgentConversation(agentId, {
|
||||
runtime: 'agent-harness',
|
||||
@@ -100,6 +119,44 @@ function AgentConversationController({
|
||||
() => filterTurnsPersistedInHistory(turns, historyMessages),
|
||||
[historyMessages, turns],
|
||||
)
|
||||
// Persisted turns that still need to surface their FileCardStrip
|
||||
// — history items don't carry produced-files data, so without
|
||||
// these the strip would vanish on history reload.
|
||||
const stripOnlyTurns = useMemo(
|
||||
() => selectStripOnlyTurns(turns, historyMessages),
|
||||
[historyMessages, turns],
|
||||
)
|
||||
// Two outputs from the per-turn matcher:
|
||||
// - filesByAssistantId → strip rendered directly under the
|
||||
// matching assistant history bubble.
|
||||
// - tailUnmatched → groups with no history pair (orphans);
|
||||
// rendered at the conversation tail.
|
||||
// Both are filtered to exclude turnIds already covered by a
|
||||
// live or strip-only optimistic turn (those carry their own
|
||||
// strip and history hasn't reloaded yet).
|
||||
const { filesByAssistantId, tailStripGroups } = useMemo(() => {
|
||||
if (!isOpenClawAgent) {
|
||||
return {
|
||||
filesByAssistantId: new Map<string, ProducedFilesRailGroup>(),
|
||||
tailStripGroups: [] as ProducedFilesRailGroup[],
|
||||
}
|
||||
}
|
||||
const coveredTurnIds = new Set<string>()
|
||||
for (const turn of turns) {
|
||||
if (turn.turnId) coveredTurnIds.add(turn.turnId)
|
||||
}
|
||||
const eligibleGroups = agentOutputGroups.filter(
|
||||
(group) => !coveredTurnIds.has(group.turnId),
|
||||
)
|
||||
const { byAssistantMessageId, unmatched } = mapHistoryToProducedFilesGroups(
|
||||
historyMessages,
|
||||
eligibleGroups,
|
||||
)
|
||||
return {
|
||||
filesByAssistantId: byAssistantMessageId,
|
||||
tailStripGroups: unmatched,
|
||||
}
|
||||
}, [agentOutputGroups, isOpenClawAgent, historyMessages, turns])
|
||||
onInitialMessageConsumedRef.current = onInitialMessageConsumed
|
||||
|
||||
const disabled = !agent
|
||||
@@ -171,12 +228,16 @@ function AgentConversationController({
|
||||
agentName={agentName}
|
||||
historyMessages={historyMessages}
|
||||
turns={visibleTurns}
|
||||
stripOnlyTurns={stripOnlyTurns}
|
||||
filesByAssistantId={filesByAssistantId}
|
||||
tailStripGroups={tailStripGroups}
|
||||
streaming={streaming}
|
||||
isInitialLoading={harnessHistoryQuery.isLoading}
|
||||
error={error}
|
||||
hasNextPage={false}
|
||||
isFetchingNextPage={false}
|
||||
onFetchNextPage={() => {}}
|
||||
onOpenOutputsRail={onOpenOutputsRail}
|
||||
onRetry={() => {
|
||||
void harnessHistoryQuery.refetch()
|
||||
}}
|
||||
@@ -287,6 +348,45 @@ export const AgentCommandConversation: FC<AgentCommandConversationProps> = ({
|
||||
const isPageVariant = variant === 'page'
|
||||
const backLabel = isPageVariant ? 'Back to agents' : 'Back to home'
|
||||
|
||||
const isOpenClawAgent = harnessAgent?.adapter === 'openclaw'
|
||||
const [outputsRailOpen, setOutputsRailOpen] =
|
||||
useOutputsRailOpen(resolvedAgentId)
|
||||
const railVisible = isOpenClawAgent && outputsRailOpen
|
||||
|
||||
// Deep-link target for the rail. Set when (a) the user clicks
|
||||
// View / +N on an inline file-card strip, or (b) an external nav
|
||||
// arrived with `?outputsTurn=<turnId>`. Cleared by the rail
|
||||
// itself once it has scrolled to + expanded the matching group.
|
||||
const urlOutputsTurn = searchParams.get('outputsTurn')
|
||||
const [focusTurnId, setFocusTurnId] = useState<string | null>(urlOutputsTurn)
|
||||
// If the URL param flips while we're already on this agent, sync.
|
||||
useEffect(() => {
|
||||
if (!urlOutputsTurn) return
|
||||
setFocusTurnId(urlOutputsTurn)
|
||||
if (isOpenClawAgent) setOutputsRailOpen(true)
|
||||
}, [urlOutputsTurn, isOpenClawAgent, setOutputsRailOpen])
|
||||
|
||||
const handleOpenOutputsRail = (turnId?: string | null) => {
|
||||
if (!isOpenClawAgent) return
|
||||
setOutputsRailOpen(true)
|
||||
setFocusTurnId(turnId ?? null)
|
||||
}
|
||||
const handleFocusTurnConsumed = () => {
|
||||
setFocusTurnId(null)
|
||||
if (urlOutputsTurn) {
|
||||
// Drop the URL param so a back-nav doesn't re-trigger the
|
||||
// scroll. `replace: true` keeps history clean.
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev)
|
||||
next.delete('outputsTurn')
|
||||
return next
|
||||
},
|
||||
{ replace: true },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const adapterHealth = useMemo<AgentAdapterHealth | null>(() => {
|
||||
const adapterId = harnessAgent?.adapter
|
||||
if (!adapterId) return null
|
||||
@@ -346,13 +446,34 @@ export const AgentCommandConversation: FC<AgentCommandConversationProps> = ({
|
||||
onPinToggle={(next) =>
|
||||
handlePinToggle(harnessAgent ?? null, next)
|
||||
}
|
||||
headerExtra={
|
||||
isOpenClawAgent ? (
|
||||
<Button
|
||||
variant={railVisible ? 'secondary' : 'ghost'}
|
||||
size="icon"
|
||||
className="size-8 rounded-xl"
|
||||
onClick={() => setOutputsRailOpen(!railVisible)}
|
||||
title={railVisible ? 'Hide outputs' : 'Show outputs'}
|
||||
>
|
||||
<PanelRight className="size-4" />
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body grid: rail list + chat. Both columns share the same
|
||||
top edge (the band above) so headers can never drift. */}
|
||||
<div className="grid min-h-0 flex-1 grid-rows-[minmax(0,1fr)] lg:grid-cols-[288px_minmax(0,1fr)]">
|
||||
{/* Body grid: rail list + chat (+ outputs rail when an
|
||||
openclaw agent has it open). Columns share the same top
|
||||
edge as the band above so headers can never drift. */}
|
||||
<div
|
||||
className={cn(
|
||||
'grid min-h-0 flex-1 grid-rows-[minmax(0,1fr)]',
|
||||
railVisible
|
||||
? 'lg:grid-cols-[288px_minmax(0,1fr)_320px]'
|
||||
: 'lg:grid-cols-[288px_minmax(0,1fr)]',
|
||||
)}
|
||||
>
|
||||
<AgentRail
|
||||
agents={harnessAgents}
|
||||
adapters={adapters}
|
||||
@@ -367,13 +488,34 @@ export const AgentCommandConversation: FC<AgentCommandConversationProps> = ({
|
||||
agentId={resolvedAgentId}
|
||||
agents={agents}
|
||||
initialMessage={initialMessage}
|
||||
onInitialMessageConsumed={() =>
|
||||
setSearchParams({}, { replace: true })
|
||||
}
|
||||
onInitialMessageConsumed={() => {
|
||||
// Preserve the outputsTurn deep-link if present —
|
||||
// dropping all params would erase the rail focus
|
||||
// before it had a chance to consume.
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams()
|
||||
const turn = prev.get('outputsTurn')
|
||||
if (turn) next.set('outputsTurn', turn)
|
||||
return next
|
||||
},
|
||||
{ replace: true },
|
||||
)
|
||||
}}
|
||||
agentPathPrefix={agentPathPrefix}
|
||||
createAgentPath={createAgentPath}
|
||||
onOpenOutputsRail={isOpenClawAgent ? handleOpenOutputsRail : null}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{railVisible ? (
|
||||
<OutputsRail
|
||||
agentId={resolvedAgentId}
|
||||
onClose={() => setOutputsRailOpen(false)}
|
||||
focusTurnId={focusTurnId}
|
||||
onFocusTurnConsumed={handleFocusTurnConsumed}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -162,12 +162,16 @@ export const AgentCommandHome: FC = () => {
|
||||
<>
|
||||
<div className="flex flex-col items-center gap-5 pt-[max(10vh,24px)] text-center">
|
||||
<div className="space-y-3">
|
||||
<h1 className="font-semibold text-[clamp(2rem,4vw,3.25rem)] leading-tight tracking-tight">
|
||||
What should your agent work on next?
|
||||
<h1 className="font-semibold text-[clamp(2.25rem,4.5vw,3.5rem)] leading-[1.08] tracking-[-0.025em] [text-wrap:balance]">
|
||||
What should your agent{' '}
|
||||
<span className="font-medium text-[var(--accent-orange)] italic">
|
||||
work on
|
||||
</span>{' '}
|
||||
next?
|
||||
</h1>
|
||||
<p className="mx-auto max-w-2xl text-muted-foreground text-sm leading-6">
|
||||
Start with a task, continue a thread, or switch to another
|
||||
agent without leaving the new tab.
|
||||
<p className="mx-auto max-w-2xl text-muted-foreground text-sm leading-6 [text-wrap:pretty]">
|
||||
Start a task, continue a thread, or hand off to a different
|
||||
agent — all without leaving this tab.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -27,6 +27,14 @@ interface AgentSelectorProps {
|
||||
onSelectAgent: (agent: AgentEntry) => void
|
||||
onCreateAgent?: () => void
|
||||
status?: string
|
||||
/**
|
||||
* `'pill'` renders the filled-pill variant used by the calm
|
||||
* composer on `/home` — bordered, slightly elevated background,
|
||||
* mono agent name, used as the visual anchor on the left of the
|
||||
* footer chip row. Default `'ghost'` keeps the existing flat
|
||||
* shadcn ghost-button trigger used by the chat surface.
|
||||
*/
|
||||
triggerVariant?: 'ghost' | 'pill'
|
||||
}
|
||||
|
||||
function getStatusDot(status?: string) {
|
||||
@@ -42,31 +50,49 @@ export const AgentSelector: FC<AgentSelectorProps> = ({
|
||||
onSelectAgent,
|
||||
onCreateAgent,
|
||||
status,
|
||||
triggerVariant = 'ghost',
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const selectedAgent = agents.find(
|
||||
(agent) => agent.agentId === selectedAgentId,
|
||||
)
|
||||
|
||||
const triggerNode =
|
||||
triggerVariant === 'pill' ? (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'inline-flex h-6 max-w-[180px] items-center gap-1.5 rounded-full border border-border bg-accent/40 pr-2 pl-2.5 text-[11.5px] text-foreground transition-colors',
|
||||
'hover:border-border hover:bg-accent/70 data-[state=open]:border-border data-[state=open]:bg-accent/70',
|
||||
)}
|
||||
>
|
||||
<span className={cn('size-1.5 rounded-full', getStatusDot(status))} />
|
||||
<span className="truncate font-medium font-mono text-[11.5px] tracking-[-0.01em]">
|
||||
{selectedAgent?.name ?? 'Select agent'}
|
||||
</span>
|
||||
<ChevronDown className="size-3 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg px-3 py-1.5 font-medium text-sm transition-all',
|
||||
'bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
'data-[state=open]:bg-accent',
|
||||
)}
|
||||
>
|
||||
<Bot className="h-4 w-4" />
|
||||
<span className={cn('size-2 rounded-full', getStatusDot(status))} />
|
||||
<span className="max-w-32 truncate">
|
||||
{selectedAgent?.name ?? 'Select agent'}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
)
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg px-3 py-1.5 font-medium text-sm transition-all',
|
||||
'bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
'data-[state=open]:bg-accent',
|
||||
)}
|
||||
>
|
||||
<Bot className="h-4 w-4" />
|
||||
<span className={cn('size-2 rounded-full', getStatusDot(status))} />
|
||||
<span className="max-w-32 truncate">
|
||||
{selectedAgent?.name ?? 'Select agent'}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverTrigger asChild>{triggerNode}</PopoverTrigger>
|
||||
<PopoverContent side="bottom" align="start" className="w-72 p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search agents..." className="h-9" />
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Bot, Loader2, RefreshCw } from 'lucide-react'
|
||||
import { type FC, useEffect, useRef } from 'react'
|
||||
import { type FC, Fragment, useEffect, useRef } from 'react'
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationScrollButton,
|
||||
} from '@/components/ai-elements/conversation'
|
||||
import type { AgentConversationTurn } from '@/lib/agent-conversations/types'
|
||||
import type { ProducedFilesRailGroup } from '@/lib/agent-files'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { FileCardStrip } from './agent-conversation.file-card-strip'
|
||||
import { ClawChatMessage } from './ClawChatMessage'
|
||||
import { ConversationMessage } from './ConversationMessage'
|
||||
import type { ClawChatMessage as ClawChatMessageModel } from './claw-chat-types'
|
||||
@@ -15,6 +17,29 @@ interface ClawChatProps {
|
||||
agentName: string
|
||||
historyMessages: ClawChatMessageModel[]
|
||||
turns: AgentConversationTurn[]
|
||||
/**
|
||||
* Persisted turns that still need to render their FileCardStrip
|
||||
* because the history items they were filtered against don't
|
||||
* carry produced-files data. Rendered between history and the
|
||||
* live `turns` so the strip lands at the bottom of the
|
||||
* corresponding assistant turn.
|
||||
*/
|
||||
stripOnlyTurns?: AgentConversationTurn[]
|
||||
/**
|
||||
* Maps each assistant history message id → the produced-files
|
||||
* group that came from its turn. Built by
|
||||
* `mapHistoryToProducedFilesGroups` upstream so the strip
|
||||
* renders directly under the matching message instead of
|
||||
* stacking at the conversation tail.
|
||||
*/
|
||||
filesByAssistantId?: Map<string, ProducedFilesRailGroup>
|
||||
/**
|
||||
* Produced-files groups that didn't match any persisted history
|
||||
* pair (e.g. orphaned turns where history loaded after the
|
||||
* group was attributed). Rendered at the conversation tail as
|
||||
* a fallback so the user can still see them.
|
||||
*/
|
||||
tailStripGroups?: ReadonlyArray<ProducedFilesRailGroup>
|
||||
streaming: boolean
|
||||
isInitialLoading: boolean
|
||||
error: Error | null
|
||||
@@ -22,6 +47,8 @@ interface ClawChatProps {
|
||||
isFetchingNextPage: boolean
|
||||
onFetchNextPage: () => void
|
||||
onRetry: () => void
|
||||
/** Wired through to the inline file-card strip on each assistant turn. */
|
||||
onOpenOutputsRail?: ((turnId?: string | null) => void) | null
|
||||
className?: string
|
||||
}
|
||||
|
||||
@@ -78,6 +105,9 @@ export const ClawChat: FC<ClawChatProps> = ({
|
||||
agentName,
|
||||
historyMessages,
|
||||
turns,
|
||||
stripOnlyTurns,
|
||||
filesByAssistantId,
|
||||
tailStripGroups,
|
||||
streaming,
|
||||
isInitialLoading,
|
||||
error,
|
||||
@@ -85,6 +115,7 @@ export const ClawChat: FC<ClawChatProps> = ({
|
||||
isFetchingNextPage,
|
||||
onFetchNextPage,
|
||||
onRetry,
|
||||
onOpenOutputsRail,
|
||||
className,
|
||||
}) => {
|
||||
const topSentinelRef = useRef<HTMLDivElement>(null)
|
||||
@@ -147,14 +178,44 @@ export const ClawChat: FC<ClawChatProps> = ({
|
||||
Start of conversation
|
||||
</div>
|
||||
) : null}
|
||||
{historyMessages.map((message) => (
|
||||
<ClawChatMessage key={message.id} message={message} />
|
||||
{historyMessages.map((message) => {
|
||||
const matched = filesByAssistantId?.get(message.id)
|
||||
return (
|
||||
<Fragment key={message.id}>
|
||||
<ClawChatMessage message={message} />
|
||||
{matched ? (
|
||||
<FileCardStrip
|
||||
turnId={matched.turnId}
|
||||
files={matched.files}
|
||||
onOpenRail={onOpenOutputsRail ?? (() => {})}
|
||||
/>
|
||||
) : null}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
{(tailStripGroups ?? []).map((group) => (
|
||||
<FileCardStrip
|
||||
key={`tail-strip-${group.turnId}`}
|
||||
turnId={group.turnId}
|
||||
files={group.files}
|
||||
onOpenRail={onOpenOutputsRail ?? (() => {})}
|
||||
/>
|
||||
))}
|
||||
{(stripOnlyTurns ?? []).map((turn) => (
|
||||
<ConversationMessage
|
||||
key={`strip-${turn.id}`}
|
||||
turn={turn}
|
||||
streaming={false}
|
||||
stripOnly
|
||||
onOpenOutputsRail={onOpenOutputsRail}
|
||||
/>
|
||||
))}
|
||||
{turns.map((turn, index) => (
|
||||
<ConversationMessage
|
||||
key={turn.id}
|
||||
turn={turn}
|
||||
streaming={streaming && index === turns.length - 1}
|
||||
onOpenOutputsRail={onOpenOutputsRail}
|
||||
/>
|
||||
))}
|
||||
{error ? (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ArrowLeft, Home } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import type { FC, ReactNode } from 'react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { formatRelativeTime } from '@/entrypoints/app/agents/agent-display.helpers'
|
||||
@@ -20,6 +20,8 @@ interface ConversationHeaderProps {
|
||||
backTarget: 'home' | 'page'
|
||||
onGoHome: () => void
|
||||
onPinToggle: (next: boolean) => void
|
||||
/** Optional trailing slot — currently used for the Outputs rail toggle. */
|
||||
headerExtra?: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,6 +42,7 @@ export const ConversationHeader: FC<ConversationHeaderProps> = ({
|
||||
backTarget,
|
||||
onGoHome,
|
||||
onPinToggle,
|
||||
headerExtra,
|
||||
}) => {
|
||||
const BackIcon = backTarget === 'home' ? Home : ArrowLeft
|
||||
const adapter = agent?.adapter ?? fallbackAdapter
|
||||
@@ -90,16 +93,21 @@ export const ConversationHeader: FC<ConversationHeaderProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||
<StatusPill
|
||||
status={status}
|
||||
hasActiveTurn={Boolean(agent?.activeTurnId)}
|
||||
/>
|
||||
<div className="flex h-4 items-center text-[11px] text-muted-foreground">
|
||||
<span className="truncate">
|
||||
{metaParts.length > 0 ? metaParts.join(' · ') : '\u00A0'}
|
||||
</span>
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||
<StatusPill
|
||||
status={status}
|
||||
hasActiveTurn={Boolean(agent?.activeTurnId)}
|
||||
/>
|
||||
<div className="flex h-4 items-center text-[11px] text-muted-foreground">
|
||||
<span className="truncate">
|
||||
{metaParts.length > 0 ? metaParts.join(' · ') : '\u00A0'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{headerExtra ? (
|
||||
<div className="flex shrink-0 items-center">{headerExtra}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -164,7 +164,16 @@ function VoiceButton({
|
||||
)
|
||||
}
|
||||
|
||||
function ContextControls({
|
||||
/**
|
||||
* Calm-composer footer shared by both `/home` (`variant="home"`) and
|
||||
* the chat surface at `/agents/:agentId` (`variant="conversation"`).
|
||||
* Pill-shaped chips on an internal dashed divider, with a right-
|
||||
* aligned keyboard hint. The agent selector is conditional via
|
||||
* `showAgentSelector`: home shows it as a filled pill on the left,
|
||||
* the chat surface hides it (the agent is locked once you're in the
|
||||
* conversation).
|
||||
*/
|
||||
function CalmContextControls({
|
||||
agents,
|
||||
onCreateAgent,
|
||||
onSelectAgent,
|
||||
@@ -201,110 +210,128 @@ function ContextControls({
|
||||
)?.is_authenticated
|
||||
})
|
||||
|
||||
const showApps = supports(Feature.MANAGED_MCP_SUPPORT)
|
||||
const showWorkspace = supports(Feature.WORKSPACE_FOLDER_SUPPORT)
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between border-border/40 border-t px-4 py-2.5">
|
||||
<div className="flex items-center gap-1">
|
||||
{showAgentSelector ? (
|
||||
<div className="mx-3 flex items-center gap-1 border-border/60 border-t border-dashed py-2">
|
||||
{showAgentSelector ? (
|
||||
<>
|
||||
<AgentSelector
|
||||
agents={agents}
|
||||
selectedAgentId={selectedAgentId}
|
||||
onSelectAgent={onSelectAgent}
|
||||
onCreateAgent={onCreateAgent}
|
||||
status={status}
|
||||
triggerVariant="pill"
|
||||
/>
|
||||
) : null}
|
||||
{supports(Feature.WORKSPACE_FOLDER_SUPPORT) ? (
|
||||
<WorkspaceSelector>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg px-3 py-1.5 font-medium text-sm transition-all',
|
||||
'bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
'data-[state=open]:bg-accent',
|
||||
)}
|
||||
>
|
||||
<Folder className="h-4 w-4" />
|
||||
<span>{selectedFolder?.name || 'Add workspace'}</span>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</WorkspaceSelector>
|
||||
) : null}
|
||||
<TabPickerPopover
|
||||
variant="selector"
|
||||
selectedTabs={selectedTabs}
|
||||
onToggleTab={onToggleTab}
|
||||
>
|
||||
<Button
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg px-3 py-1.5 font-medium text-sm transition-all',
|
||||
selectedTabs.length > 0
|
||||
? 'bg-[var(--accent-orange)]! text-white shadow-sm'
|
||||
: 'bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
'data-[state=open]:bg-accent',
|
||||
)}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="mx-1 inline-block h-3.5 w-px shrink-0 bg-border"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{showWorkspace ? (
|
||||
<WorkspaceSelector>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-6 items-center gap-1.5 rounded-full px-2.5 text-[11.5px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground data-[state=open]:bg-accent data-[state=open]:text-foreground"
|
||||
>
|
||||
<Layers className="h-4 w-4" />
|
||||
<span>Tabs</span>
|
||||
</Button>
|
||||
</TabPickerPopover>
|
||||
<Button
|
||||
<Folder className="size-3" />
|
||||
<span>Workspace</span>
|
||||
<span className="font-mono text-[10.5px] text-muted-foreground/70">
|
||||
{selectedFolder?.name ?? 'none'}
|
||||
</span>
|
||||
</button>
|
||||
</WorkspaceSelector>
|
||||
) : null}
|
||||
<TabPickerPopover
|
||||
variant="selector"
|
||||
selectedTabs={selectedTabs}
|
||||
onToggleTab={onToggleTab}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onAttachClick}
|
||||
disabled={attachDisabled || !attachmentsEnabled}
|
||||
title="Attach files"
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg px-3 py-1.5 font-medium text-sm transition-all',
|
||||
'bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
'inline-flex h-6 items-center gap-1.5 rounded-full px-2.5 text-[11.5px] transition-colors data-[state=open]:bg-accent data-[state=open]:text-foreground',
|
||||
selectedTabs.length > 0
|
||||
? 'bg-[var(--accent-orange)] text-white hover:bg-[var(--accent-orange)]/90'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
<span>Attach</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{supports(Feature.MANAGED_MCP_SUPPORT) ? (
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<AppSelector side="bottom">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg px-3 py-1.5 font-medium text-sm transition-all',
|
||||
'bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
'data-[state=open]:bg-accent',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center -space-x-1.5">
|
||||
<Layers className="size-3" />
|
||||
<span>Tabs</span>
|
||||
<span
|
||||
className={cn(
|
||||
'font-mono text-[10.5px]',
|
||||
selectedTabs.length > 0
|
||||
? 'text-white/80'
|
||||
: 'text-muted-foreground/70',
|
||||
)}
|
||||
>
|
||||
{selectedTabs.length}
|
||||
</span>
|
||||
</button>
|
||||
</TabPickerPopover>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAttachClick}
|
||||
disabled={attachDisabled || !attachmentsEnabled}
|
||||
title="Attach files"
|
||||
className="inline-flex h-6 items-center gap-1.5 rounded-full px-2.5 text-[11.5px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<Paperclip className="size-3" />
|
||||
<span>Attach</span>
|
||||
</button>
|
||||
{showApps ? (
|
||||
<AppSelector side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-6 items-center gap-1.5 rounded-full px-2.5 text-[11.5px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground data-[state=open]:bg-accent data-[state=open]:text-foreground"
|
||||
>
|
||||
{connectedManagedServers.length > 0 ? (
|
||||
<span className="flex items-center -space-x-1.5">
|
||||
{connectedManagedServers.slice(0, 4).map((server) => (
|
||||
<div
|
||||
<span
|
||||
key={server.id}
|
||||
className="rounded-full ring-2 ring-card"
|
||||
>
|
||||
<McpServerIcon
|
||||
serverName={server.managedServerName ?? ''}
|
||||
size={16}
|
||||
size={12}
|
||||
/>
|
||||
</div>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{connectedManagedServers.length > 4 ? (
|
||||
<span className="text-xs">
|
||||
+{connectedManagedServers.length - 4}
|
||||
</span>
|
||||
) : null}
|
||||
<span>Apps</span>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</AppSelector>
|
||||
</div>
|
||||
</span>
|
||||
) : (
|
||||
<FileText className="size-3" />
|
||||
)}
|
||||
<span>Apps</span>
|
||||
<ChevronDown className="size-3" />
|
||||
</button>
|
||||
</AppSelector>
|
||||
) : null}
|
||||
<div className="ml-auto inline-flex shrink-0 items-center gap-1.5 text-[11px] text-muted-foreground/70">
|
||||
<kbd className="inline-flex h-4 min-w-4 items-center justify-center rounded border border-border bg-accent/30 px-1 font-mono text-[10px] text-muted-foreground">
|
||||
↵
|
||||
</kbd>
|
||||
<span>to run</span>
|
||||
<span className="text-muted-foreground/40">·</span>
|
||||
<kbd className="inline-flex h-4 min-w-4 items-center justify-center rounded border border-border bg-accent/30 px-1 font-mono text-[10px] text-muted-foreground">
|
||||
⇧
|
||||
</kbd>
|
||||
<kbd className="inline-flex h-4 min-w-4 items-center justify-center rounded border border-border bg-accent/30 px-1 font-mono text-[10px] text-muted-foreground">
|
||||
↵
|
||||
</kbd>
|
||||
<span>new line</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[1.55rem] border border-border/60 bg-card/95 shadow-sm">
|
||||
<div className="overflow-hidden rounded-[1.55rem] border border-border/60 bg-card/95 shadow-sm transition-[border-color,box-shadow] duration-150 focus-within:border-[var(--accent-orange)]/40 focus-within:shadow-[0_0_0_4px_color-mix(in_oklch,var(--accent-orange)_15%,transparent),0_1px_2px_rgba(15,23,42,0.04)]">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
@@ -312,7 +339,7 @@ function HomeShell({ children }: { children: ReactNode }) {
|
||||
|
||||
function ConversationShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[1.35rem] border border-border/50 bg-background/95 shadow-[0_10px_30px_rgba(15,23,42,0.06)] backdrop-blur-md">
|
||||
<div className="overflow-hidden rounded-[1.35rem] border border-border/50 bg-background/95 shadow-[0_10px_30px_rgba(15,23,42,0.06)] backdrop-blur-md transition-[border-color,box-shadow] duration-150 focus-within:border-[var(--accent-orange)]/40 focus-within:shadow-[0_0_0_4px_color-mix(in_oklch,var(--accent-orange)_15%,transparent),0_10px_30px_rgba(15,23,42,0.06)]">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
@@ -542,7 +569,7 @@ export const ConversationInput: FC<ConversationInputProps> = ({
|
||||
}
|
||||
disabled={disabled || voice.isTranscribing}
|
||||
className={cn(
|
||||
'resize-none border-none bg-transparent px-0 text-[15px] shadow-none focus-visible:ring-0',
|
||||
'resize-none border-none bg-transparent px-0 text-[15px] shadow-none focus-visible:ring-0 dark:bg-transparent',
|
||||
'[field-sizing:fixed]',
|
||||
variant === 'home'
|
||||
? 'min-h-[40px] py-2 leading-6'
|
||||
@@ -583,7 +610,7 @@ export const ConversationInput: FC<ConversationInputProps> = ({
|
||||
{voice.error}
|
||||
</div>
|
||||
) : null}
|
||||
<ContextControls
|
||||
<CalmContextControls
|
||||
agents={agents}
|
||||
onCreateAgent={onCreateAgent}
|
||||
onSelectAgent={onSelectAgent}
|
||||
|
||||
@@ -22,10 +22,26 @@ import type {
|
||||
AgentConversationTurn,
|
||||
ToolEntry,
|
||||
} from '@/lib/agent-conversations/types'
|
||||
import { FileCardStrip } from './agent-conversation.file-card-strip'
|
||||
|
||||
interface ConversationMessageProps {
|
||||
turn: AgentConversationTurn
|
||||
streaming: boolean
|
||||
/**
|
||||
* Forwarded to the inline file-card strip's "View" / "+N"
|
||||
* button. Wired up by AgentCommandConversation so the strip can
|
||||
* deep-link straight into the Outputs rail at the matching turn
|
||||
* group. `null` here disables the strip's deep-link affordance
|
||||
* — the cards still open the preview Sheet directly.
|
||||
*/
|
||||
onOpenOutputsRail?: ((turnId?: string | null) => void) | null
|
||||
/**
|
||||
* Render only the trailing FileCardStrip for this turn — used
|
||||
* when the turn's user / assistant text is already rendered
|
||||
* elsewhere (e.g. by `ClawChatMessage` from persisted history)
|
||||
* but the produced-files affordance would otherwise be lost.
|
||||
*/
|
||||
stripOnly?: boolean
|
||||
}
|
||||
|
||||
interface RenderEntry {
|
||||
@@ -88,9 +104,22 @@ function ToolStatusIcon({ status }: { status: ToolEntry['status'] }) {
|
||||
export const ConversationMessage: FC<ConversationMessageProps> = ({
|
||||
turn,
|
||||
streaming,
|
||||
onOpenOutputsRail,
|
||||
stripOnly,
|
||||
}) => {
|
||||
const entries = useMemo(() => buildRenderEntries(turn), [turn])
|
||||
|
||||
if (stripOnly) {
|
||||
if (!turn.producedFiles || turn.producedFiles.length === 0) return null
|
||||
return (
|
||||
<FileCardStrip
|
||||
turnId={turn.turnId ?? null}
|
||||
files={turn.producedFiles}
|
||||
onOpenRail={onOpenOutputsRail ?? (() => {})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Message from="user">
|
||||
@@ -185,6 +214,14 @@ export const ConversationMessage: FC<ConversationMessageProps> = ({
|
||||
</Message>
|
||||
)}
|
||||
|
||||
{turn.producedFiles && turn.producedFiles.length > 0 ? (
|
||||
<FileCardStrip
|
||||
turnId={turn.turnId ?? null}
|
||||
files={turn.producedFiles}
|
||||
onOpenRail={onOpenOutputsRail ?? (() => {})}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!turn.done && turn.parts.length === 0 && streaming && (
|
||||
<div className="flex gap-2">
|
||||
<div className="flex size-7 shrink-0 items-center justify-center rounded-full bg-[var(--accent-orange)] text-white">
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* @deprecated Replaced by `FileCardStrip` in
|
||||
* `agent-conversation.file-card-strip.tsx`. Kept temporarily so
|
||||
* any in-flight callers don't fail to import; remove in a
|
||||
* follow-up once nothing external references it.
|
||||
*
|
||||
* Compact "Files produced" card rendered under an assistant turn.
|
||||
*/
|
||||
|
||||
import { FileText, Image as ImageIcon, Paperclip } from 'lucide-react'
|
||||
import { type FC, useMemo, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { basenameOf, formatFileSize, inferFileKind } from '@/lib/agent-files'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { FilePreviewSheet } from './agent-conversation.file-preview-sheet'
|
||||
|
||||
export interface ProducedFileLike {
|
||||
id: string
|
||||
path: string
|
||||
size: number
|
||||
}
|
||||
|
||||
interface ArtifactCardProps {
|
||||
files: ReadonlyArray<ProducedFileLike>
|
||||
className?: string
|
||||
}
|
||||
|
||||
const MAX_INLINE_ROWS = 4
|
||||
|
||||
export const ArtifactCard: FC<ArtifactCardProps> = ({ files, className }) => {
|
||||
const [openFileId, setOpenFileId] = useState<string | null>(null)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
const sortedFiles = useMemo(
|
||||
() => [...files].sort((a, b) => a.path.localeCompare(b.path)),
|
||||
[files],
|
||||
)
|
||||
|
||||
if (sortedFiles.length === 0) return null
|
||||
|
||||
const visible = expanded ? sortedFiles : sortedFiles.slice(0, MAX_INLINE_ROWS)
|
||||
const hiddenCount = sortedFiles.length - visible.length
|
||||
const openFile = sortedFiles.find((file) => file.id === openFileId) ?? null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl border border-border/60 bg-card/50 px-3 py-2.5',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2 text-muted-foreground text-xs">
|
||||
<Paperclip className="size-3.5" />
|
||||
<span className="font-medium text-foreground">
|
||||
{sortedFiles.length === 1
|
||||
? '1 file produced'
|
||||
: `${sortedFiles.length} files produced`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul className="flex flex-col gap-1">
|
||||
{visible.map((file) => (
|
||||
<li key={file.id}>
|
||||
<ArtifactRow file={file} onOpen={() => setOpenFileId(file.id)} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{hiddenCount > 0 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mt-1.5 h-7 px-2 text-xs"
|
||||
onClick={() => setExpanded(true)}
|
||||
>
|
||||
Show {hiddenCount} more
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<FilePreviewSheet
|
||||
fileId={openFile?.id ?? null}
|
||||
filePath={openFile?.path ?? null}
|
||||
open={Boolean(openFileId)}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) setOpenFileId(null)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ArtifactRow({
|
||||
file,
|
||||
onOpen,
|
||||
}: {
|
||||
file: ProducedFileLike
|
||||
onOpen: () => void
|
||||
}) {
|
||||
const name = basenameOf(file.path)
|
||||
const kind = inferFileKind(file.path)
|
||||
const Icon = kind === 'image' ? ImageIcon : FileText
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors',
|
||||
'hover:bg-accent/60 focus:bg-accent/60 focus:outline-hidden',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate font-medium">{name}</span>
|
||||
<span className="shrink-0 text-muted-foreground text-xs tabular-nums">
|
||||
{formatFileSize(file.size)}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* "Files produced" strip rendered at the bottom of any assistant
|
||||
* turn that produced files (openclaw only). Replaces Phase 5.3's
|
||||
* row-list ArtifactCard with small horizontal cards for a lighter
|
||||
* visual treatment.
|
||||
*
|
||||
* Click semantics:
|
||||
* - Card → opens FilePreviewSheet directly (preview + download).
|
||||
* - View → emits onOpenRail(turnId); the parent opens the rail
|
||||
* and scrolls to the matching turn group.
|
||||
* - +N → same as View (the user is asking to see what was
|
||||
* overflowed).
|
||||
*/
|
||||
|
||||
import { ChevronRight, FileText, Image as ImageIcon } from 'lucide-react'
|
||||
import { type FC, useMemo, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { basenameOf, formatFileSize, inferFileKind } from '@/lib/agent-files'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { FilePreviewSheet } from './agent-conversation.file-preview-sheet'
|
||||
|
||||
export interface CardStripFile {
|
||||
id: string
|
||||
path: string
|
||||
size: number
|
||||
}
|
||||
|
||||
interface FileCardStripProps {
|
||||
/**
|
||||
* The turn id that produced these files. Forwarded to
|
||||
* `onOpenRail` so the rail can scroll/expand the matching group.
|
||||
* Optional because the live `produced_files` event lands before
|
||||
* the harness has stamped a server-issued turn id on the
|
||||
* optimistic turn — in that brief window, View falls back to
|
||||
* just opening the rail at the top.
|
||||
*/
|
||||
turnId?: string | null
|
||||
files: ReadonlyArray<CardStripFile>
|
||||
/** Caller wires this to `setOutputsRailOpen(true)` + deep-link. */
|
||||
onOpenRail: (turnId?: string | null) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
const MAX_VISIBLE = 4
|
||||
|
||||
export const FileCardStrip: FC<FileCardStripProps> = ({
|
||||
turnId,
|
||||
files,
|
||||
onOpenRail,
|
||||
className,
|
||||
}) => {
|
||||
const [openFileId, setOpenFileId] = useState<string | null>(null)
|
||||
|
||||
const sortedFiles = useMemo(
|
||||
() => [...files].sort((a, b) => a.path.localeCompare(b.path)),
|
||||
[files],
|
||||
)
|
||||
|
||||
if (sortedFiles.length === 0) return null
|
||||
|
||||
const visible = sortedFiles.slice(0, MAX_VISIBLE)
|
||||
const hiddenCount = sortedFiles.length - visible.length
|
||||
const openFile = sortedFiles.find((file) => file.id === openFileId) ?? null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl border border-border/60 bg-card/50 px-3 py-2.5',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{sortedFiles.length === 1
|
||||
? 'File produced'
|
||||
: `Files produced (${sortedFiles.length})`}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-auto h-7 gap-1 px-2 text-xs"
|
||||
onClick={() => onOpenRail(turnId ?? null)}
|
||||
>
|
||||
View
|
||||
<ChevronRight className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{visible.map((file) => (
|
||||
<FileCard
|
||||
key={file.id}
|
||||
file={file}
|
||||
onOpen={() => setOpenFileId(file.id)}
|
||||
/>
|
||||
))}
|
||||
{hiddenCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenRail(turnId ?? null)}
|
||||
className={cn(
|
||||
'flex h-[56px] min-w-[56px] shrink-0 items-center justify-center rounded-lg border border-border/60 px-3 text-muted-foreground text-xs',
|
||||
'transition-colors hover:border-border hover:bg-accent/40 hover:text-foreground',
|
||||
'focus:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--accent-orange)]',
|
||||
)}
|
||||
title={`See ${hiddenCount} more in the Outputs rail`}
|
||||
>
|
||||
+{hiddenCount}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<FilePreviewSheet
|
||||
fileId={openFile?.id ?? null}
|
||||
filePath={openFile?.path ?? null}
|
||||
open={Boolean(openFileId)}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) setOpenFileId(null)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FileCard({
|
||||
file,
|
||||
onOpen,
|
||||
}: {
|
||||
file: CardStripFile
|
||||
onOpen: () => void
|
||||
}) {
|
||||
const name = basenameOf(file.path)
|
||||
const kind = inferFileKind(file.path)
|
||||
const Icon = kind === 'image' ? ImageIcon : FileText
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
title={file.path}
|
||||
className={cn(
|
||||
'flex h-[56px] w-[140px] shrink-0 flex-col justify-between rounded-lg border border-border/60 bg-background px-2.5 py-1.5 text-left',
|
||||
'transition-colors hover:border-border hover:bg-accent/40',
|
||||
'focus:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--accent-orange)]',
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-xs">
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||
{formatFileSize(file.size)}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Shared preview drawer used by the inline artifact card AND the
|
||||
* Outputs rail. Branches on the FilePreview discriminated union and
|
||||
* renders the appropriate body. Always opens via a controlled
|
||||
* `open`/`onOpenChange` pair so the parent owns the selected file.
|
||||
*/
|
||||
|
||||
import { Download, FileWarning, Loader2 } from 'lucide-react'
|
||||
import { type FC, useEffect, useMemo, useRef } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { MessageResponse } from '@/components/ai-elements/message'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
basenameOf,
|
||||
buildFileDownloadUrl,
|
||||
extensionOf,
|
||||
type FilePreview,
|
||||
formatFileSize,
|
||||
useFilePreview,
|
||||
} from '@/lib/agent-files'
|
||||
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface FilePreviewSheetProps {
|
||||
fileId: string | null
|
||||
filePath: string | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
const MARKDOWN_EXTENSIONS = new Set(['md', 'markdown', 'mdx'])
|
||||
|
||||
export const FilePreviewSheet: FC<FilePreviewSheetProps> = ({
|
||||
fileId,
|
||||
filePath,
|
||||
open,
|
||||
onOpenChange,
|
||||
}) => {
|
||||
const { baseUrl } = useAgentServerUrl()
|
||||
const { preview, loading, error } = useFilePreview(fileId, open)
|
||||
|
||||
const fileName = filePath ? basenameOf(filePath) : 'File preview'
|
||||
const downloadUrl = useMemo(() => {
|
||||
if (!baseUrl || !fileId) return null
|
||||
return buildFileDownloadUrl(baseUrl, fileId)
|
||||
}, [baseUrl, fileId])
|
||||
|
||||
// Surface preview-load failures in a toast in addition to the
|
||||
// inline error block — the inline UI lives at the bottom of the
|
||||
// sheet and is easy to miss when scrolled into the body.
|
||||
const lastToastedFileIdRef = useRef<string | null>(null)
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
lastToastedFileIdRef.current = null
|
||||
return
|
||||
}
|
||||
if (!error || !fileId) return
|
||||
if (lastToastedFileIdRef.current === fileId) return
|
||||
lastToastedFileIdRef.current = fileId
|
||||
toast.error('Could not load preview', { description: error.message })
|
||||
}, [open, error, fileId])
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!downloadUrl) {
|
||||
toast.error("Couldn't reach the agent server", {
|
||||
description: 'Reconnect to BrowserOS and try again.',
|
||||
})
|
||||
return
|
||||
}
|
||||
// Manually trigger the download so any future failure (e.g. the
|
||||
// server returns 404 because the file was removed) can be
|
||||
// surfaced via toast — the bare <a download> path swallows
|
||||
// these errors silently.
|
||||
const link = document.createElement('a')
|
||||
link.href = downloadUrl
|
||||
link.download = fileName
|
||||
link.rel = 'noopener'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="flex w-full flex-col gap-0 p-0 sm:max-w-xl"
|
||||
>
|
||||
<SheetHeader className="border-border/60 border-b px-5 py-4">
|
||||
<SheetTitle className="truncate pr-8">{fileName}</SheetTitle>
|
||||
<SheetDescription className="truncate">
|
||||
{filePath ?? ''}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="px-5 py-4">
|
||||
{loading ? (
|
||||
<PreviewSkeleton />
|
||||
) : error ? (
|
||||
<PreviewError message={error.message} />
|
||||
) : preview ? (
|
||||
<PreviewBody
|
||||
preview={preview}
|
||||
filePath={filePath}
|
||||
downloadUrl={downloadUrl}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{fileId ? (
|
||||
<div className="border-border/60 border-t bg-background/90 px-5 py-3 backdrop-blur">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="w-full gap-2"
|
||||
onClick={handleDownload}
|
||||
>
|
||||
<Download className="size-3.5" />
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
function PreviewSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-xs">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
Loading preview...
|
||||
</div>
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-5/6" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PreviewError({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-destructive text-sm">
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
<FileWarning className="size-4" />
|
||||
Could not load preview
|
||||
</div>
|
||||
<p className="text-destructive/80 text-xs">{message}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PreviewBody({
|
||||
preview,
|
||||
filePath,
|
||||
downloadUrl,
|
||||
}: {
|
||||
preview: FilePreview
|
||||
filePath: string | null
|
||||
downloadUrl: string | null
|
||||
}) {
|
||||
if (preview.kind === 'missing') {
|
||||
return (
|
||||
<div className="rounded-lg border border-border/60 bg-muted/40 px-4 py-6 text-center text-muted-foreground text-sm">
|
||||
This file is no longer in the workspace. The agent may have moved or
|
||||
deleted it after the turn finished.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (preview.kind === 'image') {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<PreviewMeta preview={preview} />
|
||||
<div className="overflow-hidden rounded-lg border border-border/60 bg-muted/30">
|
||||
<img
|
||||
src={preview.dataUrl}
|
||||
alt={filePath ?? 'preview'}
|
||||
className="block max-h-[60vh] w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (preview.kind === 'pdf') {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<PreviewMeta preview={preview} />
|
||||
<div className="rounded-lg border border-border/60 bg-muted/40 px-4 py-6 text-center text-muted-foreground text-sm">
|
||||
PDF previews aren't supported inline yet. Use Download to open this
|
||||
file in your default PDF viewer.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (preview.kind === 'binary') {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<PreviewMeta preview={preview} />
|
||||
<div className="rounded-lg border border-border/60 bg-muted/40 px-4 py-6 text-center text-muted-foreground text-sm">
|
||||
No inline preview for this file type.
|
||||
{downloadUrl ? ' Use Download to save it locally.' : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <TextPreviewBody preview={preview} filePath={filePath} />
|
||||
}
|
||||
|
||||
function TextPreviewBody({
|
||||
preview,
|
||||
filePath,
|
||||
}: {
|
||||
preview: Extract<FilePreview, { kind: 'text' }>
|
||||
filePath: string | null
|
||||
}) {
|
||||
const ext = filePath ? extensionOf(filePath).toLowerCase() : ''
|
||||
const renderAsMarkdown = MARKDOWN_EXTENSIONS.has(ext)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<PreviewMeta preview={preview} />
|
||||
{renderAsMarkdown ? (
|
||||
<div
|
||||
className={cn(
|
||||
'prose prose-sm dark:prose-invert max-w-none break-words rounded-lg border border-border/60 bg-muted/30 px-4 py-3',
|
||||
"[&_[data-streamdown='code-block']]:!w-full [&_[data-streamdown='code-block']]:overflow-x-auto",
|
||||
)}
|
||||
>
|
||||
<MessageResponse mode="static" parseIncompleteMarkdown={false}>
|
||||
{preview.snippet}
|
||||
</MessageResponse>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="overflow-x-auto rounded-lg border border-border/60 bg-muted/30 px-3 py-2 text-xs leading-relaxed">
|
||||
<code className="font-mono text-foreground">{preview.snippet}</code>
|
||||
</pre>
|
||||
)}
|
||||
{preview.truncated ? (
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Showing the first part of this file. Download to see the full
|
||||
contents.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PreviewMeta({
|
||||
preview,
|
||||
}: {
|
||||
preview: Exclude<FilePreview, { kind: 'missing' }>
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-muted-foreground text-xs">
|
||||
<span className="font-medium text-foreground">
|
||||
{formatFileSize(preview.size)}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span className="font-mono">{preview.mimeType || 'unknown'}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Per-agent right-side "Outputs" panel. Lists every file the harness
|
||||
* has attributed to this agent, grouped by the turn that produced
|
||||
* them. Click a row to open the shared preview Sheet.
|
||||
*
|
||||
* Lifecycle:
|
||||
* - Open/closed state is controlled by the parent and persisted via
|
||||
* `useOutputsRailOpen(agentId)` so each agent remembers its
|
||||
* preference independently.
|
||||
* - Data refreshes whenever a turn finishes (the conversation hook
|
||||
* fires `useInvalidateAgentOutputs` from its finally block).
|
||||
* - Manual "Refresh" button is wired to `useRefreshAgentOutputs`
|
||||
* for users who navigate in mid-turn.
|
||||
*/
|
||||
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
Image as ImageIcon,
|
||||
Inbox,
|
||||
Loader2,
|
||||
PanelRightClose,
|
||||
RefreshCw,
|
||||
} from 'lucide-react'
|
||||
import { type FC, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
basenameOf,
|
||||
formatFileSize,
|
||||
inferFileKind,
|
||||
type ProducedFilesRailGroup,
|
||||
useAgentOutputs,
|
||||
useRefreshAgentOutputs,
|
||||
} from '@/lib/agent-files'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { FilePreviewSheet } from './agent-conversation.file-preview-sheet'
|
||||
|
||||
interface OutputsRailProps {
|
||||
agentId: string
|
||||
onClose: () => void
|
||||
/**
|
||||
* When set, the rail scrolls the matching `RailTurnGroup` into
|
||||
* view and force-opens its `Collapsible`. Used by the inline
|
||||
* file-card strip's "View" / "+N" deep-link path. Cleared by
|
||||
* the parent (via `onFocusTurnConsumed`) once the rail has
|
||||
* acknowledged the deep-link so subsequent renders don't keep
|
||||
* re-scrolling the same group.
|
||||
*/
|
||||
focusTurnId?: string | null
|
||||
onFocusTurnConsumed?: () => void
|
||||
}
|
||||
|
||||
const RAIL_LOCAL_STORAGE_PREFIX = 'browseros:outputs-rail:'
|
||||
|
||||
/**
|
||||
* Controlled open/close state with per-agent localStorage memory.
|
||||
* Returns a tuple compatible with React's useState shape so the
|
||||
* parent can pass it straight into the rail without an extra effect.
|
||||
*/
|
||||
export function useOutputsRailOpen(
|
||||
agentId: string,
|
||||
): [boolean, (next: boolean) => void] {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || !agentId) return
|
||||
try {
|
||||
const stored = window.localStorage.getItem(
|
||||
`${RAIL_LOCAL_STORAGE_PREFIX}${agentId}`,
|
||||
)
|
||||
setOpen(stored === '1')
|
||||
} catch {
|
||||
// localStorage may be unavailable (private mode, locked-down
|
||||
// contexts) — fall back to closed.
|
||||
}
|
||||
}, [agentId])
|
||||
|
||||
const update = (next: boolean) => {
|
||||
setOpen(next)
|
||||
if (typeof window === 'undefined' || !agentId) return
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
`${RAIL_LOCAL_STORAGE_PREFIX}${agentId}`,
|
||||
next ? '1' : '0',
|
||||
)
|
||||
} catch {
|
||||
// Best-effort persistence.
|
||||
}
|
||||
}
|
||||
|
||||
return [open, update]
|
||||
}
|
||||
|
||||
export const OutputsRail: FC<OutputsRailProps> = ({
|
||||
agentId,
|
||||
onClose,
|
||||
focusTurnId,
|
||||
onFocusTurnConsumed,
|
||||
}) => {
|
||||
const { groups, loading, error } = useAgentOutputs(agentId)
|
||||
const refresh = useRefreshAgentOutputs(agentId)
|
||||
|
||||
const [openFile, setOpenFile] = useState<{
|
||||
id: string
|
||||
path: string
|
||||
} | null>(null)
|
||||
|
||||
const totalFiles = useMemo(
|
||||
() => groups.reduce((sum, group) => sum + group.files.length, 0),
|
||||
[groups],
|
||||
)
|
||||
|
||||
return (
|
||||
<aside className="flex h-full min-h-0 w-full flex-col border-border/50 border-l bg-background">
|
||||
<header className="flex shrink-0 items-center gap-2 border-border/50 border-b px-3 py-3">
|
||||
<span className="font-semibold text-[13px] uppercase tracking-wide">
|
||||
Outputs
|
||||
</span>
|
||||
{totalFiles > 0 ? (
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{totalFiles}
|
||||
</span>
|
||||
) : null}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
onClick={() =>
|
||||
refresh.mutate(undefined, {
|
||||
onError: (err) =>
|
||||
toast.error('Refresh failed', {
|
||||
description:
|
||||
err instanceof Error ? err.message : String(err),
|
||||
}),
|
||||
})
|
||||
}
|
||||
disabled={refresh.isPending}
|
||||
title="Refresh"
|
||||
>
|
||||
{refresh.isPending ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
onClick={onClose}
|
||||
title="Hide outputs"
|
||||
>
|
||||
<PanelRightClose className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="px-2 py-2">
|
||||
{loading && groups.length === 0 ? (
|
||||
<RailSkeleton />
|
||||
) : error ? (
|
||||
<RailError message={error.message} />
|
||||
) : groups.length === 0 ? (
|
||||
<RailEmpty />
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{groups.map((group) => (
|
||||
<li key={group.turnId}>
|
||||
<RailTurnGroup
|
||||
group={group}
|
||||
focused={
|
||||
Boolean(focusTurnId) && focusTurnId === group.turnId
|
||||
}
|
||||
onFocusConsumed={onFocusTurnConsumed}
|
||||
onOpenFile={(file) =>
|
||||
setOpenFile({ id: file.id, path: file.path })
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<FilePreviewSheet
|
||||
fileId={openFile?.id ?? null}
|
||||
filePath={openFile?.path ?? null}
|
||||
open={Boolean(openFile)}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) setOpenFile(null)
|
||||
}}
|
||||
/>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function RailTurnGroup({
|
||||
group,
|
||||
focused,
|
||||
onFocusConsumed,
|
||||
onOpenFile,
|
||||
}: {
|
||||
group: ProducedFilesRailGroup
|
||||
focused: boolean
|
||||
onFocusConsumed?: () => void
|
||||
onOpenFile: (file: { id: string; path: string }) => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(true)
|
||||
const headerLabel = group.turnPrompt.trim() || 'Turn'
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Deep-link consumption: when the parent passes `focused=true`,
|
||||
// expand the collapsible (in case the user had collapsed it
|
||||
// earlier) and scroll into view. Fire `onFocusConsumed` so the
|
||||
// parent can drop the URL param and we don't re-scroll on every
|
||||
// render after that.
|
||||
useEffect(() => {
|
||||
if (!focused) return
|
||||
setOpen(true)
|
||||
containerRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
})
|
||||
onFocusConsumed?.()
|
||||
}, [focused, onFocusConsumed])
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
'flex w-full items-center gap-1.5 rounded-md px-1.5 py-1 text-left text-muted-foreground text-xs',
|
||||
'transition-colors hover:bg-accent/40 hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{open ? (
|
||||
<ChevronDown className="size-3 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="size-3 shrink-0" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate font-medium">
|
||||
{headerLabel}
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums">{group.files.length}</span>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<ul className="mt-1 ml-1 flex flex-col gap-0.5 border-border/40 border-l pl-2">
|
||||
{group.files.map((file) => (
|
||||
<li key={file.id}>
|
||||
<RailFileRow file={file} onOpen={() => onOpenFile(file)} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RailFileRow({
|
||||
file,
|
||||
onOpen,
|
||||
}: {
|
||||
file: ProducedFilesRailGroup['files'][number]
|
||||
onOpen: () => void
|
||||
}) {
|
||||
const name = basenameOf(file.path)
|
||||
const kind = inferFileKind(file.path)
|
||||
const Icon = kind === 'image' ? ImageIcon : FileText
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-md px-1.5 py-1 text-left text-xs transition-colors',
|
||||
'hover:bg-accent/60 focus:bg-accent/60 focus:outline-hidden',
|
||||
)}
|
||||
title={file.path}
|
||||
>
|
||||
<Icon className="size-3 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate">{name}</span>
|
||||
<span className="shrink-0 text-muted-foreground tabular-nums">
|
||||
{formatFileSize(file.size)}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function RailSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 px-1.5 py-1">
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
<Skeleton className="h-4 w-5/6" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RailEmpty() {
|
||||
return (
|
||||
<div className="mx-2 my-3 flex flex-col items-center gap-1.5 rounded-lg border border-border/60 border-dashed bg-muted/20 px-3 py-6 text-center text-muted-foreground text-xs">
|
||||
<Inbox className="size-4" />
|
||||
<p className="font-medium">No outputs yet</p>
|
||||
<p className="text-[11px] text-muted-foreground/70 leading-snug">
|
||||
Files this agent creates will appear here, grouped by the turn that made
|
||||
them.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RailError({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="mx-2 my-3 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-destructive text-xs">
|
||||
{message}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { OpenClawChatHistoryMessage } from '@/entrypoints/app/agents/useOpenClaw'
|
||||
import type { AgentConversationTurn } from '@/lib/agent-conversations/types'
|
||||
import type { ProducedFilesRailGroup } from '@/lib/agent-files'
|
||||
|
||||
export type ClawChatRole = 'user' | 'assistant'
|
||||
|
||||
@@ -234,6 +235,30 @@ export function filterTurnsPersistedInHistory(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Persisted turns that still carry `producedFiles` — once history
|
||||
* reloads, the assistant text is rendered by `ClawChatMessage` and
|
||||
* the optimistic turn is filtered out by
|
||||
* `filterTurnsPersistedInHistory`. The historical message has no
|
||||
* `producedFiles` field (history items don't carry that), so the
|
||||
* inline file-card strip would vanish on history reload.
|
||||
*
|
||||
* Returning these here lets the caller render a strip-only entry
|
||||
* after the corresponding history bubble — full message stays as
|
||||
* the persisted history pair, but the produced-files affordance
|
||||
* survives.
|
||||
*/
|
||||
export function selectStripOnlyTurns(
|
||||
turns: AgentConversationTurn[],
|
||||
historyMessages: ClawChatMessage[],
|
||||
): AgentConversationTurn[] {
|
||||
return turns.filter(
|
||||
(turn) =>
|
||||
Boolean(turn.producedFiles && turn.producedFiles.length > 0) &&
|
||||
isTurnPersistedInHistory(turn, historyMessages),
|
||||
)
|
||||
}
|
||||
|
||||
function isTurnPersistedInHistory(
|
||||
turn: AgentConversationTurn,
|
||||
historyMessages: ClawChatMessage[],
|
||||
@@ -285,3 +310,59 @@ function getClawMessageText(message: ClawChatMessage): string {
|
||||
.join('')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function firstNonBlankLine(value: string): string {
|
||||
for (const raw of value.split('\n')) {
|
||||
const trimmed = raw.trim()
|
||||
if (trimmed) return trimmed
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Map each assistant history message to the produced-files group
|
||||
* that came from its turn. Match key is `group.turnPrompt` (first
|
||||
* non-blank line of the user prompt that initiated the turn) vs.
|
||||
* the first non-blank line of the user message that immediately
|
||||
* preceded this assistant message — the same shape the server
|
||||
* emits when storing turnPrompt.
|
||||
*
|
||||
* Walks history forward (oldest-first per `flattenHistoryPages`)
|
||||
* and consumes groups in chronological order. A group can only
|
||||
* match once — if two turns share the same prompt the earlier
|
||||
* one wins, and the later assistant message stays unassociated
|
||||
* (those land back in `tailStripGroups` at the conversation tail).
|
||||
*/
|
||||
export function mapHistoryToProducedFilesGroups(
|
||||
historyMessages: ClawChatMessage[],
|
||||
groups: ReadonlyArray<ProducedFilesRailGroup>,
|
||||
): {
|
||||
byAssistantMessageId: Map<string, ProducedFilesRailGroup>
|
||||
unmatched: ProducedFilesRailGroup[]
|
||||
} {
|
||||
const byAssistantMessageId = new Map<string, ProducedFilesRailGroup>()
|
||||
if (groups.length === 0) {
|
||||
return { byAssistantMessageId, unmatched: [] }
|
||||
}
|
||||
// Oldest-first so the iteration order matches history.
|
||||
const remaining = [...groups].sort((a, b) => a.createdAt - b.createdAt)
|
||||
|
||||
let pendingPrompt: string | null = null
|
||||
for (const message of historyMessages) {
|
||||
if (message.role === 'user') {
|
||||
pendingPrompt = firstNonBlankLine(getClawMessageText(message))
|
||||
continue
|
||||
}
|
||||
if (message.role !== 'assistant' || !pendingPrompt) continue
|
||||
const matchIndex = remaining.findIndex(
|
||||
(group) => group.turnPrompt === pendingPrompt,
|
||||
)
|
||||
if (matchIndex >= 0) {
|
||||
const [match] = remaining.splice(matchIndex, 1)
|
||||
byAssistantMessageId.set(message.id, match)
|
||||
}
|
||||
pendingPrompt = null
|
||||
}
|
||||
|
||||
return { byAssistantMessageId, unmatched: remaining }
|
||||
}
|
||||
|
||||
@@ -10,9 +10,11 @@ import type { OpenClawChatHistoryMessage } from '@/entrypoints/app/agents/useOpe
|
||||
import type {
|
||||
AgentConversationTurn,
|
||||
AssistantPart,
|
||||
ConversationTurnFile,
|
||||
ToolEntry,
|
||||
UserAttachmentPreview,
|
||||
} from '@/lib/agent-conversations/types'
|
||||
import { useInvalidateAgentOutputs } from '@/lib/agent-files'
|
||||
import type { ServerAttachmentPayload } from '@/lib/attachments'
|
||||
import { consumeSSEStream } from '@/lib/sse'
|
||||
import { buildToolLabel } from '@/lib/tool-labels'
|
||||
@@ -53,6 +55,12 @@ export function useAgentConversation(
|
||||
) {
|
||||
const [turns, setTurns] = useState<AgentConversationTurn[]>([])
|
||||
const [streaming, setStreaming] = useState(false)
|
||||
const invalidateAgentOutputs = useInvalidateAgentOutputs()
|
||||
// Stable ref so the resume effect doesn't re-subscribe on every
|
||||
// render (the hook's returned callable is freshly closured each
|
||||
// time, but the underlying queryClient is stable).
|
||||
const invalidateAgentOutputsRef = useRef(invalidateAgentOutputs)
|
||||
invalidateAgentOutputsRef.current = invalidateAgentOutputs
|
||||
const sessionKeyRef = useRef(options.sessionKey ?? '')
|
||||
const historyRef = useRef<OpenClawChatHistoryMessage[]>(options.history ?? [])
|
||||
const textAccRef = useRef('')
|
||||
@@ -152,6 +160,17 @@ export function useAgentConversation(
|
||||
})
|
||||
}
|
||||
|
||||
const setProducedFilesOnCurrentTurn = (files: ConversationTurnFile[]) => {
|
||||
setTurns((prev) => {
|
||||
const last = prev[prev.length - 1]
|
||||
if (!last) return prev
|
||||
// Replace, don't merge: the server's diff is authoritative for
|
||||
// the just-completed turn — duplicate events shouldn't grow the
|
||||
// list, and a re-attribution should overwrite an earlier one.
|
||||
return [...prev.slice(0, -1), { ...last, producedFiles: files }]
|
||||
})
|
||||
}
|
||||
|
||||
const upsertAgentHarnessTool = (event: AgentHarnessStreamEvent) => {
|
||||
if (event.type !== 'tool_call') return
|
||||
const rawName = event.title || event.rawType || 'tool call'
|
||||
@@ -208,6 +227,9 @@ export function useAgentConversation(
|
||||
case 'tool_call':
|
||||
upsertAgentHarnessTool(event)
|
||||
break
|
||||
case 'produced_files':
|
||||
setProducedFilesOnCurrentTurn(event.files)
|
||||
break
|
||||
case 'done':
|
||||
markCurrentTurnDone()
|
||||
break
|
||||
@@ -259,6 +281,7 @@ export function useAgentConversation(
|
||||
...prev,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
turnId: active.turnId,
|
||||
userText: active.prompt ?? '',
|
||||
parts: [],
|
||||
done: false,
|
||||
@@ -304,9 +327,14 @@ export function useAgentConversation(
|
||||
// When `cancelled` is true the next run will set these
|
||||
// itself, so resetting here would only cause a brief flicker.
|
||||
if (!cancelled && weStartedStream) {
|
||||
const finishedTurnId = turnIdRef.current
|
||||
turnIdRef.current = null
|
||||
lastSeqRef.current = null
|
||||
setStreaming(false)
|
||||
void invalidateAgentOutputsRef.current(
|
||||
agentId,
|
||||
finishedTurnId ?? undefined,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -318,6 +346,60 @@ export function useAgentConversation(
|
||||
}
|
||||
}, [agentId, activeTurnIdDep])
|
||||
|
||||
/**
|
||||
* Send the chat request and follow the 409-active-turn redirect
|
||||
* once. Pulled out of `send` to keep its cognitive complexity in
|
||||
* check — the retry adds a branch that biome counts heavily.
|
||||
*/
|
||||
const openSendStream = async (
|
||||
targetAgentId: string,
|
||||
text: string,
|
||||
attachments: ServerAttachmentPayload[],
|
||||
signal: AbortSignal,
|
||||
): Promise<Response> => {
|
||||
const initial = await chatWithHarnessAgent(
|
||||
targetAgentId,
|
||||
text,
|
||||
signal,
|
||||
attachments,
|
||||
)
|
||||
if (initial.status !== 409) return initial
|
||||
// 409 means the server already has an active turn for this agent
|
||||
// (a previous tab kicked one off and we're a fresh mount that
|
||||
// missed the resume window). Attach to it instead of double-sending.
|
||||
const body = (await initial.json()) as { turnId?: string }
|
||||
if (!body.turnId) return initial
|
||||
return attachToHarnessTurn(targetAgentId, {
|
||||
turnId: body.turnId,
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull session-key / turn-id off response headers and propagate to
|
||||
* refs + the optimistic turn. Stamping `turnId` here lets the
|
||||
* inline artifact card fall back to /files/turn/<id> on a resumed
|
||||
* mount that missed the live `produced_files` event.
|
||||
*/
|
||||
const applyResponseHeadersToTurn = (response: Response) => {
|
||||
const responseSessionKey =
|
||||
response.headers.get('X-Session-Key') ??
|
||||
response.headers.get('X-Session-Id')
|
||||
if (responseSessionKey) {
|
||||
sessionKeyRef.current = responseSessionKey
|
||||
onSessionKeyChangeRef.current?.(responseSessionKey)
|
||||
}
|
||||
const responseTurnId = response.headers.get('X-Turn-Id')
|
||||
if (!responseTurnId) return
|
||||
turnIdRef.current = responseTurnId
|
||||
lastSeqRef.current = null
|
||||
setTurns((prev) => {
|
||||
const last = prev[prev.length - 1]
|
||||
if (!last) return prev
|
||||
return [...prev.slice(0, -1), { ...last, turnId: responseTurnId }]
|
||||
})
|
||||
}
|
||||
|
||||
const send = async (input: string | SendInput) => {
|
||||
const normalized: SendInput =
|
||||
typeof input === 'string' ? { text: input } : input
|
||||
@@ -346,37 +428,13 @@ export function useAgentConversation(
|
||||
streamAbortRef.current = abortController
|
||||
|
||||
try {
|
||||
let response = await chatWithHarnessAgent(
|
||||
const response = await openSendStream(
|
||||
agentId,
|
||||
trimmed,
|
||||
abortController.signal,
|
||||
attachments,
|
||||
abortController.signal,
|
||||
)
|
||||
// 409 means the server already has an active turn for this
|
||||
// agent (e.g. a previous tab kicked one off and we're a fresh
|
||||
// mount that missed the resume window). Attach to it instead of
|
||||
// double-sending.
|
||||
if (response.status === 409) {
|
||||
const body = (await response.json()) as { turnId?: string }
|
||||
if (body.turnId) {
|
||||
response = await attachToHarnessTurn(agentId, {
|
||||
turnId: body.turnId,
|
||||
signal: abortController.signal,
|
||||
})
|
||||
}
|
||||
}
|
||||
const responseSessionKey =
|
||||
response.headers.get('X-Session-Key') ??
|
||||
response.headers.get('X-Session-Id')
|
||||
if (responseSessionKey) {
|
||||
sessionKeyRef.current = responseSessionKey
|
||||
onSessionKeyChangeRef.current?.(responseSessionKey)
|
||||
}
|
||||
const responseTurnId = response.headers.get('X-Turn-Id')
|
||||
if (responseTurnId) {
|
||||
turnIdRef.current = responseTurnId
|
||||
lastSeqRef.current = null
|
||||
}
|
||||
applyResponseHeadersToTurn(response)
|
||||
if (!response.ok) {
|
||||
const err = await response.text()
|
||||
updateCurrentTurnParts((parts) => [
|
||||
@@ -404,10 +462,15 @@ export function useAgentConversation(
|
||||
if (streamAbortRef.current === abortController) {
|
||||
streamAbortRef.current = null
|
||||
}
|
||||
// Capture before nulling — the invalidation needs the turn id so
|
||||
// useAgentTurnFiles consumers also flush, not just the agent-wide
|
||||
// rail query.
|
||||
const finishedTurnId = turnIdRef.current
|
||||
turnIdRef.current = null
|
||||
lastSeqRef.current = null
|
||||
onCompleteRef.current?.()
|
||||
setStreaming(false)
|
||||
void invalidateAgentOutputs(agentId, finishedTurnId ?? undefined)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,21 @@ import type { AgentEntry } from './useOpenClaw'
|
||||
|
||||
export type HarnessAgentAdapter = 'claude' | 'codex' | 'openclaw'
|
||||
|
||||
/**
|
||||
* One file the harness attributed to the assistant turn that just
|
||||
* finished. Mirrors the server-side `ProducedFileEventEntry` shape so
|
||||
* the inline artifact card can render alongside the streamed text the
|
||||
* user just watched complete. Only present for openclaw adapter
|
||||
* turns; claude / codex don't produce these events in v1.
|
||||
*/
|
||||
export interface HarnessProducedFile {
|
||||
id: string
|
||||
/** Workspace-relative POSIX path. */
|
||||
path: string
|
||||
size: number
|
||||
mtimeMs: number
|
||||
}
|
||||
|
||||
export type AgentHarnessStreamEvent =
|
||||
| {
|
||||
type: 'text_delta'
|
||||
@@ -22,6 +37,10 @@ export type AgentHarnessStreamEvent =
|
||||
text: string
|
||||
rawType?: string
|
||||
}
|
||||
| {
|
||||
type: 'produced_files'
|
||||
files: HarnessProducedFile[]
|
||||
}
|
||||
| {
|
||||
type: 'done'
|
||||
text?: string
|
||||
|
||||
@@ -25,12 +25,18 @@ interface HarnessAgentsResponse {
|
||||
|
||||
export type { AgentHarnessStreamEvent }
|
||||
|
||||
const AGENT_QUERY_KEYS = {
|
||||
export const AGENT_QUERY_KEYS = {
|
||||
adapters: 'agent-harness-adapters',
|
||||
agents: 'agent-harness-agents',
|
||||
/** Outputs-rail data for one agent — `[agentOutputs, baseUrl, agentId]`. */
|
||||
agentOutputs: 'agent-harness-agent-outputs',
|
||||
/** Per-turn artifact-card files — `[agentTurnFiles, baseUrl, agentId, turnId]`. */
|
||||
agentTurnFiles: 'agent-harness-agent-turn-files',
|
||||
/** Single-file preview payload — `[filePreview, baseUrl, fileId]`. */
|
||||
filePreview: 'agent-harness-file-preview',
|
||||
} as const
|
||||
|
||||
async function agentsFetch<T>(
|
||||
export async function agentsFetch<T>(
|
||||
baseUrl: string,
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
|
||||
@@ -1,535 +0,0 @@
|
||||
import { AlertCircle, Eye, Pencil, Plus, Trash2, Wand2 } from 'lucide-react'
|
||||
import { type FC, useEffect, useState } from 'react'
|
||||
import Markdown from 'react-markdown'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { MarkdownEditor } from '@/components/ui/MarkdownEditor'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { type SkillDetail, type SkillMeta, useSkills } from './useSkills'
|
||||
|
||||
const loadingSkillCards = [
|
||||
'loading-a',
|
||||
'loading-b',
|
||||
'loading-c',
|
||||
'loading-d',
|
||||
'loading-e',
|
||||
'loading-f',
|
||||
]
|
||||
|
||||
export const SkillsPage: FC = () => {
|
||||
const {
|
||||
skills,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
createSkill,
|
||||
updateSkill,
|
||||
deleteSkill,
|
||||
fetchSkillDetail,
|
||||
} = useSkills()
|
||||
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
const [editingSkill, setEditingSkill] = useState<SkillDetail | null>(null)
|
||||
const [skillToDelete, setSkillToDelete] = useState<SkillMeta | null>(null)
|
||||
|
||||
const enabledCount = skills.filter((skill) => skill.enabled).length
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingSkill(null)
|
||||
setIsDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleEdit = async (skill: SkillMeta) => {
|
||||
try {
|
||||
const detail = await fetchSkillDetail(skill.id)
|
||||
setEditingSkill(detail)
|
||||
setIsDialogOpen(true)
|
||||
} catch {
|
||||
toast.error('Failed to load skill details')
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggle = async (skill: SkillMeta, enabled: boolean) => {
|
||||
try {
|
||||
await updateSkill(skill.id, { enabled })
|
||||
} catch {
|
||||
toast.error('Failed to toggle skill')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!skillToDelete) return
|
||||
try {
|
||||
await deleteSkill(skillToDelete.id)
|
||||
toast.success(`Deleted "${skillToDelete.name}"`)
|
||||
} catch {
|
||||
toast.error('Failed to delete skill')
|
||||
}
|
||||
setSkillToDelete(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fade-in slide-in-from-bottom-5 animate-in space-y-6 duration-500">
|
||||
<SkillsHeader
|
||||
skillCount={skills.length}
|
||||
enabledCount={enabledCount}
|
||||
onCreateClick={handleCreate}
|
||||
/>
|
||||
|
||||
{isLoading ? <SkillsLoadingState /> : null}
|
||||
|
||||
{!isLoading && error ? (
|
||||
<SkillsErrorState onRetry={() => void refetch()} />
|
||||
) : null}
|
||||
|
||||
{!isLoading && !error && skills.length === 0 ? (
|
||||
<EmptyState onCreateClick={handleCreate} />
|
||||
) : null}
|
||||
|
||||
{!isLoading && !error && skills.length > 0 ? (
|
||||
<SkillSections
|
||||
skills={skills}
|
||||
onEdit={handleEdit}
|
||||
onDelete={(skill) => setSkillToDelete(skill)}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<SkillDialog
|
||||
open={isDialogOpen}
|
||||
onOpenChange={setIsDialogOpen}
|
||||
editingSkill={editingSkill}
|
||||
readOnly={editingSkill?.builtIn}
|
||||
onSave={async (data) => {
|
||||
try {
|
||||
if (editingSkill) {
|
||||
await updateSkill(editingSkill.id, data)
|
||||
toast.success('Skill updated')
|
||||
} else {
|
||||
await createSkill(data)
|
||||
toast.success('Skill created')
|
||||
}
|
||||
setIsDialogOpen(false)
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to save')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
open={!!skillToDelete}
|
||||
onOpenChange={(open) => !open && setSkillToDelete(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Skill</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete "{skillToDelete?.name}"? This
|
||||
action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete}>Delete</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SkillsHeader: FC<{
|
||||
skillCount: number
|
||||
enabledCount: number
|
||||
onCreateClick: () => void
|
||||
}> = ({ skillCount, enabledCount, onCreateClick }) => {
|
||||
const skillLabel = `${skillCount} skill${skillCount === 1 ? '' : 's'}`
|
||||
const enabledLabel = `${enabledCount} enabled`
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 className="font-semibold text-2xl tracking-tight">Skills</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Define reusable instructions that extend how your agent responds.
|
||||
</p>
|
||||
<p className="mt-1 text-muted-foreground text-xs">
|
||||
{skillLabel} • {enabledLabel}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={onCreateClick} size="sm" className="shrink-0">
|
||||
<Plus className="mr-1.5 size-4" />
|
||||
New Skill
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SkillsLoadingState: FC = () => (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{loadingSkillCards.map((cardKey) => (
|
||||
<Card key={cardKey} className="h-full py-0">
|
||||
<CardContent className="space-y-4 p-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="size-10 animate-pulse rounded-xl bg-muted" />
|
||||
<div className="h-6 w-11 animate-pulse rounded-full bg-muted" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-28 animate-pulse rounded bg-muted" />
|
||||
<div className="h-4 w-full animate-pulse rounded bg-muted" />
|
||||
<div className="h-4 w-4/5 animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
<div className="h-8 w-16 animate-pulse rounded bg-muted" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
const SkillsErrorState: FC<{ onRetry: () => void }> = ({ onRetry }) => (
|
||||
<Card className="border-destructive/20 bg-destructive/5 py-0">
|
||||
<CardContent className="flex flex-col gap-4 p-5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-destructive/10 text-destructive">
|
||||
<AlertCircle className="size-4" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h2 className="font-semibold">Couldn't load skills</h2>
|
||||
<p className="text-destructive/80 text-sm">
|
||||
Check that the local agent services are running, then retry.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" onClick={onRetry}>
|
||||
Retry
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
const EmptyState: FC<{ onCreateClick: () => void }> = ({ onCreateClick }) => (
|
||||
<Card className="border-dashed py-0">
|
||||
<CardContent className="flex flex-col items-center justify-center py-14 text-center">
|
||||
<div className="mb-4 flex size-12 items-center justify-center rounded-2xl bg-[var(--accent-orange)]/10 text-[var(--accent-orange)]">
|
||||
<Wand2 className="size-5" />
|
||||
</div>
|
||||
<h3 className="mb-1 font-medium text-lg">No skills yet</h3>
|
||||
<p className="mb-5 max-w-sm text-muted-foreground text-sm leading-6">
|
||||
Skills teach your agent how to handle repeatable tasks like research,
|
||||
extraction, and repeatable browser tasks.
|
||||
</p>
|
||||
<Button onClick={onCreateClick} size="sm">
|
||||
<Plus className="mr-1.5 size-4" />
|
||||
Create your first skill
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
const SkillGrid: FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
const SkillSections: FC<{
|
||||
skills: SkillMeta[]
|
||||
onEdit: (skill: SkillMeta) => void
|
||||
onDelete: (skill: SkillMeta) => void
|
||||
onToggle: (skill: SkillMeta, enabled: boolean) => void
|
||||
}> = ({ skills, onEdit, onDelete, onToggle }) => {
|
||||
const userSkills = skills.filter((s) => !s.builtIn)
|
||||
const builtInSkills = skills.filter((s) => s.builtIn)
|
||||
|
||||
const renderCard = (skill: SkillMeta) => (
|
||||
<SkillCard
|
||||
key={skill.id}
|
||||
skill={skill}
|
||||
onEdit={() => onEdit(skill)}
|
||||
onDelete={() => onDelete(skill)}
|
||||
onToggle={(enabled) => onToggle(skill, enabled)}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{userSkills.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold text-sm">My Skills</h3>
|
||||
<SkillGrid>{userSkills.map(renderCard)}</SkillGrid>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{builtInSkills.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold text-sm">BrowserOS Skills</h3>
|
||||
<SkillGrid>{builtInSkills.map(renderCard)}</SkillGrid>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SkillCard: FC<{
|
||||
skill: SkillMeta
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
onToggle: (enabled: boolean) => void
|
||||
}> = ({ skill, onEdit, onDelete, onToggle }) => (
|
||||
<Card className="h-full py-0 shadow-sm">
|
||||
<CardContent className="flex h-full flex-col p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="font-semibold text-sm leading-5">{skill.name}</h2>
|
||||
{skill.builtIn ? (
|
||||
<Badge variant="secondary" className="px-1.5 py-0 text-[10px]">
|
||||
Built-in
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<Switch
|
||||
checked={skill.enabled}
|
||||
onCheckedChange={onToggle}
|
||||
aria-label={`Toggle ${skill.name}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex-1">
|
||||
<p className="line-clamp-3 text-muted-foreground text-sm leading-5">
|
||||
{skill.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onEdit}
|
||||
className="-ml-2 h-7 px-2 text-muted-foreground hover:bg-transparent hover:text-foreground"
|
||||
>
|
||||
{skill.builtIn ? (
|
||||
<>
|
||||
<Eye className="size-3.5" />
|
||||
View
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pencil className="size-3.5" />
|
||||
Edit
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{!skill.builtIn ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onDelete}
|
||||
className="size-7 text-muted-foreground hover:bg-transparent hover:text-destructive"
|
||||
aria-label={`Delete ${skill.name}`}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
const SkillDialog: FC<{
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
editingSkill: SkillDetail | null
|
||||
readOnly?: boolean
|
||||
onSave: (data: {
|
||||
name: string
|
||||
description: string
|
||||
content: string
|
||||
}) => Promise<void>
|
||||
}> = ({ open, onOpenChange, editingSkill, readOnly, onSave }) => {
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [content, setContent] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setSaving(false)
|
||||
if (!open) return
|
||||
setName(editingSkill?.name ?? '')
|
||||
setDescription(editingSkill?.description ?? '')
|
||||
setContent(editingSkill?.content ?? '')
|
||||
}, [editingSkill, open])
|
||||
|
||||
const isValid =
|
||||
name.trim().length > 0 &&
|
||||
description.trim().length > 0 &&
|
||||
content.trim().length > 0
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!isValid || saving) return
|
||||
setSaving(true)
|
||||
try {
|
||||
await onSave({
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
content,
|
||||
})
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleContentKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (
|
||||
event,
|
||||
) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
void handleSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex max-h-[90vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-5xl">
|
||||
<DialogHeader className="border-b px-6 py-5">
|
||||
<DialogTitle>
|
||||
{readOnly
|
||||
? 'View Skill'
|
||||
: editingSkill
|
||||
? 'Edit Skill'
|
||||
: 'Create Skill'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{readOnly
|
||||
? 'This skill is managed by BrowserOS and updated automatically.'
|
||||
: editingSkill
|
||||
? 'Refine when the agent should use this skill and how it should execute it.'
|
||||
: 'Define a reusable instruction set your agent can apply when a request matches.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid min-h-0 flex-1 overflow-y-auto lg:grid-cols-[280px_minmax(0,1fr)] lg:overflow-hidden">
|
||||
<div className="space-y-5 border-b bg-muted/20 px-6 py-5 lg:border-r lg:border-b-0">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="skill-name">Name</Label>
|
||||
<Input
|
||||
id="skill-name"
|
||||
placeholder="e.g., Read Later"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
maxLength={100}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs leading-5">
|
||||
Keep it short and recognizable in the skills list.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="skill-description">Description</Label>
|
||||
<Textarea
|
||||
id="skill-description"
|
||||
placeholder="Describe when the agent should use this skill."
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
maxLength={500}
|
||||
className="min-h-28 resize-none bg-background"
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs leading-5">
|
||||
This is the trigger summary the agent uses to pick the skill.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!readOnly ? (
|
||||
<div className="mt-auto rounded-lg border border-border/60 border-dashed bg-muted/30 px-3 py-2.5">
|
||||
<p className="font-medium text-muted-foreground text-xs">Tip</p>
|
||||
<ul className="mt-1.5 list-disc space-y-1 pl-4 text-muted-foreground text-xs leading-5">
|
||||
<li>List the ordered steps the agent should follow.</li>
|
||||
<li>Close with the output or formatting you expect back.</li>
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-col px-6 py-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<Label htmlFor="skill-content">Instructions (Markdown)</Label>
|
||||
<Badge variant="outline" className="border-border bg-background">
|
||||
{content.length} characters
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{readOnly ? (
|
||||
<div className="prose prose-sm dark:prose-invert mt-4 min-h-[320px] max-w-none flex-1 overflow-y-auto rounded-md border p-4 text-sm">
|
||||
<Markdown>{content}</Markdown>
|
||||
</div>
|
||||
) : (
|
||||
<MarkdownEditor
|
||||
id="skill-content"
|
||||
value={content}
|
||||
onChange={setContent}
|
||||
onKeyDown={handleContentKeyDown}
|
||||
placeholder="Write instructions for the agent. Use markdown for structure."
|
||||
className="mt-4 min-h-[320px] flex-1 overflow-y-auto text-sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 border-t px-6 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{readOnly
|
||||
? 'This skill is managed by BrowserOS and updated automatically.'
|
||||
: 'Saved locally and available to your agent immediately.'}
|
||||
</p>
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row">
|
||||
{readOnly ? (
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!isValid || saving}>
|
||||
{saving
|
||||
? 'Saving...'
|
||||
: editingSkill
|
||||
? 'Update Skill'
|
||||
: 'Create Skill'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
|
||||
|
||||
export type SkillMeta = {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
location: string
|
||||
enabled: boolean
|
||||
builtIn: boolean
|
||||
}
|
||||
|
||||
export type SkillDetail = SkillMeta & {
|
||||
content: string
|
||||
}
|
||||
|
||||
type CreateSkillInput = {
|
||||
name: string
|
||||
description: string
|
||||
content: string
|
||||
}
|
||||
|
||||
type UpdateSkillInput = Partial<CreateSkillInput> & {
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
const SKILLS_QUERY_KEY = 'skills'
|
||||
|
||||
async function fetchSkills(baseUrl: string): Promise<SkillMeta[]> {
|
||||
const res = await fetch(`${baseUrl}/skills`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = await res.json()
|
||||
return data.skills
|
||||
}
|
||||
|
||||
async function fetchSkill(baseUrl: string, id: string): Promise<SkillDetail> {
|
||||
const res = await fetch(`${baseUrl}/skills/${id}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = await res.json()
|
||||
return data.skill
|
||||
}
|
||||
|
||||
async function postSkill(
|
||||
baseUrl: string,
|
||||
input: CreateSkillInput,
|
||||
): Promise<SkillMeta> {
|
||||
const res = await fetch(`${baseUrl}/skills`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
throw new Error(data.error || `HTTP ${res.status}`)
|
||||
}
|
||||
const data = await res.json()
|
||||
return data.skill
|
||||
}
|
||||
|
||||
async function putSkill(
|
||||
baseUrl: string,
|
||||
id: string,
|
||||
input: UpdateSkillInput,
|
||||
): Promise<SkillMeta> {
|
||||
const res = await fetch(`${baseUrl}/skills/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
throw new Error(data.error || `HTTP ${res.status}`)
|
||||
}
|
||||
const data = await res.json()
|
||||
return data.skill
|
||||
}
|
||||
|
||||
async function removeSkill(baseUrl: string, id: string): Promise<void> {
|
||||
const res = await fetch(`${baseUrl}/skills/${id}`, { method: 'DELETE' })
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
}
|
||||
|
||||
export function useSkills() {
|
||||
const { baseUrl, isLoading: urlLoading } = useAgentServerUrl()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery<SkillMeta[], Error>({
|
||||
queryKey: [SKILLS_QUERY_KEY, baseUrl],
|
||||
queryFn: () => fetchSkills(baseUrl as string),
|
||||
enabled: !!baseUrl && !urlLoading,
|
||||
})
|
||||
|
||||
const invalidate = () =>
|
||||
queryClient.invalidateQueries({ queryKey: [SKILLS_QUERY_KEY] })
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (input: CreateSkillInput) =>
|
||||
postSkill(baseUrl as string, input),
|
||||
onSuccess: invalidate,
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, input }: { id: string; input: UpdateSkillInput }) =>
|
||||
putSkill(baseUrl as string, id, input),
|
||||
onSuccess: invalidate,
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => removeSkill(baseUrl as string, id),
|
||||
onSuccess: invalidate,
|
||||
})
|
||||
|
||||
return {
|
||||
skills: data ?? [],
|
||||
isLoading: isLoading || urlLoading,
|
||||
error,
|
||||
refetch,
|
||||
createSkill: createMutation.mutateAsync,
|
||||
updateSkill: (id: string, input: UpdateSkillInput) =>
|
||||
updateMutation.mutateAsync({ id, input }),
|
||||
deleteSkill: deleteMutation.mutateAsync,
|
||||
fetchSkillDetail: (id: string) => fetchSkill(baseUrl as string, id),
|
||||
}
|
||||
}
|
||||
@@ -47,10 +47,6 @@ export const TIPS: Tip[] = [
|
||||
id: 'mcp-servers',
|
||||
text: 'Add MCP servers for Google Calendar, Gmail, Notion, and more to power multi-service automations.',
|
||||
},
|
||||
{
|
||||
id: 'skills',
|
||||
text: 'Create a Skill if you want the agent to follow the same instructions every time for a task.',
|
||||
},
|
||||
{
|
||||
id: 'smart-nudges',
|
||||
text: 'If BrowserOS offers to connect an app, saying yes lets it use that app directly next time.',
|
||||
|
||||
@@ -22,7 +22,7 @@ describe('route-utils', () => {
|
||||
expect(shouldHideFocusGrid('/home')).toBe(true)
|
||||
expect(shouldHideFocusGrid('/home/agents/main')).toBe(true)
|
||||
expect(shouldHideFocusGrid('/home/chat')).toBe(true)
|
||||
expect(shouldHideFocusGrid('/home/skills')).toBe(true)
|
||||
expect(shouldHideFocusGrid('/home/skills')).toBe(false)
|
||||
expect(shouldHideFocusGrid('/home/personalize')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,6 @@ const HIDE_FOCUS_GRID_PATHS = new Set([
|
||||
'/home',
|
||||
'/home/soul',
|
||||
'/home/memory',
|
||||
'/home/skills',
|
||||
'/home/chat',
|
||||
])
|
||||
|
||||
|
||||
@@ -42,11 +42,34 @@ export interface UserAttachmentPreview {
|
||||
dataUrl?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Files attributed to this turn by the harness's per-turn workspace
|
||||
* diff. Populated either via the live `produced_files` SSE event or
|
||||
* (on resume) the `useAgentTurnFiles` fallback. Mirrors the wire
|
||||
* shape from `agent-harness-types.HarnessProducedFile` minus the
|
||||
* stream-only fields the inline card doesn't need.
|
||||
*/
|
||||
export interface ConversationTurnFile {
|
||||
id: string
|
||||
path: string
|
||||
size: number
|
||||
mtimeMs: number
|
||||
}
|
||||
|
||||
export interface AgentConversationTurn {
|
||||
id: string
|
||||
/**
|
||||
* Server-issued turn id, set as soon as the response headers arrive
|
||||
* (`X-Turn-Id`) for fresh sends, or from the active-turn payload on
|
||||
* resume. Required for the historic-files fallback fetch; absent on
|
||||
* the brief optimistic window before the first header.
|
||||
*/
|
||||
turnId?: string | null
|
||||
userText: string
|
||||
userAttachments?: UserAttachmentPreview[]
|
||||
parts: AssistantPart[]
|
||||
/** Files produced during this turn (openclaw only in v1). */
|
||||
producedFiles?: ConversationTurnFile[]
|
||||
done: boolean
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Pure helpers used by the artifact card and the Outputs rail.
|
||||
* Display formatting only — no React, no fetch, no DOM. Anything
|
||||
* stateful belongs in `./useAgentOutputs` or `./useFilePreview`.
|
||||
*/
|
||||
|
||||
import { buildAgentApiUrl } from '@/entrypoints/app/agents/agent-api-url'
|
||||
|
||||
/**
|
||||
* Coarse classification of a file's intended preview / icon path.
|
||||
* Mirrors the server-side `FilePreviewKind` minus `missing` — the
|
||||
* client only ever computes a kind for a row it already has.
|
||||
*/
|
||||
export type FileKind = 'text' | 'image' | 'pdf' | 'binary'
|
||||
|
||||
const TEXT_EXTENSIONS = new Set([
|
||||
'txt',
|
||||
'md',
|
||||
'markdown',
|
||||
'json',
|
||||
'jsonl',
|
||||
'csv',
|
||||
'tsv',
|
||||
'xml',
|
||||
'yaml',
|
||||
'yml',
|
||||
'toml',
|
||||
'ini',
|
||||
'log',
|
||||
'html',
|
||||
'htm',
|
||||
'css',
|
||||
'js',
|
||||
'mjs',
|
||||
'cjs',
|
||||
'ts',
|
||||
'tsx',
|
||||
'jsx',
|
||||
'py',
|
||||
'rb',
|
||||
'go',
|
||||
'rs',
|
||||
'java',
|
||||
'kt',
|
||||
'swift',
|
||||
'c',
|
||||
'h',
|
||||
'cpp',
|
||||
'hpp',
|
||||
'sh',
|
||||
'zsh',
|
||||
'bash',
|
||||
'sql',
|
||||
'svg',
|
||||
])
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set([
|
||||
'png',
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'gif',
|
||||
'webp',
|
||||
'bmp',
|
||||
'ico',
|
||||
'heic',
|
||||
'heif',
|
||||
])
|
||||
|
||||
/** Best-effort kind based on extension only. Server's preview API
|
||||
* is the source of truth for actual rendering — this is just for
|
||||
* picking an icon / sort hint without a network round-trip. */
|
||||
export function inferFileKind(path: string): FileKind {
|
||||
const ext = extensionOf(path).toLowerCase()
|
||||
if (ext === 'pdf') return 'pdf'
|
||||
if (IMAGE_EXTENSIONS.has(ext)) return 'image'
|
||||
if (TEXT_EXTENSIONS.has(ext)) return 'text'
|
||||
return 'binary'
|
||||
}
|
||||
|
||||
/** Plain extension without the leading dot. Empty string when none. */
|
||||
export function extensionOf(path: string): string {
|
||||
const dot = path.lastIndexOf('.')
|
||||
if (dot === -1) return ''
|
||||
const slash = path.lastIndexOf('/')
|
||||
if (dot < slash) return ''
|
||||
return path.slice(dot + 1)
|
||||
}
|
||||
|
||||
/** File name (final path segment), no directory prefix. */
|
||||
export function basenameOf(path: string): string {
|
||||
const slash = path.lastIndexOf('/')
|
||||
return slash === -1 ? path : path.slice(slash + 1)
|
||||
}
|
||||
|
||||
const SIZE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] as const
|
||||
|
||||
/** "2.4 MB" / "340 KB" / "78 B" — for the artifact card's right-side
|
||||
* metadata. Not localised; the rail uses one space + the unit. */
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes < 0) return '—'
|
||||
if (bytes < 1024) return `${bytes} ${SIZE_UNITS[0]}`
|
||||
let value = bytes
|
||||
let unit = 0
|
||||
while (value >= 1024 && unit < SIZE_UNITS.length - 1) {
|
||||
value /= 1024
|
||||
unit += 1
|
||||
}
|
||||
// 1-digit precision below 10, integer above — feels less noisy.
|
||||
const formatted = value < 10 ? value.toFixed(1) : Math.round(value).toString()
|
||||
return `${formatted} ${SIZE_UNITS[unit]}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the per-file download URL using the same agent-api root the
|
||||
* rest of the harness hits. Returned URL is already absolute.
|
||||
*/
|
||||
export function buildFileDownloadUrl(baseUrl: string, fileId: string): string {
|
||||
return buildAgentApiUrl(
|
||||
baseUrl,
|
||||
`/files/${encodeURIComponent(fileId)}/download`,
|
||||
)
|
||||
}
|
||||
32
packages/browseros-agent/apps/agent/lib/agent-files/index.ts
Normal file
32
packages/browseros-agent/apps/agent/lib/agent-files/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export {
|
||||
basenameOf,
|
||||
buildFileDownloadUrl,
|
||||
extensionOf,
|
||||
type FileKind,
|
||||
formatFileSize,
|
||||
inferFileKind,
|
||||
} from './file-helpers'
|
||||
export type {
|
||||
BinaryFilePreview,
|
||||
FilePreview,
|
||||
FilePreviewKind,
|
||||
ImageFilePreview,
|
||||
MissingFilePreview,
|
||||
PdfFilePreview,
|
||||
ProducedFile,
|
||||
ProducedFilesRailGroup,
|
||||
TextFilePreview,
|
||||
} from './types'
|
||||
export {
|
||||
useAgentOutputs,
|
||||
useAgentTurnFiles,
|
||||
useInvalidateAgentOutputs,
|
||||
useRefreshAgentOutputs,
|
||||
} from './useAgentOutputs'
|
||||
export { useFilePreview } from './useFilePreview'
|
||||
75
packages/browseros-agent/apps/agent/lib/agent-files/types.ts
Normal file
75
packages/browseros-agent/apps/agent/lib/agent-files/types.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Wire types shared by the inline artifact card and the per-agent
|
||||
* Outputs rail. These mirror `ProducedFileEntry` /
|
||||
* `ProducedFilesRailGroup` on the server and the `FilePreview`
|
||||
* discriminated union from `apps/server/src/api/services/openclaw/file-preview.ts`.
|
||||
*
|
||||
* The schema mirror is deliberate (vs sharing a workspace package)
|
||||
* because the server keeps the on-disk row shape — `agentDefinitionId`,
|
||||
* `sessionKey` — out of the wire payload. Dropping those columns at the
|
||||
* type boundary keeps the client honest about what it can refer to.
|
||||
*/
|
||||
|
||||
export interface ProducedFile {
|
||||
id: string
|
||||
/** Workspace-relative POSIX path. */
|
||||
path: string
|
||||
size: number
|
||||
mtimeMs: number
|
||||
/** Server clock when the file was first attributed to its turn. */
|
||||
createdAt: number
|
||||
detectedBy: 'diff' | 'tool'
|
||||
}
|
||||
|
||||
export interface ProducedFilesRailGroup {
|
||||
turnId: string
|
||||
/** First non-blank line of the user prompt that initiated this turn. */
|
||||
turnPrompt: string
|
||||
createdAt: number
|
||||
files: ProducedFile[]
|
||||
}
|
||||
|
||||
export type FilePreviewKind = 'text' | 'image' | 'pdf' | 'binary' | 'missing'
|
||||
|
||||
interface BasePreview {
|
||||
kind: FilePreviewKind
|
||||
mimeType: string
|
||||
size: number
|
||||
mtimeMs: number
|
||||
}
|
||||
|
||||
export interface TextFilePreview extends BasePreview {
|
||||
kind: 'text'
|
||||
snippet: string
|
||||
/** True when the on-disk file is larger than the server's snippet cap. */
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
export interface ImageFilePreview extends BasePreview {
|
||||
kind: 'image'
|
||||
/** Base64 data URL (incl. `data:` prefix). Suitable for `<img src>`. */
|
||||
dataUrl: string
|
||||
}
|
||||
|
||||
export interface PdfFilePreview extends BasePreview {
|
||||
kind: 'pdf'
|
||||
}
|
||||
|
||||
export interface BinaryFilePreview extends BasePreview {
|
||||
kind: 'binary'
|
||||
}
|
||||
|
||||
export interface MissingFilePreview {
|
||||
kind: 'missing'
|
||||
}
|
||||
|
||||
export type FilePreview =
|
||||
| TextFilePreview
|
||||
| ImageFilePreview
|
||||
| PdfFilePreview
|
||||
| BinaryFilePreview
|
||||
| MissingFilePreview
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* React Query hooks backing the per-agent Outputs rail and the
|
||||
* inline artifact card.
|
||||
*
|
||||
* Live updates: the consumer of `useAgentConversation` (see Phase 5)
|
||||
* is expected to call `useInvalidateAgentOutputs(agentId)` whenever
|
||||
* an assistant turn completes, so the rail picks up the new
|
||||
* `produced_files` rows the server attributed during that turn.
|
||||
* No SSE channel here — invalidation off the existing chat-stream
|
||||
* completion is enough for v1.
|
||||
*/
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
AGENT_QUERY_KEYS,
|
||||
agentsFetch,
|
||||
} from '@/entrypoints/app/agents/useAgents'
|
||||
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
|
||||
import type { ProducedFile, ProducedFilesRailGroup } from './types'
|
||||
|
||||
interface OutputsResponse {
|
||||
groups: ProducedFilesRailGroup[]
|
||||
}
|
||||
|
||||
interface TurnFilesResponse {
|
||||
files: ProducedFile[]
|
||||
}
|
||||
|
||||
export function useAgentOutputs(agentId: string, enabled = true) {
|
||||
const {
|
||||
baseUrl,
|
||||
isLoading: urlLoading,
|
||||
error: urlError,
|
||||
} = useAgentServerUrl()
|
||||
|
||||
const query = useQuery<ProducedFilesRailGroup[], Error>({
|
||||
queryKey: [AGENT_QUERY_KEYS.agentOutputs, baseUrl, agentId],
|
||||
queryFn: async () => {
|
||||
const data = await agentsFetch<OutputsResponse>(
|
||||
baseUrl as string,
|
||||
`/${encodeURIComponent(agentId)}/files`,
|
||||
)
|
||||
return data.groups ?? []
|
||||
},
|
||||
enabled: Boolean(baseUrl) && !urlLoading && enabled && Boolean(agentId),
|
||||
})
|
||||
|
||||
return {
|
||||
groups: query.data ?? [],
|
||||
loading: query.isLoading || urlLoading,
|
||||
error: query.error ?? urlError,
|
||||
refetch: query.refetch,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-turn fetch for the inline artifact card. Used both as the
|
||||
* fallback when an SSE `produced_files` event was missed, and to
|
||||
* rehydrate a turn the user scrolled back to.
|
||||
*/
|
||||
export function useAgentTurnFiles(
|
||||
agentId: string,
|
||||
turnId: string | null,
|
||||
enabled = true,
|
||||
) {
|
||||
const {
|
||||
baseUrl,
|
||||
isLoading: urlLoading,
|
||||
error: urlError,
|
||||
} = useAgentServerUrl()
|
||||
|
||||
const query = useQuery<ProducedFile[], Error>({
|
||||
queryKey: [AGENT_QUERY_KEYS.agentTurnFiles, baseUrl, agentId, turnId],
|
||||
queryFn: async () => {
|
||||
const data = await agentsFetch<TurnFilesResponse>(
|
||||
baseUrl as string,
|
||||
`/${encodeURIComponent(agentId)}/files/turn/${encodeURIComponent(
|
||||
turnId as string,
|
||||
)}`,
|
||||
)
|
||||
return data.files ?? []
|
||||
},
|
||||
enabled:
|
||||
Boolean(baseUrl) &&
|
||||
!urlLoading &&
|
||||
enabled &&
|
||||
Boolean(agentId) &&
|
||||
Boolean(turnId),
|
||||
})
|
||||
|
||||
return {
|
||||
files: query.data ?? [],
|
||||
loading: query.isLoading || urlLoading,
|
||||
error: query.error ?? urlError,
|
||||
refetch: query.refetch,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a callable that invalidates outputs / turn-files queries
|
||||
* for one agent across any baseUrl. Call after an assistant turn
|
||||
* completes so the rail (and the inline file-card strip) pick up
|
||||
* the new attributed rows. Cheap when the queries aren't mounted
|
||||
* — react-query just marks the cached value stale.
|
||||
*
|
||||
* Implementation note: react-query's `invalidateQueries({ queryKey })`
|
||||
* does positional partial-match, so passing `undefined` as the
|
||||
* baseUrl placeholder does NOT match a cached `[…, baseUrl, …]`
|
||||
* key — the cache stayed stale. Use a predicate so we ignore the
|
||||
* baseUrl position entirely.
|
||||
*/
|
||||
export function useInvalidateAgentOutputs() {
|
||||
const queryClient = useQueryClient()
|
||||
return async (agentId: string, turnId?: string) => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) => {
|
||||
const key = query.queryKey
|
||||
return (
|
||||
Array.isArray(key) &&
|
||||
key[0] === AGENT_QUERY_KEYS.agentOutputs &&
|
||||
key[2] === agentId
|
||||
)
|
||||
},
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) => {
|
||||
const key = query.queryKey
|
||||
if (
|
||||
!Array.isArray(key) ||
|
||||
key[0] !== AGENT_QUERY_KEYS.agentTurnFiles ||
|
||||
key[2] !== agentId
|
||||
) {
|
||||
return false
|
||||
}
|
||||
// When a turnId was supplied, scope to just that turn's
|
||||
// entry. Otherwise flush every cached turn for this agent.
|
||||
return turnId ? key[3] === turnId : true
|
||||
},
|
||||
}),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tiny mutation wrapper so the Outputs rail's "Refresh" button can
|
||||
* surface an `isPending` indicator while the new query is in flight.
|
||||
* No body — just triggers `refetch` on the rail's query for this
|
||||
* agent and resolves when it settles.
|
||||
*/
|
||||
export function useRefreshAgentOutputs(agentId: string) {
|
||||
const queryClient = useQueryClient()
|
||||
const { baseUrl } = useAgentServerUrl()
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
await queryClient.refetchQueries({
|
||||
queryKey: [AGENT_QUERY_KEYS.agentOutputs, baseUrl, agentId],
|
||||
exact: true,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Single-file preview hook used by the inline artifact card and the
|
||||
* Outputs rail's preview Sheet. Always opt-in (`enabled`) — the
|
||||
* preview is fetched only when the user clicks a row, never
|
||||
* eagerly.
|
||||
*/
|
||||
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
AGENT_QUERY_KEYS,
|
||||
agentsFetch,
|
||||
} from '@/entrypoints/app/agents/useAgents'
|
||||
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
|
||||
import type { FilePreview } from './types'
|
||||
|
||||
export function useFilePreview(fileId: string | null, enabled = true) {
|
||||
const {
|
||||
baseUrl,
|
||||
isLoading: urlLoading,
|
||||
error: urlError,
|
||||
} = useAgentServerUrl()
|
||||
|
||||
const query = useQuery<FilePreview, Error>({
|
||||
queryKey: [AGENT_QUERY_KEYS.filePreview, baseUrl, fileId],
|
||||
queryFn: async () => {
|
||||
return agentsFetch<FilePreview>(
|
||||
baseUrl as string,
|
||||
`/files/${encodeURIComponent(fileId as string)}/preview`,
|
||||
)
|
||||
},
|
||||
enabled: Boolean(baseUrl) && !urlLoading && enabled && Boolean(fileId),
|
||||
// Previews are immutable for a given fileId — once loaded, never
|
||||
// refetch on focus / reconnect. They go stale only when the
|
||||
// underlying file is removed (rare in v1; no rename / delete).
|
||||
staleTime: Infinity,
|
||||
gcTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
return {
|
||||
preview: query.data ?? null,
|
||||
loading: query.isLoading || urlLoading,
|
||||
error: query.error ?? urlError,
|
||||
refetch: query.refetch,
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { resolveStaticFeatureSupport } from './capabilities'
|
||||
import { Feature, resolveStaticFeatureSupport } from './capabilities'
|
||||
|
||||
describe('resolveStaticFeatureSupport', () => {
|
||||
it('does not expose the unshipped Skills feature gate', () => {
|
||||
expect(Object.values(Feature)).not.toContain('SKILLS_SUPPORT')
|
||||
})
|
||||
|
||||
it('enables alpha-gated features automatically in development', () => {
|
||||
expect(
|
||||
resolveStaticFeatureSupport({
|
||||
|
||||
@@ -46,8 +46,6 @@ export enum Feature {
|
||||
VERTICAL_TABS_SUPPORT = 'VERTICAL_TABS_SUPPORT',
|
||||
// Memory page: core memory viewer and editor
|
||||
MEMORY_SUPPORT = 'MEMORY_SUPPORT',
|
||||
// Skills page: agent skills viewer and editor
|
||||
SKILLS_SUPPORT = 'SKILLS_SUPPORT',
|
||||
// ChatGPT Pro OAuth LLM provider
|
||||
CHATGPT_PRO_SUPPORT = 'CHATGPT_PRO_SUPPORT',
|
||||
// GitHub Copilot OAuth LLM provider
|
||||
@@ -83,7 +81,6 @@ const FEATURE_CONFIG: { [K in Feature]: FeatureConfig } = {
|
||||
[Feature.NEWTAB_CHAT_SUPPORT]: { minBrowserOSVersion: '0.40.0.0' },
|
||||
[Feature.VERTICAL_TABS_SUPPORT]: { minBrowserOSVersion: '0.42.0.0' },
|
||||
[Feature.MEMORY_SUPPORT]: { minServerVersion: '0.0.73' },
|
||||
[Feature.SKILLS_SUPPORT]: { minBrowserOSVersion: '0.43.0.0' },
|
||||
[Feature.CHATGPT_PRO_SUPPORT]: { minServerVersion: '0.0.77' },
|
||||
[Feature.GITHUB_COPILOT_SUPPORT]: { minServerVersion: '0.0.77' },
|
||||
[Feature.QWEN_CODE_SUPPORT]: { minServerVersion: '0.0.77' },
|
||||
|
||||
@@ -83,15 +83,6 @@ The agent loop uses the [Vercel AI SDK](https://sdk.vercel.ai) to orchestrate mu
|
||||
|
||||
The provider factory (`src/agent/provider-factory.ts`) creates AI SDK providers from runtime configuration, supporting hot-swapping between providers without restart.
|
||||
|
||||
## Skills System
|
||||
|
||||
Skills are custom instruction sets that shape agent behavior:
|
||||
|
||||
- **Catalog** (`src/skills/catalog.ts`) — registry of available skills
|
||||
- **Defaults** (`src/skills/defaults/`) — built-in skill definitions
|
||||
- **Loader** (`src/skills/loader.ts`) — loads skills from local and remote sources
|
||||
- **Remote sync** (`src/skills/remote-sync.ts`) — syncs skills from the BrowserOS cloud
|
||||
|
||||
## Dependencies
|
||||
|
||||
Notable runtime dependencies worth calling out:
|
||||
@@ -121,7 +112,6 @@ apps/server/
|
||||
│ │ ├── memory/
|
||||
│ │ ├── filesystem/
|
||||
│ │ └── ...
|
||||
│ ├── skills/ # Skills system
|
||||
│ ├── lib/ # Shared utilities
|
||||
│ └── rpc.ts # JSON-RPC type definitions
|
||||
├── tests/
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
"test:integration": "bun run ./tests/__helpers__/run-test-group.ts integration",
|
||||
"test:lib": "bun run ./tests/__helpers__/run-test-group.ts lib",
|
||||
"test:root": "bun run ./tests/__helpers__/run-test-group.ts root",
|
||||
"test:skills": "bun run ./tests/__helpers__/run-test-group.ts skills",
|
||||
"test:tools": "bun run ./tests/__helpers__/run-test-group.ts tools",
|
||||
"test:tools:acl": "bun run test:cleanup && bun --env-file=.env.development test ./tests/tools/acl-scorer.test.ts",
|
||||
"test:tools:filesystem": "bun run test:cleanup && bun --env-file=.env.development test ./tests/tools/filesystem",
|
||||
@@ -104,7 +103,6 @@
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"eventsource-parser": "^3.0.0",
|
||||
"fuse.js": "^7.1.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"hono": "^4.12.3",
|
||||
"jimp": "^1.6.0",
|
||||
"klavis": "^2.15.0",
|
||||
|
||||
@@ -24,8 +24,6 @@ import type { Browser } from '../browser/browser'
|
||||
import { logger } from '../lib/logger'
|
||||
import { metrics } from '../lib/metrics'
|
||||
import { isSoulBootstrap, readSoul } from '../lib/soul'
|
||||
import { buildSkillsCatalog } from '../skills/catalog'
|
||||
import { loadSkills } from '../skills/loader'
|
||||
import { buildFilesystemToolSet } from '../tools/filesystem/build-toolset'
|
||||
import type { ToolContext } from '../tools/framework'
|
||||
import { buildMemoryToolSet } from '../tools/memory/build-toolset'
|
||||
@@ -215,11 +213,6 @@ export class AiSdkAgent {
|
||||
const soulContent = await readSoul()
|
||||
const isBootstrap = await isSoulBootstrap()
|
||||
|
||||
// Load skills catalog for prompt injection
|
||||
const skills = await loadSkills()
|
||||
const skillsCatalog =
|
||||
skills.length > 0 ? buildSkillsCatalog(skills) : undefined
|
||||
|
||||
const instructions = buildSystemPrompt({
|
||||
userSystemPrompt: config.resolvedConfig.userSystemPrompt,
|
||||
exclude: excludeSections,
|
||||
@@ -231,7 +224,6 @@ export class AiSdkAgent {
|
||||
chatMode: config.resolvedConfig.chatMode,
|
||||
connectedApps: config.browserContext?.enabledMcpServers,
|
||||
declinedApps: config.resolvedConfig.declinedApps,
|
||||
skillsCatalog,
|
||||
origin: config.resolvedConfig.origin,
|
||||
})
|
||||
|
||||
|
||||
@@ -549,16 +549,9 @@ You can read, write, search, and execute files in this directory:
|
||||
- \`filesystem_bash\` → execute shell commands
|
||||
|
||||
Use the filesystem to save extracted data, run scripts, or process files.
|
||||
Skills may reference scripts in their directory — use absolute paths.
|
||||
</workspace>`
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// section: skills
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// Skills are injected via options.skillsCatalog from the catalog builder.
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// section: nudges
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -727,8 +720,6 @@ const promptSections: Record<string, PromptSectionFn> = {
|
||||
'error-recovery': getErrorRecovery,
|
||||
'memory-and-identity': getMemoryAndIdentity,
|
||||
workspace: getWorkspace,
|
||||
skills: (_exclude: Set<string>, options?: BuildSystemPromptOptions) =>
|
||||
options?.skillsCatalog || '',
|
||||
nudges: getNudges,
|
||||
style: getStyle,
|
||||
'user-context': getUserContext,
|
||||
@@ -748,7 +739,6 @@ export interface BuildSystemPromptOptions {
|
||||
connectedApps?: string[]
|
||||
/** Apps the user previously declined to connect (chose "do it manually"). */
|
||||
declinedApps?: string[]
|
||||
skillsCatalog?: string
|
||||
/** Where the chat session originates from — determines navigation behavior. */
|
||||
origin?: 'sidepanel' | 'newtab'
|
||||
}
|
||||
|
||||
@@ -39,11 +39,13 @@ import {
|
||||
MessageQueueFullError,
|
||||
type OpenClawProvisioner,
|
||||
OpenClawProvisionerUnavailableError,
|
||||
type ProducedFileEntry,
|
||||
type ProducedFilesRailGroup,
|
||||
type QueuedMessage,
|
||||
TurnAlreadyActiveError,
|
||||
UnknownAgentError,
|
||||
} from '../services/agents/agent-harness-service'
|
||||
import type { OpenClawGatewayChatClient } from '../services/openclaw/openclaw-gateway-chat-client'
|
||||
import type { FilePreview } from '../services/openclaw/file-preview'
|
||||
import type { Env } from '../types'
|
||||
import { resolveBrowserContextPageIds } from '../utils/resolve-browser-context-page-ids'
|
||||
|
||||
@@ -95,6 +97,23 @@ type AgentRouteService = {
|
||||
messageId: string
|
||||
}): Promise<boolean>
|
||||
listQueuedMessages(agentId: string): Promise<QueuedMessage[]>
|
||||
|
||||
// Files API — Phase 3 of TKT-762.
|
||||
listAgentFiles(
|
||||
agentId: string,
|
||||
options?: { limit?: number },
|
||||
): Promise<ProducedFilesRailGroup[]>
|
||||
listAgentFilesForTurn(
|
||||
agentId: string,
|
||||
turnId: string,
|
||||
): Promise<ProducedFileEntry[]>
|
||||
previewProducedFile(fileId: string): Promise<FilePreview | null>
|
||||
resolveProducedFileForDownload(fileId: string): Promise<{
|
||||
absolutePath: string
|
||||
fileName: string
|
||||
mimeType: string
|
||||
size: number
|
||||
} | null>
|
||||
}
|
||||
|
||||
type AgentRouteDeps = {
|
||||
@@ -109,18 +128,19 @@ type AgentRouteDeps = {
|
||||
openclawGateway?: OpenclawGatewayAccessor
|
||||
/**
|
||||
* Optional. Enables the image-attachment carve-out for OpenClaw
|
||||
* agents — image-bearing turns route through the gateway HTTP
|
||||
* `/v1/chat/completions` instead of the ACP bridge (which drops
|
||||
* image content blocks).
|
||||
*/
|
||||
openclawGatewayChat?: OpenClawGatewayChatClient
|
||||
/**
|
||||
* Required to dual-create/delete `openclaw` adapter agents on the
|
||||
* gateway side. Without this, openclaw create requests fail with 503.
|
||||
*/
|
||||
openclawProvisioner?: OpenClawProvisioner
|
||||
/** Optional override; defaults to a fresh in-memory checker. */
|
||||
adapterHealth?: AdapterHealthChecker
|
||||
/**
|
||||
* Optional listener attached to the constructed harness. Receives
|
||||
* turn lifecycle events for every running agent. Wired by the server
|
||||
* to feed OpenClaw's ClawSession dashboard from the same stream the
|
||||
* chat panel sees, so no second WS observer is needed.
|
||||
*/
|
||||
onTurnLifecycle?: import('../services/agents/agent-harness-service').TurnLifecycleListener
|
||||
}
|
||||
|
||||
type SidepanelAgentChatRequest = {
|
||||
@@ -139,267 +159,381 @@ export function createAgentRoutes(deps: AgentRouteDeps = {}) {
|
||||
new AgentHarnessService({
|
||||
browserosServerPort: deps.browserosServerPort,
|
||||
openclawGateway: deps.openclawGateway,
|
||||
openclawGatewayChat: deps.openclawGatewayChat,
|
||||
openclawProvisioner: deps.openclawProvisioner,
|
||||
})
|
||||
if (deps.onTurnLifecycle && service instanceof AgentHarnessService) {
|
||||
service.onTurnLifecycle(deps.onTurnLifecycle)
|
||||
}
|
||||
// One checker per route mount. Cached probes refresh every 5min;
|
||||
// tests can swap in an alternate via deps if needed.
|
||||
const adapterHealth = deps.adapterHealth ?? new AdapterHealthChecker()
|
||||
|
||||
return new Hono<Env>()
|
||||
.get('/adapters', async (c) => {
|
||||
const adapters = await Promise.all(
|
||||
AGENT_ADAPTER_CATALOG.map(async (descriptor) => ({
|
||||
...descriptor,
|
||||
health: await adapterHealth.getHealth(descriptor.id),
|
||||
})),
|
||||
)
|
||||
return c.json({ adapters })
|
||||
})
|
||||
.get('/', async (c) => {
|
||||
// Single round-trip the agents page consumes: enriched agents
|
||||
// (status + lastUsedAt) plus the gateway lifecycle snapshot the
|
||||
// GatewayStatusBar / GatewayStateCards / ControlPlaneAlert used
|
||||
// to fetch from `/claw/status`. Lets the page poll one endpoint.
|
||||
const [agents, gateway] = await Promise.all([
|
||||
service.listAgentsWithActivity(),
|
||||
service.getGatewayStatus(),
|
||||
])
|
||||
return c.json({ agents, gateway })
|
||||
})
|
||||
.post('/', async (c) => {
|
||||
const parsed = await parseCreateAgentBody(c)
|
||||
if ('error' in parsed) return c.json({ error: parsed.error }, 400)
|
||||
try {
|
||||
return c.json({ agent: await service.createAgent(parsed) })
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.post('/:agentId/sidepanel/chat', async (c) => {
|
||||
const agentId = c.req.param('agentId')
|
||||
const parsed = await parseSidepanelAgentChatBody(c)
|
||||
if ('error' in parsed) return c.json({ error: parsed.error }, 400)
|
||||
|
||||
try {
|
||||
const agent = await service.getAgent(agentId)
|
||||
if (!agent) return c.json({ error: 'Unknown agent' }, 404)
|
||||
|
||||
let browserContext = parsed.browserContext
|
||||
if (deps.browser) {
|
||||
browserContext = await resolveBrowserContextPageIds(
|
||||
deps.browser,
|
||||
browserContext,
|
||||
)
|
||||
}
|
||||
|
||||
const userContent = formatUserMessage(
|
||||
parsed.message,
|
||||
browserContext,
|
||||
parsed.selectedText,
|
||||
parsed.selectedTextSource,
|
||||
return (
|
||||
new Hono<Env>()
|
||||
.get('/adapters', async (c) => {
|
||||
const adapters = await Promise.all(
|
||||
AGENT_ADAPTER_CATALOG.map(async (descriptor) => ({
|
||||
...descriptor,
|
||||
health: await adapterHealth.getHealth(descriptor.id),
|
||||
})),
|
||||
)
|
||||
const message = parsed.userSystemPrompt?.trim()
|
||||
? `${parsed.userSystemPrompt.trim()}\n\n${userContent}`
|
||||
: userContent
|
||||
return c.json({ adapters })
|
||||
})
|
||||
.get('/', async (c) => {
|
||||
// Single round-trip the agents page consumes: enriched agents
|
||||
// (status + lastUsedAt) plus the gateway lifecycle snapshot the
|
||||
// GatewayStatusBar / GatewayStateCards / ControlPlaneAlert used
|
||||
// to fetch from `/claw/status`. Lets the page poll one endpoint.
|
||||
const [agents, gateway] = await Promise.all([
|
||||
service.listAgentsWithActivity(),
|
||||
service.getGatewayStatus(),
|
||||
])
|
||||
return c.json({ agents, gateway })
|
||||
})
|
||||
.post('/', async (c) => {
|
||||
const parsed = await parseCreateAgentBody(c)
|
||||
if ('error' in parsed) return c.json({ error: parsed.error }, 400)
|
||||
try {
|
||||
return c.json({ agent: await service.createAgent(parsed) })
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.post('/:agentId/sidepanel/chat', async (c) => {
|
||||
const agentId = c.req.param('agentId')
|
||||
const parsed = await parseSidepanelAgentChatBody(c)
|
||||
if ('error' in parsed) return c.json({ error: parsed.error }, 400)
|
||||
|
||||
try {
|
||||
const agent = await service.getAgent(agentId)
|
||||
if (!agent) return c.json({ error: 'Unknown agent' }, 404)
|
||||
|
||||
let browserContext = parsed.browserContext
|
||||
if (deps.browser) {
|
||||
browserContext = await resolveBrowserContextPageIds(
|
||||
deps.browser,
|
||||
browserContext,
|
||||
)
|
||||
}
|
||||
|
||||
const userContent = formatUserMessage(
|
||||
parsed.message,
|
||||
browserContext,
|
||||
parsed.selectedText,
|
||||
parsed.selectedTextSource,
|
||||
)
|
||||
const message = parsed.userSystemPrompt?.trim()
|
||||
? `${parsed.userSystemPrompt.trim()}\n\n${userContent}`
|
||||
: userContent
|
||||
|
||||
let started: { turnId: string; frames: ReadableStream<TurnFrame> }
|
||||
try {
|
||||
started = await service.startTurn({
|
||||
agentId: agent.id,
|
||||
message,
|
||||
cwd: parsed.userWorkingDir,
|
||||
})
|
||||
} catch (err) {
|
||||
if (err instanceof TurnAlreadyActiveError) {
|
||||
return c.json(
|
||||
{
|
||||
error: 'Turn already active',
|
||||
turnId: err.turnId,
|
||||
attachUrl: `/agents/${agent.id}/chat/stream?turnId=${err.turnId}`,
|
||||
},
|
||||
409,
|
||||
)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
let didRequestCancel = false
|
||||
const cancelStartedTurn = () => {
|
||||
if (didRequestCancel) return
|
||||
didRequestCancel = true
|
||||
service.cancelTurn({
|
||||
agentId: agent.id,
|
||||
turnId: started.turnId,
|
||||
reason: 'sidepanel stream cancelled',
|
||||
})
|
||||
}
|
||||
if (c.req.raw.signal.aborted) {
|
||||
cancelStartedTurn()
|
||||
} else {
|
||||
c.req.raw.signal.addEventListener('abort', cancelStartedTurn, {
|
||||
once: true,
|
||||
})
|
||||
}
|
||||
|
||||
const events = turnFramesToAgentEvents(started.frames, {
|
||||
onCancel: cancelStartedTurn,
|
||||
})
|
||||
|
||||
return createAcpUIMessageStreamResponse(events, {
|
||||
headers: {
|
||||
'X-Session-Id': 'main',
|
||||
'X-Turn-Id': started.turnId,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.get('/:agentId', async (c) => {
|
||||
try {
|
||||
const agent = await service.getAgent(c.req.param('agentId'))
|
||||
if (!agent) return c.json({ error: 'Unknown agent' }, 404)
|
||||
return c.json({ agent })
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.delete('/:agentId', async (c) => {
|
||||
try {
|
||||
return c.json({
|
||||
success: await service.deleteAgent(c.req.param('agentId')),
|
||||
})
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.patch('/:agentId', async (c) => {
|
||||
const parsed = await parseAgentPatchBody(c)
|
||||
if ('error' in parsed) return c.json({ error: parsed.error }, 400)
|
||||
try {
|
||||
const agent = await service.updateAgent(
|
||||
c.req.param('agentId'),
|
||||
parsed.patch,
|
||||
)
|
||||
if (!agent) return c.json({ error: 'Unknown agent' }, 404)
|
||||
return c.json({ agent })
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.get('/:agentId/sessions/main/history', async (c) => {
|
||||
try {
|
||||
return c.json(await service.getHistory(c.req.param('agentId')))
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.post('/:agentId/chat', async (c) => {
|
||||
const agentId = c.req.param('agentId')
|
||||
const parsed = await parseChatBody(c)
|
||||
if ('error' in parsed) return c.json({ error: parsed.error }, 400)
|
||||
|
||||
let started: { turnId: string; frames: ReadableStream<TurnFrame> }
|
||||
try {
|
||||
started = await service.startTurn({
|
||||
agentId: agent.id,
|
||||
message,
|
||||
cwd: parsed.userWorkingDir,
|
||||
agentId,
|
||||
message: parsed.message,
|
||||
attachments: parsed.attachments,
|
||||
cwd: parsed.cwd,
|
||||
})
|
||||
} catch (err) {
|
||||
if (err instanceof TurnAlreadyActiveError) {
|
||||
// Caller can attach via GET /chat/stream?turnId=… instead.
|
||||
return c.json(
|
||||
{
|
||||
error: 'Turn already active',
|
||||
turnId: err.turnId,
|
||||
attachUrl: `/agents/${agent.id}/chat/stream?turnId=${err.turnId}`,
|
||||
attachUrl: `/agents/${agentId}/chat/stream?turnId=${err.turnId}`,
|
||||
},
|
||||
409,
|
||||
)
|
||||
}
|
||||
throw err
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
|
||||
let didRequestCancel = false
|
||||
const cancelStartedTurn = () => {
|
||||
if (didRequestCancel) return
|
||||
didRequestCancel = true
|
||||
service.cancelTurn({
|
||||
agentId: agent.id,
|
||||
turnId: started.turnId,
|
||||
reason: 'sidepanel stream cancelled',
|
||||
})
|
||||
}
|
||||
if (c.req.raw.signal.aborted) {
|
||||
cancelStartedTurn()
|
||||
} else {
|
||||
c.req.raw.signal.addEventListener('abort', cancelStartedTurn, {
|
||||
once: true,
|
||||
})
|
||||
}
|
||||
|
||||
const events = turnFramesToAgentEvents(started.frames, {
|
||||
onCancel: cancelStartedTurn,
|
||||
return streamTurnFrames(c, started.frames, {
|
||||
turnId: started.turnId,
|
||||
})
|
||||
|
||||
return createAcpUIMessageStreamResponse(events, {
|
||||
headers: {
|
||||
'X-Session-Id': 'main',
|
||||
'X-Turn-Id': started.turnId,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.get('/:agentId', async (c) => {
|
||||
try {
|
||||
const agent = await service.getAgent(c.req.param('agentId'))
|
||||
if (!agent) return c.json({ error: 'Unknown agent' }, 404)
|
||||
return c.json({ agent })
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.delete('/:agentId', async (c) => {
|
||||
try {
|
||||
return c.json({
|
||||
success: await service.deleteAgent(c.req.param('agentId')),
|
||||
})
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.patch('/:agentId', async (c) => {
|
||||
const parsed = await parseAgentPatchBody(c)
|
||||
if ('error' in parsed) return c.json({ error: parsed.error }, 400)
|
||||
try {
|
||||
const agent = await service.updateAgent(
|
||||
c.req.param('agentId'),
|
||||
parsed.patch,
|
||||
)
|
||||
if (!agent) return c.json({ error: 'Unknown agent' }, 404)
|
||||
return c.json({ agent })
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.get('/:agentId/sessions/main/history', async (c) => {
|
||||
try {
|
||||
return c.json(await service.getHistory(c.req.param('agentId')))
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.post('/:agentId/chat', async (c) => {
|
||||
const agentId = c.req.param('agentId')
|
||||
const parsed = await parseChatBody(c)
|
||||
if ('error' in parsed) return c.json({ error: parsed.error }, 400)
|
||||
|
||||
let started: { turnId: string; frames: ReadableStream<TurnFrame> }
|
||||
try {
|
||||
started = await service.startTurn({
|
||||
agentId,
|
||||
message: parsed.message,
|
||||
attachments: parsed.attachments,
|
||||
cwd: parsed.cwd,
|
||||
})
|
||||
} catch (err) {
|
||||
if (err instanceof TurnAlreadyActiveError) {
|
||||
// Caller can attach via GET /chat/stream?turnId=… instead.
|
||||
return c.json(
|
||||
{
|
||||
error: 'Turn already active',
|
||||
turnId: err.turnId,
|
||||
attachUrl: `/agents/${agentId}/chat/stream?turnId=${err.turnId}`,
|
||||
},
|
||||
409,
|
||||
)
|
||||
}
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
|
||||
return streamTurnFrames(c, started.frames, {
|
||||
turnId: started.turnId,
|
||||
})
|
||||
})
|
||||
.get('/:agentId/chat/active', (c) => {
|
||||
const agentId = c.req.param('agentId')
|
||||
const info = service.getActiveTurn(agentId, 'main')
|
||||
return c.json({ active: info })
|
||||
})
|
||||
.get('/:agentId/chat/stream', (c) => {
|
||||
const agentId = c.req.param('agentId')
|
||||
const url = new URL(c.req.url)
|
||||
const queryTurnId = url.searchParams.get('turnId')?.trim() || undefined
|
||||
const turnId =
|
||||
queryTurnId ?? service.getActiveTurn(agentId, 'main')?.turnId
|
||||
if (!turnId) {
|
||||
return c.json({ error: 'No active turn for this agent' }, 404)
|
||||
}
|
||||
const lastEventId =
|
||||
c.req.header('Last-Event-ID') ??
|
||||
url.searchParams.get('lastSeq') ??
|
||||
undefined
|
||||
const lastSeq = parseLastSeq(lastEventId)
|
||||
const frames = service.attachTurn({ turnId, lastSeq })
|
||||
if (!frames) {
|
||||
return c.json({ error: 'Unknown turn' }, 404)
|
||||
}
|
||||
return streamTurnFrames(c, frames, { turnId })
|
||||
})
|
||||
.post('/:agentId/chat/cancel', async (c) => {
|
||||
const agentId = c.req.param('agentId')
|
||||
const body = await readJsonBody(c)
|
||||
const turnId =
|
||||
'value' in body && typeof body.value.turnId === 'string'
|
||||
? body.value.turnId.trim() || undefined
|
||||
: undefined
|
||||
const reason =
|
||||
'value' in body && typeof body.value.reason === 'string'
|
||||
? body.value.reason
|
||||
: undefined
|
||||
const cancelled = service.cancelTurn({ agentId, turnId, reason })
|
||||
return c.json({ cancelled })
|
||||
})
|
||||
.get('/:agentId/queue', async (c) => {
|
||||
try {
|
||||
const queue = await service.listQueuedMessages(c.req.param('agentId'))
|
||||
return c.json({ queue })
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.post('/:agentId/queue', async (c) => {
|
||||
const parsed = await parseEnqueueBody(c)
|
||||
if ('error' in parsed) return c.json({ error: parsed.error }, 400)
|
||||
try {
|
||||
const queued = await service.enqueueMessage({
|
||||
agentId: c.req.param('agentId'),
|
||||
message: parsed.message,
|
||||
attachments: parsed.attachments,
|
||||
})
|
||||
return c.json({ queued })
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.delete('/:agentId/queue/:messageId', async (c) => {
|
||||
try {
|
||||
const removed = await service.removeQueuedMessage({
|
||||
agentId: c.req.param('agentId'),
|
||||
messageId: c.req.param('messageId'),
|
||||
})
|
||||
if (!removed) return c.json({ error: 'Queued message not found' }, 404)
|
||||
return c.json({ removed })
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.get('/:agentId/chat/active', (c) => {
|
||||
const agentId = c.req.param('agentId')
|
||||
const info = service.getActiveTurn(agentId, 'main')
|
||||
return c.json({ active: info })
|
||||
})
|
||||
.get('/:agentId/chat/stream', (c) => {
|
||||
const agentId = c.req.param('agentId')
|
||||
const url = new URL(c.req.url)
|
||||
const queryTurnId = url.searchParams.get('turnId')?.trim() || undefined
|
||||
const turnId =
|
||||
queryTurnId ?? service.getActiveTurn(agentId, 'main')?.turnId
|
||||
if (!turnId) {
|
||||
return c.json({ error: 'No active turn for this agent' }, 404)
|
||||
}
|
||||
const lastEventId =
|
||||
c.req.header('Last-Event-ID') ??
|
||||
url.searchParams.get('lastSeq') ??
|
||||
undefined
|
||||
const lastSeq = parseLastSeq(lastEventId)
|
||||
const frames = service.attachTurn({ turnId, lastSeq })
|
||||
if (!frames) {
|
||||
return c.json({ error: 'Unknown turn' }, 404)
|
||||
}
|
||||
return streamTurnFrames(c, frames, { turnId })
|
||||
})
|
||||
.post('/:agentId/chat/cancel', async (c) => {
|
||||
const agentId = c.req.param('agentId')
|
||||
const body = await readJsonBody(c)
|
||||
const turnId =
|
||||
'value' in body && typeof body.value.turnId === 'string'
|
||||
? body.value.turnId.trim() || undefined
|
||||
: undefined
|
||||
const reason =
|
||||
'value' in body && typeof body.value.reason === 'string'
|
||||
? body.value.reason
|
||||
: undefined
|
||||
const cancelled = service.cancelTurn({ agentId, turnId, reason })
|
||||
return c.json({ cancelled })
|
||||
})
|
||||
.get('/:agentId/queue', async (c) => {
|
||||
try {
|
||||
const queue = await service.listQueuedMessages(c.req.param('agentId'))
|
||||
return c.json({ queue })
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.post('/:agentId/queue', async (c) => {
|
||||
const parsed = await parseEnqueueBody(c)
|
||||
if ('error' in parsed) return c.json({ error: parsed.error }, 400)
|
||||
try {
|
||||
const queued = await service.enqueueMessage({
|
||||
agentId: c.req.param('agentId'),
|
||||
message: parsed.message,
|
||||
attachments: parsed.attachments,
|
||||
})
|
||||
return c.json({ queued })
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.delete('/:agentId/queue/:messageId', async (c) => {
|
||||
try {
|
||||
const removed = await service.removeQueuedMessage({
|
||||
agentId: c.req.param('agentId'),
|
||||
messageId: c.req.param('messageId'),
|
||||
})
|
||||
if (!removed)
|
||||
return c.json({ error: 'Queued message not found' }, 404)
|
||||
return c.json({ removed })
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
|
||||
// ── Files (TKT-762) ────────────────────────────────────────────
|
||||
//
|
||||
// V1 surfaces files OpenClaw agents produce inside their workspace
|
||||
// dir (`~/.browseros/vm/openclaw/.openclaw/workspace[-<name>]/`)
|
||||
// as outputs, attributed back to the chat turn that produced them
|
||||
// by the per-turn workspace diff in
|
||||
// `agent-harness-service.runDetachedTurn`. Adapter-gated to
|
||||
// openclaw on the service side; for claude / codex these endpoints
|
||||
// simply return empty lists.
|
||||
//
|
||||
// The file-id-scoped endpoints (`/files/:fileId/{preview,download}`)
|
||||
// accept an opaque `fileId` and resolve the on-disk path
|
||||
// server-side, so the client never sees a raw path and traversal
|
||||
// is impossible by construction.
|
||||
|
||||
.get('/:agentId/files', async (c) => {
|
||||
try {
|
||||
const groups = await service.listAgentFiles(
|
||||
c.req.param('agentId'),
|
||||
parseAgentFilesLimit(c.req.query('limit')),
|
||||
)
|
||||
return c.json({ groups })
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.get('/:agentId/files/turn/:turnId', async (c) => {
|
||||
try {
|
||||
const files = await service.listAgentFilesForTurn(
|
||||
c.req.param('agentId'),
|
||||
c.req.param('turnId'),
|
||||
)
|
||||
return c.json({ files })
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.get('/files/:fileId/preview', async (c) => {
|
||||
try {
|
||||
const preview = await service.previewProducedFile(
|
||||
c.req.param('fileId'),
|
||||
)
|
||||
if (!preview || preview.kind === 'missing') {
|
||||
return c.json({ error: 'File not found' }, 404)
|
||||
}
|
||||
return c.json(preview)
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.get('/files/:fileId/download', async (c) => {
|
||||
try {
|
||||
const resolved = await service.resolveProducedFileForDownload(
|
||||
c.req.param('fileId'),
|
||||
)
|
||||
if (!resolved) return c.json({ error: 'File not found' }, 404)
|
||||
|
||||
// Stream raw bytes via Bun's lazy file handle. Sets
|
||||
// Content-Disposition so browsers save instead of preview.
|
||||
const file = Bun.file(resolved.absolutePath)
|
||||
return new Response(file.stream(), {
|
||||
headers: {
|
||||
'Content-Type': resolved.mimeType,
|
||||
'Content-Length': String(resolved.size),
|
||||
'Content-Disposition': `attachment; ${encodeRfc6266Filename(resolved.fileName)}`,
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** Hard cap on `?limit=` for /agents/:id/files — guards against
|
||||
* a caller-supplied huge value forcing a per-agent table scan. */
|
||||
const MAX_FILES_LIMIT = 500
|
||||
|
||||
/**
|
||||
* Parse + clamp the `limit` query for /agents/:id/files. Returns
|
||||
* `undefined` when the param is absent or unparseable so the
|
||||
* service falls back to its own default.
|
||||
*/
|
||||
function parseAgentFilesLimit(
|
||||
raw: string | undefined,
|
||||
): { limit: number } | undefined {
|
||||
if (!raw) return undefined
|
||||
const parsed = Number.parseInt(raw, 10)
|
||||
if (!Number.isFinite(parsed)) return undefined
|
||||
return { limit: Math.min(Math.max(1, parsed), MAX_FILES_LIMIT) }
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 6266 / RFC 5987 filename attributes for `Content-Disposition`.
|
||||
* Returns the `filename="..."` attribute (always) plus a
|
||||
* percent-encoded `filename*=UTF-8''…` attribute when the name
|
||||
* contains non-ASCII characters, so browsers download with the
|
||||
* original name even on stricter HTTP clients.
|
||||
*/
|
||||
function encodeRfc6266Filename(filename: string): string {
|
||||
// Strip CRLFs and quotes (header injection guard).
|
||||
const safe = filename.replace(/["\r\n]/g, '_')
|
||||
// Detect non-ASCII; emit the RFC 5987 fallback attribute when
|
||||
// present. `encodeURIComponent` is the standard browser-safe
|
||||
// percent-encoder for this purpose.
|
||||
const hasNonAscii = /[^ -~]/.test(safe)
|
||||
if (!hasNonAscii) return `filename="${safe}"`
|
||||
return `filename="${safe}"; filename*=UTF-8''${encodeURIComponent(safe)}`
|
||||
}
|
||||
|
||||
function turnFramesToAgentEvents(
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import { zValidator } from '@hono/zod-validator'
|
||||
import { Hono } from 'hono'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
createSkill,
|
||||
deleteSkill,
|
||||
getSkill,
|
||||
listSkills,
|
||||
updateSkill,
|
||||
} from '../../skills/service'
|
||||
|
||||
const CreateSkillSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
description: z.string().min(1).max(500),
|
||||
content: z.string().min(1).max(50_000),
|
||||
})
|
||||
|
||||
const UpdateSkillSchema = z.object({
|
||||
name: z.string().min(1).max(100).optional(),
|
||||
description: z.string().min(1).max(500).optional(),
|
||||
content: z.string().max(50_000).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export function createSkillsRoutes() {
|
||||
return new Hono()
|
||||
.get('/', async (c) => {
|
||||
const skills = await listSkills()
|
||||
return c.json({ skills })
|
||||
})
|
||||
.get('/:id', async (c) => {
|
||||
const skill = await getSkill(c.req.param('id'))
|
||||
if (!skill) return c.json({ error: 'Skill not found' }, 404)
|
||||
return c.json({ skill })
|
||||
})
|
||||
.post('/', zValidator('json', CreateSkillSchema), async (c) => {
|
||||
try {
|
||||
const skill = await createSkill(c.req.valid('json'))
|
||||
return c.json({ skill }, 201)
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to create'
|
||||
return c.json({ error: msg }, 400)
|
||||
}
|
||||
})
|
||||
.put('/:id', zValidator('json', UpdateSkillSchema), async (c) => {
|
||||
try {
|
||||
const skill = await updateSkill(c.req.param('id'), c.req.valid('json'))
|
||||
return c.json({ skill })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to update'
|
||||
const status = msg.includes('not found') ? 404 : 500
|
||||
return c.json({ error: msg }, status)
|
||||
}
|
||||
})
|
||||
.delete('/:id', async (c) => {
|
||||
try {
|
||||
await deleteSkill(c.req.param('id'))
|
||||
return c.json({ ok: true })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to delete'
|
||||
const status = msg.includes('not found')
|
||||
? 404
|
||||
: msg.includes('built-in')
|
||||
? 403
|
||||
: 500
|
||||
return c.json({ error: msg }, status)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -37,7 +37,6 @@ import { createOpenClawRoutes } from './routes/openclaw'
|
||||
import { createProviderRoutes } from './routes/provider'
|
||||
import { createRefinePromptRoutes } from './routes/refine-prompt'
|
||||
import { createShutdownRoute } from './routes/shutdown'
|
||||
import { createSkillsRoutes } from './routes/skills'
|
||||
import { createSoulRoutes } from './routes/soul'
|
||||
import { createStatusRoute } from './routes/status'
|
||||
import { createTerminalRoutes } from './routes/terminal'
|
||||
@@ -46,7 +45,7 @@ import {
|
||||
connectKlavisInBackground,
|
||||
type KlavisProxyRef,
|
||||
} from './services/klavis/strata-proxy'
|
||||
import { OpenClawGatewayChatClient } from './services/openclaw/openclaw-gateway-chat-client'
|
||||
import { convertOpenClawHistoryToAgentHistory } from './services/openclaw/history-mapper'
|
||||
import { getOpenClawService } from './services/openclaw/openclaw-service'
|
||||
import type { Env, HttpServerConfig } from './types'
|
||||
import { defaultCorsConfig } from './utils/cors'
|
||||
@@ -137,16 +136,11 @@ export async function createHttpServer(config: HttpServerConfig) {
|
||||
browserosServerPort: port,
|
||||
browser,
|
||||
openclawGateway: {
|
||||
getGatewayToken: () => getOpenClawService().getGatewayToken(),
|
||||
getContainerName: () => OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
getLimaHomeDir: () => getLimaHomeDir(),
|
||||
getLimactlPath: () => resolveBundledLimactl(resourcesDir),
|
||||
getVmName: () => VM_NAME,
|
||||
},
|
||||
openclawGatewayChat: new OpenClawGatewayChatClient(
|
||||
() => getOpenClawService().getPort(),
|
||||
async () => getOpenClawService().getGatewayToken(),
|
||||
),
|
||||
openclawProvisioner: {
|
||||
createAgent: (input) => getOpenClawService().createAgent(input),
|
||||
removeAgent: (agentId) => getOpenClawService().removeAgent(agentId),
|
||||
@@ -159,6 +153,23 @@ export async function createHttpServer(config: HttpServerConfig) {
|
||||
}))
|
||||
},
|
||||
getStatus: () => getOpenClawService().getStatus(),
|
||||
getAgentHistory: async (agentId) => {
|
||||
// Aggregated across the agent's main + every sub-session
|
||||
// (cron / hook / channel) so autonomous turns surface in
|
||||
// the chat panel alongside user-initiated ones.
|
||||
const raw = await getOpenClawService().getSessionHistory(
|
||||
`agent:${agentId}:main`,
|
||||
)
|
||||
return convertOpenClawHistoryToAgentHistory(agentId, raw)
|
||||
},
|
||||
},
|
||||
onTurnLifecycle: (agent, event) => {
|
||||
if (agent.adapter !== 'openclaw') return
|
||||
getOpenClawService().recordAgentTurnEvent(
|
||||
agent.id,
|
||||
agent.sessionKey,
|
||||
event,
|
||||
)
|
||||
},
|
||||
}),
|
||||
)
|
||||
@@ -184,7 +195,6 @@ export async function createHttpServer(config: HttpServerConfig) {
|
||||
.route('/status', createStatusRoute({ browser }))
|
||||
.route('/soul', createSoulRoutes())
|
||||
.route('/memory', createMemoryRoutes())
|
||||
.route('/skills', createSkillsRoutes())
|
||||
.route('/monitoring', monitoringRoutes)
|
||||
.route('/acl-rules', aclRoutes)
|
||||
.route('/test-provider', createProviderRoutes({ browserosId }))
|
||||
|
||||
@@ -31,14 +31,26 @@ export {
|
||||
type QueuedMessageAttachment,
|
||||
} from '../../../lib/agents/message-queue'
|
||||
|
||||
import { basename } from 'node:path'
|
||||
import type {
|
||||
AgentHistoryPage,
|
||||
AgentRowSnapshot,
|
||||
AgentRuntime,
|
||||
AgentStreamEvent,
|
||||
} from '../../../lib/agents/types'
|
||||
import { getOpenClawDir } from '../../../lib/browseros-dir'
|
||||
import { logger } from '../../../lib/logger'
|
||||
import type { OpenClawGatewayChatClient } from '../openclaw/openclaw-gateway-chat-client'
|
||||
import {
|
||||
buildFilePreview,
|
||||
detectMimeType,
|
||||
type FilePreview,
|
||||
} from '../openclaw/file-preview'
|
||||
import { getHostWorkspaceDir } from '../openclaw/openclaw-env'
|
||||
import {
|
||||
type FileSnapshot,
|
||||
type ProducedFileRow,
|
||||
ProducedFilesStore,
|
||||
} from '../openclaw/produced-files-store'
|
||||
|
||||
export type AgentLiveness = 'working' | 'idle' | 'asleep' | 'error'
|
||||
|
||||
@@ -120,6 +132,15 @@ export interface OpenClawProvisioner {
|
||||
* gateway is not configured at all).
|
||||
*/
|
||||
getStatus?(): Promise<GatewayStatusSnapshot | null>
|
||||
/**
|
||||
* Optional. When wired, the harness uses this for `getHistory` on
|
||||
* openclaw-adapter agents so the chat panel sees autonomous
|
||||
* (cron / hook / channel) turns alongside user-typed turns. Without
|
||||
* this, history reads come from AcpxRuntime's local session record
|
||||
* which only contains user-initiated turns — autonomous activity
|
||||
* fires correctly but stays invisible to the panel.
|
||||
*/
|
||||
getAgentHistory?(agentId: string): Promise<AgentHistoryPage>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -152,12 +173,41 @@ export interface GatewayStatusSnapshot {
|
||||
| null
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-turn event the harness emits to subscribers. Lets services that
|
||||
* want to track liveness for a specific adapter (e.g. OpenClaw's
|
||||
* ClawSession dashboard) react to the same stream the chat panel sees,
|
||||
* without each adapter spawning its own gateway-side observer.
|
||||
*/
|
||||
export type TurnLifecycleEvent =
|
||||
| { type: 'turn_started' }
|
||||
| { type: 'turn_event'; event: AgentStreamEvent }
|
||||
| { type: 'turn_ended'; error?: string }
|
||||
|
||||
export type TurnLifecycleListener = (
|
||||
agent: {
|
||||
id: string
|
||||
adapter: AgentDefinition['adapter']
|
||||
sessionKey: string
|
||||
},
|
||||
event: TurnLifecycleEvent,
|
||||
) => void
|
||||
|
||||
export class AgentHarnessService {
|
||||
private readonly agentStore: AgentStore
|
||||
private readonly runtime: AgentRuntime
|
||||
private readonly openclawProvisioner: OpenClawProvisioner | null
|
||||
private readonly turnRegistry: TurnRegistry
|
||||
private readonly messageQueue: FileMessageQueue
|
||||
private readonly turnLifecycleListeners = new Set<TurnLifecycleListener>()
|
||||
/**
|
||||
* Lazy-initialised so tests that swap in a fake `agentStore` don't
|
||||
* eagerly hit `getDb()` (which throws when the test harness hasn't
|
||||
* called `initializeDb`). Tests that exercise file attribution can
|
||||
* inject an explicit store via `deps.producedFilesStore`.
|
||||
*/
|
||||
private explicitProducedFilesStore: ProducedFilesStore | null = null
|
||||
private cachedProducedFilesStore: ProducedFilesStore | null = null
|
||||
private inFlightReconcile: Promise<void> | null = null
|
||||
// In-memory liveness tracker. Lost on server restart (acceptable —
|
||||
// `lastUsedAt` survives via the acpx session record's `lastUsedAt`,
|
||||
@@ -174,10 +224,10 @@ export class AgentHarnessService {
|
||||
runtime?: AgentRuntime
|
||||
browserosServerPort?: number
|
||||
openclawGateway?: OpenclawGatewayAccessor
|
||||
openclawGatewayChat?: OpenClawGatewayChatClient
|
||||
openclawProvisioner?: OpenClawProvisioner
|
||||
turnRegistry?: TurnRegistry
|
||||
messageQueue?: FileMessageQueue
|
||||
producedFilesStore?: ProducedFilesStore
|
||||
} = {},
|
||||
) {
|
||||
this.agentStore = deps.agentStore ?? new DbAgentStore()
|
||||
@@ -186,11 +236,13 @@ export class AgentHarnessService {
|
||||
new AcpxRuntime({
|
||||
browserosServerPort: deps.browserosServerPort,
|
||||
openclawGateway: deps.openclawGateway,
|
||||
openclawGatewayChat: deps.openclawGatewayChat,
|
||||
})
|
||||
this.openclawProvisioner = deps.openclawProvisioner ?? null
|
||||
this.turnRegistry = deps.turnRegistry ?? new TurnRegistry()
|
||||
this.messageQueue = deps.messageQueue ?? new FileMessageQueue()
|
||||
if (deps.producedFilesStore) {
|
||||
this.explicitProducedFilesStore = deps.producedFilesStore
|
||||
}
|
||||
// Drain any agents whose queue file survived a restart. The check
|
||||
// for `getActiveFor` inside `maybeStartNextFromQueue` guards
|
||||
// against double-firing if the in-memory turn registry happens to
|
||||
@@ -314,6 +366,39 @@ export class AgentHarnessService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to turn lifecycle events for every running agent. Returns
|
||||
* an unsubscribe function. Listeners are best-effort: a throwing
|
||||
* listener does not break the turn.
|
||||
*/
|
||||
onTurnLifecycle(listener: TurnLifecycleListener): () => void {
|
||||
this.turnLifecycleListeners.add(listener)
|
||||
return () => this.turnLifecycleListeners.delete(listener)
|
||||
}
|
||||
|
||||
private emitTurnLifecycle(
|
||||
agent: AgentDefinition,
|
||||
event: TurnLifecycleEvent,
|
||||
): void {
|
||||
if (this.turnLifecycleListeners.size === 0) return
|
||||
const summary = {
|
||||
id: agent.id,
|
||||
adapter: agent.adapter,
|
||||
sessionKey: agent.sessionKey,
|
||||
}
|
||||
for (const listener of this.turnLifecycleListeners) {
|
||||
try {
|
||||
listener(summary, event)
|
||||
} catch (err) {
|
||||
logger.warn('Turn lifecycle listener threw', {
|
||||
agentId: agent.id,
|
||||
eventType: event.type,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Mark `agentId` as actively running a turn. */
|
||||
notifyTurnStarted(agentId: string): void {
|
||||
this.activity.set(agentId, { status: 'working', lastEventAt: Date.now() })
|
||||
@@ -599,9 +684,112 @@ export class AgentHarnessService {
|
||||
|
||||
async getHistory(agentId: string): Promise<AgentHistoryPage> {
|
||||
const agent = await this.requireAgent(agentId)
|
||||
// OpenClaw agents persist conversation in the gateway, not in the
|
||||
// AcpxRuntime's local session record. Reading the local record
|
||||
// would miss autonomous (cron / hook / channel) turns. Route
|
||||
// through the provisioner so the panel sees the full history.
|
||||
if (
|
||||
agent.adapter === 'openclaw' &&
|
||||
this.openclawProvisioner?.getAgentHistory
|
||||
) {
|
||||
return this.openclawProvisioner.getAgentHistory(agentId)
|
||||
}
|
||||
return this.runtime.getHistory({ agent, sessionId: 'main' })
|
||||
}
|
||||
|
||||
// ── Produced files (Files rail / inline artifact card) ───────────
|
||||
|
||||
/**
|
||||
* Outputs-rail data for one agent. Returns groups of files keyed
|
||||
* by the assistant turn that produced them, newest first. Empty
|
||||
* array when the agent hasn't produced anything yet, or when the
|
||||
* adapter doesn't track outputs (claude / codex — see Phase 2
|
||||
* commit).
|
||||
*/
|
||||
async listAgentFiles(
|
||||
agentId: string,
|
||||
options: { limit?: number } = {},
|
||||
): Promise<ProducedFilesRailGroup[]> {
|
||||
const agent = await this.requireAgent(agentId)
|
||||
const store = this.tryGetProducedFilesStore()
|
||||
if (!store) return []
|
||||
const rows = await store.listByAgent(agent.id, options)
|
||||
return store
|
||||
.groupByTurn(rows)
|
||||
.map(({ turnId, turnPrompt, createdAt, files }) => ({
|
||||
turnId,
|
||||
turnPrompt,
|
||||
createdAt,
|
||||
files: files.map(toProducedFileEntry),
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline-card data for one assistant turn. Used by the SSE
|
||||
* `produced_files` event consumer to refresh metadata after the
|
||||
* turn completes; also handy for direct fetches by clients that
|
||||
* missed the live event.
|
||||
*/
|
||||
async listAgentFilesForTurn(
|
||||
agentId: string,
|
||||
turnId: string,
|
||||
): Promise<ProducedFileEntry[]> {
|
||||
await this.requireAgent(agentId)
|
||||
const store = this.tryGetProducedFilesStore()
|
||||
if (!store) return []
|
||||
const rows = await store.listByTurn(turnId)
|
||||
return rows.map(toProducedFileEntry)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a preview payload for a single file. Returns null when the
|
||||
* file id is unknown OR the on-disk path no longer exists. The
|
||||
* route layer maps null → 404.
|
||||
*/
|
||||
async previewProducedFile(fileId: string): Promise<FilePreview | null> {
|
||||
const store = this.tryGetProducedFilesStore()
|
||||
if (!store) return null
|
||||
const row = await store.findById(fileId)
|
||||
if (!row) return null
|
||||
const agent = await this.agentStore.get(row.agentDefinitionId)
|
||||
if (!agent || agent.adapter !== 'openclaw') return null
|
||||
const workspaceDir = getHostWorkspaceDir(getOpenClawDir(), agent.name)
|
||||
const resolved = await store.resolveFilePath({ fileId, workspaceDir })
|
||||
if (!resolved) return null
|
||||
return buildFilePreview(resolved.absolutePath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a file id to an absolute on-disk path + metadata for the
|
||||
* download route to stream. Null when the file id is unknown or
|
||||
* the path escaped the workspace root (containment check happens
|
||||
* inside `producedFilesStore.resolveFilePath`).
|
||||
*/
|
||||
async resolveProducedFileForDownload(fileId: string): Promise<{
|
||||
absolutePath: string
|
||||
fileName: string
|
||||
mimeType: string
|
||||
size: number
|
||||
} | null> {
|
||||
const store = this.tryGetProducedFilesStore()
|
||||
if (!store) return null
|
||||
const row = await store.findById(fileId)
|
||||
if (!row) return null
|
||||
const agent = await this.agentStore.get(row.agentDefinitionId)
|
||||
if (!agent || agent.adapter !== 'openclaw') return null
|
||||
const workspaceDir = getHostWorkspaceDir(getOpenClawDir(), agent.name)
|
||||
const resolved = await store.resolveFilePath({ fileId, workspaceDir })
|
||||
if (!resolved) return null
|
||||
const mimeType = await detectMimeType(resolved.absolutePath)
|
||||
const fileName = basename(row.path)
|
||||
return {
|
||||
absolutePath: resolved.absolutePath,
|
||||
fileName,
|
||||
mimeType,
|
||||
size: row.size,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kick off a new agent turn that survives the caller's HTTP lifetime.
|
||||
* Events are pushed into a per-turn buffer; the returned `frames`
|
||||
@@ -627,6 +815,7 @@ export class AgentHarnessService {
|
||||
prompt: input.message,
|
||||
})
|
||||
this.notifyTurnStarted(agent.id)
|
||||
this.emitTurnLifecycle(agent, { type: 'turn_started' })
|
||||
|
||||
// Kick off the runtime call in the background. The per-turn
|
||||
// AbortController — NOT the HTTP request signal — is what cancels
|
||||
@@ -728,6 +917,26 @@ export class AgentHarnessService {
|
||||
const turn = this.turnRegistry.get(turnId)
|
||||
if (!turn) return
|
||||
let lastErrorMessage: string | undefined
|
||||
|
||||
// Bracket openclaw turns with a workspace snapshot so any file the
|
||||
// agent produces during the turn is attributable back to it (rail
|
||||
// + inline artifact UX). Adapter-gated for v1 — Claude / Codex
|
||||
// write to the user's host filesystem and don't need this; their
|
||||
// outputs are already visible via the user's own tools.
|
||||
const isOpenclaw = agent.adapter === 'openclaw'
|
||||
const workspaceDir = isOpenclaw ? this.resolveSafeWorkspaceDir(agent) : null
|
||||
const producedFilesStore = workspaceDir
|
||||
? this.tryGetProducedFilesStore()
|
||||
: null
|
||||
const workspaceSnapshot =
|
||||
workspaceDir && producedFilesStore
|
||||
? await this.snapshotWorkspaceForTurn(
|
||||
agent,
|
||||
workspaceDir,
|
||||
producedFilesStore,
|
||||
)
|
||||
: null
|
||||
|
||||
try {
|
||||
const upstream = await this.runtime.send({
|
||||
agent,
|
||||
@@ -746,6 +955,7 @@ export class AgentHarnessService {
|
||||
if (done) break
|
||||
if (value.type === 'error') lastErrorMessage = value.message
|
||||
this.turnRegistry.pushEvent(turnId, value)
|
||||
this.emitTurnLifecycle(agent, { type: 'turn_event', event: value })
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
@@ -782,10 +992,141 @@ export class AgentHarnessService {
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
// Attribute any files the agent produced during this turn. We
|
||||
// run on success, error, AND inside `finally` so an upstream
|
||||
// failure mid-turn that still managed to write files doesn't
|
||||
// lose them. We skip only when the user explicitly cancelled —
|
||||
// in that case the side effects shouldn't be surfaced as
|
||||
// "outputs you asked for."
|
||||
if (
|
||||
workspaceDir &&
|
||||
workspaceSnapshot !== null &&
|
||||
producedFilesStore &&
|
||||
!turn.abortController.signal.aborted
|
||||
) {
|
||||
await this.attributeTurnFiles({
|
||||
producedFilesStore,
|
||||
workspaceDir,
|
||||
before: workspaceSnapshot,
|
||||
agent,
|
||||
turnId,
|
||||
turnPrompt: input.message,
|
||||
})
|
||||
}
|
||||
this.notifyTurnEnded(agent.id, {
|
||||
ok: lastErrorMessage === undefined,
|
||||
error: lastErrorMessage,
|
||||
})
|
||||
this.emitTurnLifecycle(agent, {
|
||||
type: 'turn_ended',
|
||||
error: lastErrorMessage,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the host-side workspace dir for an openclaw agent,
|
||||
* returning `null` when the agent's display name fails the
|
||||
* path-traversal guard. Logs a warning so the safety-disabled
|
||||
* case is observable in production.
|
||||
*/
|
||||
private resolveSafeWorkspaceDir(agent: AgentDefinition): string | null {
|
||||
try {
|
||||
return getHostWorkspaceDir(getOpenClawDir(), agent.name)
|
||||
} catch (err) {
|
||||
logger.warn('Skipping openclaw file attribution: unsafe agent name', {
|
||||
agentId: agent.id,
|
||||
agentName: agent.name,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-turn workspace snapshot. Returns `null` on any failure so
|
||||
* the rest of the turn flow continues without file attribution.
|
||||
*/
|
||||
private async snapshotWorkspaceForTurn(
|
||||
agent: AgentDefinition,
|
||||
workspaceDir: string,
|
||||
producedFilesStore: ProducedFilesStore,
|
||||
): Promise<FileSnapshot | null> {
|
||||
try {
|
||||
return await producedFilesStore.snapshotWorkspace(workspaceDir)
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
'Failed to snapshot openclaw workspace; file attribution disabled for this turn',
|
||||
{
|
||||
agentId: agent.id,
|
||||
workspaceDir,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily resolve the produced-files store. Returns `null` if the
|
||||
* SQLite handle isn't initialised yet — keeps the harness usable in
|
||||
* tests + during early server boot, where chat turns are unlikely
|
||||
* but allowed.
|
||||
*/
|
||||
private tryGetProducedFilesStore(): ProducedFilesStore | null {
|
||||
if (this.explicitProducedFilesStore) return this.explicitProducedFilesStore
|
||||
if (this.cachedProducedFilesStore) return this.cachedProducedFilesStore
|
||||
try {
|
||||
this.cachedProducedFilesStore = new ProducedFilesStore()
|
||||
return this.cachedProducedFilesStore
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
'Produced-files store unavailable; turn-level file attribution disabled',
|
||||
{ error: err instanceof Error ? err.message : String(err) },
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Diff the workspace, persist new/modified files, and emit a
|
||||
* `produced_files` event so subscribers can render the inline
|
||||
* artifact card. Tolerant of all errors — a failure here must
|
||||
* never block the rest of the turn-end bookkeeping.
|
||||
*/
|
||||
private async attributeTurnFiles(input: {
|
||||
producedFilesStore: ProducedFilesStore
|
||||
workspaceDir: string
|
||||
before: FileSnapshot
|
||||
agent: AgentDefinition
|
||||
turnId: string
|
||||
turnPrompt: string
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const rows = await input.producedFilesStore.finalizeTurn({
|
||||
agentDefinitionId: input.agent.id,
|
||||
sessionKey: input.agent.sessionKey,
|
||||
turnId: input.turnId,
|
||||
turnPrompt: input.turnPrompt,
|
||||
workspaceDir: input.workspaceDir,
|
||||
before: input.before,
|
||||
})
|
||||
if (rows.length === 0) return
|
||||
this.turnRegistry.pushEvent(input.turnId, {
|
||||
type: 'produced_files',
|
||||
files: rows.map((row) => ({
|
||||
id: row.id,
|
||||
path: row.path,
|
||||
size: row.size,
|
||||
mtimeMs: row.mtimeMs,
|
||||
})),
|
||||
})
|
||||
} catch (err) {
|
||||
logger.warn('Failed to attribute produced files for turn', {
|
||||
agentId: input.agent.id,
|
||||
turnId: input.turnId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -860,3 +1201,38 @@ export class TurnAlreadyActiveError extends Error {
|
||||
this.name = 'TurnAlreadyActiveError'
|
||||
}
|
||||
}
|
||||
|
||||
// ── Files API DTO ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Wire shape for one produced-file entry returned by the rail and
|
||||
* inline-card endpoints. Trimmed from the on-disk row — clients
|
||||
* never see `agentDefinitionId` or `sessionKey`.
|
||||
*/
|
||||
export interface ProducedFileEntry {
|
||||
id: string
|
||||
path: string
|
||||
size: number
|
||||
mtimeMs: number
|
||||
createdAt: number
|
||||
detectedBy: 'diff' | 'tool'
|
||||
}
|
||||
|
||||
export interface ProducedFilesRailGroup {
|
||||
turnId: string
|
||||
/** First non-blank line of the user prompt that initiated this turn. */
|
||||
turnPrompt: string
|
||||
createdAt: number
|
||||
files: ProducedFileEntry[]
|
||||
}
|
||||
|
||||
function toProducedFileEntry(row: ProducedFileRow): ProducedFileEntry {
|
||||
return {
|
||||
id: row.id,
|
||||
path: row.path,
|
||||
size: row.size,
|
||||
mtimeMs: row.mtimeMs,
|
||||
createdAt: row.createdAt,
|
||||
detectedBy: row.detectedBy,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,6 @@ export type GatewayContainerSpec = {
|
||||
hostPort: number
|
||||
hostHome: string
|
||||
envFilePath: string
|
||||
gatewayToken?: string
|
||||
timezone: string
|
||||
}
|
||||
|
||||
@@ -414,9 +413,7 @@ export class ContainerRuntime {
|
||||
TZ: input.timezone,
|
||||
PATH: GATEWAY_PATH,
|
||||
NPM_CONFIG_PREFIX: GATEWAY_NPM_PREFIX,
|
||||
...(input.gatewayToken
|
||||
? { OPENCLAW_GATEWAY_TOKEN: input.gatewayToken }
|
||||
: {}),
|
||||
OPENCLAW_GATEWAY_PRIVATE_INGRESS_NO_AUTH: '1',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Helpers used by the `/claw/files/:id/preview` and
|
||||
* `/claw/files/:id/download` routes:
|
||||
*
|
||||
* - MIME-type detection (extension first, magic-byte fallback for
|
||||
* ambiguous extensions).
|
||||
* - Bounded text-snippet reader for inline previews.
|
||||
* - Image bytes reader for the rail's thumbnails.
|
||||
*
|
||||
* No streaming code lives here — the download route streams via Hono
|
||||
* directly. This module only handles the small in-memory reads the
|
||||
* preview UX needs.
|
||||
*/
|
||||
|
||||
import { open, stat } from 'node:fs/promises'
|
||||
import { extname } from 'node:path'
|
||||
|
||||
/** Hard cap on the inline text snippet returned by the preview API. */
|
||||
export const TEXT_PREVIEW_MAX_BYTES = 1 * 1024 * 1024 // 1 MB
|
||||
|
||||
/** Hard cap on inline image bytes returned as a base64 data URL. */
|
||||
export const IMAGE_PREVIEW_MAX_BYTES = 4 * 1024 * 1024 // 4 MB
|
||||
|
||||
const MIME_BY_EXTENSION: Record<string, string> = {
|
||||
'.txt': 'text/plain',
|
||||
'.md': 'text/markdown',
|
||||
'.markdown': 'text/markdown',
|
||||
'.json': 'application/json',
|
||||
'.jsonl': 'application/x-ndjson',
|
||||
'.csv': 'text/csv',
|
||||
'.tsv': 'text/tab-separated-values',
|
||||
'.xml': 'application/xml',
|
||||
'.yaml': 'application/yaml',
|
||||
'.yml': 'application/yaml',
|
||||
'.toml': 'application/toml',
|
||||
'.ini': 'text/plain',
|
||||
'.log': 'text/plain',
|
||||
'.html': 'text/html',
|
||||
'.htm': 'text/html',
|
||||
'.css': 'text/css',
|
||||
'.js': 'text/javascript',
|
||||
'.mjs': 'text/javascript',
|
||||
'.cjs': 'text/javascript',
|
||||
'.ts': 'text/typescript',
|
||||
'.tsx': 'text/typescript',
|
||||
'.jsx': 'text/javascript',
|
||||
'.py': 'text/x-python',
|
||||
'.rb': 'text/x-ruby',
|
||||
'.go': 'text/x-go',
|
||||
'.rs': 'text/x-rust',
|
||||
'.java': 'text/x-java',
|
||||
'.kt': 'text/x-kotlin',
|
||||
'.swift': 'text/x-swift',
|
||||
'.c': 'text/x-c',
|
||||
'.h': 'text/x-c',
|
||||
'.cpp': 'text/x-c++',
|
||||
'.hpp': 'text/x-c++',
|
||||
'.sh': 'application/x-sh',
|
||||
'.zsh': 'application/x-sh',
|
||||
'.bash': 'application/x-sh',
|
||||
'.sql': 'application/sql',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.bmp': 'image/bmp',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.ico': 'image/x-icon',
|
||||
'.heic': 'image/heic',
|
||||
'.heif': 'image/heif',
|
||||
'.pdf': 'application/pdf',
|
||||
'.zip': 'application/zip',
|
||||
'.tar': 'application/x-tar',
|
||||
'.gz': 'application/gzip',
|
||||
'.tgz': 'application/gzip',
|
||||
'.bz2': 'application/x-bzip2',
|
||||
'.7z': 'application/x-7z-compressed',
|
||||
'.mp3': 'audio/mpeg',
|
||||
'.wav': 'audio/wav',
|
||||
'.ogg': 'audio/ogg',
|
||||
'.mp4': 'video/mp4',
|
||||
'.webm': 'video/webm',
|
||||
'.mov': 'video/quicktime',
|
||||
'.docx':
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'.pptx':
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
}
|
||||
|
||||
/**
|
||||
* Magic-byte signatures for cases where the extension is missing or
|
||||
* misleading. Only covers the formats whose preview path differs from
|
||||
* the default binary path (text vs image vs PDF vs other).
|
||||
*/
|
||||
const MAGIC_BYTE_SIGNATURES: Array<{
|
||||
mime: string
|
||||
matches: (head: Uint8Array) => boolean
|
||||
}> = [
|
||||
{
|
||||
mime: 'image/png',
|
||||
matches: (h) =>
|
||||
h[0] === 0x89 &&
|
||||
h[1] === 0x50 &&
|
||||
h[2] === 0x4e &&
|
||||
h[3] === 0x47 &&
|
||||
h[4] === 0x0d &&
|
||||
h[5] === 0x0a,
|
||||
},
|
||||
{
|
||||
mime: 'image/jpeg',
|
||||
matches: (h) => h[0] === 0xff && h[1] === 0xd8 && h[2] === 0xff,
|
||||
},
|
||||
{
|
||||
mime: 'image/gif',
|
||||
matches: (h) =>
|
||||
h[0] === 0x47 && h[1] === 0x49 && h[2] === 0x46 && h[3] === 0x38,
|
||||
},
|
||||
{
|
||||
mime: 'image/webp',
|
||||
matches: (h) =>
|
||||
h[0] === 0x52 &&
|
||||
h[1] === 0x49 &&
|
||||
h[2] === 0x46 &&
|
||||
h[3] === 0x46 &&
|
||||
h[8] === 0x57 &&
|
||||
h[9] === 0x45 &&
|
||||
h[10] === 0x42 &&
|
||||
h[11] === 0x50,
|
||||
},
|
||||
{
|
||||
mime: 'application/pdf',
|
||||
matches: (h) =>
|
||||
h[0] === 0x25 && h[1] === 0x50 && h[2] === 0x44 && h[3] === 0x46,
|
||||
},
|
||||
]
|
||||
|
||||
const MAGIC_BYTE_PROBE_LEN = 12
|
||||
|
||||
/**
|
||||
* Best-effort MIME detection. Tries the extension map first, then
|
||||
* falls back to magic-byte sniffing for the formats whose preview
|
||||
* path differs from the default binary handling. Returns
|
||||
* `application/octet-stream` when we can't tell.
|
||||
*/
|
||||
export async function detectMimeType(absolutePath: string): Promise<string> {
|
||||
const fromExtension = MIME_BY_EXTENSION[extname(absolutePath).toLowerCase()]
|
||||
if (fromExtension) return fromExtension
|
||||
|
||||
let head: Uint8Array
|
||||
try {
|
||||
const handle = await open(absolutePath, 'r')
|
||||
try {
|
||||
const buffer = new Uint8Array(MAGIC_BYTE_PROBE_LEN)
|
||||
const { bytesRead } = await handle.read(
|
||||
buffer,
|
||||
0,
|
||||
MAGIC_BYTE_PROBE_LEN,
|
||||
0,
|
||||
)
|
||||
head = buffer.subarray(0, bytesRead)
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
} catch {
|
||||
return 'application/octet-stream'
|
||||
}
|
||||
|
||||
for (const sig of MAGIC_BYTE_SIGNATURES) {
|
||||
if (sig.matches(head)) return sig.mime
|
||||
}
|
||||
|
||||
if (looksLikeText(head)) return 'text/plain'
|
||||
return 'application/octet-stream'
|
||||
}
|
||||
|
||||
export type PreviewKind = 'text' | 'image' | 'pdf' | 'binary' | 'missing'
|
||||
|
||||
export interface BasePreview {
|
||||
kind: PreviewKind
|
||||
mimeType: string
|
||||
size: number
|
||||
mtimeMs: number
|
||||
}
|
||||
|
||||
export interface TextPreview extends BasePreview {
|
||||
kind: 'text'
|
||||
snippet: string
|
||||
/** True when the on-disk file is larger than `TEXT_PREVIEW_MAX_BYTES`. */
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
export interface ImagePreview extends BasePreview {
|
||||
kind: 'image'
|
||||
/** Base64 data URL (incl. `data:` prefix) suitable for `<img src>`. */
|
||||
dataUrl: string
|
||||
}
|
||||
|
||||
export interface PdfPreview extends BasePreview {
|
||||
kind: 'pdf'
|
||||
}
|
||||
|
||||
export interface BinaryPreview extends BasePreview {
|
||||
kind: 'binary'
|
||||
}
|
||||
|
||||
export interface MissingPreview {
|
||||
kind: 'missing'
|
||||
}
|
||||
|
||||
export type FilePreview =
|
||||
| TextPreview
|
||||
| ImagePreview
|
||||
| PdfPreview
|
||||
| BinaryPreview
|
||||
| MissingPreview
|
||||
|
||||
/**
|
||||
* Build a preview payload for the inline-card / rail preview Sheet.
|
||||
* Reads at most `TEXT_PREVIEW_MAX_BYTES` (text) or
|
||||
* `IMAGE_PREVIEW_MAX_BYTES` (image) into memory; everything else
|
||||
* returns a metadata-only `binary` preview and the UI offers a
|
||||
* download instead.
|
||||
*/
|
||||
export async function buildFilePreview(
|
||||
absolutePath: string,
|
||||
): Promise<FilePreview> {
|
||||
let stats: Awaited<ReturnType<typeof stat>>
|
||||
try {
|
||||
stats = await stat(absolutePath)
|
||||
} catch {
|
||||
return { kind: 'missing' }
|
||||
}
|
||||
|
||||
const mimeType = await detectMimeType(absolutePath)
|
||||
const base = {
|
||||
mimeType,
|
||||
size: stats.size,
|
||||
mtimeMs: stats.mtimeMs,
|
||||
} as const
|
||||
|
||||
if (mimeType === 'application/pdf') {
|
||||
return { kind: 'pdf', ...base }
|
||||
}
|
||||
|
||||
if (isTextMime(mimeType)) {
|
||||
return readTextPreview(absolutePath, base)
|
||||
}
|
||||
|
||||
if (isImageMime(mimeType)) {
|
||||
return readImagePreview(absolutePath, base)
|
||||
}
|
||||
|
||||
return { kind: 'binary', ...base }
|
||||
}
|
||||
|
||||
async function readTextPreview(
|
||||
absolutePath: string,
|
||||
base: { mimeType: string; size: number; mtimeMs: number },
|
||||
): Promise<TextPreview> {
|
||||
const handle = await open(absolutePath, 'r')
|
||||
try {
|
||||
const length = Math.min(base.size, TEXT_PREVIEW_MAX_BYTES)
|
||||
const buffer = new Uint8Array(length)
|
||||
const { bytesRead } = await handle.read(buffer, 0, length, 0)
|
||||
const snippet = new TextDecoder('utf-8', { fatal: false }).decode(
|
||||
buffer.subarray(0, bytesRead),
|
||||
)
|
||||
return {
|
||||
kind: 'text',
|
||||
...base,
|
||||
snippet,
|
||||
truncated: base.size > TEXT_PREVIEW_MAX_BYTES,
|
||||
}
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
|
||||
async function readImagePreview(
|
||||
absolutePath: string,
|
||||
base: { mimeType: string; size: number; mtimeMs: number },
|
||||
): Promise<ImagePreview | BinaryPreview> {
|
||||
if (base.size > IMAGE_PREVIEW_MAX_BYTES) {
|
||||
// Too big to inline — let the user download.
|
||||
return { kind: 'binary', ...base }
|
||||
}
|
||||
const handle = await open(absolutePath, 'r')
|
||||
try {
|
||||
const buffer = new Uint8Array(base.size)
|
||||
await handle.read(buffer, 0, base.size, 0)
|
||||
const dataUrl = `data:${base.mimeType};base64,${Buffer.from(buffer).toString('base64')}`
|
||||
return { kind: 'image', ...base, dataUrl }
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
|
||||
function isTextMime(mime: string): boolean {
|
||||
if (mime.startsWith('text/')) return true
|
||||
return (
|
||||
mime === 'application/json' ||
|
||||
mime === 'application/x-ndjson' ||
|
||||
mime === 'application/xml' ||
|
||||
mime === 'application/yaml' ||
|
||||
mime === 'application/toml' ||
|
||||
mime === 'application/sql' ||
|
||||
mime === 'application/x-sh'
|
||||
)
|
||||
}
|
||||
|
||||
function isImageMime(mime: string): boolean {
|
||||
return mime.startsWith('image/') && mime !== 'image/svg+xml'
|
||||
// SVG is text — let it go through the text path so users can read
|
||||
// markup, not view a base64 blob.
|
||||
}
|
||||
|
||||
/**
|
||||
* Crude text-vs-binary heuristic for files whose extension and magic
|
||||
* bytes both fail to identify them. Counts NUL bytes — text files
|
||||
* essentially never contain them; binaries usually do.
|
||||
*/
|
||||
function looksLikeText(head: Uint8Array): boolean {
|
||||
if (head.length === 0) return true
|
||||
let nulCount = 0
|
||||
for (const byte of head) {
|
||||
if (byte === 0) nulCount += 1
|
||||
}
|
||||
return nulCount === 0
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Converts an aggregated OpenClaw session history (rich content blocks
|
||||
* across the agent's main + sub-sessions) into the flat AgentHistoryPage
|
||||
* shape the chat panel consumes.
|
||||
*
|
||||
* Input: OpenClawSessionHistory.messages — each message has `content`
|
||||
* that is either a string OR an array of typed blocks
|
||||
* ({type: 'text'|'thinking'|'toolCall'|'toolResult'}). The HTTP endpoint
|
||||
* returns the array form even though the type definition says string.
|
||||
*
|
||||
* Output: AgentHistoryEntry[] — flat text per entry, separate `reasoning`
|
||||
* and `toolCalls` fields the UI renders as collapsible sections.
|
||||
*
|
||||
* Tool result pairing: `toolCall` blocks emit on assistant messages;
|
||||
* the matching `toolResult` arrives in a later message (typically with
|
||||
* role 'tool' or 'toolResult'). We pair them by `toolCallId` so the
|
||||
* resulting AgentHistoryToolCall has both input and output.
|
||||
*/
|
||||
|
||||
import { unwrapBrowserosAcpUserMessage } from '../../../lib/agents/acpx-runtime'
|
||||
import type {
|
||||
AgentHistoryEntry,
|
||||
AgentHistoryToolCall,
|
||||
} from '../../../lib/agents/agent-types'
|
||||
import type { AgentHistoryPage } from '../../../lib/agents/types'
|
||||
import type {
|
||||
OpenClawSessionHistory,
|
||||
OpenClawSessionHistoryMessage,
|
||||
} from './openclaw-http-client'
|
||||
|
||||
const CRON_PROMPT_PREFIX_PATTERN =
|
||||
/^\[cron:[0-9a-f-]+ ([^\]]+)\]\s*([\s\S]*?)\n*Current time:[^\n]*(?:\n[\s\S]*)?$/
|
||||
const CRON_DELIVERY_TRAILER =
|
||||
/\n*Use the message tool if you need to notify the user directly[\s\S]*$/
|
||||
const QUEUED_MARKER_LINE =
|
||||
/^\[Queued user message that arrived while the previous turn was still active\]\s*$/m
|
||||
const SUBAGENT_CONTEXT_PREFIX = /^\[Subagent Context\][\s\S]*$/
|
||||
// Emitted by OpenClaw's acp-cli ahead of the BrowserOS envelope. Three
|
||||
// prefix shapes (any combination, in this stack order):
|
||||
//
|
||||
// 1. `[media attached: <internal-path> (<mime>)]` ← per attachment
|
||||
// 2. `[<weekday> <YYYY-MM-DD HH:MM> <TZ>]` ← injectTimestamp
|
||||
// 3. `[Working directory: <path>]` ← acp-cli prefixCwd
|
||||
//
|
||||
// Stacks #1 may appear multiple times (one per image). Stack #2 and #3
|
||||
// can render on the same line separated by a space. Each known prefix is
|
||||
// anchored on its content shape (not just `[…]`) to avoid clobbering
|
||||
// user-typed lines that happen to start with a bracket.
|
||||
const OPENCLAW_MEDIA_PREFIX_LINE = /^\[media attached:[^\]\n]*\]\n/
|
||||
const OPENCLAW_TIMESTAMP_PREFIX =
|
||||
/^\[(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) \d{4}-\d{2}-\d{2} \d{2}:\d{2}[^\]\n]*\][ \t]*/
|
||||
const OPENCLAW_WORKDIR_PREFIX = /^\[Working directory: [^\]\n]*\]\n+/
|
||||
|
||||
function stripOpenClawAcpCliEnvelope(value: string): string {
|
||||
let s = value
|
||||
while (OPENCLAW_MEDIA_PREFIX_LINE.test(s)) {
|
||||
s = s.replace(OPENCLAW_MEDIA_PREFIX_LINE, '')
|
||||
}
|
||||
s = s.replace(OPENCLAW_TIMESTAMP_PREFIX, '')
|
||||
s = s.replace(OPENCLAW_WORKDIR_PREFIX, '')
|
||||
return s
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip OpenClaw + BrowserOS scaffolding from a "user" message before
|
||||
* showing it in the chat panel.
|
||||
*
|
||||
* BrowserOS-side envelope (`<role>…</role>\n\n<user_request>…</user_request>`)
|
||||
* is delegated to `unwrapBrowserosAcpUserMessage`, which performs an
|
||||
* exact-string match against the same constants `buildBrowserosAcpPrompt`
|
||||
* uses to wrap. Matcher and wrapper live in the same repo, so the two
|
||||
* always travel together.
|
||||
*
|
||||
* OpenClaw's acp-cli prepends a `[Working directory: <path>]\n\n` line
|
||||
* before the BrowserOS envelope (see /app/dist/acp-cli-*.js, line 1361).
|
||||
* We strip that single line up-front so the `^<role>` anchor in
|
||||
* `unwrapBrowserosAcpUserMessage` matches.
|
||||
*
|
||||
* OpenClaw-injected scaffolding (cron prefix, queued-marker, subagent
|
||||
* context) is still pattern-matched here. Removing those requires either
|
||||
* an OpenClaw schema change exposing the structured trigger payload, or a
|
||||
* BrowserOS-side side-channel (cache cron payloads on `cron.add` and look
|
||||
* up by jobId). Tracked as the next cleanup; until then this is best-
|
||||
* effort with text-level patterns.
|
||||
*/
|
||||
export function cleanHistoryUserText(raw: string): string {
|
||||
if (!raw) return raw
|
||||
// Queued-marker case: this is structurally a multi-message blob, so
|
||||
// split first and recurse into each chunk. We keep the join character
|
||||
// narrow (single newline) so e.g. five cron payloads render as five
|
||||
// visually-separate lines rather than one wall of text.
|
||||
if (QUEUED_MARKER_LINE.test(raw)) {
|
||||
const chunks = raw
|
||||
.split(
|
||||
/^\[Queued user message that arrived while the previous turn was still active\]\s*$/m,
|
||||
)
|
||||
.map((chunk) => cleanSingleUserMessage(chunk))
|
||||
.filter((chunk) => chunk.length > 0)
|
||||
return chunks.join('\n')
|
||||
}
|
||||
return cleanSingleUserMessage(raw)
|
||||
}
|
||||
|
||||
function cleanSingleUserMessage(raw: string): string {
|
||||
const trimmed = raw.trim()
|
||||
if (!trimmed) return ''
|
||||
// Subagent context seed: pure scaffolding, drop entirely. The real
|
||||
// task lives in the subagent's system prompt; the user-message body
|
||||
// is just framing the model never produced.
|
||||
if (SUBAGENT_CONTEXT_PREFIX.test(trimmed)) {
|
||||
return ''
|
||||
}
|
||||
const cronMatch = CRON_PROMPT_PREFIX_PATTERN.exec(trimmed)
|
||||
if (cronMatch) {
|
||||
const payload = cronMatch[2] ?? ''
|
||||
return payload.replace(CRON_DELIVERY_TRAILER, '').trim()
|
||||
}
|
||||
// Strip OpenClaw's acp-cli envelope (media-attached lines + timestamp
|
||||
// + workdir) before delegating, so the BrowserOS unwrap helper's
|
||||
// `^<role>` anchor matches.
|
||||
const withoutEnvelope = stripOpenClawAcpCliEnvelope(trimmed)
|
||||
return unwrapBrowserosAcpUserMessage(withoutEnvelope).trim()
|
||||
}
|
||||
|
||||
type RichBlock =
|
||||
| { type: 'text'; text?: string }
|
||||
| { type: 'thinking'; thinking?: string; text?: string }
|
||||
| {
|
||||
type: 'toolCall'
|
||||
id?: string
|
||||
toolCallId?: string
|
||||
name?: string
|
||||
arguments?: unknown
|
||||
}
|
||||
| {
|
||||
type: 'toolResult'
|
||||
toolCallId?: string
|
||||
content?: unknown
|
||||
isError?: boolean
|
||||
}
|
||||
| { type: string; [key: string]: unknown }
|
||||
|
||||
// We hold the AgentHistoryToolCall reference itself in `pending` so a
|
||||
// later `toolResult` block mutates the same object that was already
|
||||
// pushed onto the assistant entry's `toolCalls` array.
|
||||
type PendingToolCall = AgentHistoryToolCall
|
||||
|
||||
export function convertOpenClawHistoryToAgentHistory(
|
||||
agentId: string,
|
||||
raw: OpenClawSessionHistory,
|
||||
): AgentHistoryPage {
|
||||
const items: AgentHistoryEntry[] = []
|
||||
// Resolved tool calls keyed by toolCallId — used to attach `output`
|
||||
// back to the assistant entry that issued the call once the tool
|
||||
// result arrives in a subsequent message.
|
||||
const pendingByToolCallId = new Map<string, PendingToolCall>()
|
||||
|
||||
let entryCounter = 0
|
||||
const nextId = () => `${agentId}:hist:${entryCounter++}`
|
||||
|
||||
for (const message of raw.messages) {
|
||||
const blocks = normalizeBlocks(message)
|
||||
const role = normalizeRole(message.role)
|
||||
|
||||
if (!role) {
|
||||
// 'system' / 'tool' messages aren't shown as their own chat entries;
|
||||
// tool results get folded into the assistant entry they complete.
|
||||
if (message.role === 'tool') {
|
||||
applyToolResults(blocks, pendingByToolCallId)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const rawText = collectText(blocks).trim()
|
||||
const text = role === 'user' ? cleanHistoryUserText(rawText) : rawText
|
||||
const reasoningText = collectThinking(blocks).trim()
|
||||
const toolCallEntries = collectToolCalls(blocks, pendingByToolCallId)
|
||||
|
||||
// Skip empty entries. Two cases:
|
||||
// - User: cleaner returned empty after stripping scaffolding (e.g.
|
||||
// dropped Subagent Context message). No bubble to render.
|
||||
// - Assistant: model returned only thinking blocks (common with
|
||||
// MiniMax `thinking: minimal` for trivial prompts) and no text
|
||||
// or tools. The empty bubble + dangling reasoning collapsible
|
||||
// reads as broken UI; cleaner to drop the turn entirely.
|
||||
if (!text && toolCallEntries.length === 0) continue
|
||||
|
||||
const entry: AgentHistoryEntry = {
|
||||
id: message.messageId ?? nextId(),
|
||||
agentId,
|
||||
sessionId: 'main',
|
||||
role,
|
||||
text,
|
||||
createdAt: message.timestamp ?? 0,
|
||||
}
|
||||
if (reasoningText) {
|
||||
entry.reasoning = { text: reasoningText }
|
||||
}
|
||||
if (toolCallEntries.length > 0) {
|
||||
entry.toolCalls = toolCallEntries
|
||||
}
|
||||
|
||||
items.push(entry)
|
||||
}
|
||||
|
||||
return {
|
||||
agentId,
|
||||
sessionId: 'main',
|
||||
items,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBlocks(message: OpenClawSessionHistoryMessage): RichBlock[] {
|
||||
const content = (message as { content: unknown }).content
|
||||
if (typeof content === 'string') {
|
||||
return content ? [{ type: 'text', text: content }] : []
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
return content as RichBlock[]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function normalizeRole(
|
||||
role: OpenClawSessionHistoryMessage['role'],
|
||||
): 'user' | 'assistant' | null {
|
||||
if (role === 'user' || role === 'assistant') return role
|
||||
return null
|
||||
}
|
||||
|
||||
function collectText(blocks: RichBlock[]): string {
|
||||
const parts: string[] = []
|
||||
for (const block of blocks) {
|
||||
if (block.type === 'text' && typeof block.text === 'string') {
|
||||
parts.push(block.text)
|
||||
}
|
||||
}
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
function collectThinking(blocks: RichBlock[]): string {
|
||||
const parts: string[] = []
|
||||
for (const block of blocks) {
|
||||
if (block.type === 'thinking') {
|
||||
const value =
|
||||
typeof block.thinking === 'string'
|
||||
? block.thinking
|
||||
: typeof block.text === 'string'
|
||||
? block.text
|
||||
: ''
|
||||
if (value) parts.push(value)
|
||||
}
|
||||
}
|
||||
return parts.join('\n\n')
|
||||
}
|
||||
|
||||
function collectToolCalls(
|
||||
blocks: RichBlock[],
|
||||
pending: Map<string, PendingToolCall>,
|
||||
): AgentHistoryToolCall[] {
|
||||
const out: AgentHistoryToolCall[] = []
|
||||
for (const block of blocks) {
|
||||
if (block.type !== 'toolCall') continue
|
||||
const callId =
|
||||
typeof block.toolCallId === 'string'
|
||||
? block.toolCallId
|
||||
: typeof block.id === 'string'
|
||||
? block.id
|
||||
: undefined
|
||||
if (!callId) continue
|
||||
const toolName = typeof block.name === 'string' ? block.name : 'unknown'
|
||||
const entry: AgentHistoryToolCall = {
|
||||
toolCallId: callId,
|
||||
toolName,
|
||||
status: 'completed',
|
||||
input: block.arguments,
|
||||
}
|
||||
out.push(entry)
|
||||
// Hold the same reference so a later toolResult mutates the entry
|
||||
// already pushed onto the assistant's toolCalls array.
|
||||
pending.set(callId, entry)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function applyToolResults(
|
||||
blocks: RichBlock[],
|
||||
pending: Map<string, PendingToolCall>,
|
||||
): void {
|
||||
for (const block of blocks) {
|
||||
if (block.type !== 'toolResult') continue
|
||||
const callId =
|
||||
typeof block.toolCallId === 'string' ? block.toolCallId : undefined
|
||||
if (!callId) continue
|
||||
const entry = pending.get(callId)
|
||||
if (!entry) continue
|
||||
if (block.isError) {
|
||||
entry.status = 'failed'
|
||||
entry.error =
|
||||
typeof block.content === 'string'
|
||||
? block.content
|
||||
: JSON.stringify(block.content)
|
||||
} else {
|
||||
entry.output = block.content
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,40 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { join } from 'node:path'
|
||||
import { join, relative, resolve, sep } from 'node:path'
|
||||
|
||||
const STATE_DIR_NAME = '.openclaw'
|
||||
|
||||
/**
|
||||
* Path-traversal guard for `agent.name` before it gets joined into
|
||||
* the host workspace directory. The name is user-supplied at
|
||||
* agent-create time, and `path.join` happily resolves `..` /
|
||||
* absolute segments — so a name like `../../tmp` would point the
|
||||
* workspace at the user's home directory, the harness's pre-turn
|
||||
* snapshot would walk it, and `produced_files` rows would point at
|
||||
* arbitrary host paths that subsequent download / preview routes
|
||||
* would then serve as "agent outputs".
|
||||
*
|
||||
* Reject anything that isn't a flat, single-segment name composed
|
||||
* of safe filename characters. The check is intentionally
|
||||
* conservative — agent names are short slugs in practice.
|
||||
*/
|
||||
export function isAgentWorkspaceNameSafe(name: string): boolean {
|
||||
if (typeof name !== 'string') return false
|
||||
const trimmed = name.trim()
|
||||
if (trimmed === '' || trimmed === '.' || trimmed === '..') return false
|
||||
// No path separators, no NULs, no control chars (charCode < 0x20).
|
||||
for (let i = 0; i < trimmed.length; i++) {
|
||||
const code = trimmed.charCodeAt(i)
|
||||
if (code < 0x20) return false
|
||||
}
|
||||
if (/[\\/]/.test(trimmed)) return false
|
||||
// No `..` segments and no leading dot (avoid hidden / dotfile escapes).
|
||||
if (trimmed.startsWith('.')) return false
|
||||
if (trimmed.includes('..')) return false
|
||||
return true
|
||||
}
|
||||
|
||||
export function getOpenClawStateDir(openclawDir: string): string {
|
||||
return join(openclawDir, STATE_DIR_NAME)
|
||||
}
|
||||
@@ -24,10 +54,27 @@ export function getHostWorkspaceDir(
|
||||
openclawDir: string,
|
||||
agentName: string,
|
||||
): string {
|
||||
return join(
|
||||
getOpenClawStateDir(openclawDir),
|
||||
if (agentName !== 'main' && !isAgentWorkspaceNameSafe(agentName)) {
|
||||
throw new Error(
|
||||
`Refusing to compute workspace dir for unsafe agent name: ${agentName}`,
|
||||
)
|
||||
}
|
||||
const stateDir = getOpenClawStateDir(openclawDir)
|
||||
const candidate = resolve(
|
||||
stateDir,
|
||||
agentName === 'main' ? 'workspace' : `workspace-${agentName}`,
|
||||
)
|
||||
// Defensive containment check: even with a safe-looking name the
|
||||
// resolved path must live under the state dir. If it doesn't,
|
||||
// refuse rather than return a path the caller would then trust.
|
||||
const stateDirResolved = resolve(stateDir)
|
||||
const rel = relative(stateDirResolved, candidate)
|
||||
if (rel === '' || rel.startsWith('..') || rel.startsWith(`..${sep}`)) {
|
||||
throw new Error(
|
||||
`Resolved workspace dir escapes openclaw state dir: ${candidate}`,
|
||||
)
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
export function mergeEnvContent(
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Minimal OpenAI-compatible chat client against the OpenClaw gateway.
|
||||
* Used exclusively by the harness's image carve-out: when the user
|
||||
* attaches images to an OpenClaw agent, the harness diverts the turn
|
||||
* here instead of through the ACP bridge (which silently drops image
|
||||
* content blocks). The gateway's `/v1/chat/completions` endpoint
|
||||
* accepts OpenAI-style multimodal `image_url` parts.
|
||||
*
|
||||
* Output is normalized to `AgentStreamEvent` so the rest of the harness
|
||||
* pipeline (UI streaming, history persistence) doesn't care that the
|
||||
* transport is HTTP rather than ACP for this turn.
|
||||
*/
|
||||
|
||||
import type { AgentStreamEvent } from '../../../lib/agents/types'
|
||||
import { logger } from '../../../lib/logger'
|
||||
|
||||
export type OpenAIContentPart =
|
||||
| { type: 'text'; text: string }
|
||||
| { type: 'image_url'; image_url: { url: string } }
|
||||
|
||||
export interface OpenAIChatMessage {
|
||||
role: 'system' | 'user' | 'assistant'
|
||||
content: string | OpenAIContentPart[]
|
||||
}
|
||||
|
||||
export interface GatewayChatTurnInput {
|
||||
/** Gateway-side agent name. Equal to the harness id post Step 9 backfill. */
|
||||
agentId: string
|
||||
sessionKey: string
|
||||
messages: OpenAIChatMessage[]
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export class OpenClawGatewayChatClient {
|
||||
constructor(
|
||||
private readonly getHostPort: () => number,
|
||||
private readonly getToken: () => Promise<string>,
|
||||
) {}
|
||||
|
||||
async streamTurn(
|
||||
input: GatewayChatTurnInput,
|
||||
): Promise<ReadableStream<AgentStreamEvent>> {
|
||||
const token = await this.getToken()
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${this.getHostPort()}/v1/chat/completions`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: resolveAgentModel(input.agentId),
|
||||
stream: true,
|
||||
messages: input.messages,
|
||||
user: `browseros:${input.agentId}:${input.sessionKey}`,
|
||||
}),
|
||||
signal: input.signal,
|
||||
},
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => '')
|
||||
throw new Error(
|
||||
detail || `OpenClaw gateway chat failed with status ${response.status}`,
|
||||
)
|
||||
}
|
||||
const body = response.body
|
||||
if (!body) {
|
||||
throw new Error('OpenClaw gateway chat response had no body')
|
||||
}
|
||||
|
||||
return new ReadableStream<AgentStreamEvent>({
|
||||
start(controller) {
|
||||
void pumpOpenAIChunks(body, controller, input.signal)
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAgentModel(agentId: string): string {
|
||||
// The gateway routes `openclaw` → its default `main` provider config,
|
||||
// and `openclaw/<agentId>` → the per-agent provider config. Backfilled
|
||||
// legacy agents (`main`, orphans) can use the unprefixed form.
|
||||
return agentId === 'main' ? 'openclaw' : `openclaw/${agentId}`
|
||||
}
|
||||
|
||||
async function pumpOpenAIChunks(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
controller: ReadableStreamDefaultController<AgentStreamEvent>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const reader = body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
let closed = false
|
||||
let aborted = false
|
||||
let stopReason: string | undefined
|
||||
// Re-emit explicit signal aborts as a clean cancel rather than letting
|
||||
// the underlying `reader.read()` reject — keeps the controller in a
|
||||
// sensible state if the caller bails (e.g. tab close).
|
||||
const onAbort = () => {
|
||||
aborted = true
|
||||
void reader.cancel().catch(() => {})
|
||||
}
|
||||
signal?.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
const flushLine = (line: string) => {
|
||||
if (closed || !line.startsWith('data:')) return
|
||||
const payload = line.slice(5).trim()
|
||||
if (!payload || payload === '[DONE]') {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(payload)
|
||||
} catch {
|
||||
controller.enqueue({
|
||||
type: 'error',
|
||||
message: 'Failed to parse OpenClaw gateway chunk',
|
||||
})
|
||||
finish()
|
||||
return
|
||||
}
|
||||
const text = extractDeltaText(parsed)
|
||||
if (text) {
|
||||
controller.enqueue({
|
||||
type: 'text_delta',
|
||||
text,
|
||||
stream: 'output',
|
||||
rawType: 'agent_message_chunk',
|
||||
})
|
||||
}
|
||||
const finishReason = extractFinishReason(parsed)
|
||||
if (finishReason) {
|
||||
stopReason = finishReason === 'stop' ? 'end_turn' : finishReason
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
const finish = () => {
|
||||
if (closed) return
|
||||
closed = true
|
||||
controller.enqueue({ type: 'done', stopReason: stopReason ?? 'end_turn' })
|
||||
controller.close()
|
||||
}
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
if (aborted) {
|
||||
if (!closed) {
|
||||
closed = true
|
||||
controller.close()
|
||||
}
|
||||
return
|
||||
}
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
let idx = buffer.indexOf('\n\n')
|
||||
while (idx >= 0) {
|
||||
const event = buffer.slice(0, idx)
|
||||
buffer = buffer.slice(idx + 2)
|
||||
for (const line of event.split('\n')) flushLine(line)
|
||||
if (closed) return
|
||||
idx = buffer.indexOf('\n\n')
|
||||
}
|
||||
}
|
||||
if (!closed) {
|
||||
// Stream ended without an explicit [DONE]. Treat as natural end.
|
||||
finish()
|
||||
}
|
||||
} catch (err) {
|
||||
if (closed || aborted) return
|
||||
logger.warn('OpenClaw gateway chat stream errored', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
controller.enqueue({
|
||||
type: 'error',
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
closed = true
|
||||
controller.close()
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', onAbort)
|
||||
reader.releaseLock()
|
||||
}
|
||||
}
|
||||
|
||||
interface OpenAIStreamChunk {
|
||||
choices?: Array<{
|
||||
delta?: { content?: unknown }
|
||||
finish_reason?: string | null
|
||||
}>
|
||||
}
|
||||
|
||||
function extractDeltaText(value: unknown): string {
|
||||
const chunk = value as OpenAIStreamChunk
|
||||
const content = chunk?.choices?.[0]?.delta?.content
|
||||
return typeof content === 'string' ? content : ''
|
||||
}
|
||||
|
||||
function extractFinishReason(value: unknown): string | null {
|
||||
const chunk = value as OpenAIStreamChunk
|
||||
return chunk?.choices?.[0]?.finish_reason ?? null
|
||||
}
|
||||
@@ -44,6 +44,24 @@ export interface OpenClawSessionHistoryMessage {
|
||||
messageId?: string
|
||||
messageSeq?: number
|
||||
timestamp?: number
|
||||
/**
|
||||
* OpenClaw extension envelope. The gateway records the per-session
|
||||
* monotonic sequence on `__openclaw.seq` rather than the top-level
|
||||
* `messageSeq` field, so cursor logic reads from here. `id` is the
|
||||
* gateway's stable message id.
|
||||
*/
|
||||
__openclaw?: { id?: string; seq?: number }
|
||||
/**
|
||||
* Origin of this message when the response merges multiple sessions.
|
||||
* Absent on single-session responses for backward compatibility.
|
||||
*/
|
||||
source?: 'main' | 'cron' | 'hook' | 'channel' | 'other'
|
||||
/**
|
||||
* The session key this message originated from. Differs from the
|
||||
* top-level `sessionKey` when sub-sessions (e.g. cron runs) are merged
|
||||
* into a parent agent's main-session response.
|
||||
*/
|
||||
subSessionKey?: string
|
||||
}
|
||||
|
||||
export interface OpenClawSessionHistory {
|
||||
@@ -74,10 +92,7 @@ export type OpenClawSessionHistoryEvent =
|
||||
| { type: 'error'; data: { message: string } }
|
||||
|
||||
export class OpenClawHttpClient {
|
||||
constructor(
|
||||
private readonly hostPort: number,
|
||||
private readonly getToken: () => Promise<string>,
|
||||
) {}
|
||||
constructor(private readonly hostPort: number) {}
|
||||
|
||||
async getSessionHistory(
|
||||
sessionKey: string,
|
||||
@@ -103,15 +118,9 @@ export class OpenClawHttpClient {
|
||||
|
||||
async isAuthenticated(): Promise<boolean> {
|
||||
try {
|
||||
const token = await this.getToken()
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${this.hostPort}/v1/models`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
{ method: 'GET' },
|
||||
)
|
||||
return response.ok
|
||||
} catch {
|
||||
@@ -124,15 +133,11 @@ export class OpenClawHttpClient {
|
||||
input: OpenClawSessionHistoryInput,
|
||||
extraHeaders: Record<string, string>,
|
||||
): Promise<Response> {
|
||||
const token = await this.getToken()
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${this.hostPort}${buildHistoryPath(sessionKey, input)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
...extraHeaders,
|
||||
},
|
||||
headers: extraHeaders,
|
||||
signal: input.signal,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,276 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Connects to the OpenClaw gateway's WebSocket control plane and pipes
|
||||
* chat broadcast events into a ClawSession state machine. The observer
|
||||
* is a transport layer only — it handles the WS connection lifecycle
|
||||
* (connect, handshake, reconnect) and delegates all state management
|
||||
* to ClawSession.
|
||||
*/
|
||||
|
||||
import WebSocket from 'ws'
|
||||
import { logger } from '../../../lib/logger'
|
||||
import type { ClawSession } from './claw-session'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Protocol types (subset of OpenClaw gateway protocol v3)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PROTOCOL_VERSION = 3
|
||||
const HANDSHAKE_REQUEST_ID = 'connect'
|
||||
const RECONNECT_DELAY_MS = 5_000
|
||||
const CONNECT_TIMEOUT_MS = 10_000
|
||||
|
||||
interface RequestFrame {
|
||||
type: 'req'
|
||||
id: string
|
||||
method: string
|
||||
params: Record<string, unknown>
|
||||
}
|
||||
|
||||
type IncomingFrame =
|
||||
| { type: 'res'; id: string; ok: true; payload?: unknown }
|
||||
| {
|
||||
type: 'res'
|
||||
id: string
|
||||
ok: false
|
||||
error: { code: string; message: string }
|
||||
}
|
||||
| { type: 'event'; event: string; payload?: unknown }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Observer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class OpenClawObserver {
|
||||
private ws: WebSocket | null = null
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private connected = false
|
||||
private closed = false
|
||||
private gatewayUrl: string | null = null
|
||||
private gatewayToken: string | null = null
|
||||
|
||||
constructor(private readonly session: ClawSession) {}
|
||||
|
||||
/** Start observing the gateway at the given URL with the given token. */
|
||||
connect(gatewayUrl: string, token: string): void {
|
||||
this.gatewayUrl = gatewayUrl
|
||||
this.gatewayToken = token
|
||||
this.closed = false
|
||||
this.doConnect()
|
||||
}
|
||||
|
||||
/** Stop observing and close the WebSocket. */
|
||||
disconnect(): void {
|
||||
this.closed = true
|
||||
this.clearReconnect()
|
||||
if (this.ws) {
|
||||
try {
|
||||
this.ws.close()
|
||||
} catch {}
|
||||
this.ws = null
|
||||
}
|
||||
this.connected = false
|
||||
}
|
||||
|
||||
/** Whether the observer has an active WS connection. */
|
||||
isConnected(): boolean {
|
||||
return this.connected
|
||||
}
|
||||
|
||||
// ── Private ─────────────────────────────────────────────────────────
|
||||
|
||||
private doConnect(): void {
|
||||
if (this.closed || !this.gatewayUrl || !this.gatewayToken) return
|
||||
|
||||
const wsUrl = this.gatewayUrl
|
||||
.replace(/^http:\/\//, 'ws://')
|
||||
.replace(/^https:\/\//, 'wss://')
|
||||
|
||||
logger.debug('OpenClaw observer connecting', { url: wsUrl })
|
||||
|
||||
const ws = new WebSocket(wsUrl)
|
||||
this.ws = ws
|
||||
|
||||
const connectTimeout = setTimeout(() => {
|
||||
logger.warn('OpenClaw observer handshake timeout')
|
||||
ws.terminate()
|
||||
}, CONNECT_TIMEOUT_MS)
|
||||
|
||||
let handshakeSent = false
|
||||
|
||||
ws.on('message', (raw) => {
|
||||
let frame: IncomingFrame
|
||||
try {
|
||||
frame = JSON.parse(raw.toString('utf8')) as IncomingFrame
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
// The gateway sends a connect.challenge event before accepting
|
||||
// the connect request. Send the handshake after receiving it.
|
||||
if (
|
||||
frame.type === 'event' &&
|
||||
frame.event === 'connect.challenge' &&
|
||||
!handshakeSent
|
||||
) {
|
||||
handshakeSent = true
|
||||
const connectReq: RequestFrame = {
|
||||
type: 'req',
|
||||
id: HANDSHAKE_REQUEST_ID,
|
||||
method: 'connect',
|
||||
params: {
|
||||
minProtocol: PROTOCOL_VERSION,
|
||||
maxProtocol: PROTOCOL_VERSION,
|
||||
client: {
|
||||
id: 'openclaw-tui',
|
||||
displayName: 'browseros-observer',
|
||||
version: '1.0.0',
|
||||
platform: 'node',
|
||||
mode: 'ui',
|
||||
},
|
||||
role: 'operator',
|
||||
scopes: ['operator.read'],
|
||||
auth: { token: this.gatewayToken },
|
||||
},
|
||||
}
|
||||
ws.send(JSON.stringify(connectReq))
|
||||
return
|
||||
}
|
||||
|
||||
// Handshake response
|
||||
if (frame.type === 'res' && frame.id === HANDSHAKE_REQUEST_ID) {
|
||||
clearTimeout(connectTimeout)
|
||||
if (frame.ok) {
|
||||
this.connected = true
|
||||
logger.info('OpenClaw observer connected')
|
||||
} else {
|
||||
logger.warn('OpenClaw observer handshake failed', {
|
||||
error: frame.error,
|
||||
})
|
||||
ws.close()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Broadcast events (only process after handshake completes)
|
||||
if (frame.type === 'event' && this.connected) {
|
||||
this.handleEvent(frame.event, frame.payload)
|
||||
}
|
||||
})
|
||||
|
||||
ws.on('close', () => {
|
||||
clearTimeout(connectTimeout)
|
||||
this.connected = false
|
||||
this.ws = null
|
||||
|
||||
// Reset any agents stuck in "working" to "unknown" — we missed
|
||||
// the final/end event because the WS closed mid-task. The
|
||||
// ClawSession will re-infer correct state from JSONL when the
|
||||
// observer reconnects and ensureObserverConnected() re-seeds.
|
||||
for (const [agentId, state] of this.session.getAllStates()) {
|
||||
if (state.status === 'working') {
|
||||
this.session.transition(agentId, 'unknown')
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.closed) {
|
||||
logger.debug('OpenClaw observer disconnected, scheduling reconnect')
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
})
|
||||
|
||||
ws.on('error', (err) => {
|
||||
clearTimeout(connectTimeout)
|
||||
logger.debug('OpenClaw observer WS error', {
|
||||
message: err.message,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private handleEvent(eventName: string, payload: unknown): void {
|
||||
if (eventName === 'chat') {
|
||||
this.handleChatEvent(payload)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a gateway chat broadcast event and transition the ClawSession
|
||||
* state machine accordingly.
|
||||
*/
|
||||
private handleChatEvent(payload: unknown): void {
|
||||
if (!payload || typeof payload !== 'object') return
|
||||
const p = payload as Record<string, unknown>
|
||||
|
||||
const sessionKey = typeof p.sessionKey === 'string' ? p.sessionKey : null
|
||||
const state = typeof p.state === 'string' ? p.state : null
|
||||
|
||||
if (!sessionKey || !state) return
|
||||
|
||||
const agentId = extractAgentId(sessionKey)
|
||||
if (!agentId) return
|
||||
|
||||
if (state === 'delta' || state === 'streaming') {
|
||||
this.session.transition(agentId, 'working', {
|
||||
sessionKey,
|
||||
currentTool: extractToolName(p),
|
||||
})
|
||||
} else if (state === 'final' || state === 'end') {
|
||||
this.session.transition(agentId, 'idle', { sessionKey })
|
||||
} else if (state === 'error') {
|
||||
const errorMsg =
|
||||
typeof p.errorMessage === 'string'
|
||||
? p.errorMessage
|
||||
: typeof p.error === 'string'
|
||||
? p.error
|
||||
: 'Unknown error'
|
||||
this.session.transition(agentId, 'error', { sessionKey, error: errorMsg })
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
this.clearReconnect()
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null
|
||||
this.doConnect()
|
||||
}, RECONNECT_DELAY_MS)
|
||||
}
|
||||
|
||||
private clearReconnect(): void {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Extract agentId from an OpenClaw session key.
|
||||
* Format: "agent:<agentId>:..." — we take the segment after "agent:".
|
||||
*/
|
||||
function extractAgentId(sessionKey: string): string | null {
|
||||
if (!sessionKey.startsWith('agent:')) return null
|
||||
const colonIdx = sessionKey.indexOf(':', 6)
|
||||
if (colonIdx === -1) return sessionKey.slice(6)
|
||||
return sessionKey.slice(6, colonIdx)
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to extract a tool name from a chat event payload.
|
||||
*/
|
||||
function extractToolName(payload: Record<string, unknown>): string | null {
|
||||
if (typeof payload.toolName === 'string') return payload.toolName
|
||||
if (typeof payload.tool === 'string') return payload.tool
|
||||
const content = payload.content
|
||||
if (content && typeof content === 'object' && 'name' in content) {
|
||||
const name = (content as Record<string, unknown>).name
|
||||
if (typeof name === 'string') return name
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
OPENCLAW_IMAGE,
|
||||
} from '@browseros/shared/constants/openclaw'
|
||||
import { DEFAULT_PORTS } from '@browseros/shared/constants/ports'
|
||||
import type { AgentStreamEvent } from '../../../lib/agents/types'
|
||||
import { getOpenClawDir } from '../../../lib/browseros-dir'
|
||||
import { logger } from '../../../lib/logger'
|
||||
import { withProcessLock } from '../../../lib/process-lock'
|
||||
@@ -40,6 +41,7 @@ import {
|
||||
type OpenClawAgentRecord,
|
||||
OpenClawCliClient,
|
||||
type OpenClawConfigBatchEntry,
|
||||
type OpenClawSessionEntry,
|
||||
} from './openclaw-cli-client'
|
||||
import {
|
||||
buildOpenClawCliProviderModelRef,
|
||||
@@ -61,8 +63,8 @@ import {
|
||||
OpenClawHttpClient,
|
||||
type OpenClawSessionHistory,
|
||||
type OpenClawSessionHistoryEvent,
|
||||
type OpenClawSessionHistoryMessage,
|
||||
} from './openclaw-http-client'
|
||||
import { OpenClawObserver } from './openclaw-observer'
|
||||
import {
|
||||
type ResolvedOpenClawProviderConfig,
|
||||
resolveSupportedOpenClawProvider,
|
||||
@@ -234,6 +236,104 @@ function getOpenClawBrowserOSSessionPrefix(agentId: string): string {
|
||||
return `agent:${agentId}:openai-user:browseros:${agentId}:`
|
||||
}
|
||||
|
||||
const MAIN_SESSION_KEY_PATTERN = /^agent:([^:]+):main$/
|
||||
|
||||
/**
|
||||
* Extract the agent id from a main-session key (e.g. `agent:research:main`
|
||||
* → `research`). Returns null when the key isn't a top-level main session,
|
||||
* which signals the caller to use the per-session fetch path.
|
||||
*/
|
||||
function extractAgentIdFromMainSessionKey(sessionKey: string): string | null {
|
||||
const match = MAIN_SESSION_KEY_PATTERN.exec(sessionKey)
|
||||
return match?.[1] ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a session key by its source. The pattern is `agent:<id>:<kind>:...`;
|
||||
* the third segment identifies how the session was started.
|
||||
*/
|
||||
function parseSessionSource(
|
||||
sessionKey: string,
|
||||
): NonNullable<OpenClawSessionHistoryMessage['source']> {
|
||||
const parts = sessionKey.split(':')
|
||||
if (parts[0] !== 'agent' || parts.length < 3) return 'other'
|
||||
switch (parts[2]) {
|
||||
case 'main':
|
||||
return 'main'
|
||||
case 'cron':
|
||||
return 'cron'
|
||||
case 'hook':
|
||||
return 'hook'
|
||||
case 'channel':
|
||||
return 'channel'
|
||||
default:
|
||||
return 'other'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-session monotonic sequence. Gateway encodes it inside the
|
||||
* `__openclaw` extension envelope; the legacy top-level `messageSeq`
|
||||
* field exists in the type but is rarely populated.
|
||||
*/
|
||||
function resolveMessageSeq(msg: OpenClawSessionHistoryMessage): number | null {
|
||||
const fromEnvelope = msg.__openclaw?.seq
|
||||
if (typeof fromEnvelope === 'number' && Number.isFinite(fromEnvelope)) {
|
||||
return fromEnvelope
|
||||
}
|
||||
if (typeof msg.messageSeq === 'number' && Number.isFinite(msg.messageSeq)) {
|
||||
return msg.messageSeq
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable chronological order across sessions. Falls back to seq
|
||||
* when timestamps tie or are missing, preserving intra-session order.
|
||||
*/
|
||||
function compareMessageOrder(
|
||||
a: OpenClawSessionHistoryMessage,
|
||||
b: OpenClawSessionHistoryMessage,
|
||||
): number {
|
||||
const aTs = a.timestamp ?? 0
|
||||
const bTs = b.timestamp ?? 0
|
||||
if (aTs !== bTs) return aTs - bTs
|
||||
return (resolveMessageSeq(a) ?? 0) - (resolveMessageSeq(b) ?? 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compound cursor for the aggregated history endpoint. Maps each
|
||||
* session key to either:
|
||||
* - a `messageSeq` to fetch BEFORE on the next page (more historical),
|
||||
* - or `null` meaning the session is exhausted and should be skipped.
|
||||
*
|
||||
* Encoded as base64url JSON for URL-safe transport in `?cursor=`.
|
||||
*/
|
||||
type CompoundCursor = Record<string, number | null>
|
||||
|
||||
function decodeCompoundCursor(encoded: string | undefined): CompoundCursor {
|
||||
if (!encoded) return {}
|
||||
try {
|
||||
const json = Buffer.from(encoded, 'base64url').toString('utf8')
|
||||
const parsed = JSON.parse(json)
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
const out: CompoundCursor = {}
|
||||
for (const [k, v] of Object.entries(parsed)) {
|
||||
if (typeof v === 'number' || v === null) out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
} catch {
|
||||
// Malformed cursors are treated as "first page" — preferable to
|
||||
// erroring out the entire history fetch on a bad client cursor.
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
function encodeCompoundCursor(cursor: CompoundCursor): string {
|
||||
return Buffer.from(JSON.stringify(cursor), 'utf8').toString('base64url')
|
||||
}
|
||||
|
||||
export interface AgentOverview {
|
||||
agentId: string
|
||||
status: AgentLiveStatus
|
||||
@@ -260,8 +360,6 @@ export class OpenClawService {
|
||||
private httpClient: OpenClawHttpClient
|
||||
private openclawDir: string
|
||||
private hostPort = OPENCLAW_GATEWAY_CONTAINER_PORT
|
||||
private token: string
|
||||
private tokenLoaded = false
|
||||
private lastError: string | null = null
|
||||
private browserosServerPort: number
|
||||
private resourcesDir: string | null
|
||||
@@ -272,7 +370,6 @@ export class OpenClawService {
|
||||
private stopLogTail: (() => void) | null = null
|
||||
private lifecycleLock: Promise<void> = Promise.resolve()
|
||||
private clawSession = new ClawSession()
|
||||
private observer = new OpenClawObserver(this.clawSession)
|
||||
|
||||
constructor(config: OpenClawServiceConfig = {}) {
|
||||
this.openclawDir = getOpenClawDir()
|
||||
@@ -281,13 +378,9 @@ export class OpenClawService {
|
||||
projectDir: this.openclawDir,
|
||||
browserosRoot: config.browserosDir,
|
||||
})
|
||||
this.token = crypto.randomUUID()
|
||||
this.cliClient = new OpenClawCliClient(this.runtime)
|
||||
this.bootstrapCliClient = this.buildBootstrapCliClient()
|
||||
this.httpClient = new OpenClawHttpClient(
|
||||
this.hostPort,
|
||||
async () => this.token,
|
||||
)
|
||||
this.httpClient = new OpenClawHttpClient(this.hostPort)
|
||||
this.browserosServerPort =
|
||||
config.browserosServerPort ?? DEFAULT_PORTS.server
|
||||
this.resourcesDir = config.resourcesDir ?? null
|
||||
@@ -323,19 +416,6 @@ export class OpenClawService {
|
||||
return this.hostPort
|
||||
}
|
||||
|
||||
/**
|
||||
* Current gateway auth token. The token string is loaded from
|
||||
* `gateway.auth.token` in the persisted openclaw.json during setup,
|
||||
* with a freshly generated UUID as fallback. Exposed so the ACPx
|
||||
* harness can pass it to spawned `openclaw acp` child processes via
|
||||
* the documented `OPENCLAW_GATEWAY_TOKEN` env var (avoids both the
|
||||
* `--token` process-listing leak and reliance on a token-file path
|
||||
* that doesn't exist as a discrete file inside the container).
|
||||
*/
|
||||
getGatewayToken(): string {
|
||||
return this.token
|
||||
}
|
||||
|
||||
/** Subscribe to real-time agent status changes from the ClawSession state machine. */
|
||||
onAgentStatusChange(
|
||||
listener: (agentId: string, state: AgentSessionState) => void,
|
||||
@@ -348,6 +428,70 @@ export class OpenClawService {
|
||||
return this.clawSession.getState(agentId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive the live-status state machine from a turn lifecycle event the
|
||||
* AgentHarnessService observed. Replaces the previous WS observer
|
||||
* pipeline that re-tapped the same gateway events; the harness already
|
||||
* sees them as ACP `session/update` notifications, so we forward those
|
||||
* here. Caller passes the stream events verbatim.
|
||||
*
|
||||
* `tool_call` and `tool_call_update` populate `currentTool` so the
|
||||
* dashboard SSE keeps its existing payload shape. `done` clears
|
||||
* working state to `idle`; `error` keeps a sticky error badge.
|
||||
*/
|
||||
recordAgentTurnEvent(
|
||||
agentId: string,
|
||||
sessionKey: string,
|
||||
event:
|
||||
| { type: 'turn_started' }
|
||||
| { type: 'turn_event'; event: AgentStreamEvent }
|
||||
| { type: 'turn_ended'; error?: string },
|
||||
): void {
|
||||
if (event.type === 'turn_started') {
|
||||
this.clawSession.transition(agentId, 'working', { sessionKey })
|
||||
return
|
||||
}
|
||||
if (event.type === 'turn_ended') {
|
||||
if (event.error !== undefined) {
|
||||
this.clawSession.transition(agentId, 'error', {
|
||||
sessionKey,
|
||||
error: event.error,
|
||||
})
|
||||
} else {
|
||||
this.clawSession.transition(agentId, 'idle', { sessionKey })
|
||||
}
|
||||
return
|
||||
}
|
||||
const inner = event.event
|
||||
if (inner.type === 'tool_call') {
|
||||
this.clawSession.transition(agentId, 'working', {
|
||||
sessionKey,
|
||||
currentTool: inner.title ?? null,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (inner.type === 'error') {
|
||||
this.clawSession.transition(agentId, 'error', {
|
||||
sessionKey,
|
||||
error: inner.message,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (inner.type === 'done') {
|
||||
this.clawSession.transition(agentId, 'idle', { sessionKey })
|
||||
return
|
||||
}
|
||||
if (inner.type === 'text_delta') {
|
||||
// Heartbeat — keep the existing `working` row fresh; preserve
|
||||
// the last-known currentTool by passing it through.
|
||||
const prev = this.clawSession.getState(agentId)
|
||||
this.clawSession.transition(agentId, 'working', {
|
||||
sessionKey,
|
||||
currentTool: prev.currentTool,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Lifecycle ────────────────────────────────────────────────────────
|
||||
|
||||
/** Warm the VM and gateway image so later setup/start avoids registry work. */
|
||||
@@ -394,14 +538,13 @@ export class OpenClawService {
|
||||
providerKeyCount: Object.keys(provider.envValues).length,
|
||||
})
|
||||
|
||||
await this.refreshGatewayAuthToken()
|
||||
await this.ensureGatewayPortAllocated(logProgress)
|
||||
|
||||
logProgress('Bootstrapping OpenClaw config...')
|
||||
await this.bootstrapCliClient.runOnboard({
|
||||
acceptRisk: true,
|
||||
authChoice: 'skip',
|
||||
gatewayAuth: 'token',
|
||||
gatewayAuth: 'none',
|
||||
gatewayBind: 'lan',
|
||||
gatewayPort: OPENCLAW_GATEWAY_CONTAINER_PORT,
|
||||
installDaemon: false,
|
||||
@@ -418,8 +561,6 @@ export class OpenClawService {
|
||||
logProgress('Validating OpenClaw config...')
|
||||
await this.assertConfigValid(this.bootstrapCliClient)
|
||||
|
||||
await this.refreshGatewayAuthToken()
|
||||
|
||||
logProgress('Starting OpenClaw gateway...')
|
||||
await this.runtime.startGateway(
|
||||
this.buildGatewayRuntimeSpec(),
|
||||
@@ -478,8 +619,6 @@ export class OpenClawService {
|
||||
|
||||
await this.runtime.ensureReady(logProgress)
|
||||
|
||||
logProgress('Refreshing gateway auth token...')
|
||||
await this.refreshGatewayAuthToken()
|
||||
await this.ensureStateEnvFile()
|
||||
|
||||
await this.ensureGatewayPortAllocated(logProgress)
|
||||
@@ -533,7 +672,6 @@ export class OpenClawService {
|
||||
return this.withLifecycleLock('stop', async () => {
|
||||
logger.info('Stopping OpenClaw service', { hostPort: this.hostPort })
|
||||
this.controlPlaneStatus = 'disconnected'
|
||||
this.observer.disconnect()
|
||||
this.stopGatewayLogTail()
|
||||
await this.runtime.stopGateway()
|
||||
logger.info('OpenClaw container stopped')
|
||||
@@ -550,8 +688,6 @@ export class OpenClawService {
|
||||
this.controlPlaneStatus = 'reconnecting'
|
||||
await this.runtime.ensureReady(logProgress)
|
||||
this.stopGatewayLogTail()
|
||||
logProgress('Refreshing gateway auth token...')
|
||||
await this.refreshGatewayAuthToken()
|
||||
await this.ensureStateEnvFile()
|
||||
await this.ensureGatewayPortAllocated(logProgress)
|
||||
logProgress('Restarting OpenClaw gateway...')
|
||||
@@ -596,8 +732,6 @@ export class OpenClawService {
|
||||
throw new Error('OpenClaw gateway is not ready')
|
||||
}
|
||||
|
||||
logProgress('Reloading gateway auth token...')
|
||||
await this.refreshGatewayAuthToken()
|
||||
this.controlPlaneStatus = 'reconnecting'
|
||||
logProgress('Reconnecting control plane...')
|
||||
await this.runControlPlaneCall(() => this.cliClient.probe())
|
||||
@@ -607,7 +741,6 @@ export class OpenClawService {
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
this.controlPlaneStatus = 'disconnected'
|
||||
this.observer.disconnect()
|
||||
this.stopGatewayLogTail()
|
||||
try {
|
||||
await this.runtime.stopGateway()
|
||||
@@ -794,9 +927,155 @@ export class OpenClawService {
|
||||
input: { limit?: number; cursor?: string; signal?: AbortSignal } = {},
|
||||
): Promise<OpenClawSessionHistory> {
|
||||
await this.assertGatewayReady()
|
||||
return this.runControlPlaneCall(() =>
|
||||
this.httpClient.getSessionHistory(sessionKey, input),
|
||||
return this.runControlPlaneCall(async () => {
|
||||
const agentId = extractAgentIdFromMainSessionKey(sessionKey)
|
||||
if (!agentId) {
|
||||
return this.httpClient.getSessionHistory(sessionKey, input)
|
||||
}
|
||||
return this.fetchAggregatedAgentHistory(sessionKey, agentId, input)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates the agent's main session and every sub-session (cron,
|
||||
* hook, channel) into a single chronological response. The main
|
||||
* session's own messages are included; each sub-session's messages
|
||||
* are tagged with `source` and `subSessionKey` so the UI can
|
||||
* distinguish autonomous turns from user-driven turns.
|
||||
*
|
||||
* Pagination uses a compound cursor that encodes a per-session seq
|
||||
* for each session in scope (`{<sessionKey>: seq | null}`). Each page
|
||||
* fetches each non-exhausted session with its own per-session cursor,
|
||||
* merges messages across sessions by timestamp, slices to `limit`,
|
||||
* and emits a fresh compound cursor reflecting where each session
|
||||
* should resume on the next page. A session with `null` in the
|
||||
* cursor is exhausted and skipped.
|
||||
*
|
||||
* Sub-session fetches that fail are logged and dropped — partial
|
||||
* timelines are preferable to a hard failure that hides the main
|
||||
* session.
|
||||
*/
|
||||
private async fetchAggregatedAgentHistory(
|
||||
mainSessionKey: string,
|
||||
agentId: string,
|
||||
input: { limit?: number; cursor?: string; signal?: AbortSignal },
|
||||
): Promise<OpenClawSessionHistory> {
|
||||
const compoundIn = decodeCompoundCursor(input.cursor)
|
||||
const sessions = await this.cliClient
|
||||
.listSessions(agentId)
|
||||
.catch((err): OpenClawSessionEntry[] => {
|
||||
logger.warn(
|
||||
'Failed to list OpenClaw sub-sessions; falling back to main only',
|
||||
{ agentId, error: err instanceof Error ? err.message : String(err) },
|
||||
)
|
||||
return []
|
||||
})
|
||||
|
||||
// Build the candidate set from the agent's session directory plus
|
||||
// the main key (which may not appear in `sessions.list` if the file
|
||||
// hasn't been written yet for a fresh agent).
|
||||
const targetKeys = new Set<string>([mainSessionKey])
|
||||
for (const entry of sessions) {
|
||||
if (entry.key?.startsWith(`agent:${agentId}:`)) {
|
||||
targetKeys.add(entry.key)
|
||||
}
|
||||
}
|
||||
|
||||
// Only fetch sessions that aren't exhausted by the inbound cursor.
|
||||
// A session with `null` in the cursor is fully read; skip it on
|
||||
// subsequent pages.
|
||||
const activeKeys = Array.from(targetKeys).filter(
|
||||
(k) => compoundIn[k] !== null,
|
||||
)
|
||||
|
||||
const fetchedHistories = await Promise.all(
|
||||
activeKeys.map(async (key) => {
|
||||
const sessionCursor = compoundIn[key]
|
||||
try {
|
||||
const history = await this.httpClient.getSessionHistory(key, {
|
||||
limit: input.limit,
|
||||
cursor:
|
||||
typeof sessionCursor === 'number'
|
||||
? String(sessionCursor)
|
||||
: undefined,
|
||||
signal: input.signal,
|
||||
})
|
||||
return { key, history }
|
||||
} catch (err) {
|
||||
logger.warn('Failed to fetch OpenClaw sub-session history', {
|
||||
sessionKey: key,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
return null
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
type Annotated = OpenClawSessionHistoryMessage & { __sessionKey: string }
|
||||
const merged: Annotated[] = []
|
||||
let truncated = false
|
||||
for (const result of fetchedHistories) {
|
||||
if (!result) continue
|
||||
const source = parseSessionSource(result.key)
|
||||
const isMain = result.key === mainSessionKey
|
||||
for (const msg of result.history.messages) {
|
||||
merged.push({
|
||||
...msg,
|
||||
source,
|
||||
...(isMain ? {} : { subSessionKey: result.key }),
|
||||
__sessionKey: result.key,
|
||||
})
|
||||
}
|
||||
if (result.history.truncated) truncated = true
|
||||
}
|
||||
|
||||
merged.sort(compareMessageOrder)
|
||||
|
||||
// The merged window contains the latest portion fetched. We emit
|
||||
// up to `limit` messages from the END (newest), and compute the
|
||||
// resume position for each session as the seq of the EARLIEST
|
||||
// emitted message that came from that session.
|
||||
const limited =
|
||||
typeof input.limit === 'number' && input.limit > 0
|
||||
? merged.slice(-input.limit)
|
||||
: merged
|
||||
|
||||
const compoundOut: CompoundCursor = {}
|
||||
// Carry forward exhausted sessions so subsequent pages keep skipping them.
|
||||
for (const key of Array.from(targetKeys)) {
|
||||
if (compoundIn[key] === null) {
|
||||
compoundOut[key] = null
|
||||
}
|
||||
}
|
||||
for (const result of fetchedHistories) {
|
||||
if (!result) continue
|
||||
const key = result.key
|
||||
const earliestEmitted = limited.find((m) => m.__sessionKey === key)
|
||||
const sessionFetchHasMore = Boolean(result.history.hasMore)
|
||||
const droppedFromMerge =
|
||||
result.history.messages.length >
|
||||
limited.filter((m) => m.__sessionKey === key).length
|
||||
const sessionHasMore = sessionFetchHasMore || droppedFromMerge
|
||||
if (!sessionHasMore) {
|
||||
compoundOut[key] = null
|
||||
continue
|
||||
}
|
||||
const seq = earliestEmitted ? resolveMessageSeq(earliestEmitted) : null
|
||||
compoundOut[key] = seq
|
||||
}
|
||||
|
||||
const hasMore = Object.values(compoundOut).some(
|
||||
(v) => typeof v === 'number',
|
||||
)
|
||||
const messages = limited.map(({ __sessionKey: _drop, ...rest }) => rest)
|
||||
|
||||
return {
|
||||
sessionKey: mainSessionKey,
|
||||
messages,
|
||||
cursor: hasMore ? encodeCompoundCursor(compoundOut) : null,
|
||||
hasMore,
|
||||
truncated: truncated || limited.length < merged.length,
|
||||
}
|
||||
}
|
||||
|
||||
async streamSessionHistory(
|
||||
@@ -871,7 +1150,6 @@ export class OpenClawService {
|
||||
try {
|
||||
await this.runtime.ensureReady()
|
||||
|
||||
await this.refreshGatewayAuthToken()
|
||||
await this.ensureStateEnvFile()
|
||||
|
||||
const persistedPort = await readPersistedGatewayPort(this.openclawDir)
|
||||
@@ -1001,10 +1279,7 @@ export class OpenClawService {
|
||||
private setPort(hostPort: number): void {
|
||||
if (hostPort === this.hostPort) return
|
||||
this.hostPort = hostPort
|
||||
this.httpClient = new OpenClawHttpClient(
|
||||
this.hostPort,
|
||||
async () => this.token,
|
||||
)
|
||||
this.httpClient = new OpenClawHttpClient(this.hostPort)
|
||||
}
|
||||
|
||||
private async ensureGatewayPortAllocated(
|
||||
@@ -1037,25 +1312,13 @@ export class OpenClawService {
|
||||
}
|
||||
|
||||
private async isGatewayAuthenticated(hostPort: number): Promise<boolean> {
|
||||
if (!this.tokenLoaded) {
|
||||
logger.debug(
|
||||
'OpenClaw gateway port is ready before auth token is loaded',
|
||||
{
|
||||
hostPort,
|
||||
},
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
const client =
|
||||
hostPort === this.hostPort
|
||||
? this.httpClient
|
||||
: new OpenClawHttpClient(hostPort, async () => this.token)
|
||||
: new OpenClawHttpClient(hostPort)
|
||||
const authenticated = await client.isAuthenticated()
|
||||
if (!authenticated) {
|
||||
logger.warn('OpenClaw gateway port rejected current auth token', {
|
||||
hostPort,
|
||||
})
|
||||
logger.warn('OpenClaw gateway readiness probe failed', { hostPort })
|
||||
}
|
||||
return authenticated
|
||||
}
|
||||
@@ -1096,12 +1359,10 @@ export class OpenClawService {
|
||||
|
||||
private async runControlPlaneCall<T>(fn: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
await this.ensureTokenLoaded()
|
||||
const result = await fn()
|
||||
this.controlPlaneStatus = 'connected'
|
||||
this.lastGatewayError = null
|
||||
this.lastRecoveryReason = null
|
||||
this.ensureObserverConnected()
|
||||
return result
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
@@ -1113,20 +1374,10 @@ export class OpenClawService {
|
||||
}
|
||||
}
|
||||
|
||||
private ensureObserverConnected(): void {
|
||||
if (this.observer.isConnected()) return
|
||||
// ClawSession starts empty after the JSONL seed was removed; the WS
|
||||
// observer fills in agent status as events arrive.
|
||||
const url = `http://127.0.0.1:${this.hostPort}`
|
||||
this.observer.connect(url, this.token)
|
||||
}
|
||||
|
||||
private classifyControlPlaneError(
|
||||
error: unknown,
|
||||
): OpenClawGatewayRecoveryReason {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (message.includes('Unauthorized')) return 'token_mismatch'
|
||||
if (message.includes('token')) return 'token_mismatch'
|
||||
if (message.includes('not ready')) return 'container_not_ready'
|
||||
return 'unknown'
|
||||
}
|
||||
@@ -1354,7 +1605,6 @@ export class OpenClawService {
|
||||
hostPort: this.hostPort,
|
||||
hostHome: this.openclawDir,
|
||||
envFilePath: this.getStateEnvPath(),
|
||||
gatewayToken: this.tokenLoaded ? this.token : undefined,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
}
|
||||
}
|
||||
@@ -1459,50 +1709,6 @@ export class OpenClawService {
|
||||
return true
|
||||
}
|
||||
|
||||
private async ensureTokenLoaded(): Promise<void> {
|
||||
if (this.tokenLoaded) {
|
||||
return
|
||||
}
|
||||
if (!existsSync(this.getStateConfigPath())) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.loadTokenFromConfig()
|
||||
}
|
||||
|
||||
private async refreshGatewayAuthToken(): Promise<void> {
|
||||
this.tokenLoaded = false
|
||||
if (!existsSync(this.getStateConfigPath())) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.loadTokenFromConfig()
|
||||
}
|
||||
|
||||
private async loadTokenFromConfig(): Promise<void> {
|
||||
try {
|
||||
const config = JSON.parse(
|
||||
await readFile(this.getStateConfigPath(), 'utf-8'),
|
||||
) as {
|
||||
gateway?: {
|
||||
auth?: {
|
||||
token?: unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
const token = config.gateway?.auth?.token
|
||||
if (typeof token === 'string' && token) {
|
||||
this.token = token
|
||||
this.tokenLoaded = true
|
||||
logger.info('Loaded OpenClaw gateway token from mounted config')
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Failed to load OpenClaw gateway token from mounted config', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private createProgressLogger(
|
||||
onLog?: (msg: string) => void,
|
||||
): (msg: string) => void {
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* SQLite-backed store for files an OpenClaw agent produced inside its
|
||||
* workspace during a chat turn. The detection model is a per-turn
|
||||
* snapshot diff: take a `(path → size, mtime)` map of the workspace
|
||||
* before the turn starts, re-scan after the SSE `done` event, and
|
||||
* write a row for any new or modified file.
|
||||
*
|
||||
* Adapter-agnostic by design — the watcher is injected with the
|
||||
* agent's workspace dir, so V2 can plug Claude / Codex turn lifecycle
|
||||
* into the same store with a different `workspaceDir`.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { realpath, stat } from 'node:fs/promises'
|
||||
import { relative, resolve, sep } from 'node:path'
|
||||
import { and, desc, eq } from 'drizzle-orm'
|
||||
import { type BrowserOsDatabase, getDb } from '../../../lib/db'
|
||||
import {
|
||||
agentDefinitions,
|
||||
type NewProducedFileRow,
|
||||
type ProducedFileRow,
|
||||
producedFiles,
|
||||
} from '../../../lib/db/schema'
|
||||
import { walkWorkspace } from './produced-files-walker'
|
||||
|
||||
const TURN_PROMPT_MAX_CHARS = 280
|
||||
|
||||
export interface FileSnapshotEntry {
|
||||
size: number
|
||||
mtimeMs: number
|
||||
}
|
||||
|
||||
/** A `(workspace-relative path → fs metadata)` snapshot of a workspace. */
|
||||
export type FileSnapshot = Map<string, FileSnapshotEntry>
|
||||
|
||||
export interface FinalizeTurnInput {
|
||||
agentDefinitionId: string
|
||||
sessionKey: string
|
||||
turnId: string
|
||||
/** Raw user prompt; truncated to `TURN_PROMPT_MAX_CHARS` before persist. */
|
||||
turnPrompt: string
|
||||
/** Absolute host path to the agent's workspace directory. */
|
||||
workspaceDir: string
|
||||
/** Snapshot taken before the turn began. */
|
||||
before: FileSnapshot
|
||||
}
|
||||
|
||||
export interface ResolvedFile {
|
||||
row: ProducedFileRow
|
||||
/** Absolute host path; guaranteed to live inside the original workspace. */
|
||||
absolutePath: string
|
||||
}
|
||||
|
||||
export class ProducedFilesStore {
|
||||
private readonly db: BrowserOsDatabase
|
||||
|
||||
constructor(options: { db?: BrowserOsDatabase } = {}) {
|
||||
this.db = options.db ?? getDb()
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the workspace and capture every file's size + mtime. Used to
|
||||
* bracket a chat turn so the post-turn diff knows what changed.
|
||||
*/
|
||||
async snapshotWorkspace(workspaceDir: string): Promise<FileSnapshot> {
|
||||
const snapshot: FileSnapshot = new Map()
|
||||
await walkWorkspace(workspaceDir, (relPath, metadata) => {
|
||||
snapshot.set(relPath, metadata)
|
||||
})
|
||||
return snapshot
|
||||
}
|
||||
|
||||
/**
|
||||
* Diff the live workspace against `before`, persist rows for any
|
||||
* new or modified file, return the rows so the chat-turn finalizer
|
||||
* can broadcast them on the SSE feed. Re-modifications update the
|
||||
* existing row in place (the `(agentDefinitionId, path)` unique
|
||||
* index makes the upsert deterministic).
|
||||
*/
|
||||
async finalizeTurn(input: FinalizeTurnInput): Promise<ProducedFileRow[]> {
|
||||
const after: FileSnapshot = await this.snapshotWorkspace(input.workspaceDir)
|
||||
const changed: Array<{ relPath: string; entry: FileSnapshotEntry }> = []
|
||||
for (const [relPath, entry] of after) {
|
||||
const previous = input.before.get(relPath)
|
||||
if (
|
||||
!previous ||
|
||||
previous.size !== entry.size ||
|
||||
previous.mtimeMs !== entry.mtimeMs
|
||||
) {
|
||||
changed.push({ relPath, entry })
|
||||
}
|
||||
}
|
||||
if (changed.length === 0) return []
|
||||
|
||||
const now = Date.now()
|
||||
const turnPrompt = truncatePrompt(input.turnPrompt)
|
||||
const rows: ProducedFileRow[] = []
|
||||
for (const { relPath, entry } of changed) {
|
||||
const row: NewProducedFileRow = {
|
||||
id: randomUUID(),
|
||||
agentDefinitionId: input.agentDefinitionId,
|
||||
sessionKey: input.sessionKey,
|
||||
turnId: input.turnId,
|
||||
turnPrompt,
|
||||
path: relPath,
|
||||
size: entry.size,
|
||||
mtimeMs: entry.mtimeMs,
|
||||
createdAt: now,
|
||||
detectedBy: 'diff',
|
||||
}
|
||||
// Upsert on (agent, path) — re-modifications win, no duplicates.
|
||||
const upserted = this.db
|
||||
.insert(producedFiles)
|
||||
.values(row)
|
||||
.onConflictDoUpdate({
|
||||
target: [producedFiles.agentDefinitionId, producedFiles.path],
|
||||
set: {
|
||||
sessionKey: row.sessionKey,
|
||||
turnId: row.turnId,
|
||||
turnPrompt: row.turnPrompt,
|
||||
size: row.size,
|
||||
mtimeMs: row.mtimeMs,
|
||||
createdAt: row.createdAt,
|
||||
detectedBy: row.detectedBy,
|
||||
},
|
||||
})
|
||||
.returning()
|
||||
.all()
|
||||
const persisted = upserted[0] ?? row
|
||||
rows.push(persisted as ProducedFileRow)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
/** Inline-card query — files for a single assistant turn. */
|
||||
async listByTurn(turnId: string): Promise<ProducedFileRow[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(producedFiles)
|
||||
.where(eq(producedFiles.turnId, turnId))
|
||||
.orderBy(desc(producedFiles.createdAt))
|
||||
.all()
|
||||
}
|
||||
|
||||
/**
|
||||
* Outputs-rail query — every file an agent has produced across all
|
||||
* sessions, newest first.
|
||||
*/
|
||||
async listByAgent(
|
||||
agentDefinitionId: string,
|
||||
options: { limit?: number } = {},
|
||||
): Promise<ProducedFileRow[]> {
|
||||
const limit = options.limit ?? 200
|
||||
return this.db
|
||||
.select()
|
||||
.from(producedFiles)
|
||||
.where(eq(producedFiles.agentDefinitionId, agentDefinitionId))
|
||||
.orderBy(desc(producedFiles.createdAt))
|
||||
.limit(limit)
|
||||
.all()
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a gateway-side OpenClaw agent name (e.g. `main`,
|
||||
* `chief-01`) to the corresponding `agentDefinitions.id` so file
|
||||
* rows can be FK'd back to the harness record.
|
||||
*
|
||||
* Two shapes exist on disk depending on how the agent was added:
|
||||
*
|
||||
* 1. Reconciled rows from `agentHarnessService.reconcileWithGateway`
|
||||
* use `id == openclawAgentId` directly
|
||||
* (see `agent-harness-service.ts:522`).
|
||||
* 2. BrowserOS-created rows use `id = oc-<uuid>` and store the
|
||||
* openclaw name in the `name` column (`db-agent-store.ts:55-65`).
|
||||
*
|
||||
* Lookup tries shape 1 first (direct id hit), then shape 2 by
|
||||
* `(adapter='openclaw', name)`.
|
||||
*/
|
||||
async resolveAgentDefinitionId(
|
||||
openclawAgentId: string,
|
||||
): Promise<string | null> {
|
||||
const directHit = this.db
|
||||
.select({ id: agentDefinitions.id })
|
||||
.from(agentDefinitions)
|
||||
.where(eq(agentDefinitions.id, openclawAgentId))
|
||||
.limit(1)
|
||||
.all()
|
||||
if (directHit[0]) return directHit[0].id
|
||||
|
||||
const byName = this.db
|
||||
.select({ id: agentDefinitions.id })
|
||||
.from(agentDefinitions)
|
||||
.where(
|
||||
and(
|
||||
eq(agentDefinitions.adapter, 'openclaw'),
|
||||
eq(agentDefinitions.name, openclawAgentId),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
.all()
|
||||
return byName[0]?.id ?? null
|
||||
}
|
||||
|
||||
/** Single-row lookup; null if the id is unknown. */
|
||||
async findById(id: string): Promise<ProducedFileRow | null> {
|
||||
const rows = this.db
|
||||
.select()
|
||||
.from(producedFiles)
|
||||
.where(eq(producedFiles.id, id))
|
||||
.limit(1)
|
||||
.all()
|
||||
return rows[0] ?? null
|
||||
}
|
||||
|
||||
/** Used by `removeRegisteredModel` and similar admin paths later on. */
|
||||
async deleteByAgent(agentDefinitionId: string): Promise<void> {
|
||||
this.db
|
||||
.delete(producedFiles)
|
||||
.where(eq(producedFiles.agentDefinitionId, agentDefinitionId))
|
||||
.run()
|
||||
}
|
||||
|
||||
/** Useful for hard-resetting a session's files (e.g. workspace clear). */
|
||||
async deleteBySession(sessionKey: string): Promise<void> {
|
||||
this.db
|
||||
.delete(producedFiles)
|
||||
.where(eq(producedFiles.sessionKey, sessionKey))
|
||||
.run()
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a stored file id to an absolute host path, after validating
|
||||
* that the on-disk path still lives inside `workspaceDir`. The HTTP
|
||||
* download / preview routes are the only callers; the workspace dir
|
||||
* is supplied by the openclaw service so this module stays
|
||||
* adapter-agnostic.
|
||||
*/
|
||||
async resolveFilePath(input: {
|
||||
fileId: string
|
||||
workspaceDir: string
|
||||
}): Promise<ResolvedFile | null> {
|
||||
const row = await this.findById(input.fileId)
|
||||
if (!row) return null
|
||||
|
||||
const absolutePath = await resolveSafeWorkspacePath(
|
||||
input.workspaceDir,
|
||||
row.path,
|
||||
)
|
||||
if (!absolutePath) return null
|
||||
return { row, absolutePath }
|
||||
}
|
||||
|
||||
/**
|
||||
* Group a flat list of rows by `turnId`, preserving the latest-first
|
||||
* order on the row level and keeping the most-recent group first.
|
||||
* The Outputs rail uses this shape directly.
|
||||
*/
|
||||
groupByTurn(rows: ProducedFileRow[]): Array<{
|
||||
turnId: string
|
||||
turnPrompt: string
|
||||
createdAt: number
|
||||
files: ProducedFileRow[]
|
||||
}> {
|
||||
const grouped = new Map<
|
||||
string,
|
||||
{
|
||||
turnId: string
|
||||
turnPrompt: string
|
||||
createdAt: number
|
||||
files: ProducedFileRow[]
|
||||
}
|
||||
>()
|
||||
for (const row of rows) {
|
||||
const existing = grouped.get(row.turnId)
|
||||
if (!existing) {
|
||||
grouped.set(row.turnId, {
|
||||
turnId: row.turnId,
|
||||
turnPrompt: row.turnPrompt,
|
||||
// Group's createdAt = its newest file (rows are
|
||||
// already desc-by-createdAt, so the first one wins).
|
||||
createdAt: row.createdAt,
|
||||
files: [row],
|
||||
})
|
||||
continue
|
||||
}
|
||||
existing.files.push(row)
|
||||
if (row.createdAt > existing.createdAt) {
|
||||
existing.createdAt = row.createdAt
|
||||
}
|
||||
}
|
||||
return Array.from(grouped.values()).sort(
|
||||
(a, b) => b.createdAt - a.createdAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function truncatePrompt(value: string): string {
|
||||
const trimmed = value.trim()
|
||||
if (trimmed.length <= TURN_PROMPT_MAX_CHARS) return trimmed
|
||||
return `${trimmed.slice(0, TURN_PROMPT_MAX_CHARS - 1)}…`
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve `workspaceDir + relPath` to an absolute host path, but only
|
||||
* if the resolved real path lives inside the workspace root. Returns
|
||||
* null on:
|
||||
* - lexical traversal (`..` segments escaping the root),
|
||||
* - symlink escape (a file in the workspace pointing outside it),
|
||||
* - missing files,
|
||||
* - any unreadable path component.
|
||||
*
|
||||
* Exported so the unit test can hit it without a sqlite handle.
|
||||
*/
|
||||
export async function resolveSafeWorkspacePath(
|
||||
workspaceDir: string,
|
||||
relPath: string,
|
||||
): Promise<string | null> {
|
||||
// Lexical containment first — fail fast without touching the FS.
|
||||
const workspaceRoot = resolve(workspaceDir)
|
||||
const lexical = resolve(workspaceRoot, relPath)
|
||||
const lexicalRel = relative(workspaceRoot, lexical)
|
||||
if (
|
||||
lexicalRel === '' ||
|
||||
lexicalRel.startsWith('..') ||
|
||||
lexicalRel.startsWith(`..${sep}`)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Realpath check — collapses symlinks so a workspace symlink that
|
||||
// points outside the root cannot be downloaded. Falls through to
|
||||
// null if anything errors (file gone, permissions, broken link).
|
||||
try {
|
||||
const [realRoot, realFile] = await Promise.all([
|
||||
realpath(workspaceRoot),
|
||||
realpath(lexical),
|
||||
])
|
||||
const realRel = relative(realRoot, realFile)
|
||||
if (
|
||||
realRel === '' ||
|
||||
realRel.startsWith('..') ||
|
||||
realRel.startsWith(`..${sep}`)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
await stat(realFile)
|
||||
return realFile
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export the row type so callers pulling the store don't have to
|
||||
// also import the schema module.
|
||||
export type { ProducedFileRow }
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Workspace walker used by the produced-files diff watcher. Recurses
|
||||
* an OpenClaw agent's workspace directory and yields one
|
||||
* `(workspace-relative path, size, mtime)` triple per file.
|
||||
*
|
||||
* Design choices:
|
||||
*
|
||||
* - **Pure async iteration.** No third-party deps; relies on
|
||||
* `fs.promises.readdir` + `Dirent` so directory traversal is one
|
||||
* syscall per directory.
|
||||
* - **Symlink-aware.** Symlinks themselves aren't followed (they
|
||||
* appear in `Dirent.isSymbolicLink()`); the walker skips them so
|
||||
* an agent can't smuggle host-fs paths into the diff via a
|
||||
* symlink in its workspace.
|
||||
* - **Excludes well-known cruft directories** that no useful agent
|
||||
* output ever lives inside (`node_modules`, `.git`, `.cache`).
|
||||
* These directories are also expensive to traverse, so skipping
|
||||
* them keeps the per-turn snapshot fast.
|
||||
* - **Bounded.** Hard caps on entry count and recursion depth keep
|
||||
* pathological workspaces from stalling the chat-turn finalizer.
|
||||
*/
|
||||
|
||||
import type { Dirent } from 'node:fs'
|
||||
import { readdir, stat } from 'node:fs/promises'
|
||||
import { join, relative, sep } from 'node:path'
|
||||
|
||||
const EXCLUDED_DIRECTORIES = new Set(['node_modules', '.git', '.cache'])
|
||||
|
||||
const MAX_ENTRIES = 50_000
|
||||
const MAX_DEPTH = 16
|
||||
|
||||
export interface WorkspaceFileMetadata {
|
||||
size: number
|
||||
mtimeMs: number
|
||||
}
|
||||
|
||||
export type WorkspaceFileVisitor = (
|
||||
/** Workspace-relative path (POSIX-style separators). */
|
||||
relativePath: string,
|
||||
metadata: WorkspaceFileMetadata,
|
||||
) => void
|
||||
|
||||
/**
|
||||
* Walk `workspaceDir` recursively, calling `visit` for every regular
|
||||
* file. Returns silently if the directory doesn't exist (a fresh
|
||||
* agent that hasn't produced anything yet shouldn't error here).
|
||||
*/
|
||||
export async function walkWorkspace(
|
||||
workspaceDir: string,
|
||||
visit: WorkspaceFileVisitor,
|
||||
): Promise<void> {
|
||||
let entriesSeen = 0
|
||||
await walk(workspaceDir, workspaceDir, 0, (file) => {
|
||||
entriesSeen += 1
|
||||
if (entriesSeen > MAX_ENTRIES) return false
|
||||
visit(file.relativePath, file.metadata)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
interface VisitedFile {
|
||||
relativePath: string
|
||||
metadata: WorkspaceFileMetadata
|
||||
}
|
||||
|
||||
async function walk(
|
||||
root: string,
|
||||
current: string,
|
||||
depth: number,
|
||||
yieldFile: (file: VisitedFile) => boolean,
|
||||
): Promise<boolean> {
|
||||
if (depth > MAX_DEPTH) return true
|
||||
|
||||
let entries: Dirent[]
|
||||
try {
|
||||
entries = await readdir(current, { withFileTypes: true })
|
||||
} catch {
|
||||
// Workspace dir missing or unreadable — fresh agent that hasn't
|
||||
// written anything yet, or transient permissions issue. Treat as
|
||||
// "no files" rather than throwing.
|
||||
return true
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (EXCLUDED_DIRECTORIES.has(entry.name)) continue
|
||||
const absolute = join(current, entry.name)
|
||||
|
||||
if (entry.isSymbolicLink()) {
|
||||
// Skip symlinks — never follow, never record. Prevents an
|
||||
// agent from smuggling host-fs paths into the diff via a
|
||||
// symlink in its workspace.
|
||||
continue
|
||||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
const keepGoing = await walk(root, absolute, depth + 1, yieldFile)
|
||||
if (!keepGoing) return false
|
||||
continue
|
||||
}
|
||||
|
||||
if (!entry.isFile()) continue
|
||||
|
||||
let stats: Awaited<ReturnType<typeof stat>>
|
||||
try {
|
||||
stats = await stat(absolute)
|
||||
} catch {
|
||||
// Concurrent delete between readdir and stat — skip silently.
|
||||
continue
|
||||
}
|
||||
const relativePath = toPosix(relative(root, absolute))
|
||||
const keepGoing = yieldFile({
|
||||
relativePath,
|
||||
metadata: { size: stats.size, mtimeMs: stats.mtimeMs },
|
||||
})
|
||||
if (!keepGoing) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function toPosix(value: string): string {
|
||||
if (sep === '/') return value
|
||||
return value.split(sep).join('/')
|
||||
}
|
||||
@@ -19,7 +19,6 @@ export const INLINED_ENV = {
|
||||
CODEGEN_SERVICE_URL: process.env.CODEGEN_SERVICE_URL,
|
||||
POSTHOG_API_KEY: process.env.POSTHOG_API_KEY,
|
||||
BROWSEROS_CONFIG_URL: process.env.BROWSEROS_CONFIG_URL,
|
||||
SKILLS_CATALOG_URL: process.env.SKILLS_CATALOG_URL,
|
||||
} as const
|
||||
|
||||
export const REQUIRED_FOR_PRODUCTION = [
|
||||
|
||||
@@ -4,16 +4,10 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { createRuntimeStore } from 'acpx/runtime'
|
||||
import type { OpenClawGatewayChatClient } from '../../api/services/openclaw/openclaw-gateway-chat-client'
|
||||
import type { AgentDefinition } from './agent-types'
|
||||
import { prepareClaudeCodeContext } from './claude-code/prepare'
|
||||
import { prepareCodexContext } from './codex/prepare'
|
||||
import {
|
||||
maybeHandleOpenClawTurn,
|
||||
prepareOpenClawContext,
|
||||
} from './openclaw/prepare'
|
||||
import type { AgentPromptInput, AgentStreamEvent } from './types'
|
||||
import { prepareOpenClawContext } from './openclaw/prepare'
|
||||
|
||||
export interface PreparedAcpxAgentContext {
|
||||
cwd: string
|
||||
@@ -35,29 +29,16 @@ export interface PrepareAcpxAgentContextInput {
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface AcpxAdapterTurnInput {
|
||||
prompt: AgentPromptInput
|
||||
prepared: PreparedAcpxAgentContext
|
||||
sessionStore: ReturnType<typeof createRuntimeStore>
|
||||
openclawGatewayChat: OpenClawGatewayChatClient | null
|
||||
}
|
||||
|
||||
export interface AcpxAgentAdapter {
|
||||
prepare(
|
||||
input: PrepareAcpxAgentContextInput,
|
||||
): Promise<PreparedAcpxAgentContext>
|
||||
maybeHandleTurn?(
|
||||
input: AcpxAdapterTurnInput,
|
||||
): Promise<ReadableStream<AgentStreamEvent> | null>
|
||||
}
|
||||
|
||||
const ADAPTERS: Record<AgentDefinition['adapter'], AcpxAgentAdapter> = {
|
||||
claude: { prepare: prepareClaudeCodeContext },
|
||||
codex: { prepare: prepareCodexContext },
|
||||
openclaw: {
|
||||
prepare: prepareOpenClawContext,
|
||||
maybeHandleTurn: maybeHandleOpenClawTurn,
|
||||
},
|
||||
openclaw: { prepare: prepareOpenClawContext },
|
||||
}
|
||||
|
||||
export function getAcpxAgentAdapter(
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
createAgentRegistry,
|
||||
createRuntimeStore,
|
||||
} from 'acpx/runtime'
|
||||
import type { OpenClawGatewayChatClient } from '../../api/services/openclaw/openclaw-gateway-chat-client'
|
||||
import { getBrowserosDir } from '../browseros-dir'
|
||||
import { logger } from '../logger'
|
||||
import {
|
||||
@@ -51,11 +50,10 @@ import type {
|
||||
* when spawning the openclaw ACP adapter inside the gateway container.
|
||||
*
|
||||
* Fields are getters (not snapshot values) so the harness picks up the
|
||||
* current token and VM/container paths at spawn time.
|
||||
* current VM/container paths at spawn time. The bundled gateway runs
|
||||
* with `gateway.auth.mode=none`, so no auth token is plumbed through.
|
||||
*/
|
||||
export interface OpenclawGatewayAccessor {
|
||||
/** Current gateway auth token. Passed to `openclaw acp --token`. */
|
||||
getGatewayToken(): string
|
||||
/** Container name e.g. browseros-openclaw-openclaw-gateway-1. */
|
||||
getContainerName(): string
|
||||
/** LIMA_HOME directory containing the browseros-vm instance. */
|
||||
@@ -76,15 +74,6 @@ type AcpxRuntimeOptions = {
|
||||
* claude/codex (their adapters spawn their own CLI binaries).
|
||||
*/
|
||||
openclawGateway?: OpenclawGatewayAccessor
|
||||
/**
|
||||
* Optional. When wired, the runtime diverts OpenClaw turns that
|
||||
* carry image attachments to the gateway's HTTP `/v1/chat/completions`
|
||||
* endpoint (which accepts OpenAI-style `image_url` parts) instead of
|
||||
* the ACP bridge — the bridge silently drops image content blocks.
|
||||
* Without this client, image turns to OpenClaw agents fall through to
|
||||
* the ACP path and the model never sees the image.
|
||||
*/
|
||||
openclawGatewayChat?: OpenClawGatewayChatClient
|
||||
runtimeFactory?: (options: AcpRuntimeOptions) => AcpxCoreRuntime
|
||||
}
|
||||
|
||||
@@ -98,19 +87,12 @@ interface PreparedRuntimeContext {
|
||||
openclawSessionKey: string | null
|
||||
}
|
||||
|
||||
const BROWSEROS_ACP_AGENT_INSTRUCTIONS = `<role>
|
||||
You are BrowserOS - a browser agent with full control of a Chromium browser through the BrowserOS MCP server.
|
||||
|
||||
Use the BrowserOS MCP server for all browser tasks, including browsing the web, interacting with pages, inspecting browser state, and managing tabs, windows, bookmarks, and history.
|
||||
</role>`
|
||||
|
||||
export class AcpxRuntime implements AgentRuntime {
|
||||
private readonly defaultCwd: string | null
|
||||
private readonly browserosDir: string
|
||||
private readonly stateDir: string
|
||||
private readonly browserosServerPort: number
|
||||
private readonly openclawGateway: OpenclawGatewayAccessor | null
|
||||
private readonly openclawGatewayChat: OpenClawGatewayChatClient | null
|
||||
private readonly runtimeFactory: (
|
||||
options: AcpRuntimeOptions,
|
||||
) => AcpxCoreRuntime
|
||||
@@ -127,7 +109,6 @@ export class AcpxRuntime implements AgentRuntime {
|
||||
this.browserosServerPort =
|
||||
options.browserosServerPort ?? DEFAULT_PORTS.server
|
||||
this.openclawGateway = options.openclawGateway ?? null
|
||||
this.openclawGatewayChat = options.openclawGatewayChat ?? null
|
||||
this.sessionStore = createRuntimeStore({ stateDir: this.stateDir })
|
||||
this.runtimeFactory = options.runtimeFactory ?? createAcpRuntime
|
||||
}
|
||||
@@ -205,24 +186,6 @@ export class AcpxRuntime implements AgentRuntime {
|
||||
imageAttachmentCount: imageAttachments.length,
|
||||
})
|
||||
|
||||
const adapter = getAcpxAgentAdapter(input.agent.adapter)
|
||||
const adapterStream =
|
||||
(await adapter.maybeHandleTurn?.({
|
||||
prompt: input,
|
||||
prepared: {
|
||||
cwd: prepared.cwd,
|
||||
runtimeSessionKey: prepared.runtimeSessionKey,
|
||||
runPrompt: prepared.runPrompt,
|
||||
commandEnv: prepared.agentCommandEnv,
|
||||
commandIdentity: prepared.commandIdentity,
|
||||
useBrowserosMcp: prepared.useBrowserosMcp,
|
||||
openclawSessionKey: prepared.openclawSessionKey,
|
||||
},
|
||||
sessionStore: this.sessionStore,
|
||||
openclawGatewayChat: this.openclawGatewayChat,
|
||||
})) ?? null
|
||||
if (adapterStream) return adapterStream
|
||||
|
||||
const runtime = this.getRuntime({
|
||||
cwd,
|
||||
permissionMode: input.permissionMode,
|
||||
@@ -509,14 +472,16 @@ export function unwrapBrowserosAcpUserMessage(raw: string): string {
|
||||
}
|
||||
|
||||
function stripOuterRoleEnvelope(value: string): string {
|
||||
const prefix = `${BROWSEROS_ACP_AGENT_INSTRUCTIONS}
|
||||
|
||||
<user_request>
|
||||
`
|
||||
const suffix = `
|
||||
</user_request>`
|
||||
if (!value.startsWith(prefix) || !value.endsWith(suffix)) return value
|
||||
return value.slice(prefix.length, -suffix.length)
|
||||
// Any `<role>…</role>\n\n<user_request>\n…\n</user_request>` envelope.
|
||||
// Adapter-agnostic so both the BrowserOS multi-line role block and the
|
||||
// openclaw single-line role block get unwrapped. TKT-774's exact-prefix
|
||||
// match only covered the BrowserOS form, so the openclaw envelope
|
||||
// (added when openclaw moved to its own prepare step) was landing
|
||||
// unwrapped in history payloads.
|
||||
const match = value.match(
|
||||
/^<role\b[^>]*>[\s\S]*?<\/role>\n\n<user_request>\n([\s\S]*?)\n<\/user_request>$/,
|
||||
)
|
||||
return match ? match[1] : value
|
||||
}
|
||||
|
||||
function stripOuterRuntimeEnvelope(value: string): string {
|
||||
@@ -756,8 +721,8 @@ function createBrowserosAgentRegistry(input: {
|
||||
* already installed alongside the gateway is reused; BrowserOS does
|
||||
* not require a host-side openclaw install.
|
||||
*
|
||||
* Auth: `openclaw acp --url ...` deliberately does not reuse implicit
|
||||
* env/config credentials, so pass the gateway token explicitly.
|
||||
* Auth: BrowserOS configures the bundled gateway with `gateway.auth.mode=none`,
|
||||
* so no gateway token flag is needed for the local ACP bridge.
|
||||
*
|
||||
* Banner output: OPENCLAW_HIDE_BANNER and OPENCLAW_SUPPRESS_NOTES
|
||||
* suppress non-JSON-RPC chatter on stdout that would otherwise corrupt
|
||||
@@ -767,7 +732,6 @@ function resolveOpenclawAcpCommand(
|
||||
gateway: OpenclawGatewayAccessor,
|
||||
sessionKey: string | null,
|
||||
): string {
|
||||
const token = gateway.getGatewayToken()
|
||||
const limactl = gateway.getLimactlPath()
|
||||
const vm = gateway.getVmName()
|
||||
const container = gateway.getContainerName()
|
||||
@@ -816,8 +780,6 @@ function resolveOpenclawAcpCommand(
|
||||
'acp',
|
||||
'--url',
|
||||
gatewayUrlInsideContainer,
|
||||
'--token',
|
||||
token,
|
||||
]
|
||||
if (bridgeSessionKey) {
|
||||
argv.push('--session', bridgeSessionKey)
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { AcpSessionRecord, createRuntimeStore } from 'acpx/runtime'
|
||||
import type {
|
||||
OpenAIChatMessage,
|
||||
OpenAIContentPart,
|
||||
} from '../../../api/services/openclaw/openclaw-gateway-chat-client'
|
||||
import { logger } from '../../logger'
|
||||
import type { AcpxAdapterTurnInput } from '../acpx-agent-adapter'
|
||||
import type { AgentStreamEvent } from '../types'
|
||||
|
||||
type ImageAttachment = Readonly<{ mediaType: string; data: string }>
|
||||
|
||||
export async function maybeHandleOpenClawTurn(
|
||||
input: AcpxAdapterTurnInput,
|
||||
): Promise<ReadableStream<AgentStreamEvent> | null> {
|
||||
const imageAttachments = (input.prompt.attachments ?? []).filter((a) =>
|
||||
a.mediaType.startsWith('image/'),
|
||||
)
|
||||
if (imageAttachments.length === 0 || !input.openclawGatewayChat) {
|
||||
return null
|
||||
}
|
||||
return sendOpenclawViaGateway({
|
||||
prompt: input.prompt,
|
||||
sessionStore: input.sessionStore,
|
||||
openclawGatewayChat: input.openclawGatewayChat,
|
||||
imageAttachments,
|
||||
cwd: input.prepared.cwd,
|
||||
runPrompt: input.prepared.runPrompt,
|
||||
})
|
||||
}
|
||||
|
||||
/** Handles OpenClaw image turns through the gateway HTTP chat endpoint. */
|
||||
async function sendOpenclawViaGateway(input: {
|
||||
prompt: AcpxAdapterTurnInput['prompt']
|
||||
sessionStore: AcpxAdapterTurnInput['sessionStore']
|
||||
openclawGatewayChat: NonNullable<AcpxAdapterTurnInput['openclawGatewayChat']>
|
||||
imageAttachments: ReadonlyArray<ImageAttachment>
|
||||
cwd: string
|
||||
runPrompt: string
|
||||
}): Promise<ReadableStream<AgentStreamEvent>> {
|
||||
const existingRecord = await input.sessionStore.load(input.prompt.sessionKey)
|
||||
const priorMessages = existingRecord
|
||||
? recordToOpenAIMessages(existingRecord)
|
||||
: []
|
||||
const userContent: OpenAIContentPart[] = [
|
||||
{
|
||||
type: 'text',
|
||||
text: input.runPrompt,
|
||||
},
|
||||
...input.imageAttachments.map(
|
||||
(a): OpenAIContentPart => ({
|
||||
type: 'image_url',
|
||||
image_url: { url: `data:${a.mediaType};base64,${a.data}` },
|
||||
}),
|
||||
),
|
||||
]
|
||||
const messages: OpenAIChatMessage[] = [
|
||||
...priorMessages,
|
||||
{ role: 'user', content: userContent },
|
||||
]
|
||||
|
||||
logger.info('Agent harness gateway image turn dispatched', {
|
||||
agentId: input.prompt.agent.id,
|
||||
sessionKey: input.prompt.sessionKey,
|
||||
cwd: input.cwd,
|
||||
priorMessageCount: priorMessages.length,
|
||||
imageAttachmentCount: input.imageAttachments.length,
|
||||
})
|
||||
|
||||
const upstream = await input.openclawGatewayChat.streamTurn({
|
||||
agentId: input.prompt.agent.id,
|
||||
sessionKey: input.prompt.sessionKey,
|
||||
messages,
|
||||
signal: input.prompt.signal,
|
||||
})
|
||||
|
||||
const sessionStore = input.sessionStore
|
||||
const sessionKey = input.prompt.sessionKey
|
||||
const userMessageText = input.prompt.message
|
||||
const imageAttachments = input.imageAttachments
|
||||
let accumulated = ''
|
||||
|
||||
return new ReadableStream<AgentStreamEvent>({
|
||||
start: (controller) => {
|
||||
const reader = upstream.getReader()
|
||||
const persist = async () => {
|
||||
if (!existingRecord || !accumulated) return
|
||||
try {
|
||||
await persistGatewayTurn(
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
userMessageText,
|
||||
imageAttachments,
|
||||
accumulated,
|
||||
)
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
'Failed to persist gateway image turn to acpx session record',
|
||||
{
|
||||
sessionKey,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
;(async () => {
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
if (value.type === 'text_delta') accumulated += value.text
|
||||
controller.enqueue(value)
|
||||
}
|
||||
await persist()
|
||||
controller.close()
|
||||
} catch (err) {
|
||||
controller.enqueue({
|
||||
type: 'error',
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
controller.close()
|
||||
}
|
||||
})().catch(() => {})
|
||||
},
|
||||
cancel: () => {
|
||||
// Best-effort: cancel propagation to the gateway is tracked separately.
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function persistGatewayTurn(
|
||||
sessionStore: ReturnType<typeof createRuntimeStore>,
|
||||
sessionKey: string,
|
||||
userMessageText: string,
|
||||
imageAttachments: ReadonlyArray<ImageAttachment>,
|
||||
assistantText: string,
|
||||
): Promise<void> {
|
||||
const record = await sessionStore.load(sessionKey)
|
||||
if (!record) return
|
||||
const userContent: AcpxUserContent[] = [
|
||||
{ Text: userMessageText } as AcpxUserContent,
|
||||
]
|
||||
for (const _image of imageAttachments) {
|
||||
userContent.push({ Image: { source: 'base64' } } as AcpxUserContent)
|
||||
}
|
||||
const turnId = randomUUID()
|
||||
const updated = {
|
||||
...record,
|
||||
messages: [
|
||||
...record.messages,
|
||||
{ User: { id: `user-${turnId}`, content: userContent } },
|
||||
{ Agent: { content: [{ Text: assistantText }], tool_results: {} } },
|
||||
],
|
||||
lastUsedAt: new Date().toISOString(),
|
||||
} as AcpSessionRecord
|
||||
await sessionStore.save(updated)
|
||||
}
|
||||
|
||||
function recordToOpenAIMessages(record: AcpSessionRecord): OpenAIChatMessage[] {
|
||||
const messages: OpenAIChatMessage[] = []
|
||||
for (const message of record.messages) {
|
||||
if (message === 'Resume') continue
|
||||
if ('User' in message) {
|
||||
const text = message.User.content
|
||||
.map(userContentToText)
|
||||
.filter(Boolean)
|
||||
.join('\n\n')
|
||||
.trim()
|
||||
if (text) messages.push({ role: 'user', content: text })
|
||||
continue
|
||||
}
|
||||
if ('Agent' in message) {
|
||||
const text = message.Agent.content
|
||||
.map((part) => ('Text' in part ? part.Text : ''))
|
||||
.join('')
|
||||
.trim()
|
||||
if (text) messages.push({ role: 'assistant', content: text })
|
||||
}
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
type AcpxSessionMessage = AcpSessionRecord['messages'][number]
|
||||
type AcpxUserContent = Extract<
|
||||
Exclude<AcpxSessionMessage, 'Resume'>,
|
||||
{ User: unknown }
|
||||
>['User']['content'][number]
|
||||
|
||||
function userContentToText(content: AcpxUserContent): string {
|
||||
if ('Text' in content) return unwrapPromptText(content.Text)
|
||||
if ('Mention' in content) return content.Mention.content
|
||||
if ('Image' in content) return content.Image.source ? '[image]' : ''
|
||||
return ''
|
||||
}
|
||||
|
||||
function unwrapPromptText(raw: string): string {
|
||||
const runtimeMatch = raw.match(
|
||||
/^<browseros_acpx_runtime\b[\s\S]*?<\/browseros_acpx_runtime>\n\n<user_request>\n([\s\S]*?)\n<\/user_request>$/,
|
||||
)
|
||||
if (runtimeMatch) return decodeBasicEntities(runtimeMatch[1]).trim()
|
||||
const roleMatch = raw.match(
|
||||
/^<role>[\s\S]*?<\/role>\n\n<user_request>\n([\s\S]*?)\n<\/user_request>$/,
|
||||
)
|
||||
if (roleMatch) return decodeBasicEntities(roleMatch[1]).trim()
|
||||
return raw.trim()
|
||||
}
|
||||
|
||||
function decodeBasicEntities(value: string): string {
|
||||
return value
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&')
|
||||
}
|
||||
@@ -14,8 +14,6 @@ import {
|
||||
resolveAgentRuntimePaths,
|
||||
} from '../acpx-runtime-context'
|
||||
|
||||
export { maybeHandleOpenClawTurn } from './image-turn'
|
||||
|
||||
const OPENCLAW_BROWSEROS_ACP_INSTRUCTIONS =
|
||||
'<role>You are running inside BrowserOS through the OpenClaw ACP adapter. Use your OpenClaw identity, memory, and browser tools.</role>'
|
||||
|
||||
|
||||
@@ -27,6 +27,20 @@ export interface AgentHistoryPage {
|
||||
items: AgentHistoryEntry[]
|
||||
}
|
||||
|
||||
/**
|
||||
* One file the harness attributed to the assistant turn that just
|
||||
* finished. Emitted as part of a `produced_files` event before the
|
||||
* terminal `done` so the inline artifact card renders alongside the
|
||||
* streamed text the user just watched complete.
|
||||
*/
|
||||
export interface ProducedFileEventEntry {
|
||||
id: string
|
||||
/** Workspace-relative POSIX path. */
|
||||
path: string
|
||||
size: number
|
||||
mtimeMs: number
|
||||
}
|
||||
|
||||
export type AgentStreamEvent =
|
||||
| {
|
||||
type: 'text_delta'
|
||||
@@ -47,6 +61,10 @@ export type AgentStreamEvent =
|
||||
text: string
|
||||
rawType?: string
|
||||
}
|
||||
| {
|
||||
type: 'produced_files'
|
||||
files: ProducedFileEventEntry[]
|
||||
}
|
||||
| {
|
||||
type: 'done'
|
||||
text?: string
|
||||
|
||||
@@ -39,14 +39,6 @@ export function getCoreMemoryPath(): string {
|
||||
return join(getMemoryDir(), PATHS.CORE_MEMORY_FILE_NAME)
|
||||
}
|
||||
|
||||
export function getSkillsDir(): string {
|
||||
return join(getBrowserosDir(), PATHS.SKILLS_DIR_NAME)
|
||||
}
|
||||
|
||||
export function getBuiltinSkillsDir(): string {
|
||||
return join(getSkillsDir(), PATHS.BUILTIN_DIR_NAME)
|
||||
}
|
||||
|
||||
export function getOpenClawDir(): string {
|
||||
return join(getVmStateDir(), PATHS.OPENCLAW_DIR_NAME)
|
||||
}
|
||||
@@ -113,8 +105,6 @@ export function removeServerConfigSync(): void {
|
||||
export async function ensureBrowserosDir(): Promise<void> {
|
||||
logDevelopmentBrowserosDir()
|
||||
await mkdir(getMemoryDir(), { recursive: true })
|
||||
await mkdir(getSkillsDir(), { recursive: true })
|
||||
await mkdir(getBuiltinSkillsDir(), { recursive: true })
|
||||
await mkdir(getSessionsDir(), { recursive: true })
|
||||
await mkdir(getLazyMonitoringRunsDir(), { recursive: true })
|
||||
await mkdir(getVmDisksDir(), { recursive: true })
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
CREATE TABLE `produced_files` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`agent_definition_id` text NOT NULL,
|
||||
`session_key` text NOT NULL,
|
||||
`turn_id` text NOT NULL,
|
||||
`turn_prompt` text NOT NULL,
|
||||
`path` text NOT NULL,
|
||||
`size` integer NOT NULL,
|
||||
`mtime_ms` integer NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`detected_by` text DEFAULT 'diff' NOT NULL,
|
||||
FOREIGN KEY (`agent_definition_id`) REFERENCES `agent_definitions`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `produced_files_agent_path_unique` ON `produced_files` (`agent_definition_id`,`path`);--> statement-breakpoint
|
||||
CREATE INDEX `produced_files_agent_created_idx` ON `produced_files` (`agent_definition_id`,`created_at`);--> statement-breakpoint
|
||||
CREATE INDEX `produced_files_turn_idx` ON `produced_files` (`turn_id`);--> statement-breakpoint
|
||||
CREATE INDEX `produced_files_session_idx` ON `produced_files` (`session_key`);
|
||||
@@ -0,0 +1,338 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "a8560df5-6cbe-46c2-b7df-ef0d09d232bf",
|
||||
"prevId": "6be24444-91aa-492e-96e5-d84c0f020468",
|
||||
"tables": {
|
||||
"agent_definitions": {
|
||||
"name": "agent_definitions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"adapter": {
|
||||
"name": "adapter",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"model_id": {
|
||||
"name": "model_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"reasoning_effort": {
|
||||
"name": "reasoning_effort",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"permission_mode": {
|
||||
"name": "permission_mode",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'approve-all'"
|
||||
},
|
||||
"session_key": {
|
||||
"name": "session_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"pinned": {
|
||||
"name": "pinned",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"adapter_config_json": {
|
||||
"name": "adapter_config_json",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"agent_definitions_session_key_unique": {
|
||||
"name": "agent_definitions_session_key_unique",
|
||||
"columns": [
|
||||
"session_key"
|
||||
],
|
||||
"isUnique": true
|
||||
},
|
||||
"agent_definitions_updated_at_idx": {
|
||||
"name": "agent_definitions_updated_at_idx",
|
||||
"columns": [
|
||||
"updated_at"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"agent_definitions_adapter_updated_at_idx": {
|
||||
"name": "agent_definitions_adapter_updated_at_idx",
|
||||
"columns": [
|
||||
"adapter",
|
||||
"updated_at"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"oauth_tokens": {
|
||||
"name": "oauth_tokens",
|
||||
"columns": {
|
||||
"browseros_id": {
|
||||
"name": "browseros_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"provider": {
|
||||
"name": "provider",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"account_id": {
|
||||
"name": "account_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"oauth_tokens_browseros_id_idx": {
|
||||
"name": "oauth_tokens_browseros_id_idx",
|
||||
"columns": [
|
||||
"browseros_id"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"oauth_tokens_browseros_id_provider_pk": {
|
||||
"columns": [
|
||||
"browseros_id",
|
||||
"provider"
|
||||
],
|
||||
"name": "oauth_tokens_browseros_id_provider_pk"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"produced_files": {
|
||||
"name": "produced_files",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_definition_id": {
|
||||
"name": "agent_definition_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"session_key": {
|
||||
"name": "session_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"turn_id": {
|
||||
"name": "turn_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"turn_prompt": {
|
||||
"name": "turn_prompt",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"path": {
|
||||
"name": "path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"size": {
|
||||
"name": "size",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mtime_ms": {
|
||||
"name": "mtime_ms",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"detected_by": {
|
||||
"name": "detected_by",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'diff'"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"produced_files_agent_path_unique": {
|
||||
"name": "produced_files_agent_path_unique",
|
||||
"columns": [
|
||||
"agent_definition_id",
|
||||
"path"
|
||||
],
|
||||
"isUnique": true
|
||||
},
|
||||
"produced_files_agent_created_idx": {
|
||||
"name": "produced_files_agent_created_idx",
|
||||
"columns": [
|
||||
"agent_definition_id",
|
||||
"created_at"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"produced_files_turn_idx": {
|
||||
"name": "produced_files_turn_idx",
|
||||
"columns": [
|
||||
"turn_id"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"produced_files_session_idx": {
|
||||
"name": "produced_files_session_idx",
|
||||
"columns": [
|
||||
"session_key"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"produced_files_agent_definition_id_agent_definitions_id_fk": {
|
||||
"name": "produced_files_agent_definition_id_agent_definitions_id_fk",
|
||||
"tableFrom": "produced_files",
|
||||
"tableTo": "agent_definitions",
|
||||
"columnsFrom": [
|
||||
"agent_definition_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,13 @@
|
||||
"when": 1777752799806,
|
||||
"tag": "0001_lazy_orphan",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1777902205667,
|
||||
"tag": "0002_chemical_whirlwind",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -6,3 +6,4 @@
|
||||
|
||||
export * from './agents'
|
||||
export * from './oauth'
|
||||
export * from './produced-files'
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { InferInsertModel, InferSelectModel } from 'drizzle-orm'
|
||||
import {
|
||||
index,
|
||||
integer,
|
||||
sqliteTable,
|
||||
text,
|
||||
uniqueIndex,
|
||||
} from 'drizzle-orm/sqlite-core'
|
||||
import { agentDefinitions } from './agents'
|
||||
|
||||
/**
|
||||
* Files an OpenClaw agent produced as part of a chat turn. Populated by
|
||||
* a per-turn workspace diff: snapshot the agent's CWD before
|
||||
* `chatStream(...)` runs, re-scan after the SSE `done` event fires,
|
||||
* write rows for any new or modified path. The rail UI groups by
|
||||
* `turn_id` and the inline artifact card renders one row per file.
|
||||
*
|
||||
* Schema is intentionally adapter-agnostic — V1 only enables the
|
||||
* watcher for the openclaw adapter, but V2 can plug Claude / Codex
|
||||
* into the same table without migrating.
|
||||
*/
|
||||
export const producedFiles = sqliteTable(
|
||||
'produced_files',
|
||||
{
|
||||
/** Stable id; opaque file handle in download/preview URLs. */
|
||||
id: text('id').primaryKey(),
|
||||
|
||||
/** FK → agent_definitions.id; CASCADE so agent deletion sweeps. */
|
||||
agentDefinitionId: text('agent_definition_id')
|
||||
.notNull()
|
||||
.references(() => agentDefinitions.id, { onDelete: 'cascade' }),
|
||||
|
||||
/** OpenClaw session that owns this turn (e.g. `session-abc`). */
|
||||
sessionKey: text('session_key').notNull(),
|
||||
|
||||
/** Identifier for the assistant turn that produced the file. */
|
||||
turnId: text('turn_id').notNull(),
|
||||
|
||||
/**
|
||||
* The user prompt that initiated this turn — denormalised so the
|
||||
* rail's "group by prompt" header doesn't have to join the JSONL
|
||||
* log. Capped at 280 chars in code; the column is unbounded.
|
||||
*/
|
||||
turnPrompt: text('turn_prompt').notNull(),
|
||||
|
||||
/** Workspace-relative path (e.g. `reports/q1.pdf`). */
|
||||
path: text('path').notNull(),
|
||||
|
||||
size: integer('size').notNull(),
|
||||
|
||||
/** mtime in ms — used to detect re-modifications. */
|
||||
mtimeMs: integer('mtime_ms').notNull(),
|
||||
|
||||
/** Server clock when our watcher first saw it. */
|
||||
createdAt: integer('created_at').notNull(),
|
||||
|
||||
/**
|
||||
* `'diff'` for the V1 per-turn workspace diff watcher;
|
||||
* `'tool'` reserved for the future tool-event parsing layer.
|
||||
*/
|
||||
detectedBy: text('detected_by', { enum: ['diff', 'tool'] })
|
||||
.notNull()
|
||||
.default('diff'),
|
||||
},
|
||||
(table) => [
|
||||
// One row per (agent, path) pair — re-modifications update in place,
|
||||
// so a tool that overwrites `report.pdf` doesn't accumulate
|
||||
// duplicate rows. The most recent turn that touched the file owns
|
||||
// the row.
|
||||
uniqueIndex('produced_files_agent_path_unique').on(
|
||||
table.agentDefinitionId,
|
||||
table.path,
|
||||
),
|
||||
// Outputs-rail query: latest files per agent.
|
||||
index('produced_files_agent_created_idx').on(
|
||||
table.agentDefinitionId,
|
||||
table.createdAt,
|
||||
),
|
||||
// Inline-card query: by turn.
|
||||
index('produced_files_turn_idx').on(table.turnId),
|
||||
// Cleanup hook: by session (when a session is deleted later).
|
||||
index('produced_files_session_idx').on(table.sessionKey),
|
||||
],
|
||||
)
|
||||
|
||||
export type ProducedFileRow = InferSelectModel<typeof producedFiles>
|
||||
export type NewProducedFileRow = InferInsertModel<typeof producedFiles>
|
||||
@@ -35,12 +35,6 @@ import { metrics } from './lib/metrics'
|
||||
import { isPortInUseError } from './lib/port-binding'
|
||||
import { Sentry } from './lib/sentry'
|
||||
import { seedSoulTemplate } from './lib/soul'
|
||||
import { migrateBuiltinSkills } from './skills/migrate'
|
||||
import {
|
||||
startSkillSync,
|
||||
stopSkillSync,
|
||||
syncBuiltinSkills,
|
||||
} from './skills/remote-sync'
|
||||
import { registry } from './tools/registry'
|
||||
import { VERSION } from './version'
|
||||
|
||||
@@ -122,7 +116,6 @@ export class Application {
|
||||
)
|
||||
|
||||
this.logStartupSummary()
|
||||
startSkillSync()
|
||||
|
||||
// OpenClaw is best-effort — a failure here must not crash the server.
|
||||
// The container runtime constructor throws synchronously on non-darwin
|
||||
@@ -155,7 +148,6 @@ export class Application {
|
||||
|
||||
stop(reason?: string): void {
|
||||
logger.info('Shutting down server...', { reason })
|
||||
stopSkillSync()
|
||||
getOpenClawService()
|
||||
.shutdown()
|
||||
.catch(() => {})
|
||||
@@ -177,8 +169,6 @@ export class Application {
|
||||
await ensureBrowserosDir()
|
||||
await cleanOldSessions()
|
||||
await seedSoulTemplate()
|
||||
await migrateBuiltinSkills()
|
||||
await syncBuiltinSkills()
|
||||
|
||||
initializeDb({
|
||||
dbPath: getDbPath(),
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { SkillMeta } from './types'
|
||||
|
||||
const SKILL_BEHAVIORAL_INSTRUCTION = `The following skills provide specialized instructions for specific tasks.
|
||||
When a task matches a skill's description, use filesystem_read to load the SKILL.md at the listed location before proceeding.
|
||||
When a skill references relative paths (e.g., scripts/), resolve them against the skill's directory (the parent of SKILL.md) and use absolute paths in tool calls.`
|
||||
|
||||
function escapeXml(str: string): string {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
export function buildSkillsCatalog(skills: SkillMeta[]): string {
|
||||
if (skills.length === 0) return ''
|
||||
|
||||
const skillEntries = skills
|
||||
.map(
|
||||
(s) =>
|
||||
`<skill>
|
||||
<name>${escapeXml(s.name)}</name>
|
||||
<description>${escapeXml(s.description)}</description>
|
||||
<location>${escapeXml(s.location)}</location>
|
||||
</skill>`,
|
||||
)
|
||||
.join('\n')
|
||||
|
||||
return `${SKILL_BEHAVIORAL_INSTRUCTION}
|
||||
|
||||
<available_skills>
|
||||
${skillEntries}
|
||||
</available_skills>`
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
---
|
||||
name: compare-prices
|
||||
description: Search for a product across multiple retailers in parallel, save pricing data to disk, and produce an HTML report with the best deals and direct product links. Use when the user asks to compare prices, find the best deal, or check prices across stores.
|
||||
metadata:
|
||||
display-name: Compare Prices
|
||||
enabled: "true"
|
||||
version: "1.0"
|
||||
---
|
||||
|
||||
# Compare Prices
|
||||
|
||||
Search for a product across retailers in parallel using a hidden window, save pricing data incrementally to disk, and deliver a clean HTML comparison report with direct links to every product page.
|
||||
|
||||
## When to Apply
|
||||
|
||||
Activate when the user asks to compare prices for a product, find the cheapest option, check if a price is good, or shop across multiple stores.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Phase 1 — Clarify
|
||||
|
||||
Confirm with the user before searching:
|
||||
|
||||
- **Product name** — exact model, variant, size, or color if applicable
|
||||
- **Retailer preferences** — any stores to include or exclude
|
||||
- **Region / currency** — defaults to user's locale
|
||||
|
||||
### Phase 2 — Set Up & Search
|
||||
|
||||
| Step | Tool | Detail |
|
||||
|------|------|--------|
|
||||
| Create output directory | `evaluate_script` | Create `compare-<product-slug>/` in your working directory with a `raw/` subfolder |
|
||||
| Open hidden window | `create_hidden_window` | Dedicated workspace — keeps the user's browsing undisturbed |
|
||||
| Open parallel tabs | `new_hidden_page` | Open up to **10 tabs** concurrently, one per retailer/search |
|
||||
|
||||
**Default search targets** (adjust based on product type and user's region):
|
||||
|
||||
| Tab | Target |
|
||||
|-----|--------|
|
||||
| 1 | Google Shopping — `https://www.google.com/search?tbm=shop&q=<product>` |
|
||||
| 2 | Amazon — `https://www.amazon.com/s?k=<product>` |
|
||||
| 3 | Walmart — `https://www.walmart.com/search?q=<product>` |
|
||||
| 4 | Best Buy — `https://www.bestbuy.com/site/searchpage.jsp?st=<product>` |
|
||||
| 5 | Target — `https://www.target.com/s?searchTerm=<product>` |
|
||||
| 6 | eBay — `https://www.ebay.com/sch/i.html?_nkw=<product>` |
|
||||
| 7–10 | Additional retailers relevant to the product category (Newegg for tech, Home Depot for tools, etc.) |
|
||||
|
||||
### Phase 3 — Extract & Save
|
||||
|
||||
For **each tab**, extract pricing data and save immediately:
|
||||
|
||||
| Step | Tool | Detail |
|
||||
|------|------|--------|
|
||||
| Navigate | `navigate_page` | Go to the search URL |
|
||||
| Read results | `get_page_content` | Extract the search results page as markdown |
|
||||
| Find best match | `navigate_page` | Click through to the most relevant product listing |
|
||||
| Extract pricing | `get_page_content` | Pull the product page content — price, availability, shipping, seller |
|
||||
| **Save raw data** | `evaluate_script` | Write to `raw/<retailer>.json` with all extracted fields (see format below) |
|
||||
| Close tab | `close_page` | Free the tab after saving |
|
||||
|
||||
**Never hold all retailer data in memory.** Save each retailer's data to its own file immediately after extraction.
|
||||
|
||||
#### Raw Data Format (`raw/<retailer>.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"retailer": "Amazon",
|
||||
"product_name": "Product Title as Listed",
|
||||
"product_url": "https://www.amazon.com/dp/...",
|
||||
"price": 299.99,
|
||||
"original_price": 349.99,
|
||||
"currency": "USD",
|
||||
"shipping": "Free",
|
||||
"availability": "In Stock",
|
||||
"seller": "Amazon.com",
|
||||
"condition": "New",
|
||||
"rating": "4.5/5",
|
||||
"notes": "Prime eligible",
|
||||
"extracted_at": "2025-03-11T10:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 4 — HTML Report
|
||||
|
||||
After all retailers are processed, read the saved `raw/*.json` files and generate a self-contained `report.html`:
|
||||
|
||||
| Requirement | Detail |
|
||||
|-------------|--------|
|
||||
| **Theme** | Light background (`#ffffff`), clean sans-serif typography, generous whitespace |
|
||||
| **Header** | Product name, search date, number of retailers checked |
|
||||
| **Best Deal banner** | Highlighted card at the top showing the lowest total price with a direct link to the product page |
|
||||
| **Comparison table** | All retailers sorted by total price (lowest first) with columns: Retailer, Price, Shipping, Total, Stock, Seller, Rating, Link |
|
||||
| **Product links** | Every retailer name and a "View Deal" button must be a clickable `<a href>` linking to the actual product page URL |
|
||||
| **Price highlights** | Lowest price in green, highest in muted gray. Show discount percentage if original price differs. |
|
||||
| **Self-contained** | All styles in a `<style>` block — no external CSS or JS |
|
||||
| **Responsive** | Readable on desktop and mobile |
|
||||
| **Footer** | "Generated by BrowserOS Compare Prices" with date |
|
||||
|
||||
Use `evaluate_script` to write `report.html` to the output directory.
|
||||
|
||||
### Phase 5 — Open & Notify
|
||||
|
||||
| Step | Tool | Detail |
|
||||
|------|------|--------|
|
||||
| Close hidden window | `close_window` | Clean up the research workspace |
|
||||
| Open report | `new_page` | Open `file://<path>/report.html` in the user's active window |
|
||||
| Notify user | — | Tell the user the comparison is complete, highlight the best deal, and provide the report path |
|
||||
|
||||
## Tool Reference
|
||||
|
||||
| Category | Tools Used |
|
||||
|----------|-----------|
|
||||
| Window management | `create_hidden_window`, `close_window` |
|
||||
| Tab management | `new_hidden_page`, `close_page`, `new_page` |
|
||||
| Navigation | `navigate_page` |
|
||||
| Content extraction | `get_page_content` |
|
||||
| Data & file I/O | `evaluate_script` |
|
||||
|
||||
## Tips
|
||||
|
||||
- **Always compare total price** (product + shipping), not just the listed price.
|
||||
- **Note the seller** — marketplace third-party sellers may have different return policies than the retailer itself.
|
||||
- Mention membership discounts (Prime, Walmart+) as a note, not as the default price.
|
||||
- If the product has variants (sizes, colors), ensure every retailer is quoting the same variant.
|
||||
- If a retailer blocks scraping or returns no results, skip it and note the gap in the report.
|
||||
- For used/refurbished listings, separate them from new-condition results.
|
||||
@@ -1,149 +0,0 @@
|
||||
---
|
||||
name: deep-research
|
||||
description: Research a topic across multiple sources using parallel tabs, save raw content and findings to files, then produce an HTML report and PDF. Use when the user asks to research, investigate, or gather information on a topic.
|
||||
metadata:
|
||||
display-name: Deep Research
|
||||
enabled: "true"
|
||||
version: "1.0"
|
||||
---
|
||||
|
||||
# Deep Research
|
||||
|
||||
End-to-end research workflow that searches the web in parallel tabs, persists raw content and notes to disk as it goes (instead of holding everything in memory), synthesizes findings, and delivers a polished HTML report plus PDF.
|
||||
|
||||
## When to Apply
|
||||
|
||||
Activate when the user asks to research a topic, compare information across sources, investigate something thoroughly, or compile findings from the web.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Phase 1 — Clarify & Plan
|
||||
|
||||
1. **Clarify the research question.** If the query is vague, ask the user for specifics: scope, depth, preferred sources, and where to save output (default: `research-<topic-slug>/` in your working directory).
|
||||
2. **Plan search queries.** Break the topic into 3–5 search angles. Example for "best standing desks":
|
||||
- `best standing desks 2025 reviews`
|
||||
- `standing desk comparison reddit`
|
||||
- `ergonomic standing desk features`
|
||||
- `standing desk health benefits studies`
|
||||
3. **Create the output directory.** Use `evaluate_script` to create the target folder structure:
|
||||
```
|
||||
research-<topic-slug>/
|
||||
├── sources/ ← raw page content per source
|
||||
├── findings.md ← running synthesis
|
||||
├── report.html ← final HTML report
|
||||
└── report.pdf ← final PDF report
|
||||
```
|
||||
|
||||
### Phase 2 — Parallel Research & Persistence
|
||||
|
||||
For **each** search query, open a parallel research tab and persist results to disk immediately:
|
||||
|
||||
| Step | Tool | Detail |
|
||||
|------|------|--------|
|
||||
| Open tab | `new_hidden_page` | Opens a background tab so research doesn't disrupt the user |
|
||||
| Search | `navigate_page` | Navigate to `https://www.google.com/search?q=<encoded-query>` (or the user's preferred search engine) |
|
||||
| Pick results | `get_page_content` / `get_page_links` | Read the search results page; identify the 2–3 most relevant links |
|
||||
| Visit source | `navigate_page` | Navigate to each selected result |
|
||||
| Extract content | `get_page_content` | Pull the full page text |
|
||||
| **Save raw content** | `evaluate_script` | Write a markdown file to `sources/<n>-<slug>.md` containing the page title, source URL, extraction date, and full text. **Always include the source URL** so every fact is traceable. |
|
||||
| Close tab | `close_page` | Free resources after extraction |
|
||||
|
||||
Repeat across all search angles. Run multiple tabs concurrently where possible.
|
||||
|
||||
#### Source File Format (`sources/<n>-<slug>.md`)
|
||||
|
||||
```markdown
|
||||
# <Page Title>
|
||||
|
||||
- **URL:** <source-url>
|
||||
- **Retrieved:** <date-time>
|
||||
|
||||
---
|
||||
|
||||
<extracted page content>
|
||||
```
|
||||
|
||||
### Phase 3 — Synthesize Findings
|
||||
|
||||
After all sources are saved:
|
||||
|
||||
1. **Read each source file** and extract key facts, data points, expert opinions, and areas of agreement or disagreement.
|
||||
2. **Write `findings.md`** in the output directory using the format below. Every claim must reference the source file and URL it came from.
|
||||
3. Continuously append to `findings.md` as you process each source — do not hold all content in memory.
|
||||
|
||||
#### Findings File Format (`findings.md`)
|
||||
|
||||
```markdown
|
||||
# Research Findings: <Topic>
|
||||
|
||||
**Date:** <current date>
|
||||
**Sources consulted:** <count>
|
||||
**Output directory:** <path>
|
||||
|
||||
## Key Findings
|
||||
|
||||
1. **<Finding title>**
|
||||
<Detail with supporting evidence>
|
||||
_Source: [<source name>](<url>) — sources/<n>-<slug>.md_
|
||||
|
||||
2. **<Finding title>**
|
||||
...
|
||||
|
||||
## Source Summary
|
||||
|
||||
| # | Source | URL | Key Insight | Credibility |
|
||||
|---|--------|-----|-------------|-------------|
|
||||
| 1 | <name> | <url> | <insight> | high / med / low |
|
||||
|
||||
## Agreements & Disagreements
|
||||
|
||||
- **Consensus:** ...
|
||||
- **Conflicting views:** ...
|
||||
|
||||
## Conclusion
|
||||
|
||||
<Synthesis of findings with actionable recommendation>
|
||||
```
|
||||
|
||||
### Phase 4 — HTML Report
|
||||
|
||||
Generate a self-contained `report.html` in the output directory with the following requirements:
|
||||
|
||||
| Requirement | Detail |
|
||||
|-------------|--------|
|
||||
| **Theme** | Light background (`#ffffff`), clean sans-serif typography, generous whitespace |
|
||||
| **Sections** | Title banner, executive summary, key findings (numbered cards), source table, conclusion |
|
||||
| **Source links** | Every finding must hyperlink to its original source URL. The source table must include clickable links. |
|
||||
| **Self-contained** | All styles inline or in a `<style>` block — no external CSS or JS dependencies |
|
||||
| **Responsive** | Readable on both desktop and mobile viewports |
|
||||
| **Footer** | "Generated by BrowserOS Deep Research" with the current date |
|
||||
|
||||
Use `evaluate_script` to write the HTML string to `report.html` in the output directory.
|
||||
|
||||
### Phase 5 — Open, Export & Notify
|
||||
|
||||
| Step | Tool | Detail |
|
||||
|------|------|--------|
|
||||
| Open report | `new_page` | Open `file://<path>/report.html` so the user sees the finished report |
|
||||
| Export PDF | `save_pdf` | Save the currently open report page as `report.pdf` in the same output directory |
|
||||
| Notify user | — | Tell the user research is complete and provide paths to both `report.html` and `report.pdf` |
|
||||
|
||||
## Tool Reference
|
||||
|
||||
| Category | Tools Used |
|
||||
|----------|-----------|
|
||||
| Tab management | `new_hidden_page`, `new_page`, `close_page` |
|
||||
| Navigation | `navigate_page` |
|
||||
| Content extraction | `get_page_content`, `get_page_links` |
|
||||
| File I/O & scripting | `evaluate_script` |
|
||||
| Export | `save_pdf` |
|
||||
|
||||
## Tips
|
||||
|
||||
- **4–6 sources** is the sweet spot for balanced coverage. More isn't always better.
|
||||
- **Prioritize recent sources** — check publication dates and prefer current information.
|
||||
- **Note disagreements** between sources rather than hiding them; surface conflicting data.
|
||||
- **Always record the source URL** next to every fact so the report is fully traceable.
|
||||
- For product research, include pricing and availability.
|
||||
- For technical topics, prefer official documentation and peer-reviewed sources.
|
||||
- If a Google search returns unhelpful results, try alternative queries or go directly to known authoritative sites.
|
||||
@@ -1,136 +0,0 @@
|
||||
---
|
||||
name: extract-data
|
||||
description: Extract structured data from web pages — tables, lists, product info, pricing — into clean CSV, JSON, or markdown tables. Parallelizes across hidden tabs for multi-source extraction and saves results to disk incrementally. Use when the user asks to scrape, extract, or pull data from a page.
|
||||
metadata:
|
||||
display-name: Extract Data
|
||||
enabled: "true"
|
||||
version: "1.0"
|
||||
---
|
||||
|
||||
# Extract Data
|
||||
|
||||
End-to-end data extraction workflow that pulls structured content from one or many web pages, saves results to disk incrementally (never accumulating everything in memory), and delivers clean output in the user's preferred format.
|
||||
|
||||
## When to Apply
|
||||
|
||||
Activate when the user asks to extract, scrape, pull, or collect structured data from web pages — tables, product listings, pricing, contact info, search results, leaderboards, or any repeating data pattern.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Phase 1 — Clarify & Plan
|
||||
|
||||
1. **Clarify the request.** Before extracting, confirm with the user:
|
||||
- **Source(s):** Single page, list of URLs, or search-then-extract?
|
||||
- **Output format:** CSV, JSON, or Markdown table? Default to CSV if not specified.
|
||||
- **Output location:** Where to save files. Default: `extract-<topic-slug>/` in your working directory.
|
||||
- **What data to extract:** Column names, specific fields, or "everything in the table."
|
||||
2. **Create the output directory.** Use `evaluate_script` to create the target folder:
|
||||
```
|
||||
extract-<topic-slug>/
|
||||
├── raw/ ← per-page extracted content
|
||||
├── merged.<format> ← final combined output (csv / json)
|
||||
└── extraction.log ← progress log with source URLs
|
||||
```
|
||||
|
||||
### Phase 2 — Single-Page Extraction
|
||||
|
||||
For a **single page** (or each individual page in a batch):
|
||||
|
||||
| Step | Tool | Detail |
|
||||
|------|------|--------|
|
||||
| Navigate | `navigate_page` | Go to the target URL (skip if already on the page) |
|
||||
| Read content | `get_page_content` | Extract the page as markdown — this captures tables, lists, and text in a structured format |
|
||||
| Identify structure | — | Determine the data pattern: HTML table, repeated cards, key-value pairs, etc. |
|
||||
| Extract data | `evaluate_script` | For complex structures (e.g., product grids, nested cards), run JavaScript to query elements and return a JSON array. For clean markdown tables from `get_page_content`, parse directly. |
|
||||
| **Save immediately** | `evaluate_script` | Write the extracted data to `raw/<n>-<slug>.<format>` with a header comment containing the source URL and timestamp |
|
||||
| Log progress | `evaluate_script` | Append the source URL, row count, and status to `extraction.log` |
|
||||
|
||||
#### Handling Pagination
|
||||
|
||||
If the page has pagination (next buttons, page numbers, infinite scroll):
|
||||
|
||||
1. Extract the current page's data and save to `raw/<n>-page-<p>.<format>`
|
||||
2. Use `click` or `navigate_page` to go to the next page
|
||||
3. Repeat until all pages are processed or a user-specified limit is reached
|
||||
4. Each page's data is saved to its own file immediately — never accumulate across pages in memory
|
||||
|
||||
### Phase 3 — Multi-Source Parallel Extraction
|
||||
|
||||
When extracting from **multiple URLs or sources**, parallelize using a hidden window:
|
||||
|
||||
| Step | Tool | Detail |
|
||||
|------|------|--------|
|
||||
| Create workspace | `create_hidden_window` | Open a dedicated hidden window for extraction work — keeps the user's browsing undisturbed |
|
||||
| Open batch of tabs | `new_hidden_page` | Open up to **10 tabs concurrently** within the hidden window, one per source URL |
|
||||
| Extract per tab | `navigate_page` → `get_page_content` → `evaluate_script` | For each tab: navigate, extract content, parse structured data |
|
||||
| Save per tab | `evaluate_script` | Write each tab's results to `raw/<n>-<slug>.<format>` immediately after extraction |
|
||||
| Close tab | `close_page` | Free the tab after its data is saved |
|
||||
| Next batch | — | Once a batch of 10 completes, open the next batch. Continue until all sources are processed. |
|
||||
| Close workspace | `close_window` | Close the hidden window after all extraction is done |
|
||||
|
||||
**Concurrency rule:** Never exceed 10 open tabs at a time. Process in batches of 10, saving and closing before opening the next batch.
|
||||
|
||||
### Phase 4 — Merge & Format
|
||||
|
||||
After all raw files are saved:
|
||||
|
||||
1. **Read each raw file** from `raw/` using `evaluate_script`.
|
||||
2. **Merge into a single output file** (`merged.csv`, `merged.json`, or `merged.md`) with:
|
||||
- Consistent column headers / keys across all sources
|
||||
- A `source_url` column so every row is traceable to its origin
|
||||
- Deduplication if the same record appears in multiple sources
|
||||
3. **Write the merged file** to the output directory.
|
||||
4. For large datasets, provide a summary: total rows, sources processed, any errors.
|
||||
|
||||
#### Output Formats
|
||||
|
||||
| Format | File | Notes |
|
||||
|--------|------|-------|
|
||||
| **CSV** | `merged.csv` | Header row, comma-separated, properly escaped. Include `source_url` as the last column. |
|
||||
| **JSON** | `merged.json` | Array of objects with consistent keys. Each object includes a `source_url` field. |
|
||||
| **Markdown** | `merged.md` | Aligned table with headers. Source URL in the last column. |
|
||||
|
||||
### Phase 5 — HTML Report
|
||||
|
||||
Generate a self-contained `report.html` in the output directory that serves as an index for the entire extraction.
|
||||
|
||||
| Requirement | Detail |
|
||||
|-------------|--------|
|
||||
| **Theme** | Light background (`#ffffff`), clean sans-serif typography, generous whitespace |
|
||||
| **Header** | Title, date, total rows extracted, number of sources processed |
|
||||
| **What was done** | Brief description of the extraction: source URLs, data fields extracted, format used |
|
||||
| **File index** | Table listing every file in the output directory (`raw/*`, `merged.*`, `extraction.log`) with file paths as clickable `file://` links so the user can open them directly |
|
||||
| **Data preview** | First 20 rows of the merged dataset rendered as an HTML table |
|
||||
| **Source list** | All source URLs as clickable hyperlinks with the row count extracted from each |
|
||||
| **Self-contained** | All styles inline or in a `<style>` block — no external dependencies |
|
||||
| **Footer** | "Generated by BrowserOS Extract Data" with the current date |
|
||||
|
||||
Use `evaluate_script` to write the HTML file to the output directory.
|
||||
|
||||
### Phase 6 — Open & Notify
|
||||
|
||||
| Step | Tool | Detail |
|
||||
|------|------|--------|
|
||||
| Open report | `new_page` | Open `file://<path>/report.html` so the user sees the extraction summary |
|
||||
| Notify user | — | Tell the user: extraction is complete, total rows, source count, and paths to `report.html` and `merged.<format>` |
|
||||
|
||||
## Tool Reference
|
||||
|
||||
| Category | Tools Used |
|
||||
|----------|-----------|
|
||||
| Window management | `create_hidden_window`, `close_window` |
|
||||
| Tab management | `new_hidden_page`, `close_page`, `new_page` |
|
||||
| Navigation | `navigate_page` |
|
||||
| Content extraction | `get_page_content` |
|
||||
| Data parsing & file I/O | `evaluate_script` |
|
||||
| Interaction | `click` (for pagination) |
|
||||
|
||||
## Tips
|
||||
|
||||
- **Always ask the format first.** CSV, JSON, and Markdown have different strengths — let the user decide.
|
||||
- **Save after every page.** Never hold more than one page's worth of data in memory at a time.
|
||||
- **10 tabs max.** More tabs degrades performance and risks timeouts. Batch in groups of 10.
|
||||
- **Record the source URL** on every row and in every raw file so data is fully traceable.
|
||||
- Clean up extracted data: trim whitespace, normalize currency symbols, remove hidden characters.
|
||||
- For paginated sites, check for a total count or "showing X of Y" to estimate progress.
|
||||
- If a page requires login or blocks extraction, report it to the user rather than retrying silently.
|
||||
@@ -1,49 +0,0 @@
|
||||
---
|
||||
name: fill-form
|
||||
description: Intelligently fill web forms using provided data — handles text fields, dropdowns, checkboxes, radio buttons, and multi-step forms. Use when the user asks to fill out, complete, or submit a form.
|
||||
metadata:
|
||||
display-name: Fill Form
|
||||
enabled: "true"
|
||||
version: "1.0"
|
||||
---
|
||||
|
||||
# Fill Form
|
||||
|
||||
## When to Use
|
||||
|
||||
Activate when the user asks to fill out a form, complete an application, enter data into fields, or submit information on a web page.
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Collect the data to fill.** Ask the user for the information if not already provided. Organize it as key-value pairs.
|
||||
|
||||
2. **Take a snapshot** using `take_snapshot` to see the form fields and understand the layout.
|
||||
|
||||
3. **Map data to fields.** Match the user's data keys to form field labels. Handle common variations:
|
||||
- "Name" may map to "Full Name", "Your Name", or separate "First Name" + "Last Name" fields
|
||||
- "Phone" may map to "Phone Number", "Mobile", "Tel"
|
||||
- "Address" may need to split into Street, City, State, Zip
|
||||
|
||||
4. **Fill fields in order.** For each field:
|
||||
- **Text inputs:** Use `fill` with the field selector and value
|
||||
- **Dropdowns/selects:** Use `select_option` with the appropriate value
|
||||
- **Checkboxes:** Use `check` to toggle on/off
|
||||
- **Radio buttons:** Use `click` on the correct option
|
||||
- **Date pickers:** Try `fill` first; if that fails, interact with the date picker UI using `click`
|
||||
- **File uploads:** Use `upload_file` for attachment fields
|
||||
|
||||
5. **Handle multi-step forms.** After filling visible fields:
|
||||
- Look for "Next", "Continue", or "Step 2" buttons
|
||||
- Use `click` to advance
|
||||
- Take a new snapshot to see the next step's fields
|
||||
- Repeat the fill process
|
||||
|
||||
6. **Review before submission.** Take a final `take_snapshot` and present the filled form to the user for confirmation before clicking Submit.
|
||||
|
||||
## Tips
|
||||
|
||||
- Fill fields top-to-bottom, left-to-right to match natural tab order.
|
||||
- For auto-complete fields (like address), type slowly and wait for suggestions to appear, then select.
|
||||
- If a field has validation errors after filling, read the error message and adjust the value.
|
||||
- Never submit payment forms without explicit user confirmation.
|
||||
- For CAPTCHA fields, inform the user they need to complete it manually.
|
||||
@@ -1,189 +0,0 @@
|
||||
---
|
||||
name: find-alternatives
|
||||
description: Find alternative products to something the user is looking at or considering. Searches across retailers and review sites, compares options, and delivers a ranked HTML report with ratings, pricing, and direct links. Use when the user asks for alternatives, similar products, or "something like this but..."
|
||||
metadata:
|
||||
display-name: Find Alternatives
|
||||
enabled: "true"
|
||||
version: "1.1"
|
||||
---
|
||||
|
||||
# Find Alternatives
|
||||
|
||||
Search for alternative products across retailers and review sites, save research data incrementally to disk, rank the top 5 alternatives on a 1–5 scale, and deliver a clean HTML comparison report with direct product links.
|
||||
|
||||
## When to Apply
|
||||
|
||||
Activate when the user:
|
||||
|
||||
- Asks for alternatives to a product they're viewing or considering
|
||||
- Says "something like this but cheaper / better / different"
|
||||
- Wants to explore options before buying
|
||||
- Asks "what else is out there" for a product category
|
||||
|
||||
## Workflow
|
||||
|
||||
### Phase 1 — Understand the Product
|
||||
|
||||
1. **Identify the reference product.** Use `get_active_page` and `get_page_content` to understand what the user is currently looking at — product name, brand, price, key features, category.
|
||||
2. **Confirm with the user:**
|
||||
- **Price range** — same range, cheaper, or open budget? If unclear, default to ±30% of the reference product's price.
|
||||
- **Key criteria** — what matters most? (e.g., price, quality, brand, specific features)
|
||||
- **Any exclusions** — brands or stores to skip
|
||||
3. **Create output directory.** Use `evaluate_script` to create in your working directory:
|
||||
```
|
||||
alternatives-<product-slug>/
|
||||
├── raw/ ← per-source research data
|
||||
├── findings.md ← running notes and rankings
|
||||
└── report.html ← final HTML report
|
||||
```
|
||||
|
||||
### Phase 2 — Research Alternatives
|
||||
|
||||
| Step | Tool | Detail |
|
||||
|------|------|--------|
|
||||
| Open hidden window | `create_hidden_window` | Dedicated research workspace |
|
||||
| Search in parallel | `new_hidden_page` | Open up to **10 tabs** concurrently across search targets |
|
||||
|
||||
**Search targets** (adapt to product category):
|
||||
|
||||
| Tab | Target | Query |
|
||||
|-----|--------|-------|
|
||||
| 1 | Google Shopping | `{product category} alternatives under ${budget}` |
|
||||
| 2 | Google Search | `best {product category} alternatives {year} reddit` |
|
||||
| 3 | Google Search | `{product category} vs comparison {year}` |
|
||||
| 4 | Amazon | `{product category}` filtered to price range |
|
||||
| 5 | Walmart | `{product category}` in price range |
|
||||
| 6 | Best Buy / category retailer | `{product category}` |
|
||||
| 7–10 | Review sites, Reddit threads, or niche retailers relevant to the category |
|
||||
|
||||
For **each tab**:
|
||||
|
||||
| Step | Tool | Detail |
|
||||
|------|------|--------|
|
||||
| Navigate | `navigate_page` | Go to the search URL |
|
||||
| Read results | `get_page_content` | Extract search results as markdown |
|
||||
| Visit promising results | `navigate_page` | Click through to individual product pages and review articles |
|
||||
| Extract data | `get_page_content` | Pull product details — name, price, features, ratings, reviews |
|
||||
| **Save immediately** | `evaluate_script` | Write to `raw/{n}-{source-slug}.json` (see format below) |
|
||||
| Close tab | `close_page` | Free the tab after saving |
|
||||
|
||||
#### Raw Data Format (`raw/{n}-{source-slug}.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "Amazon",
|
||||
"source_url": "https://www.amazon.com/...",
|
||||
"products": [
|
||||
{
|
||||
"name": "Product Name",
|
||||
"brand": "Brand",
|
||||
"product_url": "https://...",
|
||||
"price": 149.99,
|
||||
"currency": "USD",
|
||||
"rating": "4.3/5",
|
||||
"review_count": 1250,
|
||||
"key_features": ["feature 1", "feature 2"],
|
||||
"availability": "In Stock",
|
||||
"image_url": "https://..."
|
||||
}
|
||||
],
|
||||
"extracted_at": "2025-03-11T10:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 3 — Rank & Synthesize
|
||||
|
||||
After all sources are saved:
|
||||
|
||||
1. **Read each raw file** from `raw/` using `evaluate_script`.
|
||||
2. **Deduplicate** — the same product may appear across multiple retailers. Group by product, keep the best price.
|
||||
3. **Select the top 5 alternatives** based on:
|
||||
- Price relative to budget
|
||||
- User ratings and review volume
|
||||
- Feature match to the user's criteria
|
||||
- Availability
|
||||
4. **Rate each alternative 1–5** on a composite scale:
|
||||
|
||||
| Rating | Meaning |
|
||||
|--------|---------|
|
||||
| ⭐⭐⭐⭐⭐ 5 | Excellent match — great price, high ratings, strong features |
|
||||
| ⭐⭐⭐⭐ 4 | Very good — minor trade-offs |
|
||||
| ⭐⭐⭐ 3 | Decent — good in some areas, weaker in others |
|
||||
| ⭐⭐ 2 | Fair — notable compromises |
|
||||
| ⭐ 1 | Marginal — only worth considering for a specific reason |
|
||||
|
||||
5. **Write `findings.md`** with the full ranking, reasoning, and source references:
|
||||
|
||||
```markdown
|
||||
# Alternatives for: {Reference Product}
|
||||
|
||||
**Reference price:** $X
|
||||
**Budget range:** $X – $Y
|
||||
**Date:** {current date}
|
||||
|
||||
## Top 5 Alternatives
|
||||
|
||||
### 1. {Product Name} — ⭐⭐⭐⭐⭐ (5/5)
|
||||
- **Price:** $X at {Retailer}
|
||||
- **Why:** {1–2 sentence justification}
|
||||
- **Link:** {product URL}
|
||||
- _Source: raw/{n}-{slug}.json_
|
||||
|
||||
### 2. {Product Name} — ⭐⭐⭐⭐ (4/5)
|
||||
...
|
||||
|
||||
## Comparison vs Reference
|
||||
|
||||
| Feature | Reference | Alt 1 | Alt 2 | Alt 3 | Alt 4 | Alt 5 |
|
||||
|---------|-----------|-------|-------|-------|-------|-------|
|
||||
| Price | $X | $X | $X | $X | $X | $X |
|
||||
| Rating | 4.2/5 | 4.5/5 | 4.3/5 | 4.1/5 | 3.9/5 | 4.0/5 |
|
||||
```
|
||||
|
||||
### Phase 4 — HTML Report
|
||||
|
||||
Generate a self-contained `report.html` in the output directory:
|
||||
|
||||
| Requirement | Detail |
|
||||
|-------------|--------|
|
||||
| **Theme** | Light background (`#ffffff`), clean sans-serif typography, generous whitespace |
|
||||
| **Header** | "Alternatives for: {Product Name}", date, budget range |
|
||||
| **Reference product card** | Show the original product with its price, rating, and link as the baseline |
|
||||
| **Top 5 cards** | Each alternative as a card showing: rank, name, rating (star visualization), price, key features, and a clickable "View Product" link to the actual product page |
|
||||
| **Comparison table** | Side-by-side table with the reference product and all 5 alternatives — price, rating, key features, pros/cons |
|
||||
| **Rating explanation** | Brief note on how the 1–5 rating was determined |
|
||||
| **Product links** | Every product name and "View Product" button must be a clickable link to the actual product URL |
|
||||
| **Source references** | Footer section listing all sources consulted with links |
|
||||
| **Self-contained** | All styles in a style block — no external CSS or JS |
|
||||
| **Responsive** | Readable on desktop and mobile |
|
||||
| **Footer** | "Generated by BrowserOS Find Alternatives" with date |
|
||||
|
||||
Use `evaluate_script` to write the HTML file.
|
||||
|
||||
### Phase 5 — Open & Notify
|
||||
|
||||
| Step | Tool | Detail |
|
||||
|------|------|--------|
|
||||
| Close hidden window | `close_window` | Clean up the research workspace |
|
||||
| Open report | `new_page` | Open `file://{path}/report.html` in the user's active window |
|
||||
| Notify user | — | Summarize the top pick, mention the report path, and highlight any standout findings |
|
||||
|
||||
## Tool Reference
|
||||
|
||||
| Category | Tools Used |
|
||||
|----------|-----------|
|
||||
| Page info | `get_active_page` |
|
||||
| Window management | `create_hidden_window`, `close_window` |
|
||||
| Tab management | `new_hidden_page`, `close_page`, `new_page` |
|
||||
| Navigation | `navigate_page` |
|
||||
| Content extraction | `get_page_content` |
|
||||
| Data & file I/O | `evaluate_script` |
|
||||
|
||||
## Tips
|
||||
|
||||
- **Save after every source.** Never accumulate all research data in memory.
|
||||
- **10 tabs max** at a time. Batch if there are more sources.
|
||||
- **Deduplicate across retailers** — the same product on Amazon and Walmart should appear once with the best price noted.
|
||||
- If the reference product is niche, broaden the search to the general category rather than exact alternatives.
|
||||
- Include at least one budget option and one premium option to give the user a range.
|
||||
- If a product has very few reviews (under 50), note the low confidence in the rating.
|
||||
@@ -1,33 +0,0 @@
|
||||
import comparePrices from './compare-prices/SKILL.md' with { type: 'text' }
|
||||
import deepResearch from './deep-research/SKILL.md' with { type: 'text' }
|
||||
import extractData from './extract-data/SKILL.md' with { type: 'text' }
|
||||
import fillForm from './fill-form/SKILL.md' with { type: 'text' }
|
||||
import findAlternatives from './find-alternatives/SKILL.md' with {
|
||||
type: 'text',
|
||||
}
|
||||
import manageBookmarks from './manage-bookmarks/SKILL.md' with { type: 'text' }
|
||||
import monitorPage from './monitor-page/SKILL.md' with { type: 'text' }
|
||||
import organizeTabs from './organize-tabs/SKILL.md' with { type: 'text' }
|
||||
import readLater from './read-later/SKILL.md' with { type: 'text' }
|
||||
import savePage from './save-page/SKILL.md' with { type: 'text' }
|
||||
import screenshotWalkthrough from './screenshot-walkthrough/SKILL.md' with {
|
||||
type: 'text',
|
||||
}
|
||||
import summarizePage from './summarize-page/SKILL.md' with { type: 'text' }
|
||||
|
||||
type DefaultSkill = { id: string; content: string }
|
||||
|
||||
export const DEFAULT_SKILLS: DefaultSkill[] = [
|
||||
{ id: 'summarize-page', content: summarizePage },
|
||||
{ id: 'deep-research', content: deepResearch },
|
||||
{ id: 'extract-data', content: extractData },
|
||||
{ id: 'fill-form', content: fillForm },
|
||||
{ id: 'screenshot-walkthrough', content: screenshotWalkthrough },
|
||||
{ id: 'organize-tabs', content: organizeTabs },
|
||||
{ id: 'compare-prices', content: comparePrices },
|
||||
{ id: 'find-alternatives', content: findAlternatives },
|
||||
{ id: 'save-page', content: savePage },
|
||||
{ id: 'monitor-page', content: monitorPage },
|
||||
{ id: 'read-later', content: readLater },
|
||||
{ id: 'manage-bookmarks', content: manageBookmarks },
|
||||
]
|
||||
@@ -1,109 +0,0 @@
|
||||
---
|
||||
name: manage-bookmarks
|
||||
description: Organize bookmarks — find duplicates, categorize by topic, create a clean folder structure, and clean up unused bookmarks. Use when the user asks to organize, clean up, sort, or manage their bookmarks.
|
||||
metadata:
|
||||
display-name: Manage Bookmarks
|
||||
enabled: "true"
|
||||
version: "1.0"
|
||||
---
|
||||
|
||||
# Manage Bookmarks
|
||||
|
||||
Analyze the user's bookmark collection, propose a clean top-level folder structure (max 5 folders), execute with confirmation, and deliver a markdown summary of everything that changed.
|
||||
|
||||
## When to Apply
|
||||
|
||||
Activate when the user asks to organize bookmarks, find duplicates, create bookmark folders, clean up old bookmarks, or restructure their bookmark library.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Phase 1 — Analyze
|
||||
|
||||
1. **Retrieve bookmarks** using `get_bookmarks` to get the full bookmark tree.
|
||||
2. **Analyze the collection thoroughly:**
|
||||
- Total bookmarks and existing folders
|
||||
- Duplicates (same URL, possibly different titles)
|
||||
- Group every bookmark by domain and inferred topic
|
||||
- Identify dead or broken patterns (e.g., `localhost`, empty titles)
|
||||
|
||||
3. **Present the analysis to the user.** Use short one-word slug categories:
|
||||
|
||||
```
|
||||
## Bookmark Analysis
|
||||
|
||||
**Total:** 342 bookmarks, 12 folders
|
||||
**Duplicates:** 8
|
||||
|
||||
### Proposed Folders (top-level)
|
||||
- dev — 94 bookmarks (GitHub, Stack Overflow, docs)
|
||||
- work — 67 bookmarks (Notion, Slack, Jira, company domains)
|
||||
- news — 45 bookmarks (HN, Reddit, RSS feeds)
|
||||
- shop — 28 bookmarks (Amazon, product pages)
|
||||
- misc — 108 bookmarks (everything else)
|
||||
|
||||
### Duplicates to Remove
|
||||
- github.com/user/repo × 3 (keep: "User/Repo - GitHub")
|
||||
- notion.so/page × 2 (keep: "Project Notes")
|
||||
```
|
||||
|
||||
**Folder naming rules:**
|
||||
- One-word lowercase slugs: `dev`, `work`, `news`, `shop`, `ref`, `social`, `misc`
|
||||
- **Maximum 3–5 top-level folders.** Fewer is better. Do not over-categorize.
|
||||
- Only suggest subfolders if the user explicitly asks for deeper organization
|
||||
|
||||
4. **Wait for confirmation.** Do not proceed until the user says to go ahead. If they want changes to the plan (rename folders, merge categories, split a group), adjust and re-present.
|
||||
|
||||
### Phase 2 — Organize
|
||||
|
||||
Once the user confirms:
|
||||
|
||||
| Step | Tool | Detail |
|
||||
|------|------|--------|
|
||||
| Create folders | `create_bookmark` | Create each top-level folder from the approved plan |
|
||||
| Move bookmarks | `move_bookmark` | Move each bookmark into its assigned folder |
|
||||
| Remove duplicates | `remove_bookmark` | Remove confirmed duplicates, keeping the one with the better title |
|
||||
|
||||
**Order matters:** Create all folders first, then move bookmarks, then remove duplicates.
|
||||
|
||||
### Phase 3 — Summary
|
||||
|
||||
After all operations complete, present a clean markdown summary:
|
||||
|
||||
```markdown
|
||||
## Bookmark Cleanup Complete
|
||||
|
||||
**Before:** 342 bookmarks, 12 folders
|
||||
**After:** 334 bookmarks, 5 folders
|
||||
|
||||
### Created Folders
|
||||
- dev (94 bookmarks)
|
||||
- work (67 bookmarks)
|
||||
- news (45 bookmarks)
|
||||
- shop (28 bookmarks)
|
||||
- misc (108 bookmarks)
|
||||
|
||||
### Duplicates Removed (8)
|
||||
- github.com/user/repo — removed 2 copies
|
||||
- notion.so/page — removed 1 copy
|
||||
|
||||
### Moved
|
||||
- 287 bookmarks reorganized into new folders
|
||||
- 47 bookmarks already in correct location
|
||||
```
|
||||
|
||||
## Tool Reference
|
||||
|
||||
| Category | Tools Used |
|
||||
|----------|-----------|
|
||||
| Read | `get_bookmarks` |
|
||||
| Create | `create_bookmark` |
|
||||
| Move | `move_bookmark` |
|
||||
| Delete | `remove_bookmark` |
|
||||
|
||||
## Tips
|
||||
|
||||
- **Never delete without confirmation.** Always present the plan and wait for the user to say proceed.
|
||||
- **Keep it flat.** 3–5 top-level folders covers most collections. Resist the urge to create deep hierarchies.
|
||||
- When removing duplicates, keep the bookmark with the more descriptive title.
|
||||
- For very large collections (500+), process in batches by category to avoid timeouts.
|
||||
- Some users prefer a flat bookmark bar — ask about their preferred structure before reorganizing.
|
||||
@@ -1,4 +0,0 @@
|
||||
declare module '*.md' {
|
||||
const content: string
|
||||
export default content
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
---
|
||||
name: monitor-page
|
||||
description: Track changes on a web page by comparing content snapshots over time. Use when the user wants to watch for updates, price drops, stock availability, or content changes.
|
||||
metadata:
|
||||
display-name: Monitor Page
|
||||
enabled: "true"
|
||||
version: "1.0"
|
||||
---
|
||||
|
||||
# Monitor Page
|
||||
|
||||
## When to Use
|
||||
|
||||
Activate when the user asks to monitor a page for changes, watch for price drops, track stock availability, detect new content, or be alerted when something changes on a website.
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Clarify what to monitor.** Ask the user:
|
||||
- What URL to watch
|
||||
- What specific content to track (price, stock status, text, any change)
|
||||
- How to identify the target content (a specific section, element, or keyword)
|
||||
|
||||
2. **Capture the baseline.** Navigate to the page and extract the current state:
|
||||
- Use `navigate_page` to load the target URL
|
||||
- Use `get_page_content` or `evaluate_script` to extract the specific content to track
|
||||
- Save the baseline to memory using `memory_write` with a descriptive key like `monitor:{url-slug}:baseline`
|
||||
|
||||
3. **Check for changes.** On subsequent checks:
|
||||
- Navigate to the same URL
|
||||
- Extract the same content using the same method
|
||||
- Compare against the saved baseline
|
||||
- Report differences
|
||||
|
||||
4. **Report findings:**
|
||||
|
||||
### If changes detected:
|
||||
```
|
||||
## Page Change Detected
|
||||
|
||||
**URL:** [url]
|
||||
**Checked:** [current date/time]
|
||||
|
||||
### Changes
|
||||
- **Before:** [previous value]
|
||||
- **After:** [current value]
|
||||
```
|
||||
|
||||
### If no changes:
|
||||
```
|
||||
No changes detected on [URL].
|
||||
Last checked: [current date/time]
|
||||
Monitoring: [what you're tracking]
|
||||
```
|
||||
|
||||
5. **Update the baseline** after reporting changes, using `memory_write` to store the new state.
|
||||
|
||||
## Tips
|
||||
|
||||
- For price monitoring, extract just the price element rather than the full page to avoid false positives from ad changes.
|
||||
- Use `evaluate_script` with specific CSS selectors for precise element tracking.
|
||||
- Suggest the user set a reminder to ask you to check again — BrowserOS doesn't yet have scheduled tasks.
|
||||
- For stock availability, look for phrases like "In Stock", "Out of Stock", or "Add to Cart" button presence.
|
||||
@@ -1,65 +0,0 @@
|
||||
---
|
||||
name: organize-tabs
|
||||
description: Analyze open tabs, group related ones by topic, close duplicates, and clean up tab clutter. Use when the user asks to organize, clean up, sort, or manage their tabs.
|
||||
metadata:
|
||||
display-name: Organize Tabs
|
||||
enabled: "true"
|
||||
version: "1.0"
|
||||
---
|
||||
|
||||
# Organize Tabs
|
||||
|
||||
## When to Use
|
||||
|
||||
Activate when the user asks to organize tabs, clean up tab clutter, group related tabs, close duplicates, or manage their open browser tabs.
|
||||
|
||||
## Steps
|
||||
|
||||
1. **List all open tabs** using `list_pages` to get the full inventory of open pages with their titles and URLs.
|
||||
|
||||
2. **Analyze and categorize.** Group tabs by:
|
||||
- **Domain** — Same website tabs together
|
||||
- **Topic** — Related content across domains (e.g., all "travel planning" tabs)
|
||||
- **Activity** — Shopping, research, social media, work, entertainment
|
||||
|
||||
3. **Identify issues:**
|
||||
- **Duplicates** — Same URL open in multiple tabs
|
||||
- **Dead tabs** — Error pages, "page not found", crashed tabs
|
||||
- **Stale tabs** — Tabs that are likely no longer needed
|
||||
|
||||
4. **Present a plan to the user:**
|
||||
|
||||
```
|
||||
## Tab Analysis
|
||||
|
||||
**Total tabs:** [N]
|
||||
|
||||
### Groups Found
|
||||
- Work: [list of tabs]
|
||||
- Research: [list of tabs]
|
||||
- Shopping: [list of tabs]
|
||||
- Uncategorized: [list of tabs]
|
||||
|
||||
### Issues
|
||||
- Duplicates: [N] tabs (will close extras)
|
||||
- Dead/Error pages: [N] tabs (will close)
|
||||
|
||||
### Proposed Actions
|
||||
1. Group [N] tabs into [M] tab groups
|
||||
2. Close [N] duplicate tabs
|
||||
3. Close [N] dead tabs
|
||||
```
|
||||
|
||||
5. **Execute with user confirmation:**
|
||||
- Use `group_tabs` to create named tab groups for each category
|
||||
- Use `close_page` to close duplicates (keep the first instance)
|
||||
- Use `close_page` to close dead/error tabs
|
||||
|
||||
6. **Offer to bookmark** stale tabs before closing using `create_bookmark`.
|
||||
|
||||
## Tips
|
||||
|
||||
- Always ask before closing tabs — users may have unsaved work.
|
||||
- Keep at least one tab open at all times.
|
||||
- For duplicate detection, compare URLs after removing query parameters and fragments.
|
||||
- If the user has 50+ tabs, prioritize grouping over individual analysis.
|
||||
@@ -1,50 +0,0 @@
|
||||
---
|
||||
name: read-later
|
||||
description: Bookmark the current page to a "📚 Read Later" folder and save a PDF copy for offline reading. Use when the user wants to save a page for later, bookmark it for reading, or keep an offline copy.
|
||||
metadata:
|
||||
display-name: Read Later
|
||||
enabled: "true"
|
||||
version: "1.0"
|
||||
---
|
||||
|
||||
# Read Later
|
||||
|
||||
Quick-save the current page: bookmark it into a dedicated "📚 Read Later" folder and download a PDF copy for offline reading.
|
||||
|
||||
## When to Apply
|
||||
|
||||
Activate when the user asks to save a page for later, read it later, bookmark something to come back to, or keep an offline copy of an article.
|
||||
|
||||
## Workflow
|
||||
|
||||
| Step | Tool | Detail |
|
||||
|------|------|--------|
|
||||
| Get current page | `get_active_page` | Identify the page URL and title |
|
||||
| Check for folder | `get_bookmarks` | Look for an existing folder named "📚 Read Later" in the bookmark bar |
|
||||
| Create folder (if needed) | `create_bookmark` | If the folder doesn't exist, create "📚 Read Later" in the bookmark bar |
|
||||
| Add bookmark | `create_bookmark` | Save the current page URL and title into the "📚 Read Later" folder |
|
||||
| Save PDF | `save_pdf` | Download the full page as a PDF to the working directory |
|
||||
| Notify user | — | Tell the user the page has been saved with the bookmark location and PDF file path |
|
||||
|
||||
## Notification Format
|
||||
|
||||
```
|
||||
Saved to 📚 Read Later
|
||||
Title: <page title>
|
||||
URL: <page url>
|
||||
PDF: <download path>
|
||||
```
|
||||
|
||||
## Tool Reference
|
||||
|
||||
| Category | Tools Used |
|
||||
|----------|-----------|
|
||||
| Page info | `get_active_page` |
|
||||
| Bookmarks | `get_bookmarks`, `create_bookmark` |
|
||||
| Export | `save_pdf` |
|
||||
|
||||
## Tips
|
||||
|
||||
- Always check if "📚 Read Later" already exists before creating it — avoid duplicate folders.
|
||||
- If the page title is empty or generic, use the domain + path as the bookmark title.
|
||||
- The PDF captures the page as-is, including the current scroll position and expanded sections.
|
||||
@@ -1,46 +0,0 @@
|
||||
---
|
||||
name: save-page
|
||||
description: Save web pages as PDF files for offline reading, archiving, or sharing. Use when the user asks to save, download, export, or archive a page as PDF.
|
||||
metadata:
|
||||
display-name: Save Page
|
||||
enabled: "true"
|
||||
version: "1.0"
|
||||
---
|
||||
|
||||
# Save Page
|
||||
|
||||
## When to Use
|
||||
|
||||
Activate when the user asks to save a page as PDF, download a page for offline reading, archive a webpage, or export page content to a file.
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Navigate to the target page** using `navigate_page` if not already there. If the user provides multiple URLs, process them one by one.
|
||||
|
||||
2. **Prepare the page for saving:**
|
||||
- Dismiss any popups or overlays that would appear in the PDF
|
||||
- Scroll to load any lazy-loaded content if the page uses infinite scroll
|
||||
|
||||
3. **Save as PDF** using `save_pdf` with a descriptive filename in the working directory:
|
||||
- Pattern: `{domain}-{title-slug}-{date}.pdf`
|
||||
- Example: `nytimes-climate-report-2025-03-11.pdf`
|
||||
- Let the user specify a custom path if they prefer
|
||||
|
||||
4. **For multiple pages**, process each URL sequentially:
|
||||
- Navigate to the page
|
||||
- Save as PDF
|
||||
- Report progress to the user
|
||||
|
||||
5. **Confirm the save:**
|
||||
```
|
||||
Saved: [filename].pdf
|
||||
Source: [URL]
|
||||
Location: [file path]
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
- For articles, the PDF will capture the current page state — make sure content is fully loaded.
|
||||
- Some pages have print stylesheets that produce better PDFs — `save_pdf` uses these automatically.
|
||||
- For documentation sites with multiple pages, offer to save each section as a separate PDF.
|
||||
- If saving fails, offer the alternative of using `get_page_content` to save as markdown.
|
||||
@@ -1,60 +0,0 @@
|
||||
---
|
||||
name: screenshot-walkthrough
|
||||
description: Capture step-by-step screenshots of a workflow or process for documentation, bug reports, or tutorials. Use when the user asks to document steps, create a walkthrough, or capture a process.
|
||||
metadata:
|
||||
display-name: Screenshot Walkthrough
|
||||
enabled: "true"
|
||||
version: "1.0"
|
||||
---
|
||||
|
||||
# Screenshot Walkthrough
|
||||
|
||||
## When to Use
|
||||
|
||||
Activate when the user asks to document a workflow, create a step-by-step guide, capture a process for a bug report, or build visual documentation of a web-based procedure.
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Clarify the workflow.** Confirm with the user:
|
||||
- What process to document
|
||||
- Starting URL or page
|
||||
- Where to save the screenshots
|
||||
|
||||
2. **Navigate to the starting point** using `navigate_page`.
|
||||
|
||||
3. **For each step in the workflow:**
|
||||
a. Take a screenshot using `save_screenshot` with a descriptive filename:
|
||||
- Pattern: `step-{number}-{description}.png`
|
||||
- Example: `step-01-login-page.png`, `step-02-enter-credentials.png`
|
||||
b. Note what action to take next
|
||||
c. Perform the action (click, fill, navigate, etc.)
|
||||
d. Wait for the page to settle (new content to load)
|
||||
e. Repeat
|
||||
|
||||
4. **Compile the walkthrough** as a markdown document:
|
||||
|
||||
### Output Format
|
||||
|
||||
```markdown
|
||||
# Walkthrough: [Process Name]
|
||||
|
||||
**Date:** [current date]
|
||||
**URL:** [starting URL]
|
||||
|
||||
## Step 1: [Action Description]
|
||||

|
||||
Navigate to [URL]. You will see [what's on screen].
|
||||
|
||||
## Step 2: [Action Description]
|
||||

|
||||
Click on [element]. [What happens next].
|
||||
```
|
||||
|
||||
5. **Save the walkthrough** using `filesystem_write` alongside the screenshots.
|
||||
|
||||
## Tips
|
||||
|
||||
- Number steps with zero-padded digits (01, 02, ...) for correct file sorting.
|
||||
- Include the browser URL bar in screenshots when the URL is relevant to the step.
|
||||
- For error documentation, capture the error state and any console errors.
|
||||
- If the process involves sensitive data, warn the user before capturing screenshots.
|
||||
@@ -1,46 +0,0 @@
|
||||
---
|
||||
name: summarize-page
|
||||
description: Extract and summarize the main content of the current web page into structured markdown. Use when the user asks to summarize, digest, or get the gist of a page.
|
||||
metadata:
|
||||
display-name: Summarize Page
|
||||
enabled: "true"
|
||||
version: "1.0"
|
||||
---
|
||||
|
||||
# Summarize Page
|
||||
|
||||
## When to Use
|
||||
|
||||
Activate when the user asks to summarize, digest, condense, or get the key points from the current page or a specific URL.
|
||||
|
||||
## Steps
|
||||
|
||||
1. If the user provided a URL, use `navigate_page` to go there first.
|
||||
2. Use `get_page_content` to extract the full text content of the page.
|
||||
3. Identify the page type (article, documentation, product page, forum thread, etc.) and adapt the summary format accordingly.
|
||||
4. Produce a structured markdown summary:
|
||||
|
||||
### Output Format
|
||||
|
||||
```
|
||||
## Summary: [Page Title]
|
||||
|
||||
**Source:** [URL]
|
||||
**Type:** [article/docs/product/forum/etc.]
|
||||
|
||||
### Key Points
|
||||
- [3-5 bullet points capturing the main ideas]
|
||||
|
||||
### Details
|
||||
[2-3 paragraphs expanding on the most important content]
|
||||
|
||||
### Takeaways
|
||||
- [Actionable items or conclusions, if applicable]
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
- For long pages, focus on headings, first paragraphs of sections, and any emphasized text.
|
||||
- For product pages, emphasize specs, pricing, and reviews.
|
||||
- For news articles, lead with the who/what/when/where/why.
|
||||
- If the page content is behind a paywall or login, inform the user rather than summarizing partial content.
|
||||
@@ -1,115 +0,0 @@
|
||||
import { readdir, readFile, stat } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { PATHS } from '@browseros/shared/constants/paths'
|
||||
import matter from 'gray-matter'
|
||||
import { getBuiltinSkillsDir, getSkillsDir } from '../lib/browseros-dir'
|
||||
import { logger } from '../lib/logger'
|
||||
import type { SkillFrontmatter, SkillMeta } from './types'
|
||||
|
||||
async function isDirectory(dirPath: string): Promise<boolean> {
|
||||
try {
|
||||
const s = await stat(dirPath)
|
||||
return s.isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function isValidFrontmatter(data: unknown): data is SkillFrontmatter {
|
||||
if (typeof data !== 'object' || data === null) return false
|
||||
const d = data as Record<string, unknown>
|
||||
return (
|
||||
typeof d.name === 'string' &&
|
||||
d.name.length > 0 &&
|
||||
typeof d.description === 'string' &&
|
||||
d.description.length > 0
|
||||
)
|
||||
}
|
||||
|
||||
async function parseSkillFile(
|
||||
skillMdPath: string,
|
||||
dirName: string,
|
||||
builtIn: boolean,
|
||||
): Promise<SkillMeta | null> {
|
||||
try {
|
||||
const content = await readFile(skillMdPath, 'utf-8')
|
||||
const { data } = matter(content)
|
||||
|
||||
if (!isValidFrontmatter(data)) {
|
||||
logger.warn('Skill missing required frontmatter fields', {
|
||||
path: skillMdPath,
|
||||
dirName,
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const meta = data.metadata
|
||||
return {
|
||||
id: dirName,
|
||||
name: meta?.['display-name'] || data.name,
|
||||
description: data.description,
|
||||
location: skillMdPath,
|
||||
enabled: meta?.enabled !== 'false',
|
||||
version: meta?.version,
|
||||
builtIn,
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Failed to parse skill', {
|
||||
path: skillMdPath,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function scanDir(
|
||||
dir: string,
|
||||
builtIn: boolean,
|
||||
skipDirs?: Set<string>,
|
||||
): Promise<SkillMeta[]> {
|
||||
let entries: string[]
|
||||
try {
|
||||
entries = await readdir(dir)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
const skills: SkillMeta[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (const entry of entries) {
|
||||
if (skipDirs?.has(entry)) continue
|
||||
const entryPath = join(dir, entry)
|
||||
if (!(await isDirectory(entryPath))) continue
|
||||
|
||||
const skillMdPath = join(entryPath, 'SKILL.md')
|
||||
try {
|
||||
await stat(skillMdPath)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
const skill = await parseSkillFile(skillMdPath, entry, builtIn)
|
||||
if (!skill || seen.has(skill.id)) continue
|
||||
|
||||
seen.add(skill.id)
|
||||
skills.push(skill)
|
||||
}
|
||||
|
||||
return skills
|
||||
}
|
||||
|
||||
export async function loadAllSkills(): Promise<SkillMeta[]> {
|
||||
const builtinSkills = await scanDir(getBuiltinSkillsDir(), true)
|
||||
const userSkills = await scanDir(
|
||||
getSkillsDir(),
|
||||
false,
|
||||
new Set([PATHS.BUILTIN_DIR_NAME]),
|
||||
)
|
||||
return [...builtinSkills, ...userSkills]
|
||||
}
|
||||
|
||||
export async function loadSkills(): Promise<SkillMeta[]> {
|
||||
const all = await loadAllSkills()
|
||||
return all.filter((s) => s.enabled)
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { readdir, rename, stat } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { getBuiltinSkillsDir, getSkillsDir } from '../lib/browseros-dir'
|
||||
import { logger } from '../lib/logger'
|
||||
import { DEFAULT_SKILLS } from './defaults'
|
||||
|
||||
const DEFAULT_SKILL_IDS = new Set(DEFAULT_SKILLS.map((s) => s.id))
|
||||
|
||||
export async function migrateBuiltinSkills(): Promise<void> {
|
||||
const builtinDir = getBuiltinSkillsDir()
|
||||
|
||||
try {
|
||||
const entries = await readdir(builtinDir)
|
||||
if (entries.some((e) => !e.startsWith('.'))) return
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const skillsDir = getSkillsDir()
|
||||
let migrated = 0
|
||||
|
||||
for (const id of DEFAULT_SKILL_IDS) {
|
||||
const sourcePath = join(skillsDir, id)
|
||||
try {
|
||||
const s = await stat(join(sourcePath, 'SKILL.md'))
|
||||
if (!s.isFile()) continue
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
await rename(sourcePath, join(builtinDir, id))
|
||||
migrated++
|
||||
} catch (err) {
|
||||
logger.warn('Failed to migrate builtin skill', {
|
||||
id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (migrated > 0) {
|
||||
logger.info(`Migrated ${migrated} built-in skills to builtin/ directory`)
|
||||
}
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { TIMEOUTS } from '@browseros/shared/constants/timeouts'
|
||||
import { EXTERNAL_URLS } from '@browseros/shared/constants/urls'
|
||||
import { INLINED_ENV } from '../env'
|
||||
import { getBuiltinSkillsDir } from '../lib/browseros-dir'
|
||||
import { logger } from '../lib/logger'
|
||||
import { DEFAULT_SKILLS } from './defaults'
|
||||
import { safeBuiltinSkillDir } from './service'
|
||||
import type { RemoteSkillCatalog, RemoteSkillEntry } from './types'
|
||||
|
||||
let syncTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function extractVersion(content: string): string {
|
||||
const match = content.match(/^\s*version:\s*["']?([^"'\n]+)["']?/m)
|
||||
return match?.[1]?.trim() || '1.0'
|
||||
}
|
||||
|
||||
function extractEnabled(content: string): string | null {
|
||||
const match = content.match(/^\s*enabled:\s*["']?(true|false)["']?/m)
|
||||
return match?.[1] ?? null
|
||||
}
|
||||
|
||||
function setEnabled(content: string, enabled: string): string {
|
||||
return content.replace(
|
||||
/^(\s*enabled:\s*)["']?(?:true|false)["']?/m,
|
||||
`$1"${enabled}"`,
|
||||
)
|
||||
}
|
||||
|
||||
function isValidSkillEntry(entry: unknown): entry is RemoteSkillEntry {
|
||||
if (typeof entry !== 'object' || entry === null) return false
|
||||
const e = entry as Record<string, unknown>
|
||||
return (
|
||||
typeof e.id === 'string' &&
|
||||
typeof e.version === 'string' &&
|
||||
typeof e.content === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
function isValidCatalog(data: unknown): data is RemoteSkillCatalog {
|
||||
if (typeof data !== 'object' || data === null) return false
|
||||
const d = data as Record<string, unknown>
|
||||
return (
|
||||
typeof d.version === 'number' &&
|
||||
Array.isArray(d.skills) &&
|
||||
d.skills.every(isValidSkillEntry)
|
||||
)
|
||||
}
|
||||
|
||||
export async function fetchRemoteCatalog(): Promise<RemoteSkillCatalog | null> {
|
||||
const url = INLINED_ENV.SKILLS_CATALOG_URL || EXTERNAL_URLS.SKILLS_CATALOG
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: AbortSignal.timeout(TIMEOUTS.SKILLS_FETCH),
|
||||
})
|
||||
if (!response.ok) {
|
||||
logger.warn('Failed to fetch remote skill catalog', {
|
||||
status: response.status,
|
||||
})
|
||||
return null
|
||||
}
|
||||
const data: unknown = await response.json()
|
||||
if (!isValidCatalog(data)) {
|
||||
logger.warn('Remote skill catalog has invalid format')
|
||||
return null
|
||||
}
|
||||
return data
|
||||
} catch (err) {
|
||||
logger.debug('Remote skill catalog unavailable', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncBuiltinSkills(): Promise<void> {
|
||||
const catalog = await fetchRemoteCatalog()
|
||||
|
||||
const contentMap = new Map<string, { version: string; content: string }>()
|
||||
for (const skill of DEFAULT_SKILLS) {
|
||||
contentMap.set(skill.id, {
|
||||
version: extractVersion(skill.content),
|
||||
content: skill.content,
|
||||
})
|
||||
}
|
||||
if (catalog) {
|
||||
for (const skill of catalog.skills) {
|
||||
contentMap.set(skill.id, {
|
||||
version: skill.version,
|
||||
content: skill.content,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, source] of contentMap) {
|
||||
try {
|
||||
await syncOneSkill(id, source)
|
||||
} catch (err) {
|
||||
logger.warn('Failed to sync builtin skill', {
|
||||
id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (catalog) await removeObsoleteSkills(contentMap)
|
||||
}
|
||||
|
||||
async function syncOneSkill(
|
||||
id: string,
|
||||
source: { version: string; content: string },
|
||||
): Promise<void> {
|
||||
const dir = safeBuiltinSkillDir(id)
|
||||
const filePath = join(dir, 'SKILL.md')
|
||||
|
||||
let localContent: string | null = null
|
||||
try {
|
||||
localContent = await readFile(filePath, 'utf-8')
|
||||
} catch {}
|
||||
|
||||
if (localContent && extractVersion(localContent) === source.version) return
|
||||
|
||||
let content = source.content
|
||||
if (localContent && extractEnabled(localContent) === 'false') {
|
||||
content = setEnabled(content, 'false')
|
||||
}
|
||||
|
||||
await mkdir(dir, { recursive: true })
|
||||
await writeFile(filePath, content)
|
||||
}
|
||||
|
||||
async function removeObsoleteSkills(
|
||||
keepIds: Map<string, unknown>,
|
||||
): Promise<void> {
|
||||
const builtinDir = getBuiltinSkillsDir()
|
||||
let entries: string[]
|
||||
try {
|
||||
entries = await readdir(builtinDir)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.startsWith('.') || keepIds.has(entry)) continue
|
||||
try {
|
||||
const entryPath = join(builtinDir, entry)
|
||||
const s = await stat(entryPath)
|
||||
if (s.isDirectory()) await rm(entryPath, { recursive: true })
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export function startSkillSync(): void {
|
||||
if (syncTimer) return
|
||||
syncTimer = setInterval(() => {
|
||||
syncBuiltinSkills().catch((err) => {
|
||||
logger.warn('Skill sync failed', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
})
|
||||
}, TIMEOUTS.SKILLS_SYNC_INTERVAL)
|
||||
syncTimer.unref()
|
||||
}
|
||||
|
||||
export function stopSkillSync(): void {
|
||||
if (syncTimer) {
|
||||
clearInterval(syncTimer)
|
||||
syncTimer = null
|
||||
}
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { join, resolve, sep } from 'node:path'
|
||||
import matter from 'gray-matter'
|
||||
import { getBuiltinSkillsDir, getSkillsDir } from '../lib/browseros-dir'
|
||||
import { logger } from '../lib/logger'
|
||||
import { isValidFrontmatter, loadAllSkills } from './loader'
|
||||
import type {
|
||||
CreateSkillInput,
|
||||
SkillDetail,
|
||||
SkillFrontmatter,
|
||||
SkillMeta,
|
||||
UpdateSkillInput,
|
||||
} from './types'
|
||||
|
||||
export function slugify(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
}
|
||||
|
||||
export function safeSkillDir(id: string): string {
|
||||
const skillsDir = getSkillsDir()
|
||||
const resolved = resolve(skillsDir, id)
|
||||
if (!resolved.startsWith(`${skillsDir}${sep}`)) {
|
||||
throw new Error('Invalid skill id')
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
export function safeBuiltinSkillDir(id: string): string {
|
||||
const builtinDir = getBuiltinSkillsDir()
|
||||
const resolved = resolve(builtinDir, id)
|
||||
if (!resolved.startsWith(`${builtinDir}${sep}`)) {
|
||||
throw new Error('Invalid skill id')
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
function buildSkillMd(frontmatter: SkillFrontmatter, content: string): string {
|
||||
return matter.stringify(content, frontmatter)
|
||||
}
|
||||
|
||||
async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(filePath)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveSkillDir(
|
||||
id: string,
|
||||
): Promise<{ dir: string; builtIn: boolean } | null> {
|
||||
const userDir = safeSkillDir(id)
|
||||
if (await fileExists(join(userDir, 'SKILL.md'))) {
|
||||
return { dir: userDir, builtIn: false }
|
||||
}
|
||||
const builtinDir = safeBuiltinSkillDir(id)
|
||||
if (await fileExists(join(builtinDir, 'SKILL.md'))) {
|
||||
return { dir: builtinDir, builtIn: true }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function listSkills(): Promise<SkillMeta[]> {
|
||||
return loadAllSkills()
|
||||
}
|
||||
|
||||
export async function getSkill(id: string): Promise<SkillDetail | null> {
|
||||
const resolved = await resolveSkillDir(id)
|
||||
if (!resolved) return null
|
||||
|
||||
const skillMdPath = join(resolved.dir, 'SKILL.md')
|
||||
try {
|
||||
const raw = await readFile(skillMdPath, 'utf-8')
|
||||
const parsed = matter(raw)
|
||||
|
||||
if (!isValidFrontmatter(parsed.data)) {
|
||||
logger.warn('Skill has invalid frontmatter', { id })
|
||||
return null
|
||||
}
|
||||
|
||||
const meta = parsed.data.metadata
|
||||
return {
|
||||
id,
|
||||
name: meta?.['display-name'] || parsed.data.name,
|
||||
description: parsed.data.description,
|
||||
location: skillMdPath,
|
||||
enabled: meta?.enabled !== 'false',
|
||||
version: meta?.version,
|
||||
builtIn: resolved.builtIn,
|
||||
content: parsed.content.trim(),
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Failed to read skill', {
|
||||
id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function createSkill(input: CreateSkillInput): Promise<SkillMeta> {
|
||||
const id = slugify(input.name)
|
||||
if (!id) throw new Error('Invalid skill name')
|
||||
|
||||
if (await fileExists(join(safeSkillDir(id), 'SKILL.md'))) {
|
||||
throw new Error(`Skill "${id}" already exists`)
|
||||
}
|
||||
if (await fileExists(join(safeBuiltinSkillDir(id), 'SKILL.md'))) {
|
||||
throw new Error(`Skill "${id}" already exists`)
|
||||
}
|
||||
|
||||
const dirPath = safeSkillDir(id)
|
||||
await mkdir(dirPath, { recursive: true })
|
||||
const frontmatter: SkillFrontmatter = {
|
||||
name: id,
|
||||
description: input.description,
|
||||
metadata: {
|
||||
'display-name': input.name,
|
||||
enabled: 'true',
|
||||
},
|
||||
}
|
||||
await writeFile(
|
||||
join(dirPath, 'SKILL.md'),
|
||||
buildSkillMd(frontmatter, input.content),
|
||||
)
|
||||
|
||||
return {
|
||||
id,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
location: join(dirPath, 'SKILL.md'),
|
||||
enabled: true,
|
||||
builtIn: false,
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateSkill(
|
||||
id: string,
|
||||
input: UpdateSkillInput,
|
||||
): Promise<SkillMeta> {
|
||||
const resolved = await resolveSkillDir(id)
|
||||
if (!resolved) throw new Error(`Skill "${id}" not found`)
|
||||
|
||||
const skillMdPath = join(resolved.dir, 'SKILL.md')
|
||||
const raw = await readFile(skillMdPath, 'utf-8')
|
||||
const parsed = matter(raw)
|
||||
if (!isValidFrontmatter(parsed.data)) {
|
||||
throw new Error(`Skill "${id}" has invalid frontmatter`)
|
||||
}
|
||||
|
||||
const existing = parsed.data
|
||||
const existingMeta = existing.metadata ?? {}
|
||||
const displayName =
|
||||
input.name ?? existingMeta['display-name'] ?? existing.name
|
||||
const description = input.description ?? existing.description
|
||||
const content = input.content ?? parsed.content.trim()
|
||||
const enabled = input.enabled ?? existingMeta.enabled !== 'false'
|
||||
|
||||
const frontmatter: SkillFrontmatter = {
|
||||
...existing,
|
||||
name: id,
|
||||
description,
|
||||
metadata: {
|
||||
...existingMeta,
|
||||
'display-name': displayName,
|
||||
enabled: String(enabled),
|
||||
},
|
||||
}
|
||||
|
||||
await writeFile(skillMdPath, buildSkillMd(frontmatter, content))
|
||||
|
||||
return {
|
||||
id,
|
||||
name: displayName,
|
||||
description,
|
||||
location: skillMdPath,
|
||||
enabled,
|
||||
version: existingMeta.version,
|
||||
builtIn: resolved.builtIn,
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteSkill(id: string): Promise<void> {
|
||||
const resolved = await resolveSkillDir(id)
|
||||
if (!resolved) throw new Error(`Skill "${id}" not found`)
|
||||
if (resolved.builtIn) throw new Error('Cannot delete built-in skill')
|
||||
await rm(resolved.dir, { recursive: true })
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
// agentskills.io spec — metadata is a string→string map for non-spec fields
|
||||
export type SkillMetadata = {
|
||||
'display-name'?: string
|
||||
enabled?: string
|
||||
version?: string
|
||||
[key: string]: string | undefined
|
||||
}
|
||||
|
||||
// agentskills.io spec — only these fields allowed at top level
|
||||
export type SkillFrontmatter = {
|
||||
name: string
|
||||
description: string
|
||||
license?: string
|
||||
compatibility?: string
|
||||
metadata?: SkillMetadata
|
||||
'allowed-tools'?: string
|
||||
}
|
||||
|
||||
export type SkillMeta = {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
location: string
|
||||
enabled: boolean
|
||||
version?: string
|
||||
builtIn: boolean
|
||||
}
|
||||
|
||||
export type SkillDetail = SkillMeta & {
|
||||
content: string
|
||||
}
|
||||
|
||||
export type CreateSkillInput = {
|
||||
name: string
|
||||
description: string
|
||||
content: string
|
||||
}
|
||||
|
||||
export type UpdateSkillInput = Partial<CreateSkillInput> & {
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export type RemoteSkillEntry = {
|
||||
id: string
|
||||
version: string
|
||||
content: string
|
||||
}
|
||||
|
||||
export type RemoteSkillCatalog = {
|
||||
version: number
|
||||
skills: RemoteSkillEntry[]
|
||||
}
|
||||
@@ -6,7 +6,7 @@ const projectRoot = resolve(import.meta.dir, '..', '..')
|
||||
const testsRoot = resolve(projectRoot, 'tests')
|
||||
const cleanupScript = resolve(testsRoot, '__helpers__/cleanup.sh')
|
||||
const testPreloadPath = './tests/__helpers__/test-env.ts'
|
||||
const preferredDirectoryGroups = ['agent', 'api', 'skills', 'tools', 'browser']
|
||||
const preferredDirectoryGroups = ['agent', 'api', 'tools', 'browser']
|
||||
const ignoredDirectories = new Set(['__fixtures__', '__helpers__'])
|
||||
const rootGroupExclusions = new Set(['server.integration.test.ts'])
|
||||
const testFilePattern = /\.(test|spec)\.[cm]?[jt]sx?$/
|
||||
@@ -63,7 +63,7 @@ function getCompositeGroupMembers(group: string): string[] | null {
|
||||
return listAllGroups()
|
||||
}
|
||||
if (group === 'core') {
|
||||
return ['agent', 'api', 'skills', 'root']
|
||||
return ['agent', 'api', 'root']
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
*
|
||||
* The tests are organized by concern:
|
||||
*
|
||||
* 1. SECTION PRESENCE — Ensures all 14 v6 sections exist in the output.
|
||||
* 1. SECTION PRESENCE — Ensures all v6 sections exist in the output.
|
||||
* If a section disappears, the agent loses an entire category of guidance.
|
||||
*
|
||||
* 2. WORKSPACE GATING — The most critical behavioral gate. Filesystem tools
|
||||
@@ -102,7 +102,7 @@ function buildScheduled(overrides?: Partial<BuildSystemPromptOptions>): string {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('section presence', () => {
|
||||
it('includes all 14 v6 sections in regular mode', () => {
|
||||
it('includes all v6 sections in regular mode', () => {
|
||||
const prompt = buildRegular()
|
||||
|
||||
// Each section has a unique XML tag or heading that identifies it
|
||||
@@ -133,15 +133,10 @@ describe('section presence', () => {
|
||||
expect(prompt.endsWith('</AGENT_PROMPT>')).toBe(true)
|
||||
})
|
||||
|
||||
it('includes skills catalog when provided', () => {
|
||||
it('does not include skills catalog injection', () => {
|
||||
const prompt = buildRegular({
|
||||
skillsCatalog: '<available_skills><skill>test</skill></available_skills>',
|
||||
})
|
||||
expect(prompt).toContain('<available_skills>')
|
||||
})
|
||||
|
||||
it('omits skills catalog when not provided', () => {
|
||||
const prompt = buildRegular({ skillsCatalog: undefined })
|
||||
} as Partial<BuildSystemPromptOptions> & { skillsCatalog: string })
|
||||
expect(prompt).not.toContain('<available_skills>')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -159,6 +159,31 @@ describe('ContainerRuntime', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('passes private-ingress no-auth only when requested', async () => {
|
||||
const deps = createDeps()
|
||||
const runtime = new ContainerRuntime({
|
||||
vm: deps.vm,
|
||||
shell: deps.shell,
|
||||
loader: deps.loader,
|
||||
projectDir: PROJECT_DIR,
|
||||
})
|
||||
|
||||
await runtime.startGateway({
|
||||
...defaultSpec,
|
||||
gatewayToken: undefined,
|
||||
privateIngressNoAuth: true,
|
||||
})
|
||||
|
||||
expect(deps.shell.createContainer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
OPENCLAW_GATEWAY_PRIVATE_INGRESS_NO_AUTH: '1',
|
||||
}),
|
||||
}),
|
||||
undefined,
|
||||
)
|
||||
})
|
||||
|
||||
it('delegates ensureReady and stopVm to VmRuntime', async () => {
|
||||
const deps = createDeps()
|
||||
const runtime = new ContainerRuntime({
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import {
|
||||
cleanHistoryUserText,
|
||||
convertOpenClawHistoryToAgentHistory,
|
||||
} from '../../../../src/api/services/openclaw/history-mapper'
|
||||
import type { OpenClawSessionHistory } from '../../../../src/api/services/openclaw/openclaw-http-client'
|
||||
|
||||
describe('cleanHistoryUserText', () => {
|
||||
it('extracts the cron payload and drops the trailer', () => {
|
||||
const raw =
|
||||
'[cron:681df8ba-85e0-404e-a6ea-891d0f5068af hello-8] Print hello\n' +
|
||||
'Current time: Tuesday, May 5th, 2026 - 2:26 AM (Asia/Calcutta) / 2026-05-04 20:56 UTC\n\n' +
|
||||
'Use the message tool if you need to notify the user directly with an explicit target. ' +
|
||||
'If you do not send directly, your final plain-text reply will be delivered automatically.'
|
||||
expect(cleanHistoryUserText(raw)).toBe('Print hello')
|
||||
})
|
||||
|
||||
it('extracts a multiline cron payload and drops the trailer', () => {
|
||||
const raw =
|
||||
'[cron:abcd1234-0000-0000-0000-000000000000 weather] Tell me the weather in Tokyo\n' +
|
||||
'and report back briefly.\n' +
|
||||
'Current time: Tuesday, May 5th, 2026 - 2:26 AM (Asia/Calcutta) / 2026-05-04 20:56 UTC\n\n' +
|
||||
'Use the message tool if you need to notify the user directly with an explicit target.'
|
||||
expect(cleanHistoryUserText(raw)).toBe(
|
||||
'Tell me the weather in Tokyo\nand report back briefly.',
|
||||
)
|
||||
})
|
||||
|
||||
// Mirrors `BROWSEROS_ACP_AGENT_INSTRUCTIONS` from acpx-runtime.ts.
|
||||
// `unwrapBrowserosAcpUserMessage`'s `stripOuterRoleEnvelope` performs
|
||||
// an exact-prefix/suffix match against this constant, so test fixtures
|
||||
// need the full text — not a truncated stand-in.
|
||||
const ROLE_BLOCK =
|
||||
'<role>\n' +
|
||||
'You are BrowserOS - a browser agent with full control of a Chromium browser through the BrowserOS MCP server.\n\n' +
|
||||
'Use the BrowserOS MCP server for all browser tasks, including browsing the web, interacting with pages, inspecting browser state, and managing tabs, windows, bookmarks, and history.\n' +
|
||||
'</role>'
|
||||
|
||||
it('unwraps the BrowserOS ACP user_request envelope', () => {
|
||||
const raw = `${ROLE_BLOCK}\n\n<user_request>\nhey\n</user_request>`
|
||||
expect(cleanHistoryUserText(raw)).toBe('hey')
|
||||
})
|
||||
|
||||
it("strips OpenClaw acp-cli's leading [Working directory:] line", () => {
|
||||
// OpenClaw 2026.5.x's acp-cli prepends `[Working directory: <path>]
|
||||
// \n\n` before the BrowserOS envelope. We strip that line up-front
|
||||
// so the inner `<role>…</role>\n\n<user_request>` envelope can be
|
||||
// unwrapped by `unwrapBrowserosAcpUserMessage`.
|
||||
const raw =
|
||||
'[Working directory: /Users/me/.browseros-dev/agents/harness/workspace]\n\n' +
|
||||
`${ROLE_BLOCK}\n\n<user_request>\nhey\n</user_request>`
|
||||
expect(cleanHistoryUserText(raw)).toBe('hey')
|
||||
})
|
||||
|
||||
it('strips the full OpenClaw acp-cli envelope on image-attachment turns', () => {
|
||||
// OpenClaw 2026.5.4+ (post image-bypass deletion) wraps user
|
||||
// messages with stacked envelope lines:
|
||||
// [media attached: <path> (<mime>)]
|
||||
// [<weekday> <date> <tz>] [Working directory: <path>]
|
||||
// <BrowserOS role envelope>
|
||||
const raw =
|
||||
'[media attached: /home/node/.openclaw/media/inbound/image---abc.png (image/png)]\n' +
|
||||
'[Thu 2026-05-07 02:07 GMT+5:30] [Working directory: /Users/me/.browseros-dev/agents/harness/workspace]\n\n' +
|
||||
`${ROLE_BLOCK}\n\n<user_request>\nWhat color is this?\n</user_request>`
|
||||
expect(cleanHistoryUserText(raw)).toBe('What color is this?')
|
||||
})
|
||||
|
||||
it('strips multiple stacked [media attached:] lines (one per attachment)', () => {
|
||||
const raw =
|
||||
'[media attached: /home/node/.openclaw/media/inbound/image---a.png (image/png)]\n' +
|
||||
'[media attached: /home/node/.openclaw/media/inbound/image---b.jpg (image/jpeg)]\n' +
|
||||
'[Thu 2026-05-07 02:07 GMT+5:30] [Working directory: /workspace]\n\n' +
|
||||
`${ROLE_BLOCK}\n\n<user_request>\nCompare these.\n</user_request>`
|
||||
expect(cleanHistoryUserText(raw)).toBe('Compare these.')
|
||||
})
|
||||
|
||||
it('splits queued-marker concatenations and cleans each chunk', () => {
|
||||
// When multiple prompts queue up while a turn is active, BrowserOS
|
||||
// joins them with the queued-marker line. Each chunk between markers
|
||||
// is its own message that should be cleaned independently.
|
||||
const raw =
|
||||
'[Queued user message that arrived while the previous turn was still active]\n' +
|
||||
"[cron:aaaa hello-job-1] print('hello')\n" +
|
||||
'Current time: 2026-05-05 16:00 UTC\n\n' +
|
||||
'Use the message tool if you need to notify the user directly with an explicit target.\n' +
|
||||
'[Queued user message that arrived while the previous turn was still active]\n' +
|
||||
"[cron:bbbb hello-job-2] print('world')\n" +
|
||||
'Current time: 2026-05-05 16:01 UTC\n\n' +
|
||||
'Use the message tool if you need to notify the user directly with an explicit target.'
|
||||
expect(cleanHistoryUserText(raw)).toBe("print('hello')\nprint('world')")
|
||||
})
|
||||
|
||||
it('drops a Subagent Context message entirely', () => {
|
||||
// OpenClaw seeds a nested subagent's session with a "Subagent
|
||||
// Context" prefix that's pure scaffolding. The actual task lives in
|
||||
// the system prompt, so the user message body is meaningless to
|
||||
// surface. cleanHistoryUserText returns empty; the converter then
|
||||
// skips the entry so it doesn't render an empty bubble.
|
||||
const raw =
|
||||
'[Subagent Context] You are running as a subagent (depth 1/1). ' +
|
||||
'Results auto-announce to your requester; do not busy-poll for status.\n\n' +
|
||||
'Begin. Your assigned task is in the system prompt under **Your Role**.'
|
||||
expect(cleanHistoryUserText(raw)).toBe('')
|
||||
})
|
||||
|
||||
it('drops empty chunks left by leading queued marker', () => {
|
||||
// The blob often opens with a marker (no content before it). Empty
|
||||
// chunks should be dropped so we don't emit a leading newline.
|
||||
const raw =
|
||||
'[Queued user message that arrived while the previous turn was still active]\n' +
|
||||
'[cron:aaaa job] payload-only\n' +
|
||||
'Current time: now'
|
||||
expect(cleanHistoryUserText(raw)).toBe('payload-only')
|
||||
})
|
||||
|
||||
it('preserves messages that match no known scaffolding', () => {
|
||||
expect(cleanHistoryUserText('hello there')).toBe('hello there')
|
||||
expect(cleanHistoryUserText('multi\nline\nuser text')).toBe(
|
||||
'multi\nline\nuser text',
|
||||
)
|
||||
})
|
||||
|
||||
it('returns empty string unchanged', () => {
|
||||
expect(cleanHistoryUserText('')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('convertOpenClawHistoryToAgentHistory', () => {
|
||||
it('strips cron scaffolding from user messages while preserving assistant text', () => {
|
||||
const raw: OpenClawSessionHistory = {
|
||||
sessionKey: 'agent:demo:main',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: '' as never,
|
||||
// The HTTP endpoint actually returns content as an array of typed
|
||||
// blocks at runtime; the type is `string` for backward-compat.
|
||||
// Cast via `unknown` to reflect runtime.
|
||||
...({
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text:
|
||||
'[cron:abc-123 hello-1] Print hello\n' +
|
||||
'Current time: 2026-05-05 16:00 UTC\n\n' +
|
||||
'Use the message tool if you need to notify the user directly with an explicit target.',
|
||||
},
|
||||
],
|
||||
} as unknown as { content: never }),
|
||||
timestamp: 1000,
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '' as never,
|
||||
...({
|
||||
content: [{ type: 'text', text: 'hello' }],
|
||||
} as unknown as { content: never }),
|
||||
timestamp: 1001,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const out = convertOpenClawHistoryToAgentHistory('demo', raw)
|
||||
expect(out.items.map((i) => ({ role: i.role, text: i.text }))).toEqual([
|
||||
{ role: 'user', text: 'Print hello' },
|
||||
{ role: 'assistant', text: 'hello' },
|
||||
])
|
||||
})
|
||||
|
||||
it('drops assistant turns that have only reasoning (no text, no tools)', () => {
|
||||
// MiniMax with thinking:minimal often returns only `thinking` blocks
|
||||
// for trivial prompts ("Print hello"). The empty text bubble with a
|
||||
// dangling reasoning collapsible reads as broken UI; cleaner to skip.
|
||||
const raw: OpenClawSessionHistory = {
|
||||
sessionKey: 'agent:demo:main',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: '' as never,
|
||||
...({
|
||||
content: [{ type: 'text', text: 'hi' }],
|
||||
} as unknown as { content: never }),
|
||||
timestamp: 1000,
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '' as never,
|
||||
...({
|
||||
content: [
|
||||
{
|
||||
type: 'thinking',
|
||||
thinking: 'I should respond with a greeting.',
|
||||
},
|
||||
],
|
||||
} as unknown as { content: never }),
|
||||
timestamp: 1001,
|
||||
},
|
||||
],
|
||||
}
|
||||
const out = convertOpenClawHistoryToAgentHistory('demo', raw)
|
||||
expect(out.items.map((i) => ({ role: i.role, text: i.text }))).toEqual([
|
||||
{ role: 'user', text: 'hi' },
|
||||
])
|
||||
})
|
||||
|
||||
it('drops Subagent Context user messages entirely (no empty bubble)', () => {
|
||||
const raw: OpenClawSessionHistory = {
|
||||
sessionKey: 'agent:demo:main',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: '' as never,
|
||||
...({
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text:
|
||||
'[Subagent Context] You are running as a subagent (depth 1/1).\n\n' +
|
||||
'Begin. Your assigned task is in the system prompt.',
|
||||
},
|
||||
],
|
||||
} as unknown as { content: never }),
|
||||
timestamp: 1000,
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '' as never,
|
||||
...({
|
||||
content: [{ type: 'text', text: 'real reply' }],
|
||||
} as unknown as { content: never }),
|
||||
timestamp: 1001,
|
||||
},
|
||||
],
|
||||
}
|
||||
const out = convertOpenClawHistoryToAgentHistory('demo', raw)
|
||||
expect(out.items.map((i) => ({ role: i.role, text: i.text }))).toEqual([
|
||||
{ role: 'assistant', text: 'real reply' },
|
||||
])
|
||||
})
|
||||
|
||||
it('attaches assistant reasoning and pairs tool call output across messages', () => {
|
||||
const raw: OpenClawSessionHistory = {
|
||||
sessionKey: 'agent:demo:main',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: '' as never,
|
||||
...({
|
||||
content: [{ type: 'text', text: 'navigate to example.com' }],
|
||||
} as unknown as { content: never }),
|
||||
timestamp: 1000,
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '' as never,
|
||||
...({
|
||||
content: [
|
||||
{
|
||||
type: 'thinking',
|
||||
thinking: 'I should call the navigate tool.',
|
||||
},
|
||||
{
|
||||
type: 'toolCall',
|
||||
id: 'call-1',
|
||||
name: 'navigate',
|
||||
arguments: { url: 'https://example.com' },
|
||||
},
|
||||
],
|
||||
} as unknown as { content: never }),
|
||||
timestamp: 1001,
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
content: '' as never,
|
||||
...({
|
||||
content: [
|
||||
{
|
||||
type: 'toolResult',
|
||||
toolCallId: 'call-1',
|
||||
content: 'navigated',
|
||||
},
|
||||
],
|
||||
} as unknown as { content: never }),
|
||||
timestamp: 1002,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const out = convertOpenClawHistoryToAgentHistory('demo', raw)
|
||||
// 'tool' role messages are folded into the prior assistant entry, not surfaced
|
||||
expect(out.items.map((i) => i.role)).toEqual(['user', 'assistant'])
|
||||
const assistant = out.items[1]
|
||||
expect(assistant.reasoning?.text).toBe('I should call the navigate tool.')
|
||||
expect(assistant.toolCalls).toEqual([
|
||||
{
|
||||
toolCallId: 'call-1',
|
||||
toolName: 'navigate',
|
||||
status: 'completed',
|
||||
input: { url: 'https://example.com' },
|
||||
output: 'navigated',
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -4,7 +4,73 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { mergeEnvContent } from '../../../../src/api/services/openclaw/openclaw-env'
|
||||
import {
|
||||
getHostWorkspaceDir,
|
||||
isAgentWorkspaceNameSafe,
|
||||
mergeEnvContent,
|
||||
} from '../../../../src/api/services/openclaw/openclaw-env'
|
||||
|
||||
describe('isAgentWorkspaceNameSafe', () => {
|
||||
it('accepts plain slugs', () => {
|
||||
expect(isAgentWorkspaceNameSafe('agent-01')).toBe(true)
|
||||
expect(isAgentWorkspaceNameSafe('research_bot')).toBe(true)
|
||||
expect(isAgentWorkspaceNameSafe('My Agent')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects empty or whitespace-only', () => {
|
||||
expect(isAgentWorkspaceNameSafe('')).toBe(false)
|
||||
expect(isAgentWorkspaceNameSafe(' ')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects path-traversal segments', () => {
|
||||
expect(isAgentWorkspaceNameSafe('..')).toBe(false)
|
||||
expect(isAgentWorkspaceNameSafe('../tmp')).toBe(false)
|
||||
expect(isAgentWorkspaceNameSafe('foo/../bar')).toBe(false)
|
||||
expect(isAgentWorkspaceNameSafe('foo..bar')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects path separators and NULs', () => {
|
||||
expect(isAgentWorkspaceNameSafe('foo/bar')).toBe(false)
|
||||
expect(isAgentWorkspaceNameSafe('foo\\bar')).toBe(false)
|
||||
expect(isAgentWorkspaceNameSafe('foo\0bar')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects names that start with a dot (hidden / dotfile)', () => {
|
||||
expect(isAgentWorkspaceNameSafe('.hidden')).toBe(false)
|
||||
expect(isAgentWorkspaceNameSafe('.')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects control characters', () => {
|
||||
expect(isAgentWorkspaceNameSafe('foo\nbar')).toBe(false)
|
||||
expect(isAgentWorkspaceNameSafe('foo\x07bar')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getHostWorkspaceDir', () => {
|
||||
it("returns the canonical 'main' workspace path", () => {
|
||||
expect(getHostWorkspaceDir('/tmp/openclaw', 'main')).toBe(
|
||||
'/tmp/openclaw/.openclaw/workspace',
|
||||
)
|
||||
})
|
||||
|
||||
it('returns a per-agent workspace for safe names', () => {
|
||||
expect(getHostWorkspaceDir('/tmp/openclaw', 'agent-01')).toBe(
|
||||
'/tmp/openclaw/.openclaw/workspace-agent-01',
|
||||
)
|
||||
})
|
||||
|
||||
it('throws for path-traversal names instead of escaping the state dir', () => {
|
||||
expect(() => getHostWorkspaceDir('/tmp/openclaw', '../../etc')).toThrow(
|
||||
/unsafe agent name/i,
|
||||
)
|
||||
})
|
||||
|
||||
it('throws for names containing path separators', () => {
|
||||
expect(() => getHostWorkspaceDir('/tmp/openclaw', 'foo/bar')).toThrow(
|
||||
/unsafe agent name/i,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeEnvContent', () => {
|
||||
it('appends new env keys and normalizes trailing newline', () => {
|
||||
|
||||
@@ -14,10 +14,10 @@ describe('OpenClawHttpClient', () => {
|
||||
globalThis.fetch = originalFetch
|
||||
})
|
||||
|
||||
it('checks gateway authentication with the current bearer token', async () => {
|
||||
it('checks no-auth gateway availability without an Authorization header', async () => {
|
||||
const fetchMock = mock(() => Promise.resolve(new Response('{}')))
|
||||
globalThis.fetch = fetchMock as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
const client = new OpenClawHttpClient(18789)
|
||||
|
||||
await expect(client.isAuthenticated()).resolves.toBe(true)
|
||||
|
||||
@@ -26,17 +26,15 @@ describe('OpenClawHttpClient', () => {
|
||||
)
|
||||
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: 'Bearer gateway-token',
|
||||
},
|
||||
})
|
||||
expect(fetchHeaders(fetchMock)).not.toHaveProperty('Authorization')
|
||||
})
|
||||
|
||||
it('treats rejected gateway authentication as unavailable', async () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(new Response('Unauthorized', { status: 401 })),
|
||||
) as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
const client = new OpenClawHttpClient(18789)
|
||||
|
||||
await expect(client.isAuthenticated()).resolves.toBe(false)
|
||||
})
|
||||
@@ -45,13 +43,13 @@ describe('OpenClawHttpClient', () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.reject(new Error('connect ECONNREFUSED')),
|
||||
) as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
const client = new OpenClawHttpClient(18789)
|
||||
|
||||
await expect(client.isAuthenticated()).resolves.toBe(false)
|
||||
})
|
||||
|
||||
describe('getSessionHistory', () => {
|
||||
it('sends GET with bearer auth and forwards limit/cursor as query params', async () => {
|
||||
it('sends GET and forwards limit/cursor as query params', async () => {
|
||||
const fetchMock = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
@@ -69,7 +67,7 @@ describe('OpenClawHttpClient', () => {
|
||||
),
|
||||
)
|
||||
globalThis.fetch = fetchMock as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
const client = new OpenClawHttpClient(18789)
|
||||
|
||||
const result = await client.getSessionHistory('agent:main:main', {
|
||||
limit: 50,
|
||||
@@ -79,10 +77,8 @@ describe('OpenClawHttpClient', () => {
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
'http://127.0.0.1:18789/sessions/agent%3Amain%3Amain/history?limit=50&cursor=abc',
|
||||
)
|
||||
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
|
||||
method: 'GET',
|
||||
headers: { Authorization: 'Bearer gateway-token' },
|
||||
})
|
||||
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ method: 'GET' })
|
||||
expect(fetchHeaders(fetchMock)).not.toHaveProperty('Authorization')
|
||||
expect(result).toEqual({
|
||||
sessionKey: 'agent:main:main',
|
||||
messages: [
|
||||
@@ -94,6 +90,25 @@ describe('OpenClawHttpClient', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('sends no Authorization header when no token provider is configured', async () => {
|
||||
const fetchMock = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify({ sessionKey: 'k', messages: [] }), {
|
||||
status: 200,
|
||||
}),
|
||||
),
|
||||
)
|
||||
globalThis.fetch = fetchMock as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789)
|
||||
|
||||
await client.getSessionHistory('k')
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
|
||||
method: 'GET',
|
||||
})
|
||||
expect(fetchHeaders(fetchMock)).not.toHaveProperty('Authorization')
|
||||
})
|
||||
|
||||
it('omits limit and cursor from the query when undefined', async () => {
|
||||
const fetchMock = mock(() =>
|
||||
Promise.resolve(
|
||||
@@ -103,7 +118,7 @@ describe('OpenClawHttpClient', () => {
|
||||
),
|
||||
)
|
||||
globalThis.fetch = fetchMock as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
const client = new OpenClawHttpClient(18789)
|
||||
|
||||
await client.getSessionHistory('k')
|
||||
|
||||
@@ -116,7 +131,7 @@ describe('OpenClawHttpClient', () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(new Response('not found', { status: 404 })),
|
||||
) as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
const client = new OpenClawHttpClient(18789)
|
||||
|
||||
await expect(
|
||||
client.getSessionHistory('missing-key'),
|
||||
@@ -127,7 +142,7 @@ describe('OpenClawHttpClient', () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(new Response('boom', { status: 500 })),
|
||||
) as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
const client = new OpenClawHttpClient(18789)
|
||||
|
||||
await expect(client.getSessionHistory('k')).rejects.toThrow('boom')
|
||||
})
|
||||
@@ -142,7 +157,7 @@ describe('OpenClawHttpClient', () => {
|
||||
)
|
||||
globalThis.fetch = fetchMock as typeof globalThis.fetch
|
||||
const controller = new AbortController()
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
const client = new OpenClawHttpClient(18789)
|
||||
|
||||
await client.getSessionHistory('k', { signal: controller.signal })
|
||||
|
||||
@@ -179,7 +194,7 @@ describe('OpenClawHttpClient', () => {
|
||||
),
|
||||
)
|
||||
globalThis.fetch = fetchMock as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
const client = new OpenClawHttpClient(18789)
|
||||
|
||||
const stream = await client.streamSessionHistory('k', { limit: 20 })
|
||||
|
||||
@@ -189,11 +204,9 @@ describe('OpenClawHttpClient', () => {
|
||||
)
|
||||
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'text/event-stream',
|
||||
Authorization: 'Bearer gateway-token',
|
||||
},
|
||||
headers: { Accept: 'text/event-stream' },
|
||||
})
|
||||
expect(fetchHeaders(fetchMock)).not.toHaveProperty('Authorization')
|
||||
expect(events).toEqual([
|
||||
{
|
||||
type: 'history',
|
||||
@@ -215,6 +228,33 @@ describe('OpenClawHttpClient', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps SSE Accept without Authorization when no token provider is configured', async () => {
|
||||
const fetchMock = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
),
|
||||
)
|
||||
globalThis.fetch = fetchMock as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789)
|
||||
|
||||
await client.streamSessionHistory('k')
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'text/event-stream',
|
||||
},
|
||||
})
|
||||
expect(fetchHeaders(fetchMock)).not.toHaveProperty('Authorization')
|
||||
})
|
||||
|
||||
it('forwards upstream error frames and closes', async () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(
|
||||
@@ -234,7 +274,7 @@ describe('OpenClawHttpClient', () => {
|
||||
),
|
||||
),
|
||||
) as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
const client = new OpenClawHttpClient(18789)
|
||||
|
||||
const stream = await client.streamSessionHistory('k')
|
||||
|
||||
@@ -247,7 +287,7 @@ describe('OpenClawHttpClient', () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(new Response('not found', { status: 404 })),
|
||||
) as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
const client = new OpenClawHttpClient(18789)
|
||||
|
||||
await expect(client.streamSessionHistory('k')).rejects.toBeInstanceOf(
|
||||
OpenClawSessionNotFoundError,
|
||||
@@ -284,7 +324,7 @@ describe('OpenClawHttpClient', () => {
|
||||
),
|
||||
),
|
||||
) as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
const client = new OpenClawHttpClient(18789)
|
||||
|
||||
const stream = await client.streamSessionHistory('k', {
|
||||
signal: ac.signal,
|
||||
@@ -315,3 +355,10 @@ async function readEvents(
|
||||
|
||||
return events
|
||||
}
|
||||
|
||||
function fetchHeaders(
|
||||
fetchMock: ReturnType<typeof mock>,
|
||||
): Record<string, string> {
|
||||
return ((fetchMock.mock.calls[0]?.[1] as RequestInit | undefined)?.headers ??
|
||||
{}) as Record<string, string>
|
||||
}
|
||||
|
||||
@@ -338,7 +338,7 @@ describe('OpenClawService', () => {
|
||||
expect(runOnboard).toHaveBeenCalledWith({
|
||||
acceptRisk: true,
|
||||
authChoice: 'skip',
|
||||
gatewayAuth: 'token',
|
||||
gatewayAuth: 'none',
|
||||
gatewayBind: 'lan',
|
||||
gatewayPort: 18789,
|
||||
installDaemon: false,
|
||||
@@ -377,7 +377,6 @@ describe('OpenClawService', () => {
|
||||
hostPort: expect.any(Number),
|
||||
hostHome: tempDir,
|
||||
envFilePath: join(tempDir, '.openclaw', '.env'),
|
||||
gatewayToken: undefined,
|
||||
}),
|
||||
expect.any(Function),
|
||||
)
|
||||
@@ -434,66 +433,6 @@ describe('OpenClawService', () => {
|
||||
expect(restartGateway).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('loads the persisted gateway token from the mounted config before control plane calls', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
await mkdir(join(tempDir, '.openclaw'), { recursive: true })
|
||||
await writeFile(
|
||||
join(tempDir, '.openclaw', 'openclaw.json'),
|
||||
JSON.stringify({
|
||||
gateway: {
|
||||
auth: {
|
||||
token: 'cli-token',
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
|
||||
service.openclawDir = tempDir
|
||||
service.token = 'random-token'
|
||||
service.runtime = {
|
||||
isReady: async () => true,
|
||||
}
|
||||
service.cliClient = {
|
||||
listAgents: mock(async () => {
|
||||
expect(service.token).toBe('cli-token')
|
||||
return []
|
||||
}),
|
||||
}
|
||||
|
||||
await service.listAgents()
|
||||
})
|
||||
|
||||
it('caches the loaded gateway token from config across steady-state control plane calls', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
await mkdir(join(tempDir, '.openclaw'), { recursive: true })
|
||||
await writeFile(
|
||||
join(tempDir, '.openclaw', 'openclaw.json'),
|
||||
JSON.stringify({
|
||||
gateway: {
|
||||
auth: {
|
||||
token: 'cli-token',
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
const listAgents = mock(async () => [])
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
|
||||
service.openclawDir = tempDir
|
||||
service.runtime = {
|
||||
isReady: async () => true,
|
||||
}
|
||||
service.cliClient = {
|
||||
listAgents,
|
||||
}
|
||||
|
||||
await service.listAgents()
|
||||
await service.listAgents()
|
||||
|
||||
expect(listAgents).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('writes provider credentials into the mounted state env file during setup', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
@@ -672,7 +611,6 @@ describe('OpenClawService', () => {
|
||||
hostPort: expect.any(Number),
|
||||
hostHome: tempDir,
|
||||
envFilePath: join(tempDir, '.openclaw', '.env'),
|
||||
gatewayToken: 'cli-token',
|
||||
}),
|
||||
expect.any(Function),
|
||||
)
|
||||
@@ -887,7 +825,6 @@ describe('OpenClawService', () => {
|
||||
hostPort: expect.any(Number),
|
||||
hostHome: tempDir,
|
||||
envFilePath: join(tempDir, '.openclaw', '.env'),
|
||||
gatewayToken: 'cli-token',
|
||||
}),
|
||||
expect.any(Function),
|
||||
)
|
||||
@@ -1096,7 +1033,6 @@ describe('OpenClawService', () => {
|
||||
hostPort: expect.any(Number),
|
||||
hostHome: tempDir,
|
||||
envFilePath: join(tempDir, '.openclaw', '.env'),
|
||||
gatewayToken: 'cli-token',
|
||||
}),
|
||||
)
|
||||
expect(waitForReady).toHaveBeenCalledTimes(1)
|
||||
@@ -1136,6 +1072,53 @@ describe('OpenClawService', () => {
|
||||
expect(probe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('tryAutoStart reuses a ready no-auth gateway without Authorization', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
await mkdir(join(tempDir, '.openclaw'), { recursive: true })
|
||||
await writeFile(
|
||||
join(tempDir, '.openclaw', 'openclaw.json'),
|
||||
JSON.stringify({
|
||||
gateway: {
|
||||
auth: {
|
||||
mode: 'none',
|
||||
token: 'stale-token',
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
const ensureReady = mock(async () => {})
|
||||
const isReady = mock(async () => true)
|
||||
const isGatewayCurrent = mock(async () => true)
|
||||
const startGateway = mock(async () => {})
|
||||
const probe = mock(async () => {})
|
||||
const fetchMock = mock(() =>
|
||||
Promise.resolve(new Response('', { status: 200 })),
|
||||
)
|
||||
globalThis.fetch = fetchMock as typeof globalThis.fetch
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
|
||||
service.openclawDir = tempDir
|
||||
service.runtime = {
|
||||
ensureReady,
|
||||
isReady,
|
||||
isGatewayCurrent,
|
||||
startGateway,
|
||||
}
|
||||
service.cliClient = { probe }
|
||||
|
||||
await service.tryAutoStart()
|
||||
|
||||
expect(startGateway).not.toHaveBeenCalled()
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
'http://127.0.0.1:18789/v1/models',
|
||||
)
|
||||
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
|
||||
method: 'GET',
|
||||
})
|
||||
expect(fetchHeaders(fetchMock)).not.toHaveProperty('Authorization')
|
||||
expect(probe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('tryAutoStart recreates a ready gateway when the image is stale', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
await mkdir(join(tempDir, '.openclaw'), { recursive: true })
|
||||
@@ -1720,3 +1703,10 @@ function mockGatewayAuth(status = 200): ReturnType<typeof mock> {
|
||||
globalThis.fetch = fetchMock as typeof globalThis.fetch
|
||||
return fetchMock
|
||||
}
|
||||
|
||||
function fetchHeaders(
|
||||
fetchMock: ReturnType<typeof mock>,
|
||||
): Record<string, string> {
|
||||
return ((fetchMock.mock.calls[0]?.[1] as RequestInit | undefined)?.headers ??
|
||||
{}) as Record<string, string>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it } from 'bun:test'
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { mkdir, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { resolveSafeWorkspacePath } from '../../../../src/api/services/openclaw/produced-files-store'
|
||||
|
||||
describe('resolveSafeWorkspacePath', () => {
|
||||
const tempDirs: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
tempDirs.map((dir) => rm(dir, { recursive: true, force: true })),
|
||||
)
|
||||
tempDirs.length = 0
|
||||
})
|
||||
|
||||
it('resolves a regular file inside the workspace', async () => {
|
||||
const root = mkTempDir()
|
||||
const target = join(root, 'output.txt')
|
||||
await writeFile(target, 'hello')
|
||||
|
||||
const resolved = await resolveSafeWorkspacePath(root, 'output.txt')
|
||||
|
||||
expect(resolved).not.toBeNull()
|
||||
expect(resolved).toContain('output.txt')
|
||||
})
|
||||
|
||||
it('resolves a nested file using its workspace-relative path', async () => {
|
||||
const root = mkTempDir()
|
||||
const subdir = join(root, 'reports')
|
||||
await mkdir(subdir, { recursive: true })
|
||||
await writeFile(join(subdir, 'q1.csv'), 'a,b\n1,2')
|
||||
|
||||
const resolved = await resolveSafeWorkspacePath(root, 'reports/q1.csv')
|
||||
|
||||
expect(resolved).not.toBeNull()
|
||||
expect(resolved).toMatch(/reports\/q1\.csv$/)
|
||||
})
|
||||
|
||||
it('rejects lexical traversal with `..` segments', async () => {
|
||||
const root = mkTempDir()
|
||||
// Sibling file lives next to the workspace root so the lexical
|
||||
// join lands on a real, readable file — proving the rejection
|
||||
// is from the containment check, not a missing-file fallback.
|
||||
const siblingDir = mkTempDir()
|
||||
await writeFile(join(siblingDir, 'secret.txt'), 'do not leak')
|
||||
|
||||
const escapingRel = join('..', '..', 'secret.txt')
|
||||
|
||||
const resolved = await resolveSafeWorkspacePath(root, escapingRel)
|
||||
|
||||
expect(resolved).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects a symlink whose target lives outside the workspace', async () => {
|
||||
const root = mkTempDir()
|
||||
const outside = mkTempDir()
|
||||
const secret = join(outside, 'passwd')
|
||||
await writeFile(secret, 'shadow:contents')
|
||||
|
||||
// Symlink inside the workspace pointing to the outside file.
|
||||
// The lexical path stays inside the root, but the realpath
|
||||
// resolution should still reject it.
|
||||
await symlink(secret, join(root, 'looks-local'))
|
||||
|
||||
const resolved = await resolveSafeWorkspacePath(root, 'looks-local')
|
||||
|
||||
expect(resolved).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for a path that does not exist on disk', async () => {
|
||||
const root = mkTempDir()
|
||||
|
||||
const resolved = await resolveSafeWorkspacePath(root, 'never-created.bin')
|
||||
|
||||
expect(resolved).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when the workspace root itself is the resolved path', async () => {
|
||||
const root = mkTempDir()
|
||||
|
||||
// Empty rel-path collapses to the root — must not be downloadable.
|
||||
const resolved = await resolveSafeWorkspacePath(root, '')
|
||||
|
||||
expect(resolved).toBeNull()
|
||||
})
|
||||
|
||||
function mkTempDir(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'browseros-files-test-'))
|
||||
tempDirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
})
|
||||
@@ -590,6 +590,25 @@ just outer
|
||||
expect(unwrapBrowserosAcpUserMessage(outerOnly)).toBe('just outer')
|
||||
})
|
||||
|
||||
it('strips the openclaw single-line role envelope (regression: TKT-774 only matched the BrowserOS multi-line form)', () => {
|
||||
// PR #924 (ACPX agent runtime adapters) introduced a second
|
||||
// `<role>…</role>` prefix for openclaw — a single-line block
|
||||
// distinct from the BrowserOS multi-line role. The original
|
||||
// exact-prefix strip only matched the BrowserOS form, so user
|
||||
// messages from openclaw agents were landing in
|
||||
// /agents/:id/sessions/main/history with the envelope still
|
||||
// attached. The strip must be adapter-agnostic: any
|
||||
// `<role>…</role>` followed by a `<user_request>` block.
|
||||
const wrapped = `<role>You are running inside BrowserOS through the OpenClaw ACP adapter. Use your OpenClaw identity, memory, and browser tools.</role>
|
||||
|
||||
<user_request>
|
||||
Need another report this time as pdf, a comparison between both yahoo and google reports you created...
|
||||
</user_request>`
|
||||
expect(unwrapBrowserosAcpUserMessage(wrapped)).toBe(
|
||||
'Need another report this time as pdf, a comparison between both yahoo and google reports you created...',
|
||||
)
|
||||
})
|
||||
|
||||
it('strips the ACPX runtime envelope when it wraps persisted history', () => {
|
||||
const wrapped = `<browseros_acpx_runtime version="2026-05-02.v1">
|
||||
You are BrowserOS, an ACPX browser agent.
|
||||
@@ -1024,9 +1043,8 @@ Use the BrowserOS MCP server for all browser tasks, including browsing the web,
|
||||
expect(command).toContain(
|
||||
'nerdctl exec -i -e OPENCLAW_HIDE_BANNER=1 -e OPENCLAW_SUPPRESS_NOTES=1 browseros-openclaw-openclaw-gateway-1',
|
||||
)
|
||||
expect(command).toContain(
|
||||
'openclaw acp --url ws://127.0.0.1:18789 --token test-token-abc',
|
||||
)
|
||||
expect(command).toContain('openclaw acp --url ws://127.0.0.1:18789')
|
||||
expect(command).not.toContain('--token')
|
||||
// sessionKey routing: the bridge needs --session <key> to map newSession
|
||||
// requests to the matching gateway agent (acpx does not forward
|
||||
// sessionKey via ACP newSession params).
|
||||
@@ -1234,142 +1252,6 @@ Use the BrowserOS MCP server for all browser tasks, including browsing the web,
|
||||
.map((call) => (call.input as { timeoutMs?: number }).timeoutMs),
|
||||
).toEqual([1_000, 2_000])
|
||||
})
|
||||
|
||||
it('diverts OpenClaw image turns to the gateway chat client and persists them to the session record', async () => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), 'browseros-acpx-runtime-'))
|
||||
const stateDir = await mkdtemp(join(tmpdir(), 'browseros-acpx-state-'))
|
||||
tempDirs.push(cwd, stateDir)
|
||||
// Pre-seed the session record so persistence has somewhere to land.
|
||||
// (First-turn-image-only sessions deliberately skip persistence; that
|
||||
// path is covered by the empty-record test below.)
|
||||
const sessionStore = createRuntimeStore({ stateDir })
|
||||
const seedTimestamp = '2026-04-28T20:00:00.000Z'
|
||||
const seedRecord: AcpSessionRecord = {
|
||||
schema: 'acpx.session.v1',
|
||||
acpxRecordId: 'agent:img-bot:main',
|
||||
acpSessionId: 'sid-img',
|
||||
agentSessionId: 'inner-img',
|
||||
agentCommand: 'env LIMA_HOME=/tmp limactl shell vm -- nerdctl exec',
|
||||
cwd,
|
||||
name: 'agent:img-bot:main',
|
||||
createdAt: seedTimestamp,
|
||||
lastUsedAt: seedTimestamp,
|
||||
lastSeq: 0,
|
||||
eventLog: {
|
||||
active_path: '',
|
||||
segment_count: 0,
|
||||
max_segment_bytes: 0,
|
||||
max_segments: 0,
|
||||
},
|
||||
closed: false,
|
||||
messages: [
|
||||
{
|
||||
User: {
|
||||
id: 'prior-user',
|
||||
content: [{ Text: 'literal & <tag>' } as never],
|
||||
},
|
||||
},
|
||||
{ Agent: { content: [{ Text: 'Prior answer.' }], tool_results: {} } },
|
||||
],
|
||||
updated_at: seedTimestamp,
|
||||
cumulative_token_usage: {},
|
||||
request_token_usage: {},
|
||||
acpx: {},
|
||||
}
|
||||
await sessionStore.save(seedRecord)
|
||||
|
||||
const gatewayCalls: Array<{ method: string; input: unknown }> = []
|
||||
const openclawGatewayChat = {
|
||||
streamTurn: async (input: unknown) => {
|
||||
gatewayCalls.push({ method: 'streamTurn', input })
|
||||
return new ReadableStream<AgentStreamEvent>({
|
||||
start(controller) {
|
||||
controller.enqueue({
|
||||
type: 'text_delta',
|
||||
text: 'Red.',
|
||||
stream: 'output',
|
||||
})
|
||||
controller.enqueue({ type: 'done', stopReason: 'end_turn' })
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
},
|
||||
} as never
|
||||
const calls: Array<{ method: string; input: unknown }> = []
|
||||
const runtime = new AcpxRuntime({
|
||||
cwd,
|
||||
stateDir,
|
||||
openclawGatewayChat,
|
||||
// Provide a runtime factory that would fail loudly if reached —
|
||||
// image turns must NOT fall through to the ACP path.
|
||||
runtimeFactory: (options) => {
|
||||
calls.push({ method: 'createRuntime', input: options })
|
||||
throw new Error('ACP path should not be reached for image turns')
|
||||
},
|
||||
})
|
||||
|
||||
const agent: AgentDefinition = {
|
||||
id: 'img-bot',
|
||||
name: 'OpenClaw image bot',
|
||||
adapter: 'openclaw',
|
||||
permissionMode: 'approve-all',
|
||||
sessionKey: 'agent:img-bot:main',
|
||||
createdAt: 1000,
|
||||
updatedAt: 1000,
|
||||
}
|
||||
|
||||
const events = await collectStream(
|
||||
await runtime.send({
|
||||
agent,
|
||||
sessionId: 'main',
|
||||
sessionKey: agent.sessionKey,
|
||||
message: 'What color is this?',
|
||||
attachments: [{ mediaType: 'image/png', data: 'BASE64DATA' }],
|
||||
permissionMode: 'approve-all',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(events).toEqual([
|
||||
{ type: 'text_delta', text: 'Red.', stream: 'output' },
|
||||
{ type: 'done', stopReason: 'end_turn' },
|
||||
])
|
||||
expect(gatewayCalls).toHaveLength(1)
|
||||
expect(
|
||||
calls.filter((call) => call.method === 'createRuntime'),
|
||||
).toHaveLength(0)
|
||||
const gatewayInput = gatewayCalls[0]?.input as {
|
||||
agentId: string
|
||||
sessionKey: string
|
||||
messages: Array<{
|
||||
role: string
|
||||
content: string | Array<{ type: string }>
|
||||
}>
|
||||
}
|
||||
expect(gatewayInput.agentId).toBe('img-bot')
|
||||
expect(gatewayInput.messages[0]).toEqual({
|
||||
role: 'user',
|
||||
content: 'literal & <tag>',
|
||||
})
|
||||
expect(gatewayInput.messages.at(-1)?.role).toBe('user')
|
||||
const userContent = gatewayInput.messages.at(-1)?.content
|
||||
expect(Array.isArray(userContent)).toBe(true)
|
||||
expect(
|
||||
(userContent as Array<{ type: string }>).filter(
|
||||
(p) => p.type === 'image_url',
|
||||
),
|
||||
).toHaveLength(1)
|
||||
|
||||
// Persistence check: history should now show the user+assistant turn.
|
||||
const history = await runtime.getHistory({
|
||||
agent,
|
||||
sessionId: 'main',
|
||||
})
|
||||
expect(history.items.slice(-2).map((item) => item.role)).toEqual([
|
||||
'user',
|
||||
'assistant',
|
||||
])
|
||||
expect(history.items.at(-1)?.text).toBe('Red.')
|
||||
})
|
||||
})
|
||||
|
||||
function makeAgent(input: {
|
||||
|
||||
@@ -128,8 +128,6 @@ async function setupApplicationTest() {
|
||||
const metricsModule = await import('../src/lib/metrics')
|
||||
const sentryModule = await import('../src/lib/sentry')
|
||||
const soulModule = await import('../src/lib/soul')
|
||||
const migrateModule = await import('../src/skills/migrate')
|
||||
const remoteSyncModule = await import('../src/skills/remote-sync')
|
||||
|
||||
const createHttpServer = spyOn(apiServer, 'createHttpServer')
|
||||
createHttpServer.mockImplementation(async () => ({}) as never)
|
||||
@@ -179,14 +177,6 @@ async function setupApplicationTest() {
|
||||
spyOn(sentryModule.Sentry, 'captureException').mockImplementation(() => {})
|
||||
|
||||
spyOn(soulModule, 'seedSoulTemplate').mockImplementation(async () => {})
|
||||
spyOn(migrateModule, 'migrateBuiltinSkills').mockImplementation(
|
||||
async () => {},
|
||||
)
|
||||
spyOn(remoteSyncModule, 'syncBuiltinSkills').mockImplementation(
|
||||
async () => {},
|
||||
)
|
||||
spyOn(remoteSyncModule, 'startSkillSync').mockImplementation(() => {})
|
||||
spyOn(remoteSyncModule, 'stopSkillSync').mockImplementation(() => {})
|
||||
|
||||
const prewarm = mock(async () => {})
|
||||
const tryAutoStart = mock(async () => {})
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import { afterAll, beforeAll, describe, it, mock } from 'bun:test'
|
||||
import assert from 'node:assert'
|
||||
import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
let testDir: string
|
||||
let builtinDir: string
|
||||
|
||||
mock.module('../../src/lib/browseros-dir', () => ({
|
||||
getSkillsDir: () => testDir,
|
||||
getBuiltinSkillsDir: () => builtinDir,
|
||||
}))
|
||||
|
||||
mock.module('../../src/env', () => ({
|
||||
INLINED_ENV: {
|
||||
SKILLS_CATALOG_URL: 'https://cdn.browseros.com/skills/v1/catalog.json',
|
||||
},
|
||||
}))
|
||||
|
||||
const { syncBuiltinSkills } = await import('../../src/skills/remote-sync')
|
||||
|
||||
beforeAll(async () => {
|
||||
testDir = join(tmpdir(), `flow-test-${Date.now()}`)
|
||||
builtinDir = join(testDir, 'builtin')
|
||||
await mkdir(builtinDir, { recursive: true })
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(testDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('Flow tests against live CDN', () => {
|
||||
it('syncs all skills from CDN on fresh install', async () => {
|
||||
await syncBuiltinSkills()
|
||||
const entries = await readdir(builtinDir)
|
||||
const skills = entries.filter((e) => !e.startsWith('.'))
|
||||
assert.strictEqual(skills.length, 12)
|
||||
})
|
||||
|
||||
it('preserves disabled state during sync', async () => {
|
||||
const skillPath = join(builtinDir, 'summarize-page', 'SKILL.md')
|
||||
let content = await readFile(skillPath, 'utf-8')
|
||||
|
||||
content = content.replace(/enabled: "true"/, 'enabled: "false"')
|
||||
content = content.replace(/version: "1.0"/, 'version: "0.9"')
|
||||
await writeFile(skillPath, content)
|
||||
|
||||
await syncBuiltinSkills()
|
||||
|
||||
const afterSync = await readFile(skillPath, 'utf-8')
|
||||
assert.ok(
|
||||
afterSync.includes('enabled: "false"') ||
|
||||
afterSync.includes("enabled: 'false'"),
|
||||
'disabled state should be preserved',
|
||||
)
|
||||
})
|
||||
|
||||
it('reinstalls deleted builtin skill', async () => {
|
||||
await rm(join(builtinDir, 'save-page'), { recursive: true })
|
||||
await syncBuiltinSkills()
|
||||
const content = await readFile(
|
||||
join(builtinDir, 'save-page', 'SKILL.md'),
|
||||
'utf-8',
|
||||
)
|
||||
assert.ok(content.includes('name: save-page'))
|
||||
})
|
||||
|
||||
it('never touches user-created skill in root', async () => {
|
||||
const customDir = join(testDir, 'my-workflow')
|
||||
await mkdir(customDir, { recursive: true })
|
||||
const custom = '---\nname: my-workflow\ndescription: custom\n---\n# Mine\n'
|
||||
await writeFile(join(customDir, 'SKILL.md'), custom)
|
||||
|
||||
await syncBuiltinSkills()
|
||||
|
||||
const afterSync = await readFile(join(customDir, 'SKILL.md'), 'utf-8')
|
||||
assert.strictEqual(afterSync, custom)
|
||||
})
|
||||
})
|
||||
@@ -1,128 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, it, mock } from 'bun:test'
|
||||
import assert from 'node:assert'
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
let testDir: string
|
||||
let builtinDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = await mkdtemp(join(tmpdir(), 'loader-test-'))
|
||||
builtinDir = join(testDir, 'builtin')
|
||||
await mkdir(builtinDir, { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(testDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
mock.module('../../src/lib/browseros-dir', () => ({
|
||||
getSkillsDir: () => testDir,
|
||||
getBuiltinSkillsDir: () => builtinDir,
|
||||
}))
|
||||
|
||||
const { loadAllSkills, loadSkills } = await import('../../src/skills/loader')
|
||||
|
||||
const BUILTIN_SKILL = `---
|
||||
name: summarize-page
|
||||
description: Summarize a page
|
||||
metadata:
|
||||
display-name: Summarize Page
|
||||
enabled: "true"
|
||||
version: "1.0"
|
||||
---
|
||||
|
||||
# Summarize Page
|
||||
`
|
||||
|
||||
const BUILTIN_DISABLED = `---
|
||||
name: deep-research
|
||||
description: Research a topic
|
||||
metadata:
|
||||
display-name: Deep Research
|
||||
enabled: "false"
|
||||
version: "1.0"
|
||||
---
|
||||
|
||||
# Deep Research
|
||||
`
|
||||
|
||||
const USER_SKILL = `---
|
||||
name: my-workflow
|
||||
description: My custom workflow
|
||||
metadata:
|
||||
display-name: My Workflow
|
||||
enabled: "true"
|
||||
---
|
||||
|
||||
# My Workflow
|
||||
`
|
||||
|
||||
describe('loader two-directory scanning', () => {
|
||||
it('marks builtin/ skills as builtIn: true', async () => {
|
||||
await mkdir(join(builtinDir, 'summarize-page'), { recursive: true })
|
||||
await writeFile(
|
||||
join(builtinDir, 'summarize-page', 'SKILL.md'),
|
||||
BUILTIN_SKILL,
|
||||
)
|
||||
|
||||
const skills = await loadAllSkills()
|
||||
const skill = skills.find((s) => s.id === 'summarize-page')
|
||||
assert.ok(skill)
|
||||
assert.strictEqual(skill.builtIn, true)
|
||||
})
|
||||
|
||||
it('marks root skills as builtIn: false', async () => {
|
||||
await mkdir(join(testDir, 'my-workflow'), { recursive: true })
|
||||
await writeFile(join(testDir, 'my-workflow', 'SKILL.md'), USER_SKILL)
|
||||
|
||||
const skills = await loadAllSkills()
|
||||
const skill = skills.find((s) => s.id === 'my-workflow')
|
||||
assert.ok(skill)
|
||||
assert.strictEqual(skill.builtIn, false)
|
||||
})
|
||||
|
||||
it('merges skills from both directories', async () => {
|
||||
await mkdir(join(builtinDir, 'summarize-page'), { recursive: true })
|
||||
await writeFile(
|
||||
join(builtinDir, 'summarize-page', 'SKILL.md'),
|
||||
BUILTIN_SKILL,
|
||||
)
|
||||
await mkdir(join(testDir, 'my-workflow'), { recursive: true })
|
||||
await writeFile(join(testDir, 'my-workflow', 'SKILL.md'), USER_SKILL)
|
||||
|
||||
const skills = await loadAllSkills()
|
||||
assert.strictEqual(skills.length, 2)
|
||||
})
|
||||
|
||||
it('skips builtin/ subdirectory when scanning root', async () => {
|
||||
await mkdir(join(builtinDir, 'summarize-page'), { recursive: true })
|
||||
await writeFile(
|
||||
join(builtinDir, 'summarize-page', 'SKILL.md'),
|
||||
BUILTIN_SKILL,
|
||||
)
|
||||
|
||||
const skills = await loadAllSkills()
|
||||
const dupes = skills.filter((s) => s.id === 'summarize-page')
|
||||
assert.strictEqual(dupes.length, 1)
|
||||
assert.strictEqual(dupes[0].builtIn, true)
|
||||
})
|
||||
|
||||
it('loadSkills filters out disabled skills', async () => {
|
||||
await mkdir(join(builtinDir, 'summarize-page'), { recursive: true })
|
||||
await writeFile(
|
||||
join(builtinDir, 'summarize-page', 'SKILL.md'),
|
||||
BUILTIN_SKILL,
|
||||
)
|
||||
await mkdir(join(builtinDir, 'deep-research'), { recursive: true })
|
||||
await writeFile(
|
||||
join(builtinDir, 'deep-research', 'SKILL.md'),
|
||||
BUILTIN_DISABLED,
|
||||
)
|
||||
|
||||
const skills = await loadSkills()
|
||||
assert.strictEqual(skills.length, 1)
|
||||
assert.strictEqual(skills[0].id, 'summarize-page')
|
||||
})
|
||||
})
|
||||
@@ -1,106 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, it, mock } from 'bun:test'
|
||||
import assert from 'node:assert'
|
||||
import {
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readdir,
|
||||
readFile,
|
||||
rm,
|
||||
stat,
|
||||
writeFile,
|
||||
} from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
let testDir: string
|
||||
let builtinDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = await mkdtemp(join(tmpdir(), 'migrate-test-'))
|
||||
builtinDir = join(testDir, 'builtin')
|
||||
await mkdir(builtinDir, { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(testDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
mock.module('../../src/lib/browseros-dir', () => ({
|
||||
getSkillsDir: () => testDir,
|
||||
getBuiltinSkillsDir: () => builtinDir,
|
||||
}))
|
||||
|
||||
const { migrateBuiltinSkills } = await import('../../src/skills/migrate')
|
||||
|
||||
const SKILL_CONTENT = `---
|
||||
name: summarize-page
|
||||
description: Summarize a page
|
||||
metadata:
|
||||
display-name: Summarize Page
|
||||
enabled: "false"
|
||||
version: "1.0"
|
||||
---
|
||||
|
||||
# Summarize Page
|
||||
`
|
||||
|
||||
describe('migrateBuiltinSkills', () => {
|
||||
it('moves default skills from root to builtin/', async () => {
|
||||
await mkdir(join(testDir, 'summarize-page'), { recursive: true })
|
||||
await writeFile(join(testDir, 'summarize-page', 'SKILL.md'), SKILL_CONTENT)
|
||||
|
||||
await migrateBuiltinSkills()
|
||||
|
||||
const content = await readFile(
|
||||
join(builtinDir, 'summarize-page', 'SKILL.md'),
|
||||
'utf-8',
|
||||
)
|
||||
assert.strictEqual(content, SKILL_CONTENT)
|
||||
|
||||
const oldExists = await stat(join(testDir, 'summarize-page'))
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
assert.strictEqual(oldExists, false)
|
||||
})
|
||||
|
||||
it('does not move user-created skills', async () => {
|
||||
const userContent =
|
||||
'---\nname: my-workflow\ndescription: mine\n---\n# Mine\n'
|
||||
await mkdir(join(testDir, 'my-workflow'), { recursive: true })
|
||||
await writeFile(join(testDir, 'my-workflow', 'SKILL.md'), userContent)
|
||||
|
||||
await migrateBuiltinSkills()
|
||||
|
||||
const content = await readFile(
|
||||
join(testDir, 'my-workflow', 'SKILL.md'),
|
||||
'utf-8',
|
||||
)
|
||||
assert.strictEqual(content, userContent)
|
||||
})
|
||||
|
||||
it('skips if builtin/ already has skills', async () => {
|
||||
await mkdir(join(builtinDir, 'summarize-page'), { recursive: true })
|
||||
await writeFile(
|
||||
join(builtinDir, 'summarize-page', 'SKILL.md'),
|
||||
SKILL_CONTENT,
|
||||
)
|
||||
await mkdir(join(testDir, 'deep-research'), { recursive: true })
|
||||
await writeFile(join(testDir, 'deep-research', 'SKILL.md'), SKILL_CONTENT)
|
||||
|
||||
await migrateBuiltinSkills()
|
||||
|
||||
const stillInRoot = await stat(join(testDir, 'deep-research'))
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
assert.strictEqual(stillInRoot, true)
|
||||
})
|
||||
|
||||
it('is a no-op for fresh installs', async () => {
|
||||
await migrateBuiltinSkills()
|
||||
const entries = await readdir(builtinDir)
|
||||
assert.strictEqual(
|
||||
entries.filter((e: string) => !e.startsWith('.')).length,
|
||||
0,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,271 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, it, mock, spyOn } from 'bun:test'
|
||||
import assert from 'node:assert'
|
||||
import {
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readdir,
|
||||
readFile,
|
||||
rm,
|
||||
stat,
|
||||
writeFile,
|
||||
} from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { RemoteSkillCatalog } from '../../src/skills/types'
|
||||
|
||||
let testDir: string
|
||||
let builtinDir: string
|
||||
|
||||
mock.module('../../src/lib/browseros-dir', () => ({
|
||||
getSkillsDir: () => testDir,
|
||||
getBuiltinSkillsDir: () => builtinDir,
|
||||
}))
|
||||
|
||||
const { fetchRemoteCatalog, syncBuiltinSkills } = await import(
|
||||
'../../src/skills/remote-sync'
|
||||
)
|
||||
|
||||
function makeCatalog(
|
||||
skills: { id: string; version: string; content: string }[],
|
||||
): RemoteSkillCatalog {
|
||||
return { version: 1, skills }
|
||||
}
|
||||
|
||||
const SKILL_V1 = `---
|
||||
name: test-skill
|
||||
description: A test skill
|
||||
metadata:
|
||||
display-name: Test Skill
|
||||
enabled: "true"
|
||||
version: "1.0"
|
||||
---
|
||||
|
||||
# Test Skill
|
||||
|
||||
Do the thing.
|
||||
`
|
||||
|
||||
const SKILL_V2 = `---
|
||||
name: test-skill
|
||||
description: A test skill (updated)
|
||||
metadata:
|
||||
display-name: Test Skill
|
||||
enabled: "true"
|
||||
version: "2.0"
|
||||
---
|
||||
|
||||
# Test Skill v2
|
||||
|
||||
Do the thing better.
|
||||
`
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = await mkdtemp(join(tmpdir(), 'skill-sync-'))
|
||||
builtinDir = join(testDir, 'builtin')
|
||||
await mkdir(builtinDir, { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(testDir, { recursive: true, force: true })
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
describe('fetchRemoteCatalog', () => {
|
||||
it('returns null on network failure', async () => {
|
||||
const spy = spyOn(globalThis, 'fetch').mockRejectedValue(
|
||||
new Error('offline'),
|
||||
)
|
||||
assert.strictEqual(await fetchRemoteCatalog(), null)
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('returns null on non-ok response', async () => {
|
||||
const spy = spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response('Not Found', { status: 404 }),
|
||||
)
|
||||
assert.strictEqual(await fetchRemoteCatalog(), null)
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('returns catalog on success', async () => {
|
||||
const catalog = makeCatalog([
|
||||
{ id: 'test', version: '1.0', content: 'hello' },
|
||||
])
|
||||
const spy = spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify(catalog), { status: 200 }),
|
||||
)
|
||||
assert.deepStrictEqual(await fetchRemoteCatalog(), catalog)
|
||||
spy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('syncBuiltinSkills', () => {
|
||||
it('installs from remote into builtin/', async () => {
|
||||
const spy = spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify(
|
||||
makeCatalog([{ id: 'new-skill', version: '1.0', content: SKILL_V1 }]),
|
||||
),
|
||||
{ status: 200 },
|
||||
),
|
||||
)
|
||||
await syncBuiltinSkills()
|
||||
const content = await readFile(
|
||||
join(builtinDir, 'new-skill', 'SKILL.md'),
|
||||
'utf-8',
|
||||
)
|
||||
assert.strictEqual(content, SKILL_V1)
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('updates skill when remote has newer version', async () => {
|
||||
await mkdir(join(builtinDir, 'test-skill'), { recursive: true })
|
||||
await writeFile(join(builtinDir, 'test-skill', 'SKILL.md'), SKILL_V1)
|
||||
|
||||
const spy = spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify(
|
||||
makeCatalog([
|
||||
{ id: 'test-skill', version: '2.0', content: SKILL_V2 },
|
||||
]),
|
||||
),
|
||||
{ status: 200 },
|
||||
),
|
||||
)
|
||||
await syncBuiltinSkills()
|
||||
const content = await readFile(
|
||||
join(builtinDir, 'test-skill', 'SKILL.md'),
|
||||
'utf-8',
|
||||
)
|
||||
assert.strictEqual(content, SKILL_V2)
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('skips when version matches', async () => {
|
||||
await mkdir(join(builtinDir, 'test-skill'), { recursive: true })
|
||||
await writeFile(join(builtinDir, 'test-skill', 'SKILL.md'), SKILL_V1)
|
||||
|
||||
const spy = spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify(
|
||||
makeCatalog([
|
||||
{ id: 'test-skill', version: '1.0', content: SKILL_V1 },
|
||||
]),
|
||||
),
|
||||
{ status: 200 },
|
||||
),
|
||||
)
|
||||
await syncBuiltinSkills()
|
||||
const content = await readFile(
|
||||
join(builtinDir, 'test-skill', 'SKILL.md'),
|
||||
'utf-8',
|
||||
)
|
||||
assert.strictEqual(content, SKILL_V1)
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('preserves enabled:false when updating', async () => {
|
||||
const disabledV1 = SKILL_V1.replace('enabled: "true"', 'enabled: "false"')
|
||||
await mkdir(join(builtinDir, 'test-skill'), { recursive: true })
|
||||
await writeFile(join(builtinDir, 'test-skill', 'SKILL.md'), disabledV1)
|
||||
|
||||
const spy = spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify(
|
||||
makeCatalog([
|
||||
{ id: 'test-skill', version: '2.0', content: SKILL_V2 },
|
||||
]),
|
||||
),
|
||||
{ status: 200 },
|
||||
),
|
||||
)
|
||||
await syncBuiltinSkills()
|
||||
const content = await readFile(
|
||||
join(builtinDir, 'test-skill', 'SKILL.md'),
|
||||
'utf-8',
|
||||
)
|
||||
assert.ok(content.includes('v2'), 'should have v2 content')
|
||||
assert.ok(
|
||||
content.includes('enabled: "false"') ||
|
||||
content.includes("enabled: 'false'"),
|
||||
'should preserve disabled state',
|
||||
)
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('falls back to bundled defaults when offline', async () => {
|
||||
const spy = spyOn(globalThis, 'fetch').mockRejectedValue(
|
||||
new Error('offline'),
|
||||
)
|
||||
await syncBuiltinSkills()
|
||||
const entries = await readdir(builtinDir)
|
||||
const skills = entries.filter((e: string) => !e.startsWith('.'))
|
||||
assert.ok(skills.length > 0, 'should have bundled defaults')
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('removes builtin skill not in catalog', async () => {
|
||||
await mkdir(join(builtinDir, 'old-skill'), { recursive: true })
|
||||
await writeFile(join(builtinDir, 'old-skill', 'SKILL.md'), SKILL_V1)
|
||||
|
||||
const spy = spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify(
|
||||
makeCatalog([
|
||||
{ id: 'other-skill', version: '1.0', content: SKILL_V2 },
|
||||
]),
|
||||
),
|
||||
{ status: 200 },
|
||||
),
|
||||
)
|
||||
await syncBuiltinSkills()
|
||||
const exists = await stat(join(builtinDir, 'old-skill'))
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
assert.strictEqual(exists, false)
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('does not touch user skills in root', async () => {
|
||||
const custom = '---\nname: my-custom\ndescription: mine\n---\n# Mine\n'
|
||||
await mkdir(join(testDir, 'my-custom'), { recursive: true })
|
||||
await writeFile(join(testDir, 'my-custom', 'SKILL.md'), custom)
|
||||
|
||||
const spy = spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify(
|
||||
makeCatalog([
|
||||
{ id: 'test-skill', version: '1.0', content: SKILL_V1 },
|
||||
]),
|
||||
),
|
||||
{ status: 200 },
|
||||
),
|
||||
)
|
||||
await syncBuiltinSkills()
|
||||
const content = await readFile(
|
||||
join(testDir, 'my-custom', 'SKILL.md'),
|
||||
'utf-8',
|
||||
)
|
||||
assert.strictEqual(content, custom)
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('rejects path traversal in skill ids', async () => {
|
||||
const spy = spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify(
|
||||
makeCatalog([
|
||||
{ id: '../../etc/evil', version: '1.0', content: SKILL_V1 },
|
||||
]),
|
||||
),
|
||||
{ status: 200 },
|
||||
),
|
||||
)
|
||||
await syncBuiltinSkills()
|
||||
const exists = await stat(join(builtinDir, '..', '..', 'etc', 'evil'))
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
assert.strictEqual(exists, false)
|
||||
spy.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -1,126 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, it, mock } from 'bun:test'
|
||||
import assert from 'node:assert'
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
let testDir: string
|
||||
let builtinDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = await mkdtemp(join(tmpdir(), 'service-test-'))
|
||||
builtinDir = join(testDir, 'builtin')
|
||||
await mkdir(builtinDir, { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(testDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
mock.module('../../src/lib/browseros-dir', () => ({
|
||||
getSkillsDir: () => testDir,
|
||||
getBuiltinSkillsDir: () => builtinDir,
|
||||
}))
|
||||
|
||||
const { createSkill, deleteSkill, getSkill, updateSkill } = await import(
|
||||
'../../src/skills/service'
|
||||
)
|
||||
|
||||
const BUILTIN_SKILL = `---
|
||||
name: summarize-page
|
||||
description: Summarize a page
|
||||
metadata:
|
||||
display-name: Summarize Page
|
||||
enabled: "true"
|
||||
version: "1.0"
|
||||
---
|
||||
|
||||
# Summarize Page
|
||||
`
|
||||
|
||||
describe('getSkill', () => {
|
||||
it('finds builtin skill with builtIn: true', async () => {
|
||||
await mkdir(join(builtinDir, 'summarize-page'), { recursive: true })
|
||||
await writeFile(
|
||||
join(builtinDir, 'summarize-page', 'SKILL.md'),
|
||||
BUILTIN_SKILL,
|
||||
)
|
||||
const skill = await getSkill('summarize-page')
|
||||
assert.ok(skill)
|
||||
assert.strictEqual(skill.builtIn, true)
|
||||
})
|
||||
|
||||
it('finds user skill with builtIn: false', async () => {
|
||||
await createSkill({
|
||||
name: 'My Skill',
|
||||
description: 'Custom',
|
||||
content: '# Custom',
|
||||
})
|
||||
const skill = await getSkill('my-skill')
|
||||
assert.ok(skill)
|
||||
assert.strictEqual(skill.builtIn, false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createSkill', () => {
|
||||
it('creates in user directory with builtIn: false', async () => {
|
||||
const skill = await createSkill({
|
||||
name: 'My Skill',
|
||||
description: 'Custom',
|
||||
content: '# Custom',
|
||||
})
|
||||
assert.strictEqual(skill.builtIn, false)
|
||||
assert.ok(!skill.location.includes('builtin'))
|
||||
})
|
||||
|
||||
it('rejects if id collides with builtin skill', async () => {
|
||||
await mkdir(join(builtinDir, 'my-skill'), { recursive: true })
|
||||
await writeFile(join(builtinDir, 'my-skill', 'SKILL.md'), BUILTIN_SKILL)
|
||||
await assert.rejects(
|
||||
() =>
|
||||
createSkill({
|
||||
name: 'My Skill',
|
||||
description: 'Custom',
|
||||
content: '# Custom',
|
||||
}),
|
||||
/already exists/,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateSkill', () => {
|
||||
it('updates builtin skill in place', async () => {
|
||||
await mkdir(join(builtinDir, 'summarize-page'), { recursive: true })
|
||||
await writeFile(
|
||||
join(builtinDir, 'summarize-page', 'SKILL.md'),
|
||||
BUILTIN_SKILL,
|
||||
)
|
||||
const updated = await updateSkill('summarize-page', { enabled: false })
|
||||
assert.strictEqual(updated.enabled, false)
|
||||
assert.strictEqual(updated.builtIn, true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteSkill', () => {
|
||||
it('deletes user skill', async () => {
|
||||
await createSkill({
|
||||
name: 'My Skill',
|
||||
description: 'Custom',
|
||||
content: '# Custom',
|
||||
})
|
||||
await deleteSkill('my-skill')
|
||||
assert.strictEqual(await getSkill('my-skill'), null)
|
||||
})
|
||||
|
||||
it('rejects deleting builtin skill', async () => {
|
||||
await mkdir(join(builtinDir, 'summarize-page'), { recursive: true })
|
||||
await writeFile(
|
||||
join(builtinDir, 'summarize-page', 'SKILL.md'),
|
||||
BUILTIN_SKILL,
|
||||
)
|
||||
await assert.rejects(
|
||||
() => deleteSkill('summarize-page'),
|
||||
/Cannot delete built-in skill/,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -190,7 +190,6 @@
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"eventsource-parser": "^3.0.0",
|
||||
"fuse.js": "^7.1.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"hono": "^4.12.3",
|
||||
"jimp": "^1.6.0",
|
||||
"klavis": "^2.15.0",
|
||||
@@ -2730,8 +2729,6 @@
|
||||
|
||||
"graphql-ws": ["graphql-ws@6.0.6", "", { "peerDependencies": { "@fastify/websocket": "^10 || ^11", "crossws": "~0.3", "graphql": "^15.10.1 || ^16", "uWebSockets.js": "^20", "ws": "^8" }, "optionalPeers": ["@fastify/websocket", "crossws", "uWebSockets.js", "ws"] }, "sha512-zgfER9s+ftkGKUZgc0xbx8T7/HMO4AV5/YuYiFc+AtgcO5T0v8AxYYNQ+ltzuzDZgNkYJaFspm5MMYLjQzrkmw=="],
|
||||
|
||||
"gray-matter": ["gray-matter@4.0.3", "", { "dependencies": { "js-yaml": "^3.13.1", "kind-of": "^6.0.2", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" } }, "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q=="],
|
||||
|
||||
"growly": ["growly@1.3.0", "", {}, "sha512-+xGQY0YyAWCnqy7Cd++hc2JqMYzlm0dG30Jd0beaA64sROr8C4nt8Yc9V5Ro3avlSUDTN0ulqP/VBKi1/lLygw=="],
|
||||
|
||||
"gtoken": ["gtoken@7.1.0", "", { "dependencies": { "gaxios": "^6.0.0", "jws": "^4.0.0" } }, "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw=="],
|
||||
@@ -2880,7 +2877,7 @@
|
||||
|
||||
"is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
|
||||
|
||||
"is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="],
|
||||
"is-extendable": ["is-extendable@1.0.1", "", { "dependencies": { "is-plain-object": "^2.0.4" } }, "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA=="],
|
||||
|
||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||
|
||||
@@ -3002,8 +2999,6 @@
|
||||
|
||||
"khroma": ["khroma@2.1.0", "", {}, "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="],
|
||||
|
||||
"kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="],
|
||||
|
||||
"klavis": ["klavis@2.15.0", "", { "dependencies": { "form-data": "^4.0.0", "formdata-node": "^6.0.3", "js-base64": "3.7.7", "node-fetch": "^2.7.0", "qs": "^6.13.1", "readable-stream": "^4.5.2", "url-join": "4.0.1" } }, "sha512-0Vt178THYPWrbigx4wRFPz5Pu0iyX5+rJ8yLK3qtI3O2xf0FeO/LAC3Wlcz/cL36LP2vy2F7WBQOkcJD3UmwlA=="],
|
||||
|
||||
"kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
|
||||
@@ -3894,8 +3889,6 @@
|
||||
|
||||
"scule": ["scule@1.3.0", "", {}, "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g=="],
|
||||
|
||||
"section-matter": ["section-matter@1.0.0", "", { "dependencies": { "extend-shallow": "^2.0.1", "kind-of": "^6.0.0" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="],
|
||||
|
||||
"secure-json-parse": ["secure-json-parse@4.1.0", "", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="],
|
||||
|
||||
"selderee": ["selderee@0.11.0", "", { "dependencies": { "parseley": "^0.12.0" } }, "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA=="],
|
||||
@@ -4000,7 +3993,7 @@
|
||||
|
||||
"sponge-case": ["sponge-case@1.0.1", "", { "dependencies": { "tslib": "^2.0.3" } }, "sha512-dblb9Et4DAtiZ5YSUZHLl4XhH4uK80GhAZrVXdN4O2P4gQ40Wa5UIOPUHlA/nFd2PLblBZWUioLMMAVrgpoYcA=="],
|
||||
|
||||
"sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
|
||||
"sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="],
|
||||
|
||||
"static-browser-server": ["static-browser-server@1.0.3", "", { "dependencies": { "@open-draft/deferred-promise": "^2.1.0", "dotenv": "^16.0.3", "mime-db": "^1.52.0", "outvariant": "^1.3.0" } }, "sha512-ZUyfgGDdFRbZGGJQ1YhiM930Yczz5VlbJObrQLlk24+qNHVQx4OlLcYswEUo3bIyNAbQUIUR9Yr5/Hqjzqb4zA=="],
|
||||
|
||||
@@ -4036,8 +4029,6 @@
|
||||
|
||||
"strip-bom": ["strip-bom@5.0.0", "", {}, "sha512-p+byADHF7SzEcVnLvc/r3uognM1hUhObuHXxJcgLCfD194XAkaLbjq3Wzb0N5G2tgIjH0dgT708Z51QxMeu60A=="],
|
||||
|
||||
"strip-bom-string": ["strip-bom-string@1.0.0", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="],
|
||||
|
||||
"strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="],
|
||||
|
||||
"strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="],
|
||||
@@ -4912,6 +4903,8 @@
|
||||
|
||||
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
|
||||
"extend-shallow/is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="],
|
||||
|
||||
"extract-zip/get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="],
|
||||
|
||||
"fetch-blob/web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
|
||||
@@ -4956,8 +4949,6 @@
|
||||
|
||||
"graphql-config/cosmiconfig": ["cosmiconfig@8.3.6", "", { "dependencies": { "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0", "path-type": "^4.0.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA=="],
|
||||
|
||||
"gray-matter/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="],
|
||||
|
||||
"hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||
|
||||
"html-to-text/htmlparser2": ["htmlparser2@8.0.2", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1", "entities": "^4.4.0" } }, "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA=="],
|
||||
@@ -4988,16 +4979,12 @@
|
||||
|
||||
"matcher/escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||
|
||||
"merge-value/is-extendable": ["is-extendable@1.0.1", "", { "dependencies": { "is-plain-object": "^2.0.4" } }, "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA=="],
|
||||
|
||||
"merge-value/set-value": ["set-value@2.0.1", "", { "dependencies": { "extend-shallow": "^2.0.1", "is-extendable": "^0.1.1", "is-plain-object": "^2.0.3", "split-string": "^3.0.1" } }, "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw=="],
|
||||
|
||||
"mermaid/uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="],
|
||||
|
||||
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"mixin-deep/is-extendable": ["is-extendable@1.0.1", "", { "dependencies": { "is-plain-object": "^2.0.4" } }, "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA=="],
|
||||
|
||||
"mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
|
||||
|
||||
"multimatch/@types/minimatch": ["@types/minimatch@3.0.5", "", {}, "sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ=="],
|
||||
@@ -5076,8 +5063,6 @@
|
||||
|
||||
"restore-cursor/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||
|
||||
"roarr/sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="],
|
||||
|
||||
"sinon/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
|
||||
|
||||
"slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||
@@ -5692,8 +5677,6 @@
|
||||
|
||||
"graphql-config/@graphql-tools/url-loader/sync-fetch": ["sync-fetch@0.6.0-2", "", { "dependencies": { "node-fetch": "^3.3.2", "timeout-signal": "^2.0.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-c7AfkZ9udatCuAy9RSfiGPpeOKKUAUK5e1cXadLOGUjasdxqYqAK0jTNkM/FSEyJ3a5Ra27j/tw/PS0qLmaF/A=="],
|
||||
|
||||
"gray-matter/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
|
||||
|
||||
"jszip/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||
|
||||
"merge-value/set-value/is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="],
|
||||
@@ -5706,8 +5689,6 @@
|
||||
|
||||
"publish-browser-extension/listr2/cli-truncate": ["cli-truncate@4.0.0", "", { "dependencies": { "slice-ansi": "^5.0.0", "string-width": "^7.0.0" } }, "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA=="],
|
||||
|
||||
"split-string/extend-shallow/is-extendable": ["is-extendable@1.0.1", "", { "dependencies": { "is-plain-object": "^2.0.4" } }, "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA=="],
|
||||
|
||||
"string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export const OPENCLAW_AGENT_NAME = 'openclaw'
|
||||
export const OPENCLAW_IMAGE = 'ghcr.io/openclaw/openclaw:2026.4.12'
|
||||
export const OPENCLAW_IMAGE =
|
||||
'ghcr.io/browseros-ai/openclaw:2026.5.4-browseros.1'
|
||||
export const OPENCLAW_GATEWAY_CONTAINER_PORT = 18789
|
||||
export const OPENCLAW_CONTAINER_HOME = '/home/node/.openclaw'
|
||||
export const OPENCLAW_COMPOSE_PROJECT_NAME = 'browseros-openclaw'
|
||||
|
||||
@@ -18,8 +18,6 @@ export const PATHS = {
|
||||
TOOL_OUTPUT_DIR_NAME: 'tool-output',
|
||||
SOUL_FILE_NAME: 'SOUL.md',
|
||||
CORE_MEMORY_FILE_NAME: 'CORE.md',
|
||||
SKILLS_DIR_NAME: 'skills',
|
||||
BUILTIN_DIR_NAME: 'builtin',
|
||||
SERVER_CONFIG_FILE_NAME: 'server.json',
|
||||
OPENCLAW_DIR_NAME: 'openclaw',
|
||||
SOUL_MAX_LINES: 150,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user