@@ -4,6 +4,7 @@ import os from 'os'
44
55const MEMORY_PATH = path . join ( os . homedir ( ) , '.openclaw' , 'workspace' , 'MEMORY.md' )
66const SECTION_HEADER = '## Email Preferences (ClawUI Auto-Learned)'
7+ const TRAJECTORY_SECTION_HEADER = '## Trajectory Guidance (ClawUI Auto-Learned)'
78
89interface 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+
103158export interface LearnedPreference {
104159 description : string
105160 reason : string
106161 scope : string
162+ type ?: 'email' | 'trajectory'
107163}
108164
109165export 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 ( / ^ - A V O I D : ( .+ ?) \( r e a s o n : ( .+ ?) \) - S C O P E : ( .+ ) $ / )
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 ( / ^ - A V O I D : ( .+ ?) \( r e a s o n : ( .+ ?) \) - S C O P E : ( .+ ) $ / )
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 ( / ^ - A V O I D : ( .+ ?) \( s t e p : ( .+ ?) , c o n t e x t : ( .+ ?) \) - S C O P E : t r a j e c t o r y $ / )
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 ( / ^ - P R E F E R : ( .+ ?) \( s t e p : ( .+ ?) , c o n t e x t : ( .+ ?) \) - S C O P E : t r a j e c t o r y $ / )
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 {
158247export 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}
0 commit comments