Skip to content

Commit c926df9

Browse files
committed
feat: add trajectory review session type for agent execution visualization
Adds a new trajectory_review session type that lets humans review multi-step agent execution timelines, mark incorrect steps, provide corrections, and persist guidance as reusable rules in MEMORY.md. - TrajectoryPage.tsx: vertical timeline with nested steps, error panels, inline revision forms, rewrite cycle support - Server: route mapping, webhook summary, trajectory learning hook - preference.ts: learnFromTrajectoryRevisions, trajectory section in MEMORY.md - CSS: step type color variables (tool/decision/observation/error/retry) - SKILL.md: skill definition with payload schema and examples
1 parent 18c8b6d commit c926df9

File tree

8 files changed

+1141
-31
lines changed

8 files changed

+1141
-31
lines changed

packages/server/src/index.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import open from 'open'
55
import { existsSync } from 'fs'
66
import { dirname, join } from 'path'
77
import { fileURLToPath } from 'url'
8-
import { learnFromDeletions, getLearnedPreferences, clearPreferences, deletePreference } from './preference.js'
8+
import { learnFromDeletions, learnFromTrajectoryRevisions, getLearnedPreferences, clearPreferences, deletePreference } from './preference.js'
99
import { createSession, getSession, listSessions, completeSession, setSessionRewriting, updateSessionPayload } from './store.js'
1010

