Skip to content

Commit 02a81fc

Browse files
committed
feat: unified memory management UI, per-file guidance, adaptive plan layout
- Merge memory_review + memory_management into single session type; modifications[] highlights changed files with inline diff + M badge - Add per-file Update Guidance textarea persisted in preferences - Add GET/POST /api/memory/guidance endpoints - Fix MemoryReviewPage sidebar click to fetch and show full file content - Fix MemoryManagementPage mind map click (leading-slash path mismatch) - Add compact session flow to SKILL.md (read guidance → propose mods → wait → write) - PlanPage: adaptive row height (MIN_NODE_H=80) and per-row adaptive width (parallel rows split width, single-step rows fill viewport)
1 parent 5789bf2 commit 02a81fc

File tree

6 files changed

+411
-151
lines changed

6 files changed

+411
-151
lines changed

packages/server/src/index.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import {
1717
readMemoryFileContent,
1818
removeMemoryFileFromContext,
1919
updateMemoryPreferences,
20+
readFileGuidance,
21+
writeFileGuidance,
2022
} from './memory.js'
2123

2224
const app = express()
@@ -408,6 +410,21 @@ app.get('/api/memory/file', (req, res) => {
408410
res.json(file)
409411
})
410412

413+
app.get('/api/memory/guidance', (req, res) => {
414+
const filePath = typeof req.query.path === 'string' ? req.query.path : ''
415+
if (!filePath) return res.status(400).json({ error: 'Missing path query' })
416+
const guidance = readFileGuidance(filePath)
417+
res.json({ path: filePath, guidance })
418+
})
419+
420+
app.post('/api/memory/guidance', (req, res) => {
421+
const filePath = typeof req.body?.path === 'string' ? req.body.path : ''
422+
const guidance = typeof req.body?.guidance === 'string' ? req.body.guidance : ''
423+
if (!filePath) return res.status(400).json({ error: 'Missing path in request body' })
424+
writeFileGuidance(filePath, guidance)
425+
res.json({ ok: true, path: filePath, guidance })
426+
})
427+
411428
app.post('/api/memory/include', (req, res) => {
412429
const projectRoot = join(__dirname, '../../..')
413430
const filePath = typeof req.body?.path === 'string' ? req.body.path : ''
@@ -485,7 +502,7 @@ app.post('/api/memory/review/create', async (req, res) => {
485502
})
486503

487504
app.post('/api/memory/management/create', async (req, res) => {
488-
const { sessionKey, noOpen, openHome, currentContextFiles, extraMarkdownDirs, searchQuery } = req.body ?? {}
505+
const { sessionKey, noOpen, openHome, currentContextFiles, extraMarkdownDirs, searchQuery, modifications } = req.body ?? {}
489506
const projectRoot = join(__dirname, '../../..')
490507
const catalog = buildMemoryCatalog({
491508
projectRoot,
@@ -499,7 +516,7 @@ app.post('/api/memory/management/create', async (req, res) => {
499516
createSession({
500517
id,
501518
type: 'memory_management',
502-
payload: catalog,
519+
payload: { ...catalog, modifications: Array.isArray(modifications) ? modifications : [] },
503520
sessionKey,
504521
status: 'pending',
505522
pageStatus: { state: 'created', updatedAt: now },

packages/server/src/memory.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export interface MemoryFileItem {
1919
matchedBySearch: boolean
2020
preview: string
2121
sections: Array<{ id: string; title: string }>
22+
guidance: string
2223
}
2324

2425
export interface MemoryModification {
@@ -68,6 +69,7 @@ export interface MemoryResolveResult {
6869
interface MemoryPreferenceState {
6970
includedPaths: string[]
7071
includedDirectories: string[]
72+
fileGuidance: Record<string, string>
7173
}
7274

7375
const MEMORY_INCLUDE_STATE_PATH = path.join(os.homedir(), '.openclaw', 'agentclick-memory-includes.json')
@@ -120,16 +122,19 @@ function normalizePreferenceState(raw: unknown): MemoryPreferenceState {
120122
const includedDirectories = Array.isArray(parsed.includedDirectories)
121123
? parsed.includedDirectories.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
122124
: []
123-
return { includedPaths, includedDirectories }
125+
const fileGuidance = (parsed.fileGuidance && typeof parsed.fileGuidance === 'object' && !Array.isArray(parsed.fileGuidance))
126+
? Object.fromEntries(Object.entries(parsed.fileGuidance as Record<string, unknown>).filter(([, v]) => typeof v === 'string')) as Record<string, string>
127+
: {}
128+
return { includedPaths, includedDirectories, fileGuidance }
124129
}
125130

126131
function readMemoryPreferenceState(): MemoryPreferenceState {
127132
try {
128-
if (!fs.existsSync(MEMORY_INCLUDE_STATE_PATH)) return { includedPaths: [], includedDirectories: [] }
133+
if (!fs.existsSync(MEMORY_INCLUDE_STATE_PATH)) return { includedPaths: [], includedDirectories: [], fileGuidance: {} }
129134
const raw = fs.readFileSync(MEMORY_INCLUDE_STATE_PATH, 'utf-8')
130135
return normalizePreferenceState(JSON.parse(raw))
131136
} catch {
132-
return { includedPaths: [], includedDirectories: [] }
137+
return { includedPaths: [], includedDirectories: [], fileGuidance: {} }
133138
}
134139
}
135140

@@ -381,6 +386,7 @@ export function buildMemoryCatalog(input: {
381386
matchedBySearch: false,
382387
preview: firstPreview(content),
383388
sections: parseSections(content),
389+
guidance: preferenceState.fileGuidance[absPath] ?? '',
384390
})
385391
}
386392

@@ -494,6 +500,7 @@ export function includeMemoryFileInContext(input: {
494500
const nextState: MemoryPreferenceState = {
495501
includedPaths: uniqueSortedPaths(Array.from(current.values())),
496502
includedDirectories: uniqueSortedPaths(state.includedDirectories),
503+
fileGuidance: state.fileGuidance,
497504
}
498505
if (input.persist !== false) writeMemoryPreferenceState(nextState)
499506
return { ok: true, includedPaths: nextState.includedPaths, includedDirectories: nextState.includedDirectories }
@@ -515,6 +522,7 @@ export function removeMemoryFileFromContext(input: {
515522
const nextState: MemoryPreferenceState = {
516523
includedPaths: uniqueSortedPaths(Array.from(current.values())),
517524
includedDirectories: uniqueSortedPaths(state.includedDirectories),
525+
fileGuidance: state.fileGuidance,
518526
}
519527
writeMemoryPreferenceState(nextState)
520528
return { ok: true, includedPaths: nextState.includedPaths, includedDirectories: nextState.includedDirectories }
@@ -533,6 +541,7 @@ export function updateMemoryPreferences(input: {
533541
includedDirectories: uniqueSortedPaths(
534542
(input.includedDirectories ?? state.includedDirectories).map(value => normalizeInputPath(input.projectRoot, value)).filter(Boolean)
535543
),
544+
fileGuidance: state.fileGuidance,
536545
}
537546
writeMemoryPreferenceState(nextState)
538547
return nextState
@@ -595,3 +604,17 @@ export function buildMemoryReviewPayload(input: {
595604
searchQuery: input.searchQuery?.trim() || undefined,
596605
}
597606
}
607+
608+
export function readFileGuidance(filePath: string): string {
609+
const state = readMemoryPreferenceState()
610+
return state.fileGuidance[path.resolve(filePath)] ?? ''
611+
}
612+
613+
export function writeFileGuidance(filePath: string, guidance: string): void {
614+
const state = readMemoryPreferenceState()
615+
const next: MemoryPreferenceState = {
616+
...state,
617+
fileGuidance: { ...state.fileGuidance, [path.resolve(filePath)]: guidance },
618+
}
619+
writeMemoryPreferenceState(next)
620+
}

packages/web/src/pages/MemoryManagementPage.tsx

Lines changed: 121 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ interface MemoryFile {
1212
inProject: boolean
1313
inAgentCache: boolean
1414
relatedMarkdown: boolean
15+
guidance?: string
1516
}
1617

1718
interface MemoryGroup {
@@ -23,6 +24,7 @@ interface MemoryGroup {
2324
interface CatalogResponse {
2425
groups: MemoryGroup[]
2526
files: MemoryFile[]
27+
modifications?: MemoryModification[]
2628
}
2729

2830
interface FileContentResponse {
@@ -31,6 +33,34 @@ interface FileContentResponse {
3133
content: string
3234
}
3335

36+
interface MemoryModification {
37+
id: string
38+
fileId: string
39+
filePath: string
40+
location: string
41+
oldContent: string
42+
newContent: string
43+
generatedContent: string
44+
}
45+
46+
function computeSimpleDiff(oldText: string, newText: string): Array<{ type: 'context' | 'add' | 'remove'; text: string }> {
47+
const oldLines = oldText.split('\n')
48+
const newLines = newText.split('\n')
49+
const out: Array<{ type: 'context' | 'add' | 'remove'; text: string }> = []
50+
let i = 0
51+
let j = 0
52+
while (i < oldLines.length || j < newLines.length) {
53+
if (i < oldLines.length && j < newLines.length && oldLines[i] === newLines[j]) {
54+
out.push({ type: 'context', text: oldLines[i] })
55+
i += 1; j += 1
56+
continue
57+
}
58+
if (i < oldLines.length) { out.push({ type: 'remove', text: oldLines[i] ?? '' }); i += 1 }
59+
if (j < newLines.length) { out.push({ type: 'add', text: newLines[j] ?? '' }); j += 1 }
60+
}
61+
return out
62+
}
63+
3464
type PageStatusState = 'opened' | 'active' | 'hidden'
3565

3666
function renderMarkdownFriendly(markdown: string) {
@@ -114,6 +144,8 @@ export default function MemoryManagementPage() {
114144
const [error, setError] = useState('')
115145
const [agentDeleteRequest, setAgentDeleteRequest] = useState('')
116146
const [waitingForRewrite, setWaitingForRewrite] = useState(false)
147+
const [fileGuidance, setFileGuidance] = useState<string>('')
148+
const [guidanceSaving, setGuidanceSaving] = useState(false)
117149
const pollRef = useRef<number | null>(null)
118150

119151
const loadCatalog = useCallback(async () => {
@@ -199,14 +231,33 @@ export default function MemoryManagementPage() {
199231
return m
200232
}, [catalog])
201233

234+
const modificationByFileId = useMemo(() => {
235+
const m = new Map<string, MemoryModification>()
236+
for (const mod of catalog?.modifications ?? []) m.set(mod.fileId, mod)
237+
return m
238+
}, [catalog])
239+
240+
const changedFileIds = useMemo(() => new Set(Array.from(modificationByFileId.keys())), [modificationByFileId])
241+
202242
const selectedFileId = useMemo(() => {
203243
if (!selectedPath) return null
204244
const file = catalog?.files.find(f => f.path === selectedPath)
205245
return file?.id ?? null
206246
}, [catalog, selectedPath])
207247

248+
const selectedModification = useMemo(() => {
249+
if (!selectedFileId) return null
250+
return modificationByFileId.get(selectedFileId) ?? null
251+
}, [selectedFileId, modificationByFileId])
252+
253+
const diffLines = useMemo(() => {
254+
if (!selectedModification) return []
255+
return computeSimpleDiff(selectedModification.oldContent, selectedModification.newContent)
256+
}, [selectedModification])
257+
208258
const openFile = async (file: MemoryFile) => {
209259
setSelectedPath(file.path)
260+
setFileGuidance(file.guidance ?? '')
210261
setAgentDeleteRequest('')
211262
setFileLoading(true)
212263
try {
@@ -222,8 +273,25 @@ export default function MemoryManagementPage() {
222273
}
223274
}
224275

276+
const saveGuidance = async (filePath: string, value: string) => {
277+
setGuidanceSaving(true)
278+
try {
279+
await fetch('/api/memory/guidance', {
280+
method: 'POST',
281+
headers: { 'Content-Type': 'application/json' },
282+
body: JSON.stringify({ path: filePath, guidance: value }),
283+
})
284+
} finally {
285+
setGuidanceSaving(false)
286+
}
287+
}
288+
225289
const openFileByPath = (relPath: string) => {
226-
const file = catalog?.files.find(f => f.relativePath === relPath)
290+
const file = catalog?.files.find(f =>
291+
f.relativePath === relPath ||
292+
f.relativePath === `/${relPath}` ||
293+
f.relativePath === relPath.replace(/^\//, '')
294+
)
227295
if (file) openFile(file)
228296
}
229297

@@ -367,7 +435,7 @@ export default function MemoryManagementPage() {
367435
<div className="mt-4 space-y-4">
368436
{mindGroups.map(group => {
369437
const collapsed = collapsedMindGroups.has(group.id)
370-
const treeNodes = buildMemoryTree(group.files, new Set())
438+
const treeNodes = buildMemoryTree(group.files, changedFileIds)
371439
return (
372440
<div key={group.id}>
373441
<button
@@ -415,18 +483,24 @@ export default function MemoryManagementPage() {
415483
const file = fileMap.get(fileId)
416484
if (!file) return null
417485
const active = selectedPath === file.path
486+
const changed = changedFileIds.has(file.id)
418487
return (
419488
<button
420489
key={file.id}
421490
onClick={() => openFile(file)}
422491
className={`w-full text-left px-2 py-1.5 rounded border ${
423492
active
424493
? 'border-blue-300 dark:border-blue-700 bg-blue-50 dark:bg-blue-950'
425-
: 'border-gray-100 dark:border-zinc-800 hover:bg-gray-50 dark:hover:bg-zinc-800'
494+
: changed
495+
? 'border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-950 hover:bg-amber-100 dark:hover:bg-amber-900'
496+
: 'border-gray-100 dark:border-zinc-800 hover:bg-gray-50 dark:hover:bg-zinc-800'
426497
}`}
427498
title={file.path}
428499
>
429-
<p className="text-xs font-mono text-zinc-700 dark:text-slate-300 truncate">{file.relativePath}</p>
500+
<div className="flex items-center gap-1.5 min-w-0">
501+
{changed && <span className="shrink-0 text-[10px] font-bold px-1 py-0.5 rounded bg-amber-200 dark:bg-amber-900 text-amber-700 dark:text-amber-300">M</span>}
502+
<p className={`text-xs font-mono truncate ${changed ? 'text-amber-700 dark:text-amber-300' : 'text-zinc-700 dark:text-slate-300'}`}>{file.relativePath}</p>
503+
</div>
430504
<p className="text-[11px] text-zinc-500 dark:text-slate-400 mt-0.5 truncate">{file.preview}</p>
431505
</button>
432506
)
@@ -481,6 +555,49 @@ export default function MemoryManagementPage() {
481555
<p className="text-xs text-amber-700 dark:text-amber-400">{agentDeleteRequest}</p>
482556
</div>
483557
)}
558+
{selectedModification && (
559+
<div className="mt-4 mb-1">
560+
<div className="flex items-center gap-2 mb-2">
561+
<span className="text-xs font-bold px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-950 text-amber-700 dark:text-amber-300">M</span>
562+
<p className="text-xs font-medium text-zinc-700 dark:text-slate-300">Proposed Changes</p>
563+
</div>
564+
<div className="border border-gray-200 dark:border-zinc-700 rounded overflow-hidden max-h-80 overflow-y-auto mb-4">
565+
<table className="w-full text-xs font-mono border-collapse">
566+
<tbody>
567+
{diffLines.map((line, idx) => (
568+
<tr key={idx} className={line.type === 'add' ? 'bg-green-50 dark:bg-green-950' : line.type === 'remove' ? 'bg-red-50 dark:bg-red-950' : ''}>
569+
<td className="w-8 text-right px-2 py-0.5 text-zinc-400 dark:text-slate-500 select-none border-r border-gray-100 dark:border-zinc-800"
570+
style={{ borderLeft: line.type === 'add' ? '3px solid rgba(34,197,94,.8)' : line.type === 'remove' ? '3px solid rgba(239,68,68,.8)' : '3px solid transparent' }}>
571+
{idx + 1}
572+
</td>
573+
<td className="w-5 px-1 py-0.5 text-center font-bold select-none"
574+
style={{ color: line.type === 'add' ? 'rgb(21,128,61)' : line.type === 'remove' ? 'rgb(185,28,28)' : 'transparent' }}>
575+
{line.type === 'add' ? '+' : line.type === 'remove' ? '−' : ' '}
576+
</td>
577+
<td className={`px-2 py-0.5 whitespace-pre-wrap break-words ${line.type === 'add' ? 'text-green-700 dark:text-green-300' : line.type === 'remove' ? 'text-red-700 dark:text-red-300' : 'text-zinc-600 dark:text-slate-400'}`}>
578+
{line.text}
579+
</td>
580+
</tr>
581+
))}
582+
</tbody>
583+
</table>
584+
</div>
585+
</div>
586+
)}
587+
<div className="mt-4">
588+
<label className="text-xs font-medium text-zinc-500 dark:text-slate-400 block mb-1">
589+
Update Guidance
590+
{guidanceSaving && <span className="ml-2 text-zinc-400 dark:text-slate-500">saving…</span>}
591+
</label>
592+
<textarea
593+
className="w-full text-xs border border-gray-200 dark:border-zinc-700 rounded px-2 py-1.5 bg-white dark:bg-zinc-900 text-zinc-700 dark:text-slate-300 resize-none"
594+
rows={3}
595+
placeholder="Describe how this file should be updated (e.g. 'always append new bugs as bullet points under Key Bugs'). Saved as preference."
596+
value={fileGuidance}
597+
onChange={e => setFileGuidance(e.target.value)}
598+
onBlur={e => { if (selectedContent) void saveGuidance(selectedContent.path, e.target.value) }}
599+
/>
600+
</div>
484601
<div className="mt-3 max-h-[70vh] overflow-y-auto pr-1">
485602
{renderMarkdownFriendly(selectedContent.content)}
486603
</div>

0 commit comments

Comments
 (0)