Skip to content

Commit 40273cd

Browse files
committed
feat(memory): split management vs review and add readable full-file view
1 parent f31ff51 commit 40273cd

File tree

5 files changed

+310
-5
lines changed

5 files changed

+310
-5
lines changed

packages/server/src/index.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { dirname, join } from 'path'
77
import { fileURLToPath } from 'url'
88
import { learnFromDeletions, learnFromTrajectoryRevisions, getLearnedPreferences, clearPreferences, deletePreference } from './preference.js'
99
import { createSession, getSession, listSessions, completeSession, setSessionRewriting, updateSessionPayload } from './store.js'
10-
import { buildMemoryReviewPayload } from './memory.js'
10+
import { buildMemoryCatalog, buildMemoryReviewPayload, readMemoryFileContent } from './memory.js'
1111

1212
const app = express()
1313
const DEFAULT_PORT = 38173
@@ -106,6 +106,7 @@ app.get('/api/home-info', (_req, res) => {
106106
{ type: 'action_approval', route: '/approval/:id' },
107107
{ type: 'code_review', route: '/code-review/:id' },
108108
{ type: 'email_review', route: '/review/:id' },
109+
{ type: 'memory_management', route: '/memory-management' },
109110
{ type: 'plan_review', route: '/plan/:id' },
110111
{ type: 'memory_review', route: '/memory/:id' },
111112
{ type: 'trajectory_review', route: '/trajectory/:id' },
@@ -130,7 +131,9 @@ if (!SHOULD_SERVE_BUILT_WEB) {
130131
'action_approval',
131132
'code_review',
132133
'email_review',
134+
'memory_management',
133135
'plan_review',
136+
'memory_review',
134137
'trajectory_review',
135138
'form_review',
136139
'selection_review',
@@ -231,6 +234,24 @@ app.post('/api/review', async (req, res) => {
231234
res.json({ sessionId: id, url })
232235
})
233236

237+
app.get('/api/memory/files', (req, res) => {
238+
const projectRoot = join(__dirname, '../../..')
239+
const currentContextFiles = typeof req.query.currentContextFiles === 'string'
240+
? req.query.currentContextFiles.split(',').map(v => v.trim()).filter(Boolean)
241+
: undefined
242+
const catalog = buildMemoryCatalog({ projectRoot, currentContextFiles })
243+
res.json(catalog)
244+
})
245+
246+
app.get('/api/memory/file', (req, res) => {
247+
const projectRoot = join(__dirname, '../../..')
248+
const filePath = typeof req.query.path === 'string' ? req.query.path : ''
249+
if (!filePath) return res.status(400).json({ error: 'Missing path query' })
250+
const file = readMemoryFileContent({ projectRoot, filePath })
251+
if (!file) return res.status(404).json({ error: 'File not found in memory catalog' })
252+
res.json(file)
253+
})
254+
234255
app.post('/api/memory/review/create', async (req, res) => {
235256
const { sessionKey, noOpen, openHome, currentContextFiles, generatedContent } = req.body ?? {}
236257
const projectRoot = join(__dirname, '../../..')

packages/server/src/memory.ts

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,12 @@ export interface MemoryReviewPayload {
4545
compressionRecommendations: CompressionRecommendation[]
4646
}
4747

48+
export interface MemoryCatalogPayload {
49+
groups: Array<{ id: string; label: string; fileIds: string[] }>
50+
files: MemoryFileItem[]
51+
defaultIncludedFileIds: string[]
52+
}
53+
4854
function walkMarkdownFiles(baseDir: string, options?: { maxFiles?: number }): string[] {
4955
const maxFiles = options?.maxFiles ?? 200
5056
const output: string[] = []
@@ -123,11 +129,10 @@ function toId(index: number): string {
123129
return `mem_${index.toString(36)}`
124130
}
125131

126-
export function buildMemoryReviewPayload(input: {
132+
export function buildMemoryCatalog(input: {
127133
projectRoot: string
128134
currentContextFiles?: string[]
129-
generatedContent?: string
130-
}): MemoryReviewPayload {
135+
}): MemoryCatalogPayload {
131136
const projectRoot = input.projectRoot
132137
const currentContextSet = new Set((input.currentContextFiles ?? []).map(p => path.resolve(projectRoot, p)))
133138
const relatedMarkdown = walkMarkdownFiles(projectRoot, { maxFiles: 220 })
@@ -208,6 +213,43 @@ export function buildMemoryReviewPayload(input: {
208213
.filter(f => f.inCurrentContent || f.inProject || f.inAgentCache)
209214
.map(f => f.id)
210215

216+
return {
217+
groups,
218+
files,
219+
defaultIncludedFileIds,
220+
}
221+
}
222+
223+
export function readMemoryFileContent(input: {
224+
projectRoot: string
225+
filePath: string
226+
currentContextFiles?: string[]
227+
}): { path: string; relativePath: string; content: string } | null {
228+
const catalog = buildMemoryCatalog({
229+
projectRoot: input.projectRoot,
230+
currentContextFiles: input.currentContextFiles,
231+
})
232+
const target = catalog.files.find(f => path.resolve(f.path) === path.resolve(input.filePath))
233+
if (!target) return null
234+
const content = safeRead(target.path, 200000)
235+
return {
236+
path: target.path,
237+
relativePath: target.relativePath,
238+
content,
239+
}
240+
}
241+
242+
export function buildMemoryReviewPayload(input: {
243+
projectRoot: string
244+
currentContextFiles?: string[]
245+
generatedContent?: string
246+
}): MemoryReviewPayload {
247+
const catalog = buildMemoryCatalog({
248+
projectRoot: input.projectRoot,
249+
currentContextFiles: input.currentContextFiles,
250+
})
251+
const { groups, files, defaultIncludedFileIds } = catalog
252+
211253
const targetFile = files.find(f => f.inProject || f.inAgentCache) ?? files[0]
212254
const generatedContent = input.generatedContent
213255
?? `## Auto-generated Memory Update (${new Date().toISOString()})\n- Summarized from latest memory review decisions.\n- Keep relevant project guidance and discard noisy markdown.\n`
@@ -245,4 +287,3 @@ export function buildMemoryReviewPayload(input: {
245287
compressionRecommendations,
246288
}
247289
}
248-

packages/web/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import SelectionPage from './pages/SelectionPage'
99
import TrajectoryPage from './pages/TrajectoryPage'
1010
import PlanPage from './pages/PlanPage'
1111
import PlanTestPage from './pages/PlanTestPage'
12+
import MemoryManagementPage from './pages/MemoryManagementPage'
1213
import MemoryReviewPage from './pages/MemoryReviewPage'
1314
import MemoryTestPage from './pages/MemoryTestPage'
1415
import PortsPage from './pages/PortsPage'
@@ -105,6 +106,7 @@ export default function App() {
105106
<Route path="/trajectory/:id" element={<TrajectoryPage />} />
106107
<Route path="/plan/:id" element={<PlanPage />} />
107108
<Route path="/plan-test" element={<PlanTestPage />} />
109+
<Route path="/memory-management" element={<MemoryManagementPage />} />
108110
<Route path="/memory/:id" element={<MemoryReviewPage />} />
109111
<Route path="/memory-test" element={<MemoryTestPage />} />
110112
<Route path="/ports" element={<PortsPage />} />

packages/web/src/pages/HomePage.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,20 @@ export default function HomePage() {
152152
<div className="flex items-center justify-between">
153153
<h1 className="text-xl font-semibold text-zinc-900 dark:text-slate-100">Pending</h1>
154154
<div className="flex items-center gap-4">
155+
<button
156+
onClick={() => navigate('/memory-management')}
157+
className="flex items-center gap-1.5 text-sm text-zinc-400 dark:text-slate-500 hover:text-zinc-600 dark:hover:text-slate-300 transition-colors"
158+
>
159+
Memory Mgmt
160+
<span className="text-zinc-300 dark:text-zinc-600"></span>
161+
</button>
162+
<button
163+
onClick={() => navigate('/memory-test')}
164+
className="flex items-center gap-1.5 text-sm text-zinc-400 dark:text-slate-500 hover:text-zinc-600 dark:hover:text-slate-300 transition-colors"
165+
>
166+
Memory Review
167+
<span className="text-zinc-300 dark:text-zinc-600"></span>
168+
</button>
155169
<button
156170
onClick={() => navigate('/ports')}
157171
className="flex items-center gap-1.5 text-sm text-zinc-400 dark:text-slate-500 hover:text-zinc-600 dark:hover:text-slate-300 transition-colors"
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
import { useEffect, useMemo, useState } from 'react'
2+
3+
interface MemoryFile {
4+
id: string
5+
path: string
6+
relativePath: string
7+
categories: string[]
8+
preview: string
9+
}
10+
11+
interface MemoryGroup {
12+
id: string
13+
label: string
14+
fileIds: string[]
15+
}
16+
17+
interface CatalogResponse {
18+
groups: MemoryGroup[]
19+
files: MemoryFile[]
20+
}
21+
22+
interface FileContentResponse {
23+
path: string
24+
relativePath: string
25+
content: string
26+
}
27+
28+
function renderMarkdownFriendly(markdown: string) {
29+
const lines = markdown.split('\n')
30+
const out: JSX.Element[] = []
31+
let i = 0
32+
let key = 0
33+
34+
while (i < lines.length) {
35+
const line = lines[i]
36+
37+
if (line.startsWith('```')) {
38+
const code: string[] = []
39+
i += 1
40+
while (i < lines.length && !lines[i].startsWith('```')) {
41+
code.push(lines[i])
42+
i += 1
43+
}
44+
out.push(
45+
<pre key={`code-${key++}`} className="my-3 p-3 rounded bg-zinc-100 dark:bg-zinc-900 border border-gray-200 dark:border-zinc-700 overflow-x-auto text-xs font-mono text-zinc-700 dark:text-slate-300">
46+
<code>{code.join('\n')}</code>
47+
</pre>
48+
)
49+
i += 1
50+
continue
51+
}
52+
53+
if (line.startsWith('### ')) {
54+
out.push(<h3 key={`h3-${key++}`} className="text-base font-semibold mt-4 mb-1 text-zinc-900 dark:text-slate-100">{line.slice(4)}</h3>)
55+
i += 1
56+
continue
57+
}
58+
if (line.startsWith('## ')) {
59+
out.push(<h2 key={`h2-${key++}`} className="text-lg font-semibold mt-5 mb-1 text-zinc-900 dark:text-slate-100">{line.slice(3)}</h2>)
60+
i += 1
61+
continue
62+
}
63+
if (line.startsWith('# ')) {
64+
out.push(<h1 key={`h1-${key++}`} className="text-xl font-semibold mt-6 mb-2 text-zinc-900 dark:text-slate-100">{line.slice(2)}</h1>)
65+
i += 1
66+
continue
67+
}
68+
69+
if (line.startsWith('- ') || line.startsWith('* ')) {
70+
const items: string[] = []
71+
while (i < lines.length && (lines[i].startsWith('- ') || lines[i].startsWith('* '))) {
72+
items.push(lines[i].slice(2))
73+
i += 1
74+
}
75+
out.push(
76+
<ul key={`ul-${key++}`} className="list-disc ml-5 my-2 space-y-1 text-sm text-zinc-700 dark:text-slate-300">
77+
{items.map((item, idx) => <li key={`li-${idx}`}>{item}</li>)}
78+
</ul>
79+
)
80+
continue
81+
}
82+
83+
if (!line.trim()) {
84+
i += 1
85+
continue
86+
}
87+
88+
out.push(<p key={`p-${key++}`} className="text-sm leading-7 text-zinc-700 dark:text-slate-300 my-2">{line}</p>)
89+
i += 1
90+
}
91+
return out
92+
}
93+
94+
export default function MemoryManagementPage() {
95+
const [catalog, setCatalog] = useState<CatalogResponse | null>(null)
96+
const [selectedPath, setSelectedPath] = useState<string>('')
97+
const [selectedContent, setSelectedContent] = useState<FileContentResponse | null>(null)
98+
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
99+
const [loading, setLoading] = useState(true)
100+
const [fileLoading, setFileLoading] = useState(false)
101+
const [error, setError] = useState('')
102+
103+
useEffect(() => {
104+
fetch('/api/memory/files')
105+
.then(r => r.json())
106+
.then((data: CatalogResponse) => {
107+
setCatalog(data)
108+
setLoading(false)
109+
})
110+
.catch(() => {
111+
setError('Failed to load memory catalog')
112+
setLoading(false)
113+
})
114+
}, [])
115+
116+
const fileMap = useMemo(() => {
117+
const m = new Map<string, MemoryFile>()
118+
for (const file of catalog?.files ?? []) m.set(file.id, file)
119+
return m
120+
}, [catalog])
121+
122+
const openFile = async (file: MemoryFile) => {
123+
setSelectedPath(file.path)
124+
setFileLoading(true)
125+
try {
126+
const response = await fetch(`/api/memory/file?path=${encodeURIComponent(file.path)}`)
127+
if (!response.ok) throw new Error(`HTTP ${response.status}`)
128+
const data = await response.json() as FileContentResponse
129+
setSelectedContent(data)
130+
setError('')
131+
} catch {
132+
setError('Failed to load selected file content')
133+
} finally {
134+
setFileLoading(false)
135+
}
136+
}
137+
138+
const toggleGroup = (id: string) => {
139+
setCollapsedGroups(prev => {
140+
const next = new Set(prev)
141+
if (next.has(id)) next.delete(id)
142+
else next.add(id)
143+
return next
144+
})
145+
}
146+
147+
return (
148+
<div className="min-h-screen bg-gray-50 dark:bg-zinc-950">
149+
<div className="max-w-7xl mx-auto py-8 px-4">
150+
<p className="text-xs text-zinc-400 dark:text-slate-500 uppercase tracking-wider mb-1">Memory Management</p>
151+
<h1 className="text-xl font-semibold text-zinc-900 dark:text-slate-100">Memory Management</h1>
152+
<p className="text-sm text-zinc-500 dark:text-slate-400 mt-1">
153+
Browse memory-related files and open full content in a readable markdown view.
154+
</p>
155+
156+
{loading && <p className="text-sm text-zinc-400 dark:text-slate-500 mt-4">Loading memory catalog...</p>}
157+
{error && <p className="text-sm text-red-500 mt-4">{error}</p>}
158+
159+
{!loading && catalog && (
160+
<div className="mt-5 grid grid-cols-1 lg:grid-cols-[420px_1fr] gap-4">
161+
<div className="bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-700 rounded-lg p-3">
162+
<p className="text-sm font-medium text-zinc-800 dark:text-slate-200 mb-2">Memory Files</p>
163+
<div className="space-y-2">
164+
{catalog.groups.map(group => {
165+
const collapsed = collapsedGroups.has(group.id)
166+
return (
167+
<div key={group.id} className="border border-gray-100 dark:border-zinc-800 rounded">
168+
<button
169+
onClick={() => toggleGroup(group.id)}
170+
className="w-full px-2 py-1.5 text-left text-sm font-medium text-zinc-700 dark:text-slate-300 hover:bg-gray-50 dark:hover:bg-zinc-800 flex items-center gap-2"
171+
>
172+
<span className="text-xs">{collapsed ? '▸' : '▾'}</span>
173+
<span>{group.label}</span>
174+
<span className="ml-auto text-xs text-zinc-400 dark:text-slate-500">{group.fileIds.length}</span>
175+
</button>
176+
{!collapsed && (
177+
<div className="px-2 pb-2 space-y-1">
178+
{group.fileIds.map(fileId => {
179+
const file = fileMap.get(fileId)
180+
if (!file) return null
181+
const active = selectedPath === file.path
182+
return (
183+
<button
184+
key={file.id}
185+
onClick={() => openFile(file)}
186+
className={`w-full text-left px-2 py-1.5 rounded border ${
187+
active
188+
? 'border-blue-300 dark:border-blue-700 bg-blue-50 dark:bg-blue-950'
189+
: 'border-gray-100 dark:border-zinc-800 hover:bg-gray-50 dark:hover:bg-zinc-800'
190+
}`}
191+
title={file.path}
192+
>
193+
<p className="text-xs font-mono text-zinc-700 dark:text-slate-300 truncate">{file.relativePath}</p>
194+
<p className="text-[11px] text-zinc-500 dark:text-slate-400 mt-0.5 truncate">{file.preview}</p>
195+
</button>
196+
)
197+
})}
198+
</div>
199+
)}
200+
</div>
201+
)
202+
})}
203+
</div>
204+
</div>
205+
206+
<div className="bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-700 rounded-lg p-4">
207+
{!selectedContent && !fileLoading && (
208+
<p className="text-sm text-zinc-500 dark:text-slate-400">Click a memory file to view full content.</p>
209+
)}
210+
{fileLoading && <p className="text-sm text-zinc-400 dark:text-slate-500">Loading file content...</p>}
211+
{selectedContent && !fileLoading && (
212+
<>
213+
<p className="text-xs text-zinc-400 dark:text-slate-500 uppercase mb-1">Full File</p>
214+
<h2 className="text-sm font-mono text-zinc-800 dark:text-slate-200 break-all">{selectedContent.path}</h2>
215+
<div className="mt-3 max-h-[70vh] overflow-y-auto pr-1">
216+
{renderMarkdownFriendly(selectedContent.content)}
217+
</div>
218+
</>
219+
)}
220+
</div>
221+
</div>
222+
)}
223+
</div>
224+
</div>
225+
)
226+
}
227+

0 commit comments

Comments
 (0)