1111
const app = express()
@@ -46,6 +46,7 @@ app.post('/api/review', async (req, res) => {
4646
code_review: 'code-review',
4747
form_review: 'form-review',
4848
selection_review: 'selection',
49+
trajectory_review: 'trajectory',
4950
}
5051
const path = routeMap[type] ?? 'review'
5152
const url = `${WEB_ORIGIN}/${path}/${id}`
@@ -79,6 +80,7 @@ app.get('/api/sessions', (_req, res) => {
7980
to: (draft?.to ?? p?.to) as string | undefined,
8081
risk: p?.risk as string | undefined,
8182
command: p?.command as string | undefined,
83+
title: p?.title as string | undefined,
8284
}
8385
})
8486
res.json(list)
@@ -218,6 +220,12 @@ app.post('/api/sessions/:id/complete', async (req, res) => {
218220
const actions = (req.body.actions ?? []) as Array<{ type: string; paragraphId: string; reason?: string; instruction?: string }>
219221
learnFromDeletions(actions, session.payload as Record<string, unknown>)
220222

223+
// Learn from trajectory revisions
224+
const revisions = req.body.revisions as Array<{ stepId: string; action: 'mark_wrong' | 'provide_guidance' | 'skip'; correction?: string; guidance?: string; shouldLearn?: boolean }> | undefined
225+
if (revisions && revisions.length > 0) {
226+
learnFromTrajectoryRevisions(revisions, session.payload as { title: string; steps: Array<{ id: string; label: string }> })
227+
}
228+
221229
// Send result back to OpenClaw
222230
let callbackFailed = false
223231
let callbackError = ''
@@ -249,6 +257,23 @@ async function callWebhook(body: Record<string, unknown>): Promise<void> {
249257
}
250258

251259
function buildActionSummary(result: Record<string, unknown>): string {
260+
// Trajectory review: has revisions array
261+
if ('revisions' in result && Array.isArray(result.revisions)) {
262+
const approved = result.approved as boolean
263+
const revisions = result.revisions as Array<{ stepId: string; action: string; correction?: string; guidance?: string }>
264+
const globalNote = result.globalNote as string | undefined
265+
const resumeFromStep = result.resumeFromStep as string | undefined
266+
const lines = ['[agentclick] User reviewed the trajectory:']
267+
lines.push(approved ? '- Approved: proceed.' : '- Rejected: do not proceed.')
268+
const wrong = revisions.filter(r => r.action === 'mark_wrong')
269+
const guided = revisions.filter(r => r.action === 'provide_guidance')
270+
if (wrong.length > 0) lines.push(`- Marked ${wrong.length} step(s) as wrong: ${wrong.map(r => `${r.stepId}${r.correction ? ` — "${r.correction}"` : ''}`).join(', ')}`)
271+
if (guided.length > 0) lines.push(`- Guidance for ${guided.length} step(s): ${guided.map(r => `${r.stepId} — "${r.guidance}"`).join(', ')}`)
272+
if (resumeFromStep) lines.push(`- Resume from step: ${resumeFromStep}`)
273+
if (globalNote) lines.push(`- Note: ${globalNote}`)
274+
return lines.join('\n')
275+
}
276+
252277
// If result has approved field, it's an action_approval or code_review
253278
if ('approved' in result) {
254279
const approved = result.approved as boolean

packages/server/src/preference.ts

Lines changed: 117 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import os from 'os'
44

55
const MEMORY_PATH = path.join(os.homedir(), '.openclaw', 'workspace', 'MEMORY.md')
66
const SECTION_HEADER = '## Email Preferences (ClawUI Auto-Learned)'
7+
const TRAJECTORY_SECTION_HEADER = '## Trajectory Guidance (ClawUI Auto-Learned)'
78

89
interface Paragraph {
910
id: string
@@ -100,30 +101,118 @@ export function learnFromDeletions(
100101
console.log(`[agentclick] Learned ${rules.length} preference rule(s) -> ${MEMORY_PATH}`)
101102
}
102103

104+
interface StepRevision {
105+
stepId: string
106+
action: 'mark_wrong' | 'provide_guidance' | 'skip'
107+
correction?: string
108+
guidance?: string
109+
shouldLearn?: boolean
110+
}
111+
112+
interface TrajectoryPayload {
113+
title: string
114+
steps: Array<{ id: string; label: string; [key: string]: unknown }>
115+
[key: string]: unknown
116+
}
117+
118+
export function learnFromTrajectoryRevisions(
119+
revisions: StepRevision[],
120+
payload: TrajectoryPayload
121+
): void {
122+
const learnable = revisions.filter(r => r.shouldLearn && r.action !== 'skip')
123+
if (learnable.length === 0) return
124+
125+
const stepMap = new Map<string, string>()
126+
function walkSteps(steps: Array<{ id: string; label: string; children?: unknown[] }>) {
127+
for (const s of steps) {
128+
stepMap.set(s.id, s.label)
129+
if (Array.isArray(s.children)) walkSteps(s.children as typeof steps)
130+
}
131+
}
132+
walkSteps(payload.steps)
133+
134+
const rules: string[] = []
135+
for (const rev of learnable) {
136+
const stepLabel = stepMap.get(rev.stepId) ?? rev.stepId
137+
if (rev.action === 'mark_wrong' && rev.correction) {
138+
rules.push(`- AVOID: ${summarize(rev.correction)} (step: ${rev.stepId}, context: ${summarize(stepLabel)}) - SCOPE: trajectory`)
139+
}
140+
if (rev.guidance) {
141+
rules.push(`- PREFER: ${summarize(rev.guidance)} (step: ${rev.stepId}, context: ${summarize(stepLabel)}) - SCOPE: trajectory`)
142+
}
143+
}
144+
145+
if (rules.length === 0) return
146+
147+
ensureMemoryFile()
148+
const existing = fs.readFileSync(MEMORY_PATH, 'utf-8')
149+
const needsHeader = !existing.includes(TRAJECTORY_SECTION_HEADER)
150+
const block = needsHeader
151+
? `\n${TRAJECTORY_SECTION_HEADER}\n${rules.join('\n')}\n`
152+
: `${rules.join('\n')}\n`
153+
154+
fs.appendFileSync(MEMORY_PATH, block, 'utf-8')
155+
console.log(`[agentclick] Learned ${rules.length} trajectory rule(s) -> ${MEMORY_PATH}`)
156+
}
157+
103158
export interface LearnedPreference {
104159
description: string
105160
reason: string
106161
scope: string
162+
type?: 'email' | 'trajectory'
107163
}
108164

109165
export function getLearnedPreferences(): LearnedPreference[] {
110166
if (!fs.existsSync(MEMORY_PATH)) return []
111167

112168
const content = fs.readFileSync(MEMORY_PATH, 'utf-8')
113-
const sectionStart = content.indexOf(SECTION_HEADER)
114-
if (sectionStart === -1) return []
115-
116-
const sectionContent = content.slice(sectionStart + SECTION_HEADER.length)
117169
const preferences: LearnedPreference[] = []
118170

119-
for (const line of sectionContent.split('\n')) {
120-
const match = line.match(/^- AVOID: (.+?) \(reason: (.+?)\) - SCOPE: (.+)$/)
121-
if (match) {
122-
preferences.push({
123-
description: match[1].trim(),
124-
reason: match[2].trim(),
125-
scope: match[3].trim(),
126-
})
171+
// Parse email preferences section
172+
const emailStart = content.indexOf(SECTION_HEADER)
173+
if (emailStart !== -1) {
174+
const emailContent = content.slice(emailStart + SECTION_HEADER.length)
175+
// Stop at next section header
176+
const nextHeader = emailContent.indexOf('\n## ')
177+
const section = nextHeader !== -1 ? emailContent.slice(0, nextHeader) : emailContent
178+
for (const line of section.split('\n')) {
179+
const match = line.match(/^- AVOID: (.+?) \(reason: (.+?)\) - SCOPE: (.+)$/)
180+
if (match) {
181+
preferences.push({
182+
description: match[1].trim(),
183+
reason: match[2].trim(),
184+
scope: match[3].trim(),
185+
type: 'email',
186+
})
187+
}
188+
}
189+
}
190+
191+
// Parse trajectory guidance section
192+
const trajStart = content.indexOf(TRAJECTORY_SECTION_HEADER)
193+
if (trajStart !== -1) {
194+
const trajContent = content.slice(trajStart + TRAJECTORY_SECTION_HEADER.length)
195+
const nextHeader = trajContent.indexOf('\n## ')
196+
const section = nextHeader !== -1 ? trajContent.slice(0, nextHeader) : trajContent
197+
for (const line of section.split('\n')) {
198+
const avoidMatch = line.match(/^- AVOID: (.+?) \(step: (.+?), context: (.+?)\) - SCOPE: trajectory$/)
199+
if (avoidMatch) {
200+
preferences.push({
201+
description: avoidMatch[1].trim(),
202+
reason: `step ${avoidMatch[2].trim()}`,
203+
scope: 'trajectory',
204+
type: 'trajectory',
205+
})
206+
}
207+
const preferMatch = line.match(/^- PREFER: (.+?) \(step: (.+?), context: (.+?)\) - SCOPE: trajectory$/)
208+
if (preferMatch) {
209+
preferences.push({
210+
description: preferMatch[1].trim(),
211+
reason: `step ${preferMatch[2].trim()}`,
212+
scope: 'trajectory',
213+
type: 'trajectory',
214+
})
215+
}
127216
}
128217
}
129218

@@ -158,20 +247,23 @@ export function deletePreference(index: number): void {
158247
export function clearPreferences(): void {
159248
if (!fs.existsSync(MEMORY_PATH)) return
160249

161-
const content = fs.readFileSync(MEMORY_PATH, 'utf-8')
162-
const lines = content.split('\n')
163-
const headerIdx = lines.findIndex(l => l === SECTION_HEADER)
164-
if (headerIdx === -1) return
250+
let content = fs.readFileSync(MEMORY_PATH, 'utf-8')
165251

166-
// Find start of next ## section (or end of file)
167-
let endIdx = lines.length
168-
for (let i = headerIdx + 1; i < lines.length; i++) {
169-
if (lines[i].startsWith('## ')) { endIdx = i; break }
170-
}
252+
// Remove each section by header
253+
for (const header of [SECTION_HEADER, TRAJECTORY_SECTION_HEADER]) {
254+
const lines = content.split('\n')
255+
const headerIdx = lines.findIndex(l => l === header)
256+
if (headerIdx === -1) continue
171257

172-
// Also remove a preceding blank line if present
173-
const start = headerIdx > 0 && lines[headerIdx - 1] === '' ? headerIdx - 1 : headerIdx
174-
lines.splice(start, endIdx - start)
258+
let endIdx = lines.length
259+
for (let i = headerIdx + 1; i < lines.length; i++) {
260+
if (lines[i].startsWith('## ')) { endIdx = i; break }
261+
}
175262

176-
fs.writeFileSync(MEMORY_PATH, lines.join('\n'), 'utf-8')
263+
const start = headerIdx > 0 && lines[headerIdx - 1] === '' ? headerIdx - 1 : headerIdx
264+
lines.splice(start, endIdx - start)
265+
content = lines.join('\n')
266+
}
267+
268+
fs.writeFileSync(MEMORY_PATH, content, 'utf-8')
177269
}

packages/web/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import CodeReviewPage from './pages/CodeReviewPage'
66
import HomePage from './pages/HomePage'
77
import FormReviewPage from './pages/FormReviewPage'
88
import SelectionPage from './pages/SelectionPage'
9+
import TrajectoryPage from './pages/TrajectoryPage'
910
import PreferencesPage from './pages/PreferencesPage'
1011
import CompletedPage from './pages/CompletedPage'
1112

@@ -96,6 +97,7 @@ export default function App() {
9697
<Route path="/code-review/:id" element={<CodeReviewPage />} />
9798
<Route path="/form-review/:id" element={<FormReviewPage />} />
9899
<Route path="/selection/:id" element={<SelectionPage />} />
100+
<Route path="/trajectory/:id" element={<TrajectoryPage />} />
99101
<Route path="/preferences" element={<PreferencesPage />} />
100102
<Route path="/completed" element={<CompletedPage />} />
101103
</Routes>

packages/web/src/index.css

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,13 @@
4949
/* Bezier curves */
5050
--c-curve-hi: #94A3B8;
5151
--c-curve-lo: #E5E7EB;
52+
53+
/* Trajectory step types */
54+
--c-step-tool-bg: #EFF6FF; --c-step-tool-border: #BFDBFE; --c-step-tool-text: #1D4ED8;
55+
--c-step-decision-bg: #F5F3FF; --c-step-decision-border: #DDD6FE; --c-step-decision-text: #6D28D9;
56+
--c-step-observation-bg: #F8FAFC; --c-step-observation-border: #E2E8F0; --c-step-observation-text: #64748B;
57+
--c-step-error-bg: #FEF2F2; --c-step-error-border: #FECACA; --c-step-error-text: #B91C1C;
58+
--c-step-retry-bg: #FFFBEB; --c-step-retry-border: #FDE68A; --c-step-retry-text: #A16207;
5259
}
5360

5461
.dark {
@@ -98,6 +105,13 @@
98105
/* Bezier curves */
99106
--c-curve-hi: #636366;
100107
--c-curve-lo: #2C2C2E;
108+
109+
/* Trajectory step types */
110+
--c-step-tool-bg: #001933; --c-step-tool-border: #003A80; --c-step-tool-text: #0A84FF;
111+
--c-step-decision-bg: #1A0533; --c-step-decision-border: #3B0764; --c-step-decision-text: #A78BFA;
112+
--c-step-observation-bg: #1C1C1E; --c-step-observation-border: #3A3A3C; --c-step-observation-text: #98989E;
113+
--c-step-error-bg: #2B1C1C; --c-step-error-border: #6B2020; --c-step-error-text: #FF453A;
114+
--c-step-retry-bg: #2B2218; --c-step-retry-border: #6B4D10; --c-step-retry-text: #FF9F0A;
101115
}
102116

103117
body {

packages/web/src/pages/HomePage.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,25 +10,29 @@ interface SessionItem {
1010
to?: string
1111
risk?: string
1212
command?: string
13+
title?: string
1314
}
1415

1516
const TYPE_LABELS: Record<string, string> = {
16-
email_review: 'Email',
17-
code_review: 'Code',
18-
action_approval: 'Approval',
19-
form_review: 'Form',
20-
selection_review: 'Selection',
17+
email_review: 'Email',
18+
code_review: 'Code',
19+
action_approval: 'Approval',
20+
form_review: 'Form',
21+
selection_review: 'Selection',
22+
trajectory_review: 'Trajectory',
2123
}
2224

2325
function sessionPath(s: SessionItem): string {
2426
if (s.type === 'action_approval') return `/approval/${s.id}`
2527
if (s.type === 'code_review') return `/code-review/${s.id}`
2628
if (s.type === 'form_review') return `/form-review/${s.id}`
2729
if (s.type === 'selection_review') return `/selection/${s.id}`
30+
if (s.type === 'trajectory_review') return `/trajectory/${s.id}`
2831
return `/review/${s.id}`
2932
}
3033

3134
function sessionTitle(s: SessionItem): string {
35+
if (s.title) return s.title
3236
if (s.subject) return s.subject
3337
if (s.type === 'code_review' && s.command) return s.command
3438
return TYPE_LABELS[s.type] ?? s.type

0 commit comments

Comments
 (0